From a4a200c62d56657f7a0435742b4b1c2a2b711228 Mon Sep 17 00:00:00 2001 From: Alistar84 <19165796+Alistar84@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:49:05 +0200 Subject: [PATCH 01/12] Add request history browser Assisted-by: Codex --- CHANGELOG.md | 3 + docs/index.md | 20 +- resources/assets/debugbar.css | 76 +++++ resources/assets/debugbar.js | 171 +++++++++-- src/DebugBar/Collector/HistoryCollector.php | 56 ++++ .../Controllers/OpenHandlerController.php | 103 +++++++ src/DebugBar/DebugBarTypes.php | 7 + src/DebugBar/History/FilesystemHistory.php | 277 ++++++++++++++++++ src/DebugBar/History/HistoryOptions.php | 59 ++++ src/DebugBar/History/RequestMetadata.php | 34 +++ src/DebugBar/Provider.php | 71 ++++- src/DebugBar/ResponseListener.php | 43 ++- .../Collector/HistoryCollectorTest.php | 36 +++ .../Controllers/OpenHandlerControllerTest.php | 107 +++++++ .../History/FilesystemHistoryTest.php | 134 +++++++++ tests/Unit/DebugBar/ProviderTest.php | 69 +++++ tests/support/DebugBar/PanelContractTrait.php | 6 + 17 files changed, 1239 insertions(+), 33 deletions(-) create mode 100644 src/DebugBar/Collector/HistoryCollector.php create mode 100644 src/DebugBar/Controllers/OpenHandlerController.php create mode 100644 src/DebugBar/History/FilesystemHistory.php create mode 100644 src/DebugBar/History/HistoryOptions.php create mode 100644 src/DebugBar/History/RequestMetadata.php create mode 100644 tests/Unit/DebugBar/Collector/HistoryCollectorTest.php create mode 100644 tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php create mode 100644 tests/Unit/DebugBar/History/FilesystemHistoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c5b2d..b9b53a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas ### Added - Optional, extensible collector summaries rendered as headline metrics above a panel. The database collector uses them to report total queries, duplicate runs (executions after the first), and accumulated SQL time, and marks repeated statements with their execution count. +- Optional, session-isolated request history with filesystem retention, an + internal `GET /_debugbar/open` controller, and an inline request browser that + swaps the bar payload without leaving the current page. ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 9905402..12852e3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ The remainder of this document covers the debug bar. ## Registering the Debug Bar -The bar is booted by `Phalcon\DebugBar\Provider`. It takes the MVC application and an optional configuration array. Its only coupling to the application is the application's events manager, so the application must have one set before the bar boots. +The bar is booted by `Phalcon\DebugBar\Provider`. It takes the MVC application and an optional configuration array. The application must have an events manager set before the bar boots. Request history additionally requires the application's shared `router`, `request`, and `response` services. ```php ` | `[]` | Keys dropped from the output entirely. | | `redact.mask` | `list` | `[]` | Extra keys whose values are masked (added to the defaults). | @@ -85,10 +90,23 @@ use Phalcon\DebugBar\Provider; 'env' => ['var' => 'APP_ENV', 'blocked' => ['production', 'staging']], 'access' => ['allow_ips' => ['127.0.0.1', '10.0.0.5']], 'collectors' => ['cache' => false, 'view' => false], + 'history' => [ + 'enabled' => true, + 'path' => dirname(__DIR__) . '/runtime/debugbar', + 'max_requests' => 100, + 'ttl_seconds' => 86400, + ], 'redact' => ['mask' => ['api_key'], 'hidden' => ['secret_question']], ]))->boot(); ``` +When history is enabled, the provider registers `GET /_debugbar/open` and its +internal controller automatically. A request without an `id` returns the recent +request metadata; `?id=` returns a stored payload. The browser is +rendered directly above the bar and selecting an item replaces the collectors +shown below it. Storage is isolated by a SHA-256 hash of the active PHP session +id. With no active session, no request is written or exposed. + ## Collectors Each collector contributes one tab. A collector reads its data in one of four ways: diff --git a/resources/assets/debugbar.css b/resources/assets/debugbar.css index 66b2f47..bff701c 100644 --- a/resources/assets/debugbar.css +++ b/resources/assets/debugbar.css @@ -28,6 +28,7 @@ } #phalcon-debugbar.is-collapsed .phalcon-debugbar-body, +#phalcon-debugbar.is-collapsed .phalcon-debugbar-history-browser, #phalcon-debugbar.is-collapsed .phalcon-debugbar-tabs { display: none; } @@ -145,6 +146,81 @@ font-size: 13px; } +#phalcon-debugbar .phalcon-debugbar-history-browser { + display: none; + max-height: 156px; + overflow: auto; + background: #101018; + border-bottom: 1px solid #2b2b40; +} + +#phalcon-debugbar .phalcon-debugbar-history-list { + display: flex; + flex-direction: column; +} + +#phalcon-debugbar .phalcon-debugbar-history-request { + display: grid; + grid-template-columns: 58px minmax(180px, 1fr) 48px 210px; + gap: 10px; + align-items: center; + padding: 5px 14px; + background: transparent; + border: 0; + border-bottom: 1px solid #232334; + color: #b9b9d4; + font: inherit; + text-align: left; + cursor: pointer; +} + +#phalcon-debugbar .phalcon-debugbar-history-request:hover, +#phalcon-debugbar .phalcon-debugbar-history-request.is-selected { + background: #26263a; + color: #ffffff; +} + +#phalcon-debugbar .phalcon-debugbar-history-request.is-selected { + box-shadow: inset 3px 0 #7c3aed; +} + +#phalcon-debugbar .phalcon-debugbar-history-method { + color: #a78bfa; + font-weight: 700; +} + +#phalcon-debugbar .phalcon-debugbar-history-uri { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#phalcon-debugbar .phalcon-debugbar-history-status, +#phalcon-debugbar .phalcon-debugbar-history-time { + color: #8f8faa; +} + +#phalcon-debugbar .phalcon-debugbar-history-loading, +#phalcon-debugbar .phalcon-debugbar-history-empty, +#phalcon-debugbar .phalcon-debugbar-history-error { + padding: 8px 14px; + color: #8f8faa; +} + +#phalcon-debugbar .phalcon-debugbar-history-error { + color: #ff6b81; +} + +@media (max-width: 760px) { + #phalcon-debugbar .phalcon-debugbar-history-request { + grid-template-columns: 52px minmax(120px, 1fr) 42px; + } + + #phalcon-debugbar .phalcon-debugbar-history-time { + display: none; + } +} + #phalcon-debugbar table { width: 100%; border-collapse: collapse; diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index 437327d..6be9fb4 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -209,6 +209,86 @@ return badge !== null && badge !== undefined && badge !== '' && badge !== 0; } + function historyUrl(url, id) { + if (!id) { + return url; + } + + return url + (url.indexOf('?') === -1 ? '?' : '&') + 'id=' + encodeURIComponent(id); + } + + function loadJson(url) { + return window.fetch(url, { + credentials: 'same-origin', + headers: {'Accept': 'application/json'} + }).then(function (response) { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + + return response.json(); + }); + } + + function renderHistoryBrowser(mount, panel, selectedId, onSelect) { + mount.innerHTML = ''; + + var url = panel && typeof panel.url === 'string' ? panel.url : ''; + if (!url || typeof window.fetch !== 'function') { + mount.style.display = 'none'; + return; + } + + mount.style.display = 'block'; + mount.appendChild(el('div', 'phalcon-debugbar-history-loading', 'Loading request history...')); + + loadJson(url).then(function (result) { + mount.innerHTML = ''; + var requests = result && Array.isArray(result.requests) ? result.requests : []; + if (!requests.length) { + mount.appendChild(el('div', 'phalcon-debugbar-history-empty', 'No stored requests')); + return; + } + + var list = el('div', 'phalcon-debugbar-history-list'); + requests.forEach(function (request) { + request = request || {}; + var id = scalar(request.id); + var button = el('button', 'phalcon-debugbar-history-request'); + button.type = 'button'; + if (id === selectedId) { + button.classList.add('is-selected'); + } + + button.appendChild(el( + 'span', + 'phalcon-debugbar-history-method method-' + scalar(request.method).toLowerCase(), + scalar(request.method) + )); + button.appendChild(el('span', 'phalcon-debugbar-history-uri', scalar(request.uri))); + button.appendChild(el('span', 'phalcon-debugbar-history-status', scalar(request.status))); + button.appendChild(el('time', 'phalcon-debugbar-history-time', scalar(request.requested_at))); + + button.addEventListener('click', function () { + button.disabled = true; + loadJson(historyUrl(url, id)).then(function (detail) { + if (detail && detail.request && detail.request.payload) { + onSelect(detail.request.payload, id); + } + }).catch(function () { + button.disabled = false; + }); + }); + + list.appendChild(button); + }); + mount.appendChild(list); + }).catch(function () { + mount.innerHTML = ''; + mount.appendChild(el('div', 'phalcon-debugbar-history-error', 'Unable to load request history')); + }); + } + function readCollapsed() { try { return window.localStorage.getItem(STORAGE_KEY) === '1'; @@ -243,6 +323,7 @@ var widgets = (payload.meta && payload.meta.widgets) || {}; var bar = el('div', 'phalcon-debugbar-bar'); + var historyBrowser = el('div', 'phalcon-debugbar-history-browser'); var body = el('div', 'phalcon-debugbar-body'); var row = el('div', 'phalcon-debugbar-row'); var tabs = el('div', 'phalcon-debugbar-tabs'); @@ -252,6 +333,7 @@ body.style.display = 'none'; var active = null; + var selectedHistoryId = ''; function closePanel() { body.style.display = 'none'; @@ -276,45 +358,84 @@ writeCollapsed(collapsed); }); - Object.keys(data).forEach(function (name) { - var entry = data[name] || {}; - var widget = widgets[name] || {}; - var label = widget.label || titleize(name); - var type = widget.panel || inferType(entry.panel); - - var tab = el('button', 'phalcon-debugbar-tab'); - tab.type = 'button'; - tab.appendChild(el('span', 'phalcon-debugbar-tab-label', label)); - if (hasBadge(entry.badge)) { - tab.appendChild(el('span', 'phalcon-debugbar-badge', scalar(entry.badge))); + function activate(name, tab, entry, type) { + closePanel(); + tab.classList.add('is-active'); + body.innerHTML = ''; + var summary = renderSummary(entry.summary); + if (summary) { + body.appendChild(summary); } + body.appendChild(renderPanel(type, entry.panel)); + body.style.display = 'block'; + active = name; + } + + function renderData(nextPayload, preferredActive) { + payload = nextPayload || {}; + data = payload.data || {}; + widgets = (payload.meta && payload.meta.widgets) || {}; + tabs.innerHTML = ''; + body.innerHTML = ''; + body.style.display = 'none'; + active = null; - tab.addEventListener('click', function () { - if (active === name) { - closePanel(); + var preferred = null; + Object.keys(data).forEach(function (name) { + if (name === 'history') { return; } - closePanel(); - tab.classList.add('is-active'); - body.innerHTML = ''; - var summary = renderSummary(entry.summary); - if (summary) { - body.appendChild(summary); + var entry = data[name] || {}; + var widget = widgets[name] || {}; + var label = widget.label || titleize(name); + var type = widget.panel || inferType(entry.panel); + + var tab = el('button', 'phalcon-debugbar-tab'); + tab.type = 'button'; + tab.appendChild(el('span', 'phalcon-debugbar-tab-label', label)); + if (hasBadge(entry.badge)) { + tab.appendChild(el('span', 'phalcon-debugbar-badge', scalar(entry.badge))); + } + + tab.addEventListener('click', function () { + if (active === name) { + closePanel(); + return; + } + activate(name, tab, entry, type); + }); + + tabs.appendChild(tab); + if (name === preferredActive) { + preferred = [name, tab, entry, type]; } - body.appendChild(renderPanel(type, entry.panel)); - body.style.display = 'block'; - active = name; }); - tabs.appendChild(tab); - }); + if (preferred) { + activate(preferred[0], preferred[1], preferred[2], preferred[3]); + } + + var historyEntry = data.history || {}; + renderHistoryBrowser( + historyBrowser, + historyEntry.panel, + selectedHistoryId, + function (storedPayload, id) { + var activeBeforeSelection = active; + selectedHistoryId = id; + renderData(storedPayload, activeBeforeSelection); + } + ); + } row.appendChild(tabs); row.appendChild(toggle); + bar.appendChild(historyBrowser); bar.appendChild(body); bar.appendChild(row); mount.appendChild(bar); + renderData(payload, null); setCollapsed(readCollapsed()); }); })(); diff --git a/src/DebugBar/Collector/HistoryCollector.php b/src/DebugBar/Collector/HistoryCollector.php new file mode 100644 index 0000000..ef960b4 --- /dev/null +++ b/src/DebugBar/Collector/HistoryCollector.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\Collector; + +/** + * Enables the inline request-history browser. The browser itself is rendered + * by the JavaScript client; this collector only carries its internal endpoint. + */ +final class HistoryCollector extends AbstractCollector +{ + public const NAME = 'history'; + + /** + * @var string + */ + protected string $icon = 'icon-history'; + + /** + * @var string + */ + protected string $label = 'History'; + + /** + * @var string + */ + protected string $panel = 'history'; + + /** + * @param string $url + */ + public function __construct(private readonly string $url) + { + } + + /** + * @return array{panel: array{url: string}, badge: null} + */ + public function collect(): array + { + return [ + 'panel' => ['url' => $this->url], + 'badge' => null, + ]; + } +} diff --git a/src/DebugBar/Controllers/OpenHandlerController.php b/src/DebugBar/Controllers/OpenHandlerController.php new file mode 100644 index 0000000..e0d0b31 --- /dev/null +++ b/src/DebugBar/Controllers/OpenHandlerController.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\Controllers; + +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\Provider; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; +use Phalcon\Mvc\Controller; +use RuntimeException; + +use function is_string; +use function json_encode; + +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +/** + * Internal MVC adapter for GET /_debugbar/open. Without an id it returns the + * current session's request list; with an id it returns the stored entry. + */ +final class OpenHandlerController extends Controller +{ + /** + * @return ResponseInterface + */ + public function indexAction(): ResponseInterface + { + $container = $this->getDI(); + if (null === $container) { + throw new RuntimeException('The OpenHandler controller requires a DI container.'); + } + + $request = $container->getShared('request'); + $response = $container->getShared('response'); + $history = $container->getShared(Provider::HISTORY_SERVICE); + $access = $container->getShared(Provider::ACCESS_GATE_SERVICE); + + if (!$response instanceof ResponseInterface) { + throw new RuntimeException('The response service must implement ResponseInterface.'); + } + + if ( + !$request instanceof RequestInterface + || !$history instanceof FilesystemHistory + || !$access instanceof AccessGate + ) { + return $this->json($response, ['error' => 'History is unavailable.'], 500); + } + + $clientIp = $request->getClientAddress(); + if (!$access->allows(is_string($clientIp) ? $clientIp : null)) { + return $this->json($response, ['error' => 'Not found.'], 404); + } + + if ('GET' !== $request->getMethod()) { + return $this->json($response, ['error' => 'Method not allowed.'], 405); + } + + $id = $request->getQuery('id'); + if (!is_string($id) || '' === $id) { + return $this->json($response, ['requests' => $history->find()]); + } + + $entry = $history->get($id); + if (null === $entry) { + return $this->json($response, ['error' => 'Request not found.'], 404); + } + + return $this->json($response, ['request' => $entry]); + } + + /** + * @param ResponseInterface $response + * @param array $body + * @param int $status + * + * @return ResponseInterface + */ + private function json(ResponseInterface $response, array $body, int $status = 200): ResponseInterface + { + $json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $response->setStatusCode($status); + $response->setContentType('application/json', 'UTF-8'); + $response->setHeader('Cache-Control', 'no-store, private'); + $response->setContent(false === $json ? '{}' : $json); + + return $response; + } +} diff --git a/src/DebugBar/DebugBarTypes.php b/src/DebugBar/DebugBarTypes.php index 4b50fe4..13eb1c4 100644 --- a/src/DebugBar/DebugBarTypes.php +++ b/src/DebugBar/DebugBarTypes.php @@ -48,6 +48,13 @@ * access?: array{allow_ips?: list, callback?: (\Closure(): bool)|null}, * collectors?: array, * headers?: bool, + * history?: array{ + * enabled?: bool, + * url?: string, + * path?: string, + * max_requests?: int, + * ttl_seconds?: int + * }, * redact?: array{mask?: list, hidden?: list} * } */ diff --git a/src/DebugBar/History/FilesystemHistory.php b/src/DebugBar/History/FilesystemHistory.php new file mode 100644 index 0000000..1e9374a --- /dev/null +++ b/src/DebugBar/History/FilesystemHistory.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +use DateTimeImmutable; +use DateTimeZone; + +use function array_slice; +use function basename; +use function bin2hex; +use function file_get_contents; +use function file_put_contents; +use function glob; +use function hash; +use function is_array; +use function is_dir; +use function is_file; +use function is_string; +use function json_decode; +use function json_encode; +use function mkdir; +use function preg_match; +use function random_bytes; +use function rename; +use function rsort; +use function session_id; +use function session_status; +use function time; +use function unlink; + +use const JSON_PRETTY_PRINT; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; +use const LOCK_EX; +use const PHP_SESSION_ACTIVE; + +/** + * Persists request payloads in a session-scoped directory. Callers only learn + * save/find/get; atomic writes, pruning, path validation, and JSON failures stay + * inside the module. + * + * @phpstan-import-type payload from \Phalcon\DebugBar\DebugBarTypes + * @phpstan-type history_meta array{ + * requested_at: string, + * method: string, + * uri: string, + * status: int, + * ajax: bool, + * id: string, + * stored_at: string + * } + * @phpstan-type history_entry array{meta: history_meta, payload: payload} + */ +final class FilesystemHistory +{ + /** + * @param HistoryOptions $options + */ + public function __construct(private readonly HistoryOptions $options) + { + } + + /** + * @return list> + */ + public function find(): array + { + $directory = $this->sessionDirectory(false); + if (null === $directory) { + return []; + } + + $files = $this->files($directory); + $this->removeExpired($files); + $files = array_slice($this->files($directory), 0, $this->options->maxRequests); + + $requests = []; + foreach ($files as $file) { + $entry = $this->read($file); + if (null !== $entry) { + $requests[] = $entry['meta']; + } + } + + return $requests; + } + + /** + * @param string $id + * + * @return array|null + */ + public function get(string $id): ?array + { + if (1 !== preg_match('/^[0-9]{14}-[0-9]{6}-[a-f0-9]{8}$/D', $id)) { + return null; + } + + $directory = $this->sessionDirectory(false); + if (null === $directory) { + return null; + } + + return $this->read($directory . '/' . basename($id) . '.json'); + } + + /** + * @param payload $payload + * @param RequestMetadata $request + * + * @return string|null + */ + public function save(array $payload, RequestMetadata $request): ?string + { + $directory = $this->sessionDirectory(true); + if (null === $directory) { + return null; + } + + $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $id = $now->format('YmdHis-u-') . bin2hex(random_bytes(4)); + + $entry = [ + 'meta' => [ + 'requested_at' => $now->format(DATE_ATOM), + 'method' => $request->method, + 'uri' => $request->uri, + 'status' => $request->status, + 'ajax' => $request->ajax, + 'id' => $id, + 'stored_at' => $now->format(DATE_ATOM), + ], + 'payload' => $payload, + ]; + + $json = json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (false === $json) { + return null; + } + + $target = $directory . '/' . $id . '.json'; + $temporary = $target . '.tmp-' . bin2hex(random_bytes(4)); + if (false === file_put_contents($temporary, $json, LOCK_EX)) { + return null; + } + + if (!rename($temporary, $target)) { + @unlink($temporary); + + return null; + } + + $this->prune($directory); + + return $id; + } + + /** + * @param string $directory + * + * @return list + */ + private function files(string $directory): array + { + $files = glob($directory . '/*.json'); + if (false === $files) { + return []; + } + + rsort($files, SORT_STRING); + + return $files; + } + + /** + * @param string $directory + * + * @return void + */ + private function prune(string $directory): void + { + $files = $this->files($directory); + $this->removeExpired($files); + + foreach (array_slice($this->files($directory), $this->options->maxRequests) as $file) { + @unlink($file); + } + } + + /** + * @param string $file + * + * @return array{meta: array, payload: array}|null + */ + private function read(string $file): ?array + { + if (!is_file($file)) { + return null; + } + + $json = file_get_contents($file); + if (false === $json) { + return null; + } + + $entry = json_decode($json, true); + if (!is_array($entry) || !is_array($entry['meta'] ?? null) || !is_array($entry['payload'] ?? null)) { + return null; + } + + /** @var array $meta */ + $meta = $entry['meta']; + /** @var array $payload */ + $payload = $entry['payload']; + + return [ + 'meta' => $meta, + 'payload' => $payload, + ]; + } + + /** + * @param list $files + * + * @return void + */ + private function removeExpired(array $files): void + { + $oldest = time() - $this->options->ttlSeconds; + + foreach ($files as $file) { + $modified = @filemtime($file); + if (false !== $modified && $modified < $oldest) { + @unlink($file); + } + } + } + + /** + * @param bool $create + * + * @return string|null + */ + private function sessionDirectory(bool $create): ?string + { + if (PHP_SESSION_ACTIVE !== session_status()) { + return null; + } + + $sessionId = session_id(); + if (!is_string($sessionId) || '' === $sessionId) { + return null; + } + + $directory = $this->options->path . '/' . hash('sha256', $sessionId); + if (is_dir($directory)) { + return $directory; + } + + if (!$create || (!@mkdir($directory, 0700, true) && !is_dir($directory))) { + return null; + } + + return $directory; + } +} diff --git a/src/DebugBar/History/HistoryOptions.php b/src/DebugBar/History/HistoryOptions.php new file mode 100644 index 0000000..76a81ba --- /dev/null +++ b/src/DebugBar/History/HistoryOptions.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +use function max; +use function rtrim; +use function sys_get_temp_dir; + +/** + * Immutable request-history configuration shared by the provider, response + * listener, collector, and controller. + */ +final class HistoryOptions +{ + /** + * @var int + */ + public readonly int $maxRequests; + + /** + * @var string + */ + public readonly string $path; + + /** + * @var int + */ + public readonly int $ttlSeconds; + + /** + * @param bool $enabled + * @param string $url + * @param string $path + * @param int $maxRequests + * @param int $ttlSeconds + */ + public function __construct( + public readonly bool $enabled = false, + public readonly string $url = '/_debugbar/open', + string $path = '', + int $maxRequests = 100, + int $ttlSeconds = 86400 + ) { + $this->path = rtrim('' !== $path ? $path : sys_get_temp_dir() . '/phalcon-debugbar', '/\\'); + $this->maxRequests = max(1, $maxRequests); + $this->ttlSeconds = max(1, $ttlSeconds); + } +} diff --git a/src/DebugBar/History/RequestMetadata.php b/src/DebugBar/History/RequestMetadata.php new file mode 100644 index 0000000..728e0d9 --- /dev/null +++ b/src/DebugBar/History/RequestMetadata.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +/** + * The small request snapshot stored next to a collected debug-bar payload. + */ +final class RequestMetadata +{ + /** + * @param string $method + * @param string $uri + * @param int $status + * @param bool $ajax + */ + public function __construct( + public readonly string $method, + public readonly string $uri, + public readonly int $status, + public readonly bool $ajax + ) { + } +} diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index 5e59ecb..f5b74de 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -19,6 +19,7 @@ use Phalcon\DebugBar\Collector\ConfigCollector; use Phalcon\DebugBar\Collector\DatabaseCollector; use Phalcon\DebugBar\Collector\ExceptionsCollector; +use Phalcon\DebugBar\Collector\HistoryCollector; use Phalcon\DebugBar\Collector\LoggerCollector; use Phalcon\DebugBar\Collector\MessagesCollector; use Phalcon\DebugBar\Collector\RequestCollector; @@ -30,11 +31,14 @@ use Phalcon\DebugBar\Contracts\Collector; use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\DebugBar\Security\Redactor; use Phalcon\Di\DiInterface; use Phalcon\Http\RequestInterface; use Phalcon\Mvc\Application; +use Phalcon\Mvc\RouterInterface; use function getenv; use function in_array; @@ -44,13 +48,16 @@ /** * Boots the debug bar against an MVC application. Its whole coupling to the app * is: hold the `Application`, reach its EventsManager, and attach listeners. - * There is no DI service to register and no container-specific wiring - the - * app hands over its container and event bus. + * When request history is enabled it additionally registers an internal route + * and two private DI services used by its controller. * * @phpstan-import-type provider_config from DebugBarTypes */ class Provider { + public const ACCESS_GATE_SERVICE = 'debugbar.accessGate'; + public const HISTORY_SERVICE = 'debugbar.history'; + /** * @var (Closure(): bool)|null */ @@ -77,6 +84,8 @@ class Provider private bool $headers; + private HistoryOptions $historyOptions; + private ?string $nonce; private Redactor $redactor; @@ -92,6 +101,7 @@ public function __construct(private readonly Application $app, array $config = [ $assets = $config['assets'] ?? []; $access = $config['access'] ?? []; $redact = $config['redact'] ?? []; + $history = $config['history'] ?? []; $this->envVar = $env['var'] ?? 'APP_ENV'; $this->blocked = $env['blocked'] ?? ['production', 'prod']; @@ -102,6 +112,13 @@ public function __construct(private readonly Application $app, array $config = [ $this->accessCallback = $access['callback'] ?? null; $this->collectorsConfig = $config['collectors'] ?? []; $this->headers = $config['headers'] ?? true; + $this->historyOptions = new HistoryOptions( + $history['enabled'] ?? false, + $history['url'] ?? '/_debugbar/open', + $history['path'] ?? '', + $history['max_requests'] ?? 100, + $history['ttl_seconds'] ?? 86400 + ); $this->redactor = new Redactor( [...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])], $redact['hidden'] ?? [] @@ -132,13 +149,18 @@ public function boot(): void return; } - $container = $this->app->getDI(); - $request = $this->resolveRequest($container); + $container = $this->app->getDI(); + $request = $this->resolveRequest($container); + $accessGate = new AccessGate($this->allowedIps, $this->accessCallback); + $history = $this->registerHistory($container, $accessGate); $bar = new DebugBar(); foreach ($this->buildCollectors($container, $request) as $collector) { $bar->addCollector($collector); } + if (null !== $history) { + $bar->addCollector(new HistoryCollector($this->historyOptions->url)); + } Debug::setBar($bar); @@ -159,9 +181,11 @@ public function boot(): void $bar, new Renderer(), new Injector(), - new AccessGate($this->allowedIps, $this->accessCallback), + $accessGate, $request, - new BarOptions($this->headers, $this->nonce) + new BarOptions($this->headers, $this->nonce), + $history, + null !== $history ? $this->historyOptions : null ) ); } @@ -244,6 +268,41 @@ private function isCollectorEnabled(string $name): bool return $this->collectorsConfig[$name] ?? true; } + /** + * Registers the internal history module and its MVC route. History stays + * disabled when the app has no compatible container/router. + */ + private function registerHistory(?DiInterface $container, AccessGate $accessGate): ?FilesystemHistory + { + if ( + !$this->historyOptions->enabled + || null === $container + || !$container->has('router') + ) { + return null; + } + + $router = $container->getShared('router'); + if (!$router instanceof RouterInterface) { + return null; + } + + $history = new FilesystemHistory($this->historyOptions); + $container->setShared(self::HISTORY_SERVICE, $history); + $container->setShared(self::ACCESS_GATE_SERVICE, $accessGate); + + $router->addGet( + $this->historyOptions->url, + [ + 'namespace' => 'Phalcon\\DebugBar\\Controllers', + 'controller' => 'openHandler', + 'action' => 'index', + ] + )->setName('debugbar.openhandler'); + + return $history; + } + private function resolveConfig(DiInterface $container): ?ConfigInterface { if (!$container->has('config')) { diff --git a/src/DebugBar/ResponseListener.php b/src/DebugBar/ResponseListener.php index 36c3d14..9636e76 100644 --- a/src/DebugBar/ResponseListener.php +++ b/src/DebugBar/ResponseListener.php @@ -13,6 +13,9 @@ namespace Phalcon\DebugBar; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\RequestMetadata; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Events\EventInterface; use Phalcon\Http\RequestInterface; @@ -20,6 +23,9 @@ use function count; use function is_string; +use function parse_url; + +use const PHP_URL_PATH; /** * The `application:beforeSendResponse` listener. On the event it runs the access @@ -27,6 +33,7 @@ * HTML response - renders and splices the bar in. * * @phpstan-import-type request_context from DebugBarTypes + * @phpstan-import-type payload from DebugBarTypes */ final class ResponseListener { @@ -36,7 +43,9 @@ public function __construct( private readonly Injector $injector, private readonly AccessGate $accessGate, private readonly ?RequestInterface $request, - private readonly BarOptions $options + private readonly BarOptions $options, + private readonly ?FilesystemHistory $history = null, + private readonly ?HistoryOptions $historyOptions = null ) { } @@ -53,6 +62,8 @@ public function __invoke(EventInterface $event, mixed $source, mixed $response): $collected = $this->bar->collect(); + $this->record($collected, $response, $isAjax); + if (true === $this->options->headers) { $response->setHeader('X-Debug-Bar', (string) count($collected['data'])); } @@ -66,6 +77,36 @@ public function __invoke(EventInterface $event, mixed $source, mixed $response): } } + /** + * @param payload $collected + * @param ResponseInterface $response + * @param bool $isAjax + * + * @return void + */ + private function record(array $collected, ResponseInterface $response, bool $isAjax): void + { + if (null === $this->history || null === $this->historyOptions || null === $this->request) { + return; + } + + $uri = $this->request->getURI(); + $path = parse_url($uri, PHP_URL_PATH); + if (is_string($path) && $path === $this->historyOptions->url) { + return; + } + + $this->history->save( + $collected, + new RequestMetadata( + $this->request->getMethod(), + $uri, + $response->getStatusCode() ?? 200, + $isAjax + ) + ); + } + /** * @return request_context */ diff --git a/tests/Unit/DebugBar/Collector/HistoryCollectorTest.php b/tests/Unit/DebugBar/Collector/HistoryCollectorTest.php new file mode 100644 index 0000000..93fd513 --- /dev/null +++ b/tests/Unit/DebugBar/Collector/HistoryCollectorTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\Collector; + +use Phalcon\DebugBar\Collector\HistoryCollector; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\PanelContractTrait; + +final class HistoryCollectorTest extends AbstractUnitTestCase +{ + use PanelContractTrait; + + public function testCollectCarriesTheInternalEndpoint(): void + { + $collector = new HistoryCollector('/_debugbar/open'); + + $this->assertSame('history', $collector->getName()); + $this->assertSame('history', $collector->getWidget()['panel']); + $this->assertSame( + ['panel' => ['url' => '/_debugbar/open'], 'badge' => null], + $collector->collect() + ); + $this->assertPanelContract($collector); + } +} diff --git a/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php b/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php new file mode 100644 index 0000000..f14edc4 --- /dev/null +++ b/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\Controllers; + +use Phalcon\DebugBar\Controllers\OpenHandlerController; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\DebugBar\Provider; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Di\Di; +use Phalcon\Http\Request; +use Phalcon\Http\Response; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; + +use function bin2hex; +use function glob; +use function hash; +use function json_decode; +use function random_bytes; +use function session_id; +use function session_start; +use function session_write_close; +use function sys_get_temp_dir; +use function unlink; + +final class OpenHandlerControllerTest extends AbstractUnitTestCase +{ + #[RunInSeparateProcess] + public function testListsAndLoadsRequestsFromTheCurrentSession(): void + { + $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); + $path = sys_get_temp_dir() . '/phalcon-debugbar-controller-' . bin2hex(random_bytes(8)); + session_id($sessionId); + session_start(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + $id = $history->save( + ['data' => [], 'meta' => ['collectors' => 0]], + new RequestMetadata('GET', '/orders', 200, false) + ); + $this->assertIsString($id); + + $_GET = []; + $list = $this->execute($history); + $this->assertSame(200, $list->getStatusCode()); + $listBody = json_decode($list->getContent(), true); + $this->assertIsArray($listBody); + $requests = $listBody['requests']; + $this->assertIsArray($requests); + $this->assertCount(1, $requests); + + $_GET = ['id' => $id]; + $detail = $this->execute($history); + $this->assertSame(200, $detail->getStatusCode()); + $detailBody = json_decode($detail->getContent(), true); + $this->assertIsArray($detailBody); + $request = $detailBody['request']; + $this->assertIsArray($request); + $meta = $request['meta']; + $this->assertIsArray($meta); + $this->assertSame($id, $meta['id']); + $this->assertSame('no-store, private', $detail->getHeaders()->get('Cache-Control')); + } finally { + session_write_close(); + $directory = $path . '/' . hash('sha256', $sessionId); + $files = glob($directory . '/*'); + if (false !== $files) { + foreach ($files as $file) { + unlink($file); + } + } + + @rmdir($directory); + @rmdir($path); + } + } + + private function execute(FilesystemHistory $history): Response + { + $container = new Di(); + $response = new Response(); + $container->setShared('request', new Request()); + $container->setShared('response', $response); + $container->setShared(Provider::HISTORY_SERVICE, $history); + $container->setShared(Provider::ACCESS_GATE_SERVICE, new AccessGate([], null)); + + $controller = new OpenHandlerController(); + $controller->setDI($container); + $controller->indexAction(); + + return $response; + } +} diff --git a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php new file mode 100644 index 0000000..bdf6ff1 --- /dev/null +++ b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\History; + +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; + +use function bin2hex; +use function glob; +use function hash; +use function random_bytes; +use function rmdir; +use function session_id; +use function session_start; +use function session_write_close; +use function sys_get_temp_dir; +use function unlink; + +final class FilesystemHistoryTest extends AbstractUnitTestCase +{ + #[RunInSeparateProcess] + public function testMaximumRequestCountIsPruned(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 2, 60)); + for ($index = 0; $index < 3; $index++) { + $history->save( + ['data' => [], 'meta' => ['index' => $index]], + new RequestMetadata('GET', '/' . $index, 200, false) + ); + } + + $this->assertCount(2, $history->find()); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testNoActiveSessionStoresNothing(): void + { + $path = $this->temporaryPath(); + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertSame([], $history->find()); + } + #[RunInSeparateProcess] + public function testSaveFindAndGetAreSessionScoped(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); + $id = $history->save( + ['data' => [], 'meta' => ['collectors' => 0]], + new RequestMetadata('POST', '/orders', 201, true) + ); + + $this->assertIsString($id); + + $requests = $history->find(); + $this->assertCount(1, $requests); + $this->assertSame($id, $requests[0]['id']); + $this->assertSame('POST', $requests[0]['method']); + $this->assertSame('/orders', $requests[0]['uri']); + $this->assertSame(201, $requests[0]['status']); + $this->assertTrue($requests[0]['ajax']); + + $entry = $history->get($id); + $this->assertIsArray($entry); + $payload = $entry['payload']; + $this->assertIsArray($payload); + $meta = $payload['meta']; + $this->assertIsArray($meta); + $this->assertSame(0, $meta['collectors']); + $this->assertNull($history->get('../outside')); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + private function removeHistory(string $path, string $sessionId): void + { + $directory = $path . '/' . hash('sha256', $sessionId); + $files = glob($directory . '/*'); + if (false !== $files) { + foreach ($files as $file) { + unlink($file); + } + } + + @rmdir($directory); + @rmdir($path); + } + + /** + * @return array{0: string, 1: string} + */ + private function startSession(): array + { + $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); + session_id($sessionId); + session_start(); + + return [$this->temporaryPath(), $sessionId]; + } + + private function temporaryPath(): string + { + return sys_get_temp_dir() . '/phalcon-debugbar-test-' . bin2hex(random_bytes(8)); + } +} diff --git a/tests/Unit/DebugBar/ProviderTest.php b/tests/Unit/DebugBar/ProviderTest.php index 8b26b67..8036c81 100644 --- a/tests/Unit/DebugBar/ProviderTest.php +++ b/tests/Unit/DebugBar/ProviderTest.php @@ -19,17 +19,24 @@ use Phalcon\DebugBar\Exceptions\CannotUseInProduction; use Phalcon\DebugBar\Provider; use Phalcon\Di\Di; +use Phalcon\Di\FactoryDefault; use Phalcon\Events\Manager; use Phalcon\Http\Request; use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; use Phalcon\Mvc\Application; +use Phalcon\Mvc\Router; +use Phalcon\Mvc\Router\RouteInterface; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use stdClass; use function array_keys; use function is_array; +use function json_decode; use function putenv; +use function sys_get_temp_dir; #[BackupGlobals(true)] final class ProviderTest extends AbstractUnitTestCase @@ -249,6 +256,68 @@ public function testHeadersDisabledSuppressesDiagnosticHeader(): void $this->assertFalse($response->getHeaders()->has('X-Debug-Bar')); } + public function testHistoryRegistersItsCollectorRouteAndServices(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $em = new Manager(); + $router = new Router(false); + $app = $this->applicationWithServices($em, [ + 'request' => new Request(), + 'response' => new Response(), + 'router' => $router, + ]); + + (new Provider($app, [ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true, 'path' => 'var/debugbar'], + ]))->boot(); + + $container = $app->getDI(); + $this->assertNotNull($container); + $this->assertTrue($container->has(Provider::HISTORY_SERVICE)); + $this->assertTrue($container->has(Provider::ACCESS_GATE_SERVICE)); + $this->assertTrue($this->bootedBar()->hasCollector('history')); + $route = $router->getRouteByName('debugbar.openhandler'); + if (!$route instanceof RouteInterface) { + $this->fail('Expected the debugbar.openhandler route.'); + } + + $this->assertSame('/_debugbar/open', $route->getPattern()); + } + + #[RunInSeparateProcess] + public function testHistoryRouteDispatchesTheInternalController(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/_debugbar/open'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + + $container = new FactoryDefault(); + $app = new Application($container); + $app->useImplicitView(false); + $app->setEventsManager(new Manager()); + + (new Provider($app, [ + 'env' => ['var' => self::ENV_VAR], + 'history' => [ + 'enabled' => true, + 'path' => sys_get_temp_dir() . '/phalcon-debugbar-routing', + ], + ]))->boot(); + + $response = $app->handle('/_debugbar/open'); + if (!$response instanceof ResponseInterface) { + $this->fail('Expected the history route to return a response.'); + } + + $body = json_decode($response->getContent(), true); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['requests' => []], $body); + $this->assertSame('application/json; charset=UTF-8', $response->getHeaders()->get('Content-Type')); + } + public function testIsAllowedTracksTheEnvironment(): void { $provider = $this->provider($this->application(new Manager())); diff --git a/tests/support/DebugBar/PanelContractTrait.php b/tests/support/DebugBar/PanelContractTrait.php index 0396d7d..bc60070 100644 --- a/tests/support/DebugBar/PanelContractTrait.php +++ b/tests/support/DebugBar/PanelContractTrait.php @@ -81,6 +81,12 @@ protected function assertPanelContract(Renderable $collector): void case 'html': Assert::assertIsString($data); + break; + case 'history': + Assert::assertIsArray($data); + Assert::assertArrayHasKey('url', $data); + Assert::assertIsString($data['url']); + break; default: Assert::fail('Unknown panel type: ' . $panel); From 8ce7b788ee59aff5bc44c5742a78121c0b85eca1 Mon Sep 17 00:00:00 2001 From: Alistar84 <19165796+Alistar84@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:49:31 +0200 Subject: [PATCH 02/12] Add collapsible request history controls Assisted-by: Codex --- CHANGELOG.md | 5 +- docs/index.md | 14 +- resources/assets/debugbar.css | 54 ++++- resources/assets/debugbar.js | 205 +++++++++++++----- .../Controllers/OpenHandlerController.php | 42 +++- src/DebugBar/History/FilesystemHistory.php | 24 +- src/DebugBar/Provider.php | 9 + .../Controllers/OpenHandlerControllerTest.php | 18 +- .../History/FilesystemHistoryTest.php | 24 ++ tests/Unit/DebugBar/ProviderTest.php | 7 + 10 files changed, 332 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b53a1..41cb7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - Optional, extensible collector summaries rendered as headline metrics above a panel. The database collector uses them to report total queries, duplicate runs (executions after the first), and accumulated SQL time, and marks repeated statements with their execution count. - Optional, session-isolated request history with filesystem retention, an - internal `GET /_debugbar/open` controller, and an inline request browser that - swaps the bar payload without leaving the current page. + internal `GET/DELETE /_debugbar/open` controller, and a collapsible request + browser with refresh and clear controls that swaps the bar payload without + leaving the current page. ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 12852e3..b14eba1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,12 +100,14 @@ use Phalcon\DebugBar\Provider; ]))->boot(); ``` -When history is enabled, the provider registers `GET /_debugbar/open` and its -internal controller automatically. A request without an `id` returns the recent -request metadata; `?id=` returns a stored payload. The browser is -rendered directly above the bar and selecting an item replaces the collectors -shown below it. Storage is isolated by a SHA-256 hash of the active PHP session -id. With no active session, no request is written or exposed. +When history is enabled, the provider registers `GET /_debugbar/open`, +`DELETE /_debugbar/open`, and their internal controller automatically. A GET +without an `id` returns the recent request metadata; `?id=` returns +a stored payload. DELETE clears the active session's stored requests. The +`History` item in the bottom bar opens the browser above it; its controls refresh +or clear the list, and selecting an item replaces the collectors shown below. +Storage is isolated by a SHA-256 hash of the active PHP session id. With no +active session, no request is written or exposed. ## Collectors diff --git a/resources/assets/debugbar.css b/resources/assets/debugbar.css index bff701c..53a1632 100644 --- a/resources/assets/debugbar.css +++ b/resources/assets/debugbar.css @@ -148,12 +148,64 @@ #phalcon-debugbar .phalcon-debugbar-history-browser { display: none; - max-height: 156px; + max-height: 210px; overflow: auto; background: #101018; border-bottom: 1px solid #2b2b40; } +#phalcon-debugbar .phalcon-debugbar-history-toolbar { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 14px; + background: #181824; + border-bottom: 1px solid #2b2b40; +} + +#phalcon-debugbar .phalcon-debugbar-history-title { + color: #d5d5e8; + font-size: 12px; +} + +#phalcon-debugbar .phalcon-debugbar-history-actions { + display: flex; + gap: 6px; +} + +#phalcon-debugbar .phalcon-debugbar-history-action { + padding: 3px 9px; + border: 1px solid #454563; + border-radius: 3px; + background: #29293d; + color: #d5d5e8; + font: inherit; + cursor: pointer; +} + +#phalcon-debugbar .phalcon-debugbar-history-action:hover { + background: #353550; + color: #ffffff; +} + +#phalcon-debugbar .phalcon-debugbar-history-action.is-danger { + border-color: #7f3445; + color: #ff9aac; +} + +#phalcon-debugbar .phalcon-debugbar-history-action.is-danger:hover { + background: #542532; +} + +#phalcon-debugbar .phalcon-debugbar-history-action:disabled { + opacity: 0.5; + cursor: default; +} + #phalcon-debugbar .phalcon-debugbar-history-list { display: flex; flex-direction: column; diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index 6be9fb4..c75de5c 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -217,11 +217,13 @@ return url + (url.indexOf('?') === -1 ? '?' : '&') + 'id=' + encodeURIComponent(id); } - function loadJson(url) { - return window.fetch(url, { - credentials: 'same-origin', - headers: {'Accept': 'application/json'} - }).then(function (response) { + function requestJson(url, options) { + options = options || {}; + options.credentials = 'same-origin'; + options.headers = options.headers || {}; + options.headers.Accept = 'application/json'; + + return window.fetch(url, options).then(function (response) { if (!response.ok) { throw new Error('HTTP ' + response.status); } @@ -230,7 +232,11 @@ }); } - function renderHistoryBrowser(mount, panel, selectedId, onSelect) { + function loadJson(url) { + return requestJson(url, {}); + } + + function renderHistoryBrowser(mount, panel, selectedId, onSelect, onClear) { mount.innerHTML = ''; var url = panel && typeof panel.url === 'string' ? panel.url : ''; @@ -240,53 +246,96 @@ } mount.style.display = 'block'; - mount.appendChild(el('div', 'phalcon-debugbar-history-loading', 'Loading request history...')); + var toolbar = el('div', 'phalcon-debugbar-history-toolbar'); + var title = el('strong', 'phalcon-debugbar-history-title', 'Request history'); + var actions = el('div', 'phalcon-debugbar-history-actions'); + var refreshButton = el('button', 'phalcon-debugbar-history-action', 'Refresh'); + var clearButton = el('button', 'phalcon-debugbar-history-action is-danger', 'Clear'); + var content = el('div', 'phalcon-debugbar-history-content'); + refreshButton.type = 'button'; + clearButton.type = 'button'; + actions.appendChild(refreshButton); + actions.appendChild(clearButton); + toolbar.appendChild(title); + toolbar.appendChild(actions); + mount.appendChild(toolbar); + mount.appendChild(content); + + function message(className, text) { + content.innerHTML = ''; + content.appendChild(el('div', className, text)); + } - loadJson(url).then(function (result) { - mount.innerHTML = ''; - var requests = result && Array.isArray(result.requests) ? result.requests : []; - if (!requests.length) { - mount.appendChild(el('div', 'phalcon-debugbar-history-empty', 'No stored requests')); - return; - } + function refresh() { + refreshButton.disabled = true; + message('phalcon-debugbar-history-loading', 'Loading request history...'); - var list = el('div', 'phalcon-debugbar-history-list'); - requests.forEach(function (request) { - request = request || {}; - var id = scalar(request.id); - var button = el('button', 'phalcon-debugbar-history-request'); - button.type = 'button'; - if (id === selectedId) { - button.classList.add('is-selected'); + loadJson(url).then(function (result) { + content.innerHTML = ''; + var requests = result && Array.isArray(result.requests) ? result.requests : []; + clearButton.disabled = !requests.length; + if (!requests.length) { + content.appendChild(el('div', 'phalcon-debugbar-history-empty', 'No stored requests')); + return; } - button.appendChild(el( - 'span', - 'phalcon-debugbar-history-method method-' + scalar(request.method).toLowerCase(), - scalar(request.method) - )); - button.appendChild(el('span', 'phalcon-debugbar-history-uri', scalar(request.uri))); - button.appendChild(el('span', 'phalcon-debugbar-history-status', scalar(request.status))); - button.appendChild(el('time', 'phalcon-debugbar-history-time', scalar(request.requested_at))); - - button.addEventListener('click', function () { - button.disabled = true; - loadJson(historyUrl(url, id)).then(function (detail) { - if (detail && detail.request && detail.request.payload) { - onSelect(detail.request.payload, id); - } - }).catch(function () { - button.disabled = false; + var list = el('div', 'phalcon-debugbar-history-list'); + requests.forEach(function (request) { + request = request || {}; + var id = scalar(request.id); + var button = el('button', 'phalcon-debugbar-history-request'); + button.type = 'button'; + if (id === selectedId) { + button.classList.add('is-selected'); + } + + button.appendChild(el( + 'span', + 'phalcon-debugbar-history-method method-' + scalar(request.method).toLowerCase(), + scalar(request.method) + )); + button.appendChild(el('span', 'phalcon-debugbar-history-uri', scalar(request.uri))); + button.appendChild(el('span', 'phalcon-debugbar-history-status', scalar(request.status))); + button.appendChild(el('time', 'phalcon-debugbar-history-time', scalar(request.requested_at))); + + button.addEventListener('click', function () { + button.disabled = true; + loadJson(historyUrl(url, id)).then(function (detail) { + if (detail && detail.request && detail.request.payload) { + onSelect(detail.request.payload, id); + } + }).catch(function () { + button.disabled = false; + }); }); + + list.appendChild(button); }); + content.appendChild(list); + }).catch(function () { + message('phalcon-debugbar-history-error', 'Unable to load request history'); + }).then(function () { + refreshButton.disabled = false; + }); + } - list.appendChild(button); + refreshButton.addEventListener('click', refresh); + clearButton.addEventListener('click', function () { + if (!window.confirm('Clear request history?')) { + return; + } + + clearButton.disabled = true; + requestJson(url, {method: 'DELETE'}).then(function () { + onClear(); + refresh(); + }).catch(function () { + clearButton.disabled = false; + message('phalcon-debugbar-history-error', 'Unable to clear request history'); }); - mount.appendChild(list); - }).catch(function () { - mount.innerHTML = ''; - mount.appendChild(el('div', 'phalcon-debugbar-history-error', 'Unable to load request history')); }); + + refresh(); } function readCollapsed() { @@ -334,18 +383,51 @@ var active = null; var selectedHistoryId = ''; + var historyOpen = false; + var historyPanel = null; + var historyTab = null; function closePanel() { body.style.display = 'none'; active = null; - Array.prototype.forEach.call(tabs.children, function (child) { + Array.prototype.forEach.call(tabs.querySelectorAll('[data-panel-tab]'), function (child) { child.classList.remove('is-active'); }); } + function renderOpenHistory() { + if (!historyOpen || !historyPanel) { + historyBrowser.style.display = 'none'; + return; + } + + renderHistoryBrowser( + historyBrowser, + historyPanel, + selectedHistoryId, + function (storedPayload, id) { + var activeBeforeSelection = active; + selectedHistoryId = id; + renderData(storedPayload, activeBeforeSelection); + }, + function () { + selectedHistoryId = ''; + } + ); + } + + function setHistoryOpen(open) { + historyOpen = Boolean(open && historyPanel); + if (historyTab) { + historyTab.classList.toggle('is-active', historyOpen); + } + renderOpenHistory(); + } + function setCollapsed(collapsed) { if (collapsed) { closePanel(); + setHistoryOpen(false); mount.classList.add('is-collapsed'); } else { mount.classList.remove('is-collapsed'); @@ -392,6 +474,7 @@ var tab = el('button', 'phalcon-debugbar-tab'); tab.type = 'button'; + tab.setAttribute('data-panel-tab', name); tab.appendChild(el('span', 'phalcon-debugbar-tab-label', label)); if (hasBadge(entry.badge)) { tab.appendChild(el('span', 'phalcon-debugbar-badge', scalar(entry.badge))); @@ -411,21 +494,31 @@ } }); + var historyEntry = data.history || {}; + historyPanel = historyEntry.panel || null; + historyTab = null; + if (historyPanel && typeof historyPanel.url === 'string') { + var historyWidget = widgets.history || {}; + historyTab = el('button', 'phalcon-debugbar-tab'); + historyTab.type = 'button'; + historyTab.appendChild(el( + 'span', + 'phalcon-debugbar-tab-label', + historyWidget.label || 'History' + )); + historyTab.addEventListener('click', function () { + setHistoryOpen(!historyOpen); + }); + tabs.appendChild(historyTab); + } else { + historyOpen = false; + } + if (preferred) { activate(preferred[0], preferred[1], preferred[2], preferred[3]); } - var historyEntry = data.history || {}; - renderHistoryBrowser( - historyBrowser, - historyEntry.panel, - selectedHistoryId, - function (storedPayload, id) { - var activeBeforeSelection = active; - selectedHistoryId = id; - renderData(storedPayload, activeBeforeSelection); - } - ); + setHistoryOpen(historyOpen); } row.appendChild(tabs); diff --git a/src/DebugBar/Controllers/OpenHandlerController.php b/src/DebugBar/Controllers/OpenHandlerController.php index e0d0b31..5cbdc7e 100644 --- a/src/DebugBar/Controllers/OpenHandlerController.php +++ b/src/DebugBar/Controllers/OpenHandlerController.php @@ -28,11 +28,49 @@ use const JSON_UNESCAPED_UNICODE; /** - * Internal MVC adapter for GET /_debugbar/open. Without an id it returns the - * current session's request list; with an id it returns the stored entry. + * Internal MVC adapter for /_debugbar/open. GET returns the current session's + * request list or one stored entry; DELETE clears that session's history. */ final class OpenHandlerController extends Controller { + /** + * @return ResponseInterface + */ + public function clearAction(): ResponseInterface + { + $container = $this->getDI(); + if (null === $container) { + throw new RuntimeException('The OpenHandler controller requires a DI container.'); + } + + $request = $container->getShared('request'); + $response = $container->getShared('response'); + $history = $container->getShared(Provider::HISTORY_SERVICE); + $access = $container->getShared(Provider::ACCESS_GATE_SERVICE); + + if (!$response instanceof ResponseInterface) { + throw new RuntimeException('The response service must implement ResponseInterface.'); + } + + if ( + !$request instanceof RequestInterface + || !$history instanceof FilesystemHistory + || !$access instanceof AccessGate + ) { + return $this->json($response, ['error' => 'History is unavailable.'], 500); + } + + $clientIp = $request->getClientAddress(); + if (!$access->allows(is_string($clientIp) ? $clientIp : null)) { + return $this->json($response, ['error' => 'Not found.'], 404); + } + + if ('DELETE' !== $request->getMethod()) { + return $this->json($response, ['error' => 'Method not allowed.'], 405); + } + + return $this->json($response, ['cleared' => $history->clear()]); + } /** * @return ResponseInterface */ diff --git a/src/DebugBar/History/FilesystemHistory.php b/src/DebugBar/History/FilesystemHistory.php index 1e9374a..177fd55 100644 --- a/src/DebugBar/History/FilesystemHistory.php +++ b/src/DebugBar/History/FilesystemHistory.php @@ -47,7 +47,7 @@ /** * Persists request payloads in a session-scoped directory. Callers only learn - * save/find/get; atomic writes, pruning, path validation, and JSON failures stay + * save/find/get/clear; atomic writes, pruning, path validation, and JSON failures stay * inside the module. * * @phpstan-import-type payload from \Phalcon\DebugBar\DebugBarTypes @@ -71,6 +71,28 @@ public function __construct(private readonly HistoryOptions $options) { } + /** + * Removes every stored request belonging to the active PHP session. + * + * @return int Number of files successfully removed. + */ + public function clear(): int + { + $directory = $this->sessionDirectory(false); + if (null === $directory) { + return 0; + } + + $removed = 0; + foreach ($this->files($directory) as $file) { + if (@unlink($file)) { + $removed++; + } + } + + return $removed; + } + /** * @return list> */ diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index f5b74de..7adce90 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -300,6 +300,15 @@ private function registerHistory(?DiInterface $container, AccessGate $accessGate ] )->setName('debugbar.openhandler'); + $router->addDelete( + $this->historyOptions->url, + [ + 'namespace' => 'Phalcon\\DebugBar\\Controllers', + 'controller' => 'openHandler', + 'action' => 'clear', + ] + )->setName('debugbar.clearhistory'); + return $history; } diff --git a/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php b/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php index f14edc4..1a2213a 100644 --- a/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php +++ b/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php @@ -47,6 +47,7 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void session_start(); try { + $_SERVER['REQUEST_METHOD'] = 'GET'; $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); $id = $history->save( ['data' => [], 'meta' => ['collectors' => 0]], @@ -74,6 +75,15 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void $this->assertIsArray($meta); $this->assertSame($id, $meta['id']); $this->assertSame('no-store, private', $detail->getHeaders()->get('Cache-Control')); + + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = []; + $clear = $this->execute($history, 'clear'); + $this->assertSame(200, $clear->getStatusCode()); + $clearBody = json_decode($clear->getContent(), true); + $this->assertIsArray($clearBody); + $this->assertSame(1, $clearBody['cleared']); + $this->assertSame([], $history->find()); } finally { session_write_close(); $directory = $path . '/' . hash('sha256', $sessionId); @@ -89,7 +99,7 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void } } - private function execute(FilesystemHistory $history): Response + private function execute(FilesystemHistory $history, string $action = 'index'): Response { $container = new Di(); $response = new Response(); @@ -100,7 +110,11 @@ private function execute(FilesystemHistory $history): Response $controller = new OpenHandlerController(); $controller->setDI($container); - $controller->indexAction(); + if ('clear' === $action) { + $controller->clearAction(); + } else { + $controller->indexAction(); + } return $response; } diff --git a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php index bdf6ff1..07947cc 100644 --- a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php +++ b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php @@ -32,6 +32,29 @@ final class FilesystemHistoryTest extends AbstractUnitTestCase { + #[RunInSeparateProcess] + public function testClearRemovesTheCurrentSessionsRequests(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); + for ($index = 0; $index < 2; $index++) { + $history->save( + ['data' => [], 'meta' => ['index' => $index]], + new RequestMetadata('GET', '/' . $index, 200, false) + ); + } + + $this->assertSame(2, $history->clear()); + $this->assertSame([], $history->find()); + $this->assertSame(0, $history->clear()); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + #[RunInSeparateProcess] public function testMaximumRequestCountIsPruned(): void { @@ -64,6 +87,7 @@ public function testNoActiveSessionStoresNothing(): void new RequestMetadata('GET', '/', 200, false) )); $this->assertSame([], $history->find()); + $this->assertSame(0, $history->clear()); } #[RunInSeparateProcess] public function testSaveFindAndGetAreSessionScoped(): void diff --git a/tests/Unit/DebugBar/ProviderTest.php b/tests/Unit/DebugBar/ProviderTest.php index 8036c81..cb80bdd 100644 --- a/tests/Unit/DebugBar/ProviderTest.php +++ b/tests/Unit/DebugBar/ProviderTest.php @@ -283,6 +283,13 @@ public function testHistoryRegistersItsCollectorRouteAndServices(): void } $this->assertSame('/_debugbar/open', $route->getPattern()); + + $clearRoute = $router->getRouteByName('debugbar.clearhistory'); + if (!$clearRoute instanceof RouteInterface) { + $this->fail('Expected the debugbar.clearhistory route.'); + } + + $this->assertSame('/_debugbar/open', $clearRoute->getPattern()); } #[RunInSeparateProcess] From b018c288fa074ac80ef17be43ec7ace413bc4678 Mon Sep 17 00:00:00 2001 From: Alistar84 <19165796+Alistar84@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:49:31 +0200 Subject: [PATCH 03/12] Preserve history while switching requests Assisted-by: Codex --- resources/assets/debugbar.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index c75de5c..35c8ffc 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -302,8 +302,16 @@ button.disabled = true; loadJson(historyUrl(url, id)).then(function (detail) { if (detail && detail.request && detail.request.payload) { + Array.prototype.forEach.call( + list.querySelectorAll('.phalcon-debugbar-history-request'), + function (requestButton) { + requestButton.classList.remove('is-selected'); + } + ); + button.classList.add('is-selected'); onSelect(detail.request.payload, id); } + button.disabled = false; }).catch(function () { button.disabled = false; }); @@ -408,7 +416,7 @@ function (storedPayload, id) { var activeBeforeSelection = active; selectedHistoryId = id; - renderData(storedPayload, activeBeforeSelection); + renderData(storedPayload, activeBeforeSelection, true); }, function () { selectedHistoryId = ''; @@ -416,12 +424,17 @@ ); } - function setHistoryOpen(open) { + function setHistoryOpen(open, preserveBrowser) { historyOpen = Boolean(open && historyPanel); if (historyTab) { historyTab.classList.toggle('is-active', historyOpen); } - renderOpenHistory(); + + if (!historyOpen) { + historyBrowser.style.display = 'none'; + } else if (!preserveBrowser) { + renderOpenHistory(); + } } function setCollapsed(collapsed) { @@ -453,7 +466,7 @@ active = name; } - function renderData(nextPayload, preferredActive) { + function renderData(nextPayload, preferredActive, preserveHistoryBrowser) { payload = nextPayload || {}; data = payload.data || {}; widgets = (payload.meta && payload.meta.widgets) || {}; @@ -518,7 +531,7 @@ activate(preferred[0], preferred[1], preferred[2], preferred[3]); } - setHistoryOpen(historyOpen); + setHistoryOpen(historyOpen, preserveHistoryBrowser); } row.appendChild(tabs); From ddca6647d60c60c5d3e236d68f3e8f774ebd2a6a Mon Sep 17 00:00:00 2001 From: Gabriele Propersi Date: Tue, 8 Sep 2026 14:21:06 +0200 Subject: [PATCH 04/12] Address request history review feedback Harden session storage, request handling, and asynchronous history controls while preserving legacy entries and full test coverage. Assisted-by: Codex --- .github/workflows/main.yml | 3 + CHANGELOG.md | 5 +- docs/index.md | 23 +- resources/assets/debugbar.js | 141 +++++- ...erController.php => HistoryController.php} | 91 ++-- src/DebugBar/History/FilesystemHistory.php | 284 ++++++++--- .../History/HistoryFileOperations.php | 32 ++ .../History/NativeHistoryFileOperations.php | 50 ++ src/DebugBar/History/RequestMetadata.php | 6 +- src/DebugBar/Provider.php | 27 +- src/DebugBar/ResponseListener.php | 23 +- tests/JavaScript/debugbar.test.js | 449 ++++++++++++++++++ tests/JavaScript/support/dom.js | 162 +++++++ .../Controllers/HistoryControllerTest.php | 268 +++++++++++ .../Controllers/OpenHandlerControllerTest.php | 121 ----- .../History/FilesystemHistoryTest.php | 386 ++++++++++++++- tests/Unit/DebugBar/ProviderTest.php | 55 ++- tests/Unit/DebugBar/ResponseListenerTest.php | 127 +++++ .../DebugBar/History/FailingStreamWrapper.php | 69 +++ ...eCollectionMarkerFailingFileOperations.php | 54 +++ ...adataWriteFailingHistoryFileOperations.php | 60 +++ ...ayloadMoveFailingHistoryFileOperations.php | 54 +++ ...yloadReadTrackingHistoryFileOperations.php | 60 +++ .../RenameFailingHistoryFileOperations.php | 50 ++ 24 files changed, 2334 insertions(+), 266 deletions(-) rename src/DebugBar/Controllers/{OpenHandlerController.php => HistoryController.php} (58%) create mode 100644 src/DebugBar/History/HistoryFileOperations.php create mode 100644 src/DebugBar/History/NativeHistoryFileOperations.php create mode 100644 tests/JavaScript/debugbar.test.js create mode 100644 tests/JavaScript/support/dom.js create mode 100644 tests/Unit/DebugBar/Controllers/HistoryControllerTest.php delete mode 100644 tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php create mode 100644 tests/support/DebugBar/History/FailingStreamWrapper.php create mode 100644 tests/support/DebugBar/History/GarbageCollectionMarkerFailingFileOperations.php create mode 100644 tests/support/DebugBar/History/MetadataWriteFailingHistoryFileOperations.php create mode 100644 tests/support/DebugBar/History/PayloadMoveFailingHistoryFileOperations.php create mode 100644 tests/support/DebugBar/History/PayloadReadTrackingHistoryFileOperations.php create mode 100644 tests/support/DebugBar/History/RenameFailingHistoryFileOperations.php diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8bf471a..3de0da7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,6 +56,9 @@ jobs: - name: "PHP CS Fixer (dry-run)" run: composer cs-fixer + - name: "JavaScript tests" + run: node --test tests/JavaScript/debugbar.test.js + tests: name: "Tests (PHP ${{ matrix.php }}, Phalcon ${{ matrix.variant }})" permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cb7e5..5f91072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - Optional, session-isolated request history with filesystem retention, an internal `GET/DELETE /_debugbar/open` controller, and a collapsible request browser with refresh and clear controls that swaps the bar payload without - leaving the current page. + leaving the current page. Retention cleanup covers abandoned session + directories without delaying history reads, and distinguishes request-start + and persistence timestamps. Metadata sidecars keep request listings independent + of collector payload size while preserving legacy stored entries. ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index b14eba1..55f612b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,10 +74,10 @@ The second argument to `Provider` is a nested array. Every key is optional. | `env.var` | `string` | `APP_ENV` | Environment variable inspected by the gate. | | `headers` | `bool` | `true` | Emit the `X-Debug-Bar` diagnostic header. | | `history.enabled` | `bool` | `false` | Store and browse recent requests for the active session. | -| `history.url` | `string` | `/_debugbar/open` | Internal GET endpoint registered by the provider. | +| `history.url` | `string` | `/_debugbar/open` | Internal GET/DELETE endpoint registered by the provider. | | `history.path` | `string` | system temporary path | Storage directory; keep it outside the document root. | | `history.max_requests` | `int` | `100` | Maximum stored requests per session. | -| `history.ttl_seconds` | `int` | `86400` | Lifetime of stored requests in seconds. | +| `history.ttl_seconds` | `int` | `86400` | Lifetime in seconds; active-session entries are checked immediately. | | `redact.hidden` | `list` | `[]` | Keys dropped from the output entirely. | | `redact.mask` | `list` | `[]` | Extra keys whose values are masked (added to the defaults). | @@ -101,13 +101,24 @@ use Phalcon\DebugBar\Provider; ``` When history is enabled, the provider registers `GET /_debugbar/open`, -`DELETE /_debugbar/open`, and their internal controller automatically. A GET +`DELETE /_debugbar/open`, and its internal history controller automatically. A GET without an `id` returns the recent request metadata; `?id=` returns a stored payload. DELETE clears the active session's stored requests. The `History` item in the bottom bar opens the browser above it; its controls refresh -or clear the list, and selecting an item replaces the collectors shown below. -Storage is isolated by a SHA-256 hash of the active PHP session id. With no -active session, no request is written or exposed. +or clear the list, and selecting an item replaces the collectors shown below. An +empty history displays `No stored requests` and leaves the clear control disabled. + +Storage is isolated by a SHA-256 hash of the active PHP session id. Each stored +entry distinguishes the request start time (`requested_at`) from the time it was +persisted (`stored_at`). If the server does not expose `REQUEST_TIME_FLOAT`, the +persistence time is used for both values. Each payload has a small metadata sidecar, +so listing requests does not read the full collector payload. Files created by an +earlier version without a sidecar remain readable through a legacy fallback. Reads +clean expired entries only from the active session so opening the browser remains +fast on network filesystems. A rate-limited collection during request storage +removes expired entries, abandoned temporary files, and empty directories from all +sessions at most once per hour (or once per configured TTL when it is shorter). With +no active session, no request is written or exposed. ## Collectors diff --git a/resources/assets/debugbar.js b/resources/assets/debugbar.js index 35c8ffc..cb03ef1 100644 --- a/resources/assets/debugbar.js +++ b/resources/assets/debugbar.js @@ -236,7 +236,56 @@ return requestJson(url, {}); } - function renderHistoryBrowser(mount, panel, selectedId, onSelect, onClear) { + function createHistoryRequestGuard(isCurrent) { + var clearInProgress = false; + var listGeneration = 0; + var detailGeneration = 0; + + return { + startList: function () { + if (clearInProgress) { + return null; + } + + detailGeneration++; + return ++listGeneration; + }, + startDetail: function () { + if (clearInProgress) { + return null; + } + + return ++detailGeneration; + }, + startClear: function () { + if (clearInProgress) { + return null; + } + + clearInProgress = true; + detailGeneration++; + + return ++listGeneration; + }, + finishClear: function (generation) { + if (!clearInProgress || !isCurrent() || generation !== listGeneration) { + return false; + } + + clearInProgress = false; + + return true; + }, + isListCurrent: function (generation) { + return isCurrent() && generation === listGeneration; + }, + isDetailCurrent: function (generation) { + return isCurrent() && generation === detailGeneration; + } + }; + } + + function renderHistoryBrowser(mount, panel, selectedId, onSelect, onClear, isCurrent) { mount.innerHTML = ''; var url = panel && typeof panel.url === 'string' ? panel.url : ''; @@ -252,8 +301,10 @@ var refreshButton = el('button', 'phalcon-debugbar-history-action', 'Refresh'); var clearButton = el('button', 'phalcon-debugbar-history-action is-danger', 'Clear'); var content = el('div', 'phalcon-debugbar-history-content'); + var requestGuard = createHistoryRequestGuard(isCurrent); refreshButton.type = 'button'; clearButton.type = 'button'; + clearButton.disabled = true; actions.appendChild(refreshButton); actions.appendChild(clearButton); toolbar.appendChild(title); @@ -267,10 +318,19 @@ } function refresh() { + var generation = requestGuard.startList(); + if (generation === null) { + return; + } + refreshButton.disabled = true; message('phalcon-debugbar-history-loading', 'Loading request history...'); loadJson(url).then(function (result) { + if (!requestGuard.isListCurrent(generation)) { + return; + } + content.innerHTML = ''; var requests = result && Array.isArray(result.requests) ? result.requests : []; clearButton.disabled = !requests.length; @@ -299,8 +359,18 @@ button.appendChild(el('time', 'phalcon-debugbar-history-time', scalar(request.requested_at))); button.addEventListener('click', function () { + var selection = requestGuard.startDetail(); + if (selection === null) { + return; + } + button.disabled = true; loadJson(historyUrl(url, id)).then(function (detail) { + if (!requestGuard.isDetailCurrent(selection)) { + button.disabled = false; + return; + } + if (detail && detail.request && detail.request.payload) { Array.prototype.forEach.call( list.querySelectorAll('.phalcon-debugbar-history-request'), @@ -313,7 +383,9 @@ } button.disabled = false; }).catch(function () { - button.disabled = false; + if (requestGuard.isDetailCurrent(selection)) { + button.disabled = false; + } }); }); @@ -321,9 +393,13 @@ }); content.appendChild(list); }).catch(function () { - message('phalcon-debugbar-history-error', 'Unable to load request history'); + if (requestGuard.isListCurrent(generation)) { + message('phalcon-debugbar-history-error', 'Unable to load request history'); + } }).then(function () { - refreshButton.disabled = false; + if (requestGuard.isListCurrent(generation)) { + refreshButton.disabled = false; + } }); } @@ -333,13 +409,27 @@ return; } + var generation = requestGuard.startClear(); + if (generation === null) { + return; + } + + refreshButton.disabled = true; clearButton.disabled = true; + message('phalcon-debugbar-history-loading', 'Clearing request history...'); requestJson(url, {method: 'DELETE'}).then(function () { + if (!requestGuard.finishClear(generation)) { + return; + } + onClear(); refresh(); }).catch(function () { - clearButton.disabled = false; - message('phalcon-debugbar-history-error', 'Unable to clear request history'); + if (requestGuard.finishClear(generation)) { + refreshButton.disabled = false; + clearButton.disabled = false; + message('phalcon-debugbar-history-error', 'Unable to clear request history'); + } }); }); @@ -362,6 +452,17 @@ } } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + createHistoryRequestGuard: createHistoryRequestGuard, + renderHistoryBrowser: renderHistoryBrowser + }; + } + + if (typeof document === 'undefined') { + return; + } + ready(function () { var dataNode = document.getElementById('phalcon-debugbar-data'); var mount = document.getElementById('phalcon-debugbar'); @@ -393,6 +494,7 @@ var selectedHistoryId = ''; var historyOpen = false; var historyPanel = null; + var historyRenderGeneration = 0; var historyTab = null; function closePanel() { @@ -409,6 +511,7 @@ return; } + var generation = ++historyRenderGeneration; renderHistoryBrowser( historyBrowser, historyPanel, @@ -420,6 +523,9 @@ }, function () { selectedHistoryId = ''; + }, + function () { + return historyOpen && generation === historyRenderGeneration; } ); } @@ -431,6 +537,7 @@ } if (!historyOpen) { + historyRenderGeneration++; historyBrowser.style.display = 'none'; } else if (!preserveBrowser) { renderOpenHistory(); @@ -477,11 +584,16 @@ var preferred = null; Object.keys(data).forEach(function (name) { - if (name === 'history') { - return; - } var entry = data[name] || {}; var widget = widgets[name] || {}; + if ( + name === 'history' + && widget.panel === 'history' + && entry.panel + && typeof entry.panel.url === 'string' + ) { + return; + } var label = widget.label || titleize(name); var type = widget.panel || inferType(entry.panel); @@ -508,10 +620,15 @@ }); var historyEntry = data.history || {}; - historyPanel = historyEntry.panel || null; + var historyWidget = widgets.history || {}; + historyPanel = null; historyTab = null; - if (historyPanel && typeof historyPanel.url === 'string') { - var historyWidget = widgets.history || {}; + if ( + historyWidget.panel === 'history' + && historyEntry.panel + && typeof historyEntry.panel.url === 'string' + ) { + historyPanel = historyEntry.panel; historyTab = el('button', 'phalcon-debugbar-tab'); historyTab.type = 'button'; historyTab.appendChild(el( diff --git a/src/DebugBar/Controllers/OpenHandlerController.php b/src/DebugBar/Controllers/HistoryController.php similarity index 58% rename from src/DebugBar/Controllers/OpenHandlerController.php rename to src/DebugBar/Controllers/HistoryController.php index 5cbdc7e..8f24e2f 100644 --- a/src/DebugBar/Controllers/OpenHandlerController.php +++ b/src/DebugBar/Controllers/HistoryController.php @@ -31,56 +31,59 @@ * Internal MVC adapter for /_debugbar/open. GET returns the current session's * request list or one stored entry; DELETE clears that session's history. */ -final class OpenHandlerController extends Controller +final class HistoryController extends Controller { /** * @return ResponseInterface */ public function clearAction(): ResponseInterface { - $container = $this->getDI(); - if (null === $container) { - throw new RuntimeException('The OpenHandler controller requires a DI container.'); - } - - $request = $container->getShared('request'); - $response = $container->getShared('response'); - $history = $container->getShared(Provider::HISTORY_SERVICE); - $access = $container->getShared(Provider::ACCESS_GATE_SERVICE); - - if (!$response instanceof ResponseInterface) { - throw new RuntimeException('The response service must implement ResponseInterface.'); - } - - if ( - !$request instanceof RequestInterface - || !$history instanceof FilesystemHistory - || !$access instanceof AccessGate - ) { - return $this->json($response, ['error' => 'History is unavailable.'], 500); - } - - $clientIp = $request->getClientAddress(); - if (!$access->allows(is_string($clientIp) ? $clientIp : null)) { - return $this->json($response, ['error' => 'Not found.'], 404); - } - - if ('DELETE' !== $request->getMethod()) { - return $this->json($response, ['error' => 'Method not allowed.'], 405); - } - - return $this->json($response, ['cleared' => $history->clear()]); + return $this->handle( + 'DELETE', + fn ( + RequestInterface $request, + ResponseInterface $response, + FilesystemHistory $history + ): ResponseInterface => $this->json($response, ['cleared' => $history->clear()]) + ); } + /** * @return ResponseInterface */ public function indexAction(): ResponseInterface { - $container = $this->getDI(); - if (null === $container) { - throw new RuntimeException('The OpenHandler controller requires a DI container.'); - } + return $this->handle( + 'GET', + function ( + RequestInterface $request, + ResponseInterface $response, + FilesystemHistory $history + ): ResponseInterface { + $id = $request->getQuery('id'); + if (null === $id) { + return $this->json($response, ['requests' => $history->find()]); + } + if (!is_string($id)) { + return $this->json($response, ['error' => 'Request not found.'], 404); + } + + $entry = $history->get($id); + if (null === $entry) { + return $this->json($response, ['error' => 'Request not found.'], 404); + } + + return $this->json($response, ['request' => $entry]); + } + ); + } + /** + * @param callable(RequestInterface, ResponseInterface, FilesystemHistory): ResponseInterface $action + */ + private function handle(string $expectedMethod, callable $action): ResponseInterface + { + $container = $this->getDI() ?? throw new RuntimeException('The History controller requires a DI container.'); $request = $container->getShared('request'); $response = $container->getShared('response'); $history = $container->getShared(Provider::HISTORY_SERVICE); @@ -103,21 +106,11 @@ public function indexAction(): ResponseInterface return $this->json($response, ['error' => 'Not found.'], 404); } - if ('GET' !== $request->getMethod()) { + if ($expectedMethod !== $request->getMethod()) { return $this->json($response, ['error' => 'Method not allowed.'], 405); } - $id = $request->getQuery('id'); - if (!is_string($id) || '' === $id) { - return $this->json($response, ['requests' => $history->find()]); - } - - $entry = $history->get($id); - if (null === $entry) { - return $this->json($response, ['error' => 'Request not found.'], 404); - } - - return $this->json($response, ['request' => $entry]); + return $action($request, $response, $history); } /** diff --git a/src/DebugBar/History/FilesystemHistory.php b/src/DebugBar/History/FilesystemHistory.php index 177fd55..2d63e8a 100644 --- a/src/DebugBar/History/FilesystemHistory.php +++ b/src/DebugBar/History/FilesystemHistory.php @@ -19,30 +19,27 @@ use function array_slice; use function basename; use function bin2hex; -use function file_get_contents; -use function file_put_contents; use function glob; use function hash; use function is_array; use function is_dir; use function is_file; -use function is_string; use function json_decode; use function json_encode; +use function min; use function mkdir; use function preg_match; use function random_bytes; -use function rename; use function rsort; use function session_id; use function session_status; +use function str_ends_with; use function time; -use function unlink; +use const GLOB_ONLYDIR; use const JSON_PRETTY_PRINT; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; -use const LOCK_EX; use const PHP_SESSION_ACTIVE; /** @@ -64,11 +61,21 @@ */ final class FilesystemHistory { + private const GARBAGE_COLLECTION_MARKER = '.gc'; + private const GARBAGE_COLLECTION_MAX_INTERVAL_SECONDS = 3600; + private const METADATA_SUFFIX = '.meta'; + + private readonly HistoryFileOperations $fileOperations; + private bool $garbageCollectionAttempted = false; + /** * @param HistoryOptions $options */ - public function __construct(private readonly HistoryOptions $options) - { + public function __construct( + private readonly HistoryOptions $options, + ?HistoryFileOperations $fileOperations = null + ) { + $this->fileOperations = $fileOperations ?? new NativeHistoryFileOperations(); } /** @@ -78,6 +85,10 @@ public function __construct(private readonly HistoryOptions $options) */ public function clear(): int { + if (PHP_SESSION_ACTIVE !== session_status()) { + return 0; + } + $directory = $this->sessionDirectory(false); if (null === $directory) { return 0; @@ -85,10 +96,18 @@ public function clear(): int $removed = 0; foreach ($this->files($directory) as $file) { - if (@unlink($file)) { + if ($this->fileOperations->remove($file)) { $removed++; } + $this->fileOperations->remove($this->metadataFile($file)); } + foreach ($this->metadataFiles($directory) as $file) { + $this->fileOperations->remove($file); + } + foreach ($this->temporaryFiles($directory) as $file) { + $this->fileOperations->remove($file); + } + $this->removeDirectoryIfEmpty($directory); return $removed; } @@ -98,20 +117,23 @@ public function clear(): int */ public function find(): array { + if (PHP_SESSION_ACTIVE !== session_status()) { + return []; + } + $directory = $this->sessionDirectory(false); if (null === $directory) { return []; } - - $files = $this->files($directory); - $this->removeExpired($files); - $files = array_slice($this->files($directory), 0, $this->options->maxRequests); + $files = $this->removeExpired($this->files($directory)); + rsort($files, SORT_STRING); + $files = array_slice($files, 0, $this->options->maxRequests); $requests = []; foreach ($files as $file) { - $entry = $this->read($file); - if (null !== $entry) { - $requests[] = $entry['meta']; + $metadata = $this->readMetadata($file); + if (null !== $metadata) { + $requests[] = $metadata; } } @@ -129,12 +151,23 @@ public function get(string $id): ?array return null; } + if (PHP_SESSION_ACTIVE !== session_status()) { + return null; + } + $directory = $this->sessionDirectory(false); if (null === $directory) { return null; } + $file = $directory . '/' . basename($id) . '.json'; + if ($this->isExpired($file)) { + $this->removeEntry($file); + $this->removeDirectoryIfEmpty($directory); - return $this->read($directory . '/' . basename($id) . '.json'); + return null; + } + + return $this->read($file); } /** @@ -145,45 +178,50 @@ public function get(string $id): ?array */ public function save(array $payload, RequestMetadata $request): ?string { + if (PHP_SESSION_ACTIVE !== session_status()) { + return null; + } + $directory = $this->sessionDirectory(true); if (null === $directory) { return null; } - $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); - $id = $now->format('YmdHis-u-') . bin2hex(random_bytes(4)); + $storedAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $requestedAt = $request->requestedAt ?? $storedAt; + $id = $storedAt->format('YmdHis-u-') . bin2hex(random_bytes(4)); $entry = [ 'meta' => [ - 'requested_at' => $now->format(DATE_ATOM), + 'requested_at' => $requestedAt->format(DATE_ATOM), 'method' => $request->method, 'uri' => $request->uri, 'status' => $request->status, 'ajax' => $request->ajax, 'id' => $id, - 'stored_at' => $now->format(DATE_ATOM), + 'stored_at' => $storedAt->format(DATE_ATOM), ], 'payload' => $payload, ]; - $json = json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (false === $json) { - return null; - } + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + $json = json_encode($entry, $flags); + $metadataJson = json_encode($entry['meta'], $flags); + if (false === $json || false === $metadataJson) { + $this->removeDirectoryIfEmpty($directory); - $target = $directory . '/' . $id . '.json'; - $temporary = $target . '.tmp-' . bin2hex(random_bytes(4)); - if (false === file_put_contents($temporary, $json, LOCK_EX)) { return null; } - if (!rename($temporary, $target)) { - @unlink($temporary); + $target = $directory . '/' . $id . '.json'; + if (!$this->saveFiles($target, $json, $metadataJson)) { + $this->removeDirectoryIfEmpty($directory); return null; } $this->prune($directory); + $this->garbageCollect(); return $id; } @@ -195,14 +233,68 @@ public function save(array $payload, RequestMetadata $request): ?string */ private function files(string $directory): array { - $files = glob($directory . '/*.json'); - if (false === $files) { - return []; + return @glob($directory . '/*.json') ?: []; + } + + private function garbageCollect(): void + { + if ($this->garbageCollectionAttempted) { + return; } - rsort($files, SORT_STRING); + $this->garbageCollectionAttempted = true; + $marker = $this->options->path . '/' . self::GARBAGE_COLLECTION_MARKER; + $modified = @filemtime($marker); + $interval = min( + $this->options->ttlSeconds, + self::GARBAGE_COLLECTION_MAX_INTERVAL_SECONDS + ); + if (false !== $modified && $modified >= time() - $interval) { + return; + } + + if (!$this->fileOperations->write($marker, (string) time())) { + return; + } - return $files; + foreach ($this->sessionDirectories() as $directory) { + $storedFiles = [ + ...$this->files($directory), + ...$this->metadataFiles($directory), + ...$this->temporaryFiles($directory), + ]; + foreach ($storedFiles as $file) { + if ($this->isExpired($file)) { + if (str_ends_with($file, '.json')) { + $this->removeEntry($file); + } else { + $this->fileOperations->remove($file); + } + } + } + + $this->removeDirectoryIfEmpty($directory); + } + } + + private function isExpired(string $file): bool + { + $modified = @filemtime($file); + + return false !== $modified && $modified < time() - $this->options->ttlSeconds; + } + + private function metadataFile(string $file): string + { + return $file . self::METADATA_SUFFIX; + } + + /** + * @return list + */ + private function metadataFiles(string $directory): array + { + return @glob($directory . '/*.json' . self::METADATA_SUFFIX) ?: []; } /** @@ -212,11 +304,10 @@ private function files(string $directory): array */ private function prune(string $directory): void { - $files = $this->files($directory); - $this->removeExpired($files); - - foreach (array_slice($this->files($directory), $this->options->maxRequests) as $file) { - @unlink($file); + $files = $this->removeExpired($this->files($directory)); + rsort($files, SORT_STRING); + foreach (array_slice($files, $this->options->maxRequests) as $file) { + $this->removeEntry($file); } } @@ -231,7 +322,7 @@ private function read(string $file): ?array return null; } - $json = file_get_contents($file); + $json = $this->fileOperations->read($file); if (false === $json) { return null; } @@ -252,21 +343,99 @@ private function read(string $file): ?array ]; } + /** + * @return array|null + */ + private function readMetadata(string $file): ?array + { + $metadataFile = $this->metadataFile($file); + if (is_file($metadataFile)) { + $json = $this->fileOperations->read($metadataFile); + if (false !== $json) { + $metadata = json_decode($json, true); + if (is_array($metadata)) { + /** @var array $metadata */ + return $metadata; + } + } + } + + $entry = $this->read($file); + + return null === $entry ? null : $entry['meta']; + } + + private function removeDirectoryIfEmpty(string $directory): void + { + if ([] === (@glob($directory . '/*') ?: [])) { + $this->fileOperations->removeDirectory($directory); + } + } + + private function removeEntry(string $file): void + { + $this->fileOperations->remove($file); + $this->fileOperations->remove($this->metadataFile($file)); + } + /** * @param list $files * - * @return void + * @return list */ - private function removeExpired(array $files): void + private function removeExpired(array $files): array { - $oldest = time() - $this->options->ttlSeconds; - + $remaining = []; foreach ($files as $file) { - $modified = @filemtime($file); - if (false !== $modified && $modified < $oldest) { - @unlink($file); + if ($this->isExpired($file)) { + $this->removeEntry($file); + continue; } + + $remaining[] = $file; + } + + return $remaining; + } + + private function saveFiles(string $target, string $json, string $metadataJson): bool + { + $metadataTarget = $this->metadataFile($target); + $metadataTemporary = $metadataTarget . '.tmp-' . bin2hex(random_bytes(4)); + $temporary = $target . '.tmp-' . bin2hex(random_bytes(4)); + if (!$this->fileOperations->write($temporary, $json)) { + $this->fileOperations->remove($temporary); + + return false; + } + if (!$this->fileOperations->write($metadataTemporary, $metadataJson)) { + $this->fileOperations->remove($metadataTemporary); + $this->fileOperations->remove($temporary); + + return false; + } + if (!$this->fileOperations->move($metadataTemporary, $metadataTarget)) { + $this->fileOperations->remove($metadataTemporary); + $this->fileOperations->remove($temporary); + + return false; + } + if (!$this->fileOperations->move($temporary, $target)) { + $this->fileOperations->remove($temporary); + $this->fileOperations->remove($metadataTarget); + + return false; } + + return true; + } + + /** + * @return list + */ + private function sessionDirectories(): array + { + return @glob($this->options->path . '/*', GLOB_ONLYDIR) ?: []; } /** @@ -276,16 +445,7 @@ private function removeExpired(array $files): void */ private function sessionDirectory(bool $create): ?string { - if (PHP_SESSION_ACTIVE !== session_status()) { - return null; - } - - $sessionId = session_id(); - if (!is_string($sessionId) || '' === $sessionId) { - return null; - } - - $directory = $this->options->path . '/' . hash('sha256', $sessionId); + $directory = $this->options->path . '/' . hash('sha256', (string) session_id()); if (is_dir($directory)) { return $directory; } @@ -296,4 +456,12 @@ private function sessionDirectory(bool $create): ?string return $directory; } + + /** + * @return list + */ + private function temporaryFiles(string $directory): array + { + return @glob($directory . '/*.tmp-*') ?: []; + } } diff --git a/src/DebugBar/History/HistoryFileOperations.php b/src/DebugBar/History/HistoryFileOperations.php new file mode 100644 index 0000000..efeaaca --- /dev/null +++ b/src/DebugBar/History/HistoryFileOperations.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +/** + * Internal filesystem access seam used by request-history storage. + * + * @internal + */ +interface HistoryFileOperations +{ + public function move(string $source, string $target): bool; + + public function read(string $file): false | string; + + public function remove(string $file): bool; + + public function removeDirectory(string $directory): bool; + + public function write(string $file, string $contents): bool; +} diff --git a/src/DebugBar/History/NativeHistoryFileOperations.php b/src/DebugBar/History/NativeHistoryFileOperations.php new file mode 100644 index 0000000..de0ea97 --- /dev/null +++ b/src/DebugBar/History/NativeHistoryFileOperations.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +use function file_get_contents; +use function file_put_contents; +use function rename; +use function rmdir; +use function unlink; + +use const LOCK_EX; + +final class NativeHistoryFileOperations implements HistoryFileOperations +{ + public function move(string $source, string $target): bool + { + return @rename($source, $target); + } + + public function read(string $file): false | string + { + return @file_get_contents($file); + } + + public function remove(string $file): bool + { + return @unlink($file); + } + + public function removeDirectory(string $directory): bool + { + return @rmdir($directory); + } + + public function write(string $file, string $contents): bool + { + return false !== @file_put_contents($file, $contents, LOCK_EX); + } +} diff --git a/src/DebugBar/History/RequestMetadata.php b/src/DebugBar/History/RequestMetadata.php index 728e0d9..bae82ec 100644 --- a/src/DebugBar/History/RequestMetadata.php +++ b/src/DebugBar/History/RequestMetadata.php @@ -13,6 +13,8 @@ namespace Phalcon\DebugBar\History; +use DateTimeImmutable; + /** * The small request snapshot stored next to a collected debug-bar payload. */ @@ -23,12 +25,14 @@ final class RequestMetadata * @param string $uri * @param int $status * @param bool $ajax + * @param DateTimeImmutable|null $requestedAt */ public function __construct( public readonly string $method, public readonly string $uri, public readonly int $status, - public readonly bool $ajax + public readonly bool $ajax, + public readonly ?DateTimeImmutable $requestedAt = null ) { } } diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index 7adce90..fcb4949 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -37,6 +37,7 @@ use Phalcon\DebugBar\Security\Redactor; use Phalcon\Di\DiInterface; use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; use Phalcon\Mvc\Application; use Phalcon\Mvc\RouterInterface; @@ -55,7 +56,7 @@ */ class Provider { - public const ACCESS_GATE_SERVICE = 'debugbar.accessGate'; + public const ACCESS_GATE_SERVICE = 'debugbar.access_gate'; public const HISTORY_SERVICE = 'debugbar.history'; /** @@ -152,7 +153,7 @@ public function boot(): void $container = $this->app->getDI(); $request = $this->resolveRequest($container); $accessGate = new AccessGate($this->allowedIps, $this->accessCallback); - $history = $this->registerHistory($container, $accessGate); + $history = $this->registerHistory($container, $accessGate, $request); $bar = new DebugBar(); foreach ($this->buildCollectors($container, $request) as $collector) { @@ -272,18 +273,24 @@ private function isCollectorEnabled(string $name): bool * Registers the internal history module and its MVC route. History stays * disabled when the app has no compatible container/router. */ - private function registerHistory(?DiInterface $container, AccessGate $accessGate): ?FilesystemHistory - { + private function registerHistory( + ?DiInterface $container, + AccessGate $accessGate, + ?RequestInterface $request + ): ?FilesystemHistory { if ( !$this->historyOptions->enabled || null === $container + || null === $request || !$container->has('router') + || !$container->has('response') ) { return null; } - $router = $container->getShared('router'); - if (!$router instanceof RouterInterface) { + $router = $container->getShared('router'); + $response = $container->getShared('response'); + if (!$router instanceof RouterInterface || !$response instanceof ResponseInterface) { return null; } @@ -295,19 +302,19 @@ private function registerHistory(?DiInterface $container, AccessGate $accessGate $this->historyOptions->url, [ 'namespace' => 'Phalcon\\DebugBar\\Controllers', - 'controller' => 'openHandler', + 'controller' => 'history', 'action' => 'index', ] - )->setName('debugbar.openhandler'); + )->setName('debugbar.history.index'); $router->addDelete( $this->historyOptions->url, [ 'namespace' => 'Phalcon\\DebugBar\\Controllers', - 'controller' => 'openHandler', + 'controller' => 'history', 'action' => 'clear', ] - )->setName('debugbar.clearhistory'); + )->setName('debugbar.history.clear'); return $history; } diff --git a/src/DebugBar/ResponseListener.php b/src/DebugBar/ResponseListener.php index 9636e76..e097fb8 100644 --- a/src/DebugBar/ResponseListener.php +++ b/src/DebugBar/ResponseListener.php @@ -13,6 +13,8 @@ namespace Phalcon\DebugBar; +use DateTimeImmutable; +use DateTimeZone; use Phalcon\DebugBar\History\FilesystemHistory; use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\DebugBar\History\RequestMetadata; @@ -22,8 +24,10 @@ use Phalcon\Http\ResponseInterface; use function count; +use function is_float; use function is_string; use function parse_url; +use function sprintf; use const PHP_URL_PATH; @@ -102,7 +106,8 @@ private function record(array $collected, ResponseInterface $response, bool $isA $this->request->getMethod(), $uri, $response->getStatusCode() ?? 200, - $isAjax + $isAjax, + $this->requestedAt() ) ); } @@ -120,4 +125,20 @@ private function requestContext(): array return [is_string($clientIp) ? $clientIp : null, $this->request->isAjax()]; } + + private function requestedAt(): ?DateTimeImmutable + { + $timestamp = $_SERVER['REQUEST_TIME_FLOAT'] ?? null; + if (!is_float($timestamp)) { + return null; + } + + $requestedAt = DateTimeImmutable::createFromFormat( + 'U.u', + sprintf('%.6F', $timestamp), + new DateTimeZone('UTC') + ); + + return false === $requestedAt ? null : $requestedAt; + } } diff --git a/tests/JavaScript/debugbar.test.js b/tests/JavaScript/debugbar.test.js new file mode 100644 index 0000000..c846077 --- /dev/null +++ b/tests/JavaScript/debugbar.test.js @@ -0,0 +1,449 @@ +/** + * This file is part of the Phalcon Framework. + * + * (c) Phalcon Team + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +'use strict'; + +var test = require('node:test'); +var assert = require('node:assert/strict'); +var debugbarPath = require.resolve('../../resources/assets/debugbar.js'); +var debugbar = require(debugbarPath); +var dom = require('./support/dom.js'); +var createHistoryRequestGuard = debugbar.createHistoryRequestGuard; +var renderHistoryBrowser = debugbar.renderHistoryBrowser; + +test.afterEach(function () { + delete require.cache[debugbarPath]; + delete global.document; + delete global.window; +}); + +function response(body) { + return { + ok: true, + json: function () { + return Promise.resolve(body); + } + }; +} + +function errorResponse(status) { + return {ok: false, status: status}; +} + +function deferred() { + var reject; + var resolve; + var promise = new Promise(function (promiseResolve, promiseReject) { + reject = promiseReject; + resolve = promiseResolve; + }); + + return {promise: promise, reject: reject, resolve: resolve}; +} + +test('history request guard keeps only the latest list request', function () { + var guard = createHistoryRequestGuard(function () { + return true; + }); + var first = guard.startList(); + var second = guard.startList(); + + assert.equal(guard.isListCurrent(first), false); + assert.equal(guard.isListCurrent(second), true); +}); + +test('history request guard keeps only the latest detail request', function () { + var guard = createHistoryRequestGuard(function () { + return true; + }); + var first = guard.startDetail(); + var second = guard.startDetail(); + + assert.equal(guard.isDetailCurrent(first), false); + assert.equal(guard.isDetailCurrent(second), true); +}); + +test('refresh invalidates a pending detail request', function () { + var guard = createHistoryRequestGuard(function () { + return true; + }); + var detail = guard.startDetail(); + + guard.startList(); + + assert.equal(guard.isDetailCurrent(detail), false); +}); + +test('closing history invalidates every pending request', function () { + var open = true; + var guard = createHistoryRequestGuard(function () { + return open; + }); + var list = guard.startList(); + var detail = guard.startDetail(); + + open = false; + + assert.equal(guard.isListCurrent(list), false); + assert.equal(guard.isDetailCurrent(detail), false); +}); + +test('clear blocks list requests until it completes', function () { + var guard = createHistoryRequestGuard(function () { + return true; + }); + var list = guard.startList(); + var detail = guard.startDetail(); + var clear = guard.startClear(); + + assert.equal(guard.isListCurrent(list), false); + assert.equal(guard.isDetailCurrent(detail), false); + assert.equal(guard.startList(), null); + assert.equal(guard.startDetail(), null); + assert.equal(guard.startClear(), null); + assert.equal(guard.finishClear(clear), true); + assert.equal(guard.finishClear(clear), false); + assert.equal(typeof guard.startList(), 'number'); +}); + +test('empty request history renders its empty state', async function () { + global.document = dom.createDocument(); + global.window = { + fetch: function () { + return Promise.resolve(response({requests: []})); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-empty').textContent, 'No stored requests'); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-action').disabled, false); + assert.equal(dom.findByClass(mount, 'is-danger').disabled, true); +}); + +test('request history renders request metadata and selection', async function () { + global.document = dom.createDocument(); + global.window = { + fetch: function () { + return Promise.resolve(response({ + requests: [{ + id: 'request-one', + method: 'POST', + uri: '/orders', + status: 201, + requested_at: '2026-09-08T08:30:00+00:00' + }] + })); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser( + mount, + {url: '/_debugbar/open'}, + 'request-one', + function () {}, + function () {}, + function () { + return true; + } + ); + await dom.flushPromises(); + + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-method').textContent, 'POST'); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-uri').textContent, '/orders'); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-status').textContent, '201'); + assert.equal( + dom.findByClass(mount, 'phalcon-debugbar-history-time').textContent, + '2026-09-08T08:30:00+00:00' + ); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-request').classList.contains('is-selected'), true); + assert.equal(dom.findByClass(mount, 'is-danger').disabled, false); +}); + +test('selecting a request loads and exposes its stored payload', async function () { + var calls = []; + var selected = null; + global.document = dom.createDocument(); + global.window = { + fetch: function (url) { + calls.push(url); + if (calls.length === 1) { + return Promise.resolve(response({ + requests: [{id: 'request/one', method: 'GET', uri: '/orders', status: 200}] + })); + } + + return Promise.resolve(response({ + request: {payload: {data: {route: {panel: '/orders/42'}}}} + })); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function (payload, id) { + selected = {payload: payload, id: id}; + }, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); + request.click(); + await dom.flushPromises(); + + assert.equal(calls[1], '/_debugbar/open?id=request%2Fone'); + assert.deepEqual(selected, { + payload: {data: {route: {panel: '/orders/42'}}}, + id: 'request/one' + }); + assert.equal(request.classList.contains('is-selected'), true); + assert.equal(request.disabled, false); +}); + +test('refresh replaces the rendered request list', async function () { + var responses = [ + {requests: [{id: 'first', method: 'GET', uri: '/first', status: 200}]}, + {requests: [{id: 'second', method: 'GET', uri: '/second', status: 200}]} + ]; + var calls = 0; + global.document = dom.createDocument(); + global.window = { + fetch: function () { + return Promise.resolve(response(responses[calls++])); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + dom.findByClass(mount, 'phalcon-debugbar-history-action').click(); + await dom.flushPromises(); + + assert.equal(calls, 2); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-uri').textContent, '/second'); +}); + +test('clear blocks interaction and refreshes after deletion', async function () { + var deletion = deferred(); + var calls = []; + var cleared = 0; + global.document = dom.createDocument(); + global.window = { + confirm: function () { + return true; + }, + fetch: function (url, options) { + calls.push({url: url, options: options}); + if (calls.length === 1) { + return Promise.resolve(response({ + requests: [{id: 'first', method: 'GET', uri: '/first', status: 200}] + })); + } + if (calls.length === 2) { + return deletion.promise; + } + + return Promise.resolve(response({requests: []})); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () { + cleared++; + }, function () { + return true; + }); + await dom.flushPromises(); + + var refresh = dom.findByClass(mount, 'phalcon-debugbar-history-action'); + var clear = dom.findByClass(mount, 'is-danger'); + clear.click(); + + assert.equal(calls.length, 2); + assert.equal(calls[1].options.method, 'DELETE'); + assert.equal(refresh.disabled, true); + assert.equal(clear.disabled, true); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-loading').textContent, 'Clearing request history...'); + refresh.click(); + assert.equal(calls.length, 2); + + deletion.resolve(response({cleared: 1})); + await dom.flushPromises(); + + assert.equal(calls.length, 3); + assert.equal(cleared, 1); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-empty').textContent, 'No stored requests'); +}); + +test('request history reports list failures and restores refresh', async function () { + global.document = dom.createDocument(); + global.window = { + fetch: function () { + return Promise.resolve(errorResponse(503)); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + assert.equal( + dom.findByClass(mount, 'phalcon-debugbar-history-error').textContent, + 'Unable to load request history' + ); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-action').disabled, false); +}); + +test('request history restores a row after a detail failure', async function () { + var calls = 0; + var selected = false; + global.document = dom.createDocument(); + global.window = { + fetch: function () { + calls++; + if (calls === 1) { + return Promise.resolve(response({ + requests: [{id: 'first', method: 'GET', uri: '/first', status: 200}] + })); + } + + return Promise.resolve(errorResponse(404)); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () { + selected = true; + }, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); + request.click(); + await dom.flushPromises(); + + assert.equal(selected, false); + assert.equal(request.disabled, false); + assert.equal(request.classList.contains('is-selected'), false); +}); + +test('request history reports clear failures and restores controls', async function () { + var calls = 0; + var cleared = false; + global.document = dom.createDocument(); + global.window = { + confirm: function () { + return true; + }, + fetch: function () { + calls++; + if (calls === 1) { + return Promise.resolve(response({ + requests: [{id: 'first', method: 'GET', uri: '/first', status: 200}] + })); + } + + return Promise.resolve(errorResponse(500)); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, {url: '/_debugbar/open'}, '', function () {}, function () { + cleared = true; + }, function () { + return true; + }); + await dom.flushPromises(); + + var refresh = dom.findByClass(mount, 'phalcon-debugbar-history-action'); + var clear = dom.findByClass(mount, 'is-danger'); + clear.click(); + await dom.flushPromises(); + + assert.equal(cleared, false); + assert.equal(refresh.disabled, false); + assert.equal(clear.disabled, false); + assert.equal( + dom.findByClass(mount, 'phalcon-debugbar-history-error').textContent, + 'Unable to clear request history' + ); +}); + +test('selecting stored data preserves the open history browser', async function () { + var dataNode = new dom.TestElement('script'); + var mount = new dom.TestElement('div'); + var historyPanel = {url: '/_debugbar/open'}; + var widgets = { + history: {label: 'History', panel: 'history'}, + route: {label: 'Route', panel: 'grid'} + }; + dataNode.textContent = JSON.stringify({ + data: { + history: {panel: historyPanel}, + route: {panel: {path: '/current'}} + }, + meta: {widgets: widgets} + }); + global.document = dom.createDocument({ + 'phalcon-debugbar': mount, + 'phalcon-debugbar-data': dataNode + }); + var calls = 0; + global.window = { + fetch: function () { + calls++; + if (calls === 1) { + return Promise.resolve(response({ + requests: [{id: 'stored', method: 'GET', uri: '/stored', status: 200}] + })); + } + + return Promise.resolve(response({ + request: { + payload: { + data: { + history: {panel: historyPanel}, + route: {panel: {path: '/stored'}} + }, + meta: {widgets: widgets} + } + } + })); + } + }; + + require(debugbarPath); + var tabs = mount.querySelectorAll('.phalcon-debugbar-tab'); + var historyTab = tabs.filter(function (tab) { + return tab.textContent === 'History'; + })[0]; + historyTab.click(); + await dom.flushPromises(); + + var historyBrowser = dom.findByClass(mount, 'phalcon-debugbar-history-browser'); + var request = dom.findByClass(historyBrowser, 'phalcon-debugbar-history-request'); + request.click(); + await dom.flushPromises(); + + assert.equal(calls, 2); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-history-browser'), historyBrowser); + assert.equal(historyBrowser.style.display, 'block'); + assert.equal(request.classList.contains('is-selected'), true); +}); diff --git a/tests/JavaScript/support/dom.js b/tests/JavaScript/support/dom.js new file mode 100644 index 0000000..58b3755 --- /dev/null +++ b/tests/JavaScript/support/dom.js @@ -0,0 +1,162 @@ +/** + * This file is part of the Phalcon Framework. + * + * (c) Phalcon Team + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +'use strict'; + +function TestElement(tagName) { + this.tagName = String(tagName).toUpperCase(); + this.children = []; + this.parentNode = null; + this.style = {}; + this.disabled = false; + this.type = ''; + this.attributes = {}; + this._className = ''; + this._listeners = {}; + this._textContent = ''; +} + +Object.defineProperty(TestElement.prototype, 'className', { + get: function () { + return this._className; + }, + set: function (value) { + this._className = String(value || ''); + } +}); + +Object.defineProperty(TestElement.prototype, 'classList', { + get: function () { + var element = this; + + return { + add: function (name) { + var classes = element._classes(); + if (classes.indexOf(name) === -1) { + classes.push(name); + element.className = classes.join(' '); + } + }, + contains: function (name) { + return element._classes().indexOf(name) !== -1; + }, + remove: function (name) { + element.className = element._classes().filter(function (current) { + return current !== name; + }).join(' '); + }, + toggle: function (name, force) { + var enabled = force === undefined ? !this.contains(name) : Boolean(force); + if (enabled) { + this.add(name); + } else { + this.remove(name); + } + + return enabled; + } + }; + } +}); + +Object.defineProperty(TestElement.prototype, 'innerHTML', { + get: function () { + return ''; + }, + set: function () { + this.children = []; + this._textContent = ''; + } +}); + +Object.defineProperty(TestElement.prototype, 'textContent', { + get: function () { + return this._textContent + this.children.map(function (child) { + return child.textContent; + }).join(''); + }, + set: function (value) { + this.children = []; + this._textContent = String(value || ''); + } +}); + +TestElement.prototype._classes = function () { + return this.className.split(/\s+/).filter(Boolean); +}; + +TestElement.prototype.addEventListener = function (name, listener) { + this._listeners[name] = listener; +}; + +TestElement.prototype.appendChild = function (child) { + child.parentNode = this; + this.children.push(child); + + return child; +}; + +TestElement.prototype.click = function () { + if (!this.disabled && this._listeners.click) { + this._listeners.click(); + } +}; + +TestElement.prototype.querySelectorAll = function (selector) { + var className = selector.charAt(0) === '.' ? selector.slice(1) : ''; + var attribute = /^\[([^\]]+)\]$/.exec(selector); + var matches = []; + + this.children.forEach(function (child) { + if ( + (className && child.classList.contains(className)) + || (attribute && Object.prototype.hasOwnProperty.call(child.attributes, attribute[1])) + ) { + matches.push(child); + } + matches = matches.concat(child.querySelectorAll(selector)); + }); + + return matches; +}; + +TestElement.prototype.setAttribute = function (name, value) { + this.attributes[name] = String(value); +}; + +function createDocument(elements) { + elements = elements || {}; + + return { + readyState: 'complete', + createElement: function (tagName) { + return new TestElement(tagName); + }, + getElementById: function (id) { + return elements[id] || null; + } + }; +} + +function findByClass(root, className) { + return root.querySelectorAll('.' + className)[0] || null; +} + +function flushPromises() { + return new Promise(function (resolve) { + setImmediate(resolve); + }); +} + +module.exports = { + TestElement: TestElement, + createDocument: createDocument, + findByClass: findByClass, + flushPromises: flushPromises +}; diff --git a/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php b/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php new file mode 100644 index 0000000..3dcb8c3 --- /dev/null +++ b/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php @@ -0,0 +1,268 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\Controllers; + +use Phalcon\DebugBar\Controllers\HistoryController; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\DebugBar\Provider; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Di\Di; +use Phalcon\Http\Request; +use Phalcon\Http\Response; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use RuntimeException; +use stdClass; +use Throwable; + +use function bin2hex; +use function glob; +use function hash; +use function json_decode; +use function random_bytes; +use function session_id; +use function session_start; +use function session_write_close; +use function sys_get_temp_dir; +use function unlink; + +final class HistoryControllerTest extends AbstractUnitTestCase +{ + public function testActionsHideHistoryWhenAccessIsDenied(): void + { + $_SERVER['REMOTE_ADDR'] = '203.0.113.10'; + $history = new FilesystemHistory(new HistoryOptions()); + + foreach (['indexAction', 'clearAction'] as $action) { + $response = $this->executeWithServices( + $action, + new Request(), + new Response(), + $history, + new AccessGate(['127.0.0.1'], null) + ); + + $this->assertJsonResponse($response, 404, ['error' => 'Not found.']); + } + } + + public function testActionsRejectUnsupportedMethods(): void + { + $history = new FilesystemHistory(new HistoryOptions()); + + foreach ([['indexAction', 'POST'], ['clearAction', 'GET']] as [$action, $method]) { + $_SERVER['REQUEST_METHOD'] = $method; + $response = $this->executeWithServices( + $action, + new Request(), + new Response(), + $history, + new AccessGate([], null) + ); + + $this->assertJsonResponse($response, 405, ['error' => 'Method not allowed.']); + } + } + + public function testActionsReportUnavailableHistoryServices(): void + { + foreach (['indexAction', 'clearAction'] as $action) { + $response = $this->executeWithServices( + $action, + new stdClass(), + new Response(), + new stdClass(), + new stdClass() + ); + + $this->assertJsonResponse($response, 500, ['error' => 'History is unavailable.']); + } + } + #[RunInSeparateProcess] + public function testActionsRequireADiContainer(): void + { + Di::reset(); + $controller = new HistoryController(); + + foreach (['indexAction', 'clearAction'] as $action) { + try { + $controller->{$action}(); + $this->fail('Expected the controller to require a DI container.'); + } catch (Throwable $exception) { + $this->assertContains($exception->getMessage(), [ + 'The History controller requires a DI container.', + 'A dependency injection container is required to access internal services', + ]); + } + } + } + + public function testActionsRequireAResponseService(): void + { + $history = new FilesystemHistory(new HistoryOptions()); + + foreach (['indexAction', 'clearAction'] as $action) { + try { + $this->executeWithServices($action, new Request(), new stdClass(), $history, new AccessGate([], null)); + $this->fail('Expected the controller to require a response service.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'The response service must implement ResponseInterface.', + $exception->getMessage() + ); + } + } + } + + public function testInvalidStoredRequestIdentifierReturnsNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + foreach (['', ['invalid']] as $id) { + $_GET = ['id' => $id]; + $response = $this->executeWithServices( + 'indexAction', + new Request(), + new Response(), + new FilesystemHistory(new HistoryOptions()), + new AccessGate([], null) + ); + + $this->assertJsonResponse($response, 404, ['error' => 'Request not found.']); + } + } + + #[RunInSeparateProcess] + public function testListsAndLoadsRequestsFromTheCurrentSession(): void + { + $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); + $path = sys_get_temp_dir() . '/phalcon-debugbar-controller-' . bin2hex(random_bytes(8)); + session_id($sessionId); + session_start(); + + try { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + $id = $history->save( + ['data' => [], 'meta' => ['collectors' => 0]], + new RequestMetadata('GET', '/orders', 200, false) + ); + $this->assertIsString($id); + + $_GET = []; + $list = $this->execute($history); + $this->assertSame(200, $list->getStatusCode()); + $listBody = json_decode($list->getContent(), true); + $this->assertIsArray($listBody); + $requests = $listBody['requests']; + $this->assertIsArray($requests); + $this->assertCount(1, $requests); + + $_GET = ['id' => $id]; + $detail = $this->execute($history); + $this->assertSame(200, $detail->getStatusCode()); + $detailBody = json_decode($detail->getContent(), true); + $this->assertIsArray($detailBody); + $request = $detailBody['request']; + $this->assertIsArray($request); + $meta = $request['meta']; + $this->assertIsArray($meta); + $this->assertSame($id, $meta['id']); + $this->assertSame('no-store, private', $detail->getHeaders()->get('Cache-Control')); + + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET = []; + $clear = $this->execute($history, 'clear'); + $this->assertSame(200, $clear->getStatusCode()); + $clearBody = json_decode($clear->getContent(), true); + $this->assertIsArray($clearBody); + $this->assertSame(1, $clearBody['cleared']); + $this->assertSame([], $history->find()); + } finally { + session_write_close(); + $directory = $path . '/' . hash('sha256', $sessionId); + $files = glob($directory . '/*'); + if (false !== $files) { + foreach ($files as $file) { + unlink($file); + } + } + + @rmdir($directory); + @unlink($path . '/.gc'); + @rmdir($path); + } + } + + public function testMissingStoredRequestReturnsNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET = ['id' => '20260903120000-123456-deadbeef']; + + $response = $this->executeWithServices( + 'indexAction', + new Request(), + new Response(), + new FilesystemHistory(new HistoryOptions()), + new AccessGate([], null) + ); + + $this->assertJsonResponse($response, 404, ['error' => 'Request not found.']); + } + /** + * @param array $expectedBody + */ + private function assertJsonResponse(Response $response, int $status, array $expectedBody): void + { + $this->assertSame($status, $response->getStatusCode()); + $this->assertSame($expectedBody, json_decode($response->getContent(), true)); + } + + private function execute(FilesystemHistory $history, string $action = 'index'): Response + { + $response = new Response(); + + return $this->executeWithServices( + 'clear' === $action ? 'clearAction' : 'indexAction', + new Request(), + $response, + $history, + new AccessGate([], null) + ); + } + + private function executeWithServices( + string $action, + object $request, + object $response, + object $history, + object $access + ): Response { + $container = new Di(); + $container->setShared('request', $request); + $container->setShared('response', $response); + $container->setShared(Provider::HISTORY_SERVICE, $history); + $container->setShared(Provider::ACCESS_GATE_SERVICE, $access); + + $controller = new HistoryController(); + $controller->setDI($container); + $result = $controller->{$action}(); + + $this->assertInstanceOf(Response::class, $result); + + return $result; + } +} diff --git a/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php b/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php deleted file mode 100644 index 1a2213a..0000000 --- a/tests/Unit/DebugBar/Controllers/OpenHandlerControllerTest.php +++ /dev/null @@ -1,121 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE.txt - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Phalcon\Tests\Unit\DebugBar\Controllers; - -use Phalcon\DebugBar\Controllers\OpenHandlerController; -use Phalcon\DebugBar\History\FilesystemHistory; -use Phalcon\DebugBar\History\HistoryOptions; -use Phalcon\DebugBar\History\RequestMetadata; -use Phalcon\DebugBar\Provider; -use Phalcon\DebugBar\Security\AccessGate; -use Phalcon\Di\Di; -use Phalcon\Http\Request; -use Phalcon\Http\Response; -use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; - -use function bin2hex; -use function glob; -use function hash; -use function json_decode; -use function random_bytes; -use function session_id; -use function session_start; -use function session_write_close; -use function sys_get_temp_dir; -use function unlink; - -final class OpenHandlerControllerTest extends AbstractUnitTestCase -{ - #[RunInSeparateProcess] - public function testListsAndLoadsRequestsFromTheCurrentSession(): void - { - $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); - $path = sys_get_temp_dir() . '/phalcon-debugbar-controller-' . bin2hex(random_bytes(8)); - session_id($sessionId); - session_start(); - - try { - $_SERVER['REQUEST_METHOD'] = 'GET'; - $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); - $id = $history->save( - ['data' => [], 'meta' => ['collectors' => 0]], - new RequestMetadata('GET', '/orders', 200, false) - ); - $this->assertIsString($id); - - $_GET = []; - $list = $this->execute($history); - $this->assertSame(200, $list->getStatusCode()); - $listBody = json_decode($list->getContent(), true); - $this->assertIsArray($listBody); - $requests = $listBody['requests']; - $this->assertIsArray($requests); - $this->assertCount(1, $requests); - - $_GET = ['id' => $id]; - $detail = $this->execute($history); - $this->assertSame(200, $detail->getStatusCode()); - $detailBody = json_decode($detail->getContent(), true); - $this->assertIsArray($detailBody); - $request = $detailBody['request']; - $this->assertIsArray($request); - $meta = $request['meta']; - $this->assertIsArray($meta); - $this->assertSame($id, $meta['id']); - $this->assertSame('no-store, private', $detail->getHeaders()->get('Cache-Control')); - - $_SERVER['REQUEST_METHOD'] = 'DELETE'; - $_GET = []; - $clear = $this->execute($history, 'clear'); - $this->assertSame(200, $clear->getStatusCode()); - $clearBody = json_decode($clear->getContent(), true); - $this->assertIsArray($clearBody); - $this->assertSame(1, $clearBody['cleared']); - $this->assertSame([], $history->find()); - } finally { - session_write_close(); - $directory = $path . '/' . hash('sha256', $sessionId); - $files = glob($directory . '/*'); - if (false !== $files) { - foreach ($files as $file) { - unlink($file); - } - } - - @rmdir($directory); - @rmdir($path); - } - } - - private function execute(FilesystemHistory $history, string $action = 'index'): Response - { - $container = new Di(); - $response = new Response(); - $container->setShared('request', new Request()); - $container->setShared('response', $response); - $container->setShared(Provider::HISTORY_SERVICE, $history); - $container->setShared(Provider::ACCESS_GATE_SERVICE, new AccessGate([], null)); - - $controller = new OpenHandlerController(); - $controller->setDI($container); - if ('clear' === $action) { - $controller->clearAction(); - } else { - $controller->indexAction(); - } - - return $response; - } -} diff --git a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php index 07947cc..094326b 100644 --- a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php +++ b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php @@ -13,29 +13,62 @@ namespace Phalcon\Tests\Unit\DebugBar\History; +use DateTimeImmutable; use Phalcon\DebugBar\History\FilesystemHistory; use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\DebugBar\History\RequestMetadata; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\History\FailingStreamWrapper; +use Phalcon\Tests\Support\DebugBar\History\GarbageCollectionMarkerFailingFileOperations; +use Phalcon\Tests\Support\DebugBar\History\MetadataWriteFailingHistoryFileOperations; +use Phalcon\Tests\Support\DebugBar\History\PayloadMoveFailingHistoryFileOperations; +use Phalcon\Tests\Support\DebugBar\History\PayloadReadTrackingHistoryFileOperations; +use Phalcon\Tests\Support\DebugBar\History\RenameFailingHistoryFileOperations; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use function bin2hex; +use function file_exists; +use function file_put_contents; use function glob; use function hash; +use function is_dir; +use function is_file; +use function mkdir; use function random_bytes; use function rmdir; use function session_id; use function session_start; use function session_write_close; +use function str_repeat; +use function stream_wrapper_register; +use function stream_wrapper_unregister; use function sys_get_temp_dir; +use function touch; use function unlink; final class FilesystemHistoryTest extends AbstractUnitTestCase { + #[RunInSeparateProcess] + public function testActiveSessionWithoutStorageHasNoRequests(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + + $this->assertSame([], $history->find()); + $this->assertNull($history->get('20260903120000-123456-deadbeef')); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + #[RunInSeparateProcess] public function testClearRemovesTheCurrentSessionsRequests(): void { [$path, $sessionId] = $this->startSession(); + $directory = $path . '/' . hash('sha256', $sessionId); try { $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); @@ -45,8 +78,15 @@ public function testClearRemovesTheCurrentSessionsRequests(): void new RequestMetadata('GET', '/' . $index, 200, false) ); } + $temporary = $directory . '/orphan.json.tmp-deadbeef'; + $metadata = $directory . '/orphan.json.meta'; + $this->assertIsInt(file_put_contents($temporary, '{}')); + $this->assertIsInt(file_put_contents($metadata, '{}')); $this->assertSame(2, $history->clear()); + $this->assertFalse(file_exists($temporary)); + $this->assertFalse(file_exists($metadata)); + $this->assertFalse(is_dir($directory)); $this->assertSame([], $history->find()); $this->assertSame(0, $history->clear()); } finally { @@ -55,6 +95,179 @@ public function testClearRemovesTheCurrentSessionsRequests(): void } } + #[RunInSeparateProcess] + public function testFindingRequestsDoesNotReadStoredPayloads(): void + { + [$path, $sessionId] = $this->startSession(); + $fileOperations = new PayloadReadTrackingHistoryFileOperations(); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + $fileOperations + ); + + try { + $id = $history->save( + [ + 'data' => ['database' => ['panel' => str_repeat('x', 512 * 1024), 'badge' => null]], + 'meta' => [], + ], + new RequestMetadata('GET', '/large', 200, false) + ); + $this->assertIsString($id); + + $requests = $history->find(); + $this->assertCount(1, $requests); + $this->assertSame('/large', $requests[0]['uri']); + $this->assertSame(0, $fileOperations->payloadReads); + + $this->assertIsArray($history->get($id)); + $this->assertSame(1, $fileOperations->payloadReads); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testInvalidPayloadCannotBeEncoded(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + + $this->assertNull($history->save( + ['data' => ['invalid' => ['panel' => NAN, 'badge' => null]], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertFalse(is_dir($path . '/' . hash('sha256', $sessionId))); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testLegacyEntriesWithoutMetadataSidecarRemainReadable(): void + { + [$path, $sessionId] = $this->startSession(); + $directory = $path . '/' . hash('sha256', $sessionId); + $fileOperations = new PayloadReadTrackingHistoryFileOperations(); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + $fileOperations + ); + + try { + $id = $history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/legacy', 200, false) + ); + $this->assertIsString($id); + $this->assertTrue(unlink($directory . '/' . $id . '.json.meta')); + + $requests = $history->find(); + $this->assertCount(1, $requests); + $this->assertSame('/legacy', $requests[0]['uri']); + $this->assertSame(1, $fileOperations->payloadReads); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testListingOnlyCollectsTheActiveSession(): void + { + [$path, $firstSessionId] = $this->startSession(); + $firstDirectory = $path . '/' . hash('sha256', $firstSessionId); + $secondSessionId = 'debugbar-' . bin2hex(random_bytes(8)); + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 1)); + $id = $history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/expired', 200, false) + ); + $this->assertIsString($id); + $this->assertTrue(touch($firstDirectory . '/' . $id . '.json', time() - 10)); + $temporary = $firstDirectory . '/' . $id . '.json.tmp-deadbeef'; + $this->assertIsInt(file_put_contents($temporary, '{}')); + $this->assertTrue(touch($temporary, time() - 10)); + + session_write_close(); + session_id($secondSessionId); + session_start(); + + $nextRequestHistory = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path, 10, 1) + ); + $this->assertSame([], $nextRequestHistory->find()); + $this->assertTrue(file_exists($firstDirectory . '/' . $id . '.json')); + $this->assertTrue(file_exists($temporary)); + $this->assertTrue(is_dir($firstDirectory)); + + $marker = $path . '/.gc'; + $this->assertIsString($nextRequestHistory->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/rate-limited', 200, false) + )); + $this->assertTrue(file_exists($firstDirectory . '/' . $id . '.json')); + $this->assertTrue(file_exists($temporary)); + + $this->assertTrue(touch($marker, time() - 3601)); + $collectingHistory = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path, 10, 1) + ); + $this->assertIsString($collectingHistory->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/collect', 200, false) + )); + $this->assertFalse(file_exists($firstDirectory . '/' . $id . '.json')); + $this->assertFalse(file_exists($temporary)); + $this->assertFalse(is_dir($firstDirectory)); + } finally { + session_write_close(); + $this->removeHistory($path, $firstSessionId); + $this->removeHistory($path, $secondSessionId); + } + } + + public function testMalformedAndExpiredEntriesAreIgnored(): void + { + [$path, $sessionId] = $this->startSession(); + $directory = $path . '/' . hash('sha256', $sessionId); + $id = '20260903120000-123456-deadbeef'; + + try { + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 1)); + $savedId = $history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + ); + $this->assertIsString($savedId); + $this->assertTrue(touch($directory . '/' . $savedId . '.json', time() - 10)); + $this->assertNull($history->get($savedId)); + $this->assertFalse(file_exists($directory . '/' . $savedId . '.json')); + + $savedId = $history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + ); + $this->assertIsString($savedId); + $this->assertTrue(touch($directory . '/' . $savedId . '.json', time() - 10)); + $this->assertSame([], $history->find()); + $this->assertFalse(file_exists($directory . '/' . $savedId . '.json')); + + $this->assertIsInt(file_put_contents($directory . '/' . $id . '.json', '{invalid')); + $this->assertNull($history->get($id)); + $this->assertNull($history->get('20260903120000-123456-cafebabe')); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + #[RunInSeparateProcess] public function testMaximumRequestCountIsPruned(): void { @@ -76,6 +289,27 @@ public function testMaximumRequestCountIsPruned(): void } } + #[RunInSeparateProcess] + public function testMetadataWriteFailureRemovesTemporaryFilesAndReturnsNull(): void + { + [$path, $sessionId] = $this->startSession(); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + new MetadataWriteFailingHistoryFileOperations() + ); + + try { + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertFalse(is_dir($path . '/' . hash('sha256', $sessionId))); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + #[RunInSeparateProcess] public function testNoActiveSessionStoresNothing(): void { @@ -87,18 +321,65 @@ public function testNoActiveSessionStoresNothing(): void new RequestMetadata('GET', '/', 200, false) )); $this->assertSame([], $history->find()); + $this->assertNull($history->get('20260903120000-123456-deadbeef')); $this->assertSame(0, $history->clear()); } + #[RunInSeparateProcess] - public function testSaveFindAndGetAreSessionScoped(): void + public function testPayloadMoveFailureRollsBackPublishedMetadata(): void { [$path, $sessionId] = $this->startSession(); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + new PayloadMoveFailingHistoryFileOperations() + ); + + try { + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertFalse(is_dir($path . '/' . hash('sha256', $sessionId))); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testRenameFailureRemovesTemporaryFileAndReturnsNull(): void + { + [$path, $sessionId] = $this->startSession(); + $fileOperations = new RenameFailingHistoryFileOperations(); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + $fileOperations + ); + + try { + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertSame(2, $fileOperations->removeCalls); + $this->assertFalse(is_dir($path . '/' . hash('sha256', $sessionId))); + } finally { + session_write_close(); + @rmdir($path); + } + } + #[RunInSeparateProcess] + public function testSaveFindAndGetAreSessionScoped(): void + { + [$path, $firstSessionId] = $this->startSession(); + $secondSessionId = 'debugbar-' . bin2hex(random_bytes(8)); try { $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); - $id = $history->save( + $requestedAt = new DateTimeImmutable('2000-01-02T03:04:05+00:00'); + $id = $history->save( ['data' => [], 'meta' => ['collectors' => 0]], - new RequestMetadata('POST', '/orders', 201, true) + new RequestMetadata('POST', '/orders', 201, true, $requestedAt) ); $this->assertIsString($id); @@ -110,6 +391,8 @@ public function testSaveFindAndGetAreSessionScoped(): void $this->assertSame('/orders', $requests[0]['uri']); $this->assertSame(201, $requests[0]['status']); $this->assertTrue($requests[0]['ajax']); + $this->assertSame('2000-01-02T03:04:05+00:00', $requests[0]['requested_at']); + $this->assertNotSame($requests[0]['requested_at'], $requests[0]['stored_at']); $entry = $history->get($id); $this->assertIsArray($entry); @@ -119,12 +402,108 @@ public function testSaveFindAndGetAreSessionScoped(): void $this->assertIsArray($meta); $this->assertSame(0, $meta['collectors']); $this->assertNull($history->get('../outside')); + + session_write_close(); + session_id($secondSessionId); + session_start(); + + $this->assertSame([], $history->find()); + $this->assertNull($history->get($id)); + + $secondId = $history->save( + ['data' => [], 'meta' => ['collectors' => 1]], + new RequestMetadata('GET', '/customers', 200, false) + ); + $this->assertIsString($secondId); + + session_write_close(); + session_id($firstSessionId); + session_start(); + + $firstSessionHistory = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path, 10, 60) + ); + $firstRequests = $firstSessionHistory->find(); + $this->assertCount(1, $firstRequests); + $this->assertSame($id, $firstRequests[0]['id']); + $this->assertNull($firstSessionHistory->get($secondId)); + } finally { + session_write_close(); + $this->removeHistory($path, $firstSessionId); + $this->removeHistory($path, $secondSessionId); + } + } + + #[RunInSeparateProcess] + public function testSaveSucceedsWhenGarbageCollectionMarkerCannotBeWritten(): void + { + [$path, $sessionId] = $this->startSession(); + + try { + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $path), + new GarbageCollectionMarkerFailingFileOperations() + ); + + $this->assertIsString($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + $this->assertCount(1, $history->find()); + $this->assertFalse(file_exists($path . '/.gc')); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + + #[RunInSeparateProcess] + public function testStorageDirectoryCreationFailureStoresNothing(): void + { + [$path, $sessionId] = $this->startSession(); + $blockedPath = $path . '/not-a-directory'; + + try { + $this->assertTrue(mkdir($path, 0700, true)); + $this->assertIsInt(file_put_contents($blockedPath, 'blocked')); + $history = new FilesystemHistory( + new HistoryOptions(true, '/_debugbar/open', $blockedPath) + ); + + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); } finally { session_write_close(); + @unlink($blockedPath); $this->removeHistory($path, $sessionId); } } + public function testStorageFailuresReturnSafeEmptyResults(): void + { + $scheme = 'debugbar-failure'; + $this->assertTrue(stream_wrapper_register($scheme, FailingStreamWrapper::class)); + [, $sessionId] = $this->startSession(); + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $scheme . '://history')); + try { + $directory = $scheme . '://history/' . hash('sha256', $sessionId); + $this->assertTrue(is_dir($directory)); + $this->assertTrue(is_file($directory . '/20260903120000-123456-deadbeef.json')); + $this->assertSame([], $history->find()); + + $this->assertNull($history->get('20260903120000-123456-deadbeef')); + $this->assertNull($history->save( + ['data' => [], 'meta' => []], + new RequestMetadata('GET', '/', 200, false) + )); + } finally { + session_write_close(); + stream_wrapper_unregister($scheme); + } + } + private function removeHistory(string $path, string $sessionId): void { $directory = $path . '/' . hash('sha256', $sessionId); @@ -136,6 +515,7 @@ private function removeHistory(string $path, string $sessionId): void } @rmdir($directory); + @unlink($path . '/.gc'); @rmdir($path); } diff --git a/tests/Unit/DebugBar/ProviderTest.php b/tests/Unit/DebugBar/ProviderTest.php index cb80bdd..0b411a5 100644 --- a/tests/Unit/DebugBar/ProviderTest.php +++ b/tests/Unit/DebugBar/ProviderTest.php @@ -256,6 +256,25 @@ public function testHeadersDisabledSuppressesDiagnosticHeader(): void $this->assertFalse($response->getHeaders()->has('X-Debug-Bar')); } + public function testHistoryIgnoresAnIncompatibleRouterService(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $app = $this->applicationWithServices( + new Manager(), + ['router' => new stdClass()] + ); + + (new Provider($app, [ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true], + ]))->boot(); + + $container = $app->getDI(); + $this->assertNotNull($container); + $this->assertFalse($container->has(Provider::HISTORY_SERVICE)); + $this->assertFalse($this->bootedBar()->hasCollector('history')); + } + public function testHistoryRegistersItsCollectorRouteAndServices(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -276,20 +295,48 @@ public function testHistoryRegistersItsCollectorRouteAndServices(): void $this->assertNotNull($container); $this->assertTrue($container->has(Provider::HISTORY_SERVICE)); $this->assertTrue($container->has(Provider::ACCESS_GATE_SERVICE)); + $this->assertSame('debugbar.access_gate', Provider::ACCESS_GATE_SERVICE); $this->assertTrue($this->bootedBar()->hasCollector('history')); - $route = $router->getRouteByName('debugbar.openhandler'); + $route = $router->getRouteByName('debugbar.history.index'); if (!$route instanceof RouteInterface) { - $this->fail('Expected the debugbar.openhandler route.'); + $this->fail('Expected the debugbar.history.index route.'); } $this->assertSame('/_debugbar/open', $route->getPattern()); + $this->assertSame('history', $route->getPaths()['controller']); - $clearRoute = $router->getRouteByName('debugbar.clearhistory'); + $clearRoute = $router->getRouteByName('debugbar.history.clear'); if (!$clearRoute instanceof RouteInterface) { - $this->fail('Expected the debugbar.clearhistory route.'); + $this->fail('Expected the debugbar.history.clear route.'); } $this->assertSame('/_debugbar/open', $clearRoute->getPattern()); + $this->assertSame('history', $clearRoute->getPaths()['controller']); + } + + public function testHistoryRequiresCompatibleRequestAndResponseServices(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $serviceSets = [ + ['request' => new stdClass(), 'response' => new Response(), 'router' => new Router(false)], + ['request' => new Request(), 'response' => new stdClass(), 'router' => new Router(false)], + ['request' => new Request(), 'router' => new Router(false)], + ]; + + foreach ($serviceSets as $services) { + $app = $this->applicationWithServices(new Manager(), $services); + + (new Provider($app, [ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true], + ]))->boot(); + + $container = $app->getDI(); + $this->assertNotNull($container); + $this->assertFalse($container->has(Provider::HISTORY_SERVICE)); + $this->assertFalse($this->bootedBar()->hasCollector('history')); + } } #[RunInSeparateProcess] diff --git a/tests/Unit/DebugBar/ResponseListenerTest.php b/tests/Unit/DebugBar/ResponseListenerTest.php index 6b76d45..ea0bf7a 100644 --- a/tests/Unit/DebugBar/ResponseListenerTest.php +++ b/tests/Unit/DebugBar/ResponseListenerTest.php @@ -15,15 +15,30 @@ use Phalcon\DebugBar\BarOptions; use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\DebugBar\Injector; use Phalcon\DebugBar\Renderer; use Phalcon\DebugBar\ResponseListener; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Events\Event; +use Phalcon\Http\Request; +use Phalcon\Http\RequestInterface; use Phalcon\Http\Response; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\Fixtures\GridCollector; use Phalcon\Tests\Support\DebugBar\Fixtures\ListCollector; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; + +use function bin2hex; +use function glob; +use function hash; +use function random_bytes; +use function session_id; +use function session_start; +use function session_write_close; +use function sys_get_temp_dir; +use function unlink; final class ResponseListenerTest extends AbstractUnitTestCase { @@ -62,6 +77,118 @@ public function testInjectsWithoutRequestAndWithHeadersOff(): void $this->assertFalse($response->getHeaders()->get('X-Debug-Bar')); } + #[RunInSeparateProcess] + public function testRecordsRequestStartTimeFromNativeRequest(): void + { + $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); + $path = sys_get_temp_dir() . '/phalcon-debugbar-native-request-' . bin2hex(random_bytes(8)); + session_id($sessionId); + session_start(); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['REQUEST_TIME_FLOAT'] = 946782245.123456; + $_SERVER['REQUEST_URI'] = '/orders/42?full=1'; + + try { + $options = new HistoryOptions(true, '/_debugbar/open', $path); + $history = new FilesystemHistory($options); + $listener = new ResponseListener( + new DebugBar(), + new Renderer(), + new Injector(), + new AccessGate([], null), + new Request(), + new BarOptions(false, null), + $history, + $options + ); + $response = new Response(); + $response->setStatusCode(202); + $response->setContent('accepted'); + + $listener($this->event(), null, $response); + + $requests = $history->find(); + $this->assertCount(1, $requests); + $this->assertSame('2000-01-02T03:04:05+00:00', $requests[0]['requested_at']); + } finally { + session_write_close(); + $directory = $path . '/' . hash('sha256', $sessionId); + $files = glob($directory . '/*'); + if (false !== $files) { + foreach ($files as $file) { + unlink($file); + } + } + + @rmdir($directory); + @unlink($path . '/.gc'); + @rmdir($path); + } + } + + #[RunInSeparateProcess] + public function testRecordsTheCollectedResponseInRequestHistory(): void + { + $sessionId = 'debugbar-' . bin2hex(random_bytes(8)); + $path = sys_get_temp_dir() . '/phalcon-debugbar-listener-' . bin2hex(random_bytes(8)); + session_id($sessionId); + session_start(); + + try { + $request = $this->createMock(RequestInterface::class); + $request->method('getClientAddress')->willReturn('127.0.0.1'); + $request->method('isAjax')->willReturn(true); + $request->method('getURI')->willReturn('/orders/42?full=1'); + $request->method('getMethod')->willReturn('PATCH'); + $_SERVER['REQUEST_TIME_FLOAT'] = 946782245.123456; + + $options = new HistoryOptions(true, '/_debugbar/open', $path); + $history = new FilesystemHistory($options); + $listener = new ResponseListener( + new DebugBar(), + new Renderer(), + new Injector(), + new AccessGate([], null), + $request, + new BarOptions(false, null), + $history, + $options + ); + $response = new Response(); + $response->setStatusCode(202); + $response->setContent('accepted'); + + $listener($this->event(), null, $response); + unset($_SERVER['REQUEST_TIME_FLOAT']); + $listener($this->event(), null, $response); + + $requests = $history->find(); + $this->assertCount(2, $requests); + $this->assertSame('PATCH', $requests[0]['method']); + $this->assertSame('/orders/42?full=1', $requests[0]['uri']); + $this->assertSame(202, $requests[0]['status']); + $this->assertTrue($requests[0]['ajax']); + $this->assertSame($requests[0]['stored_at'], $requests[0]['requested_at']); + $this->assertSame('2000-01-02T03:04:05+00:00', $requests[1]['requested_at']); + $this->assertNotSame($requests[1]['requested_at'], $requests[1]['stored_at']); + } finally { + session_write_close(); + $directory = $path . '/' . hash('sha256', $sessionId); + $files = glob($directory . '/*'); + if (false !== $files) { + foreach ($files as $file) { + unlink($file); + } + } + + @rmdir($directory); + @unlink($path . '/.gc'); + @rmdir($path); + } + } + public function testSetsDiagnosticHeaderWhenEnabled(): void { $bar = new DebugBar(); diff --git a/tests/support/DebugBar/History/FailingStreamWrapper.php b/tests/support/DebugBar/History/FailingStreamWrapper.php new file mode 100644 index 0000000..38f054c --- /dev/null +++ b/tests/support/DebugBar/History/FailingStreamWrapper.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use function str_ends_with; +use function time; + +/** + * Test-only stream wrapper for exercising storage failures through the public + * FilesystemHistory API. + */ +final class FailingStreamWrapper +{ + /** + * @var resource|null + */ + public $context; + + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + return false; + } + + /** + * @return array + */ + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + public function url_stat(string $path, int $flags): array + { + return $this->stat(str_ends_with($path, '.json') ? 0100666 : 0040777); + } + + /** + * @return array + */ + private function stat(int $mode): array + { + $now = time(); + + return [ + 0, 0, $mode, 1, 0, 0, 0, 0, $now, $now, $now, -1, -1, + 'dev' => 0, + 'ino' => 0, + 'mode' => $mode, + 'nlink' => 1, + 'uid' => 0, + 'gid' => 0, + 'rdev' => 0, + 'size' => 0, + 'atime' => $now, + 'mtime' => $now, + 'ctime' => $now, + 'blksize' => -1, + 'blocks' => -1, + ]; + } +} diff --git a/tests/support/DebugBar/History/GarbageCollectionMarkerFailingFileOperations.php b/tests/support/DebugBar/History/GarbageCollectionMarkerFailingFileOperations.php new file mode 100644 index 0000000..eb03ce9 --- /dev/null +++ b/tests/support/DebugBar/History/GarbageCollectionMarkerFailingFileOperations.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use Phalcon\DebugBar\History\HistoryFileOperations; + +use function file_get_contents; +use function file_put_contents; +use function rename; +use function rmdir; +use function str_ends_with; +use function unlink; + +use const LOCK_EX; + +final class GarbageCollectionMarkerFailingFileOperations implements HistoryFileOperations +{ + public function move(string $source, string $target): bool + { + return @rename($source, $target); + } + + public function read(string $file): false | string + { + return @file_get_contents($file); + } + + public function remove(string $file): bool + { + return @unlink($file); + } + + public function removeDirectory(string $directory): bool + { + return @rmdir($directory); + } + + public function write(string $file, string $contents): bool + { + return !str_ends_with($file, '/.gc') + && false !== @file_put_contents($file, $contents, LOCK_EX); + } +} diff --git a/tests/support/DebugBar/History/MetadataWriteFailingHistoryFileOperations.php b/tests/support/DebugBar/History/MetadataWriteFailingHistoryFileOperations.php new file mode 100644 index 0000000..c40f17b --- /dev/null +++ b/tests/support/DebugBar/History/MetadataWriteFailingHistoryFileOperations.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use Phalcon\DebugBar\History\HistoryFileOperations; +use Phalcon\DebugBar\History\NativeHistoryFileOperations; + +use function str_contains; + +final class MetadataWriteFailingHistoryFileOperations implements HistoryFileOperations +{ + private readonly NativeHistoryFileOperations $native; + + public function __construct() + { + $this->native = new NativeHistoryFileOperations(); + } + + public function move(string $source, string $target): bool + { + return $this->native->move($source, $target); + } + + public function read(string $file): false | string + { + return $this->native->read($file); + } + + public function remove(string $file): bool + { + return $this->native->remove($file); + } + + public function removeDirectory(string $directory): bool + { + return $this->native->removeDirectory($directory); + } + + public function write(string $file, string $contents): bool + { + if (str_contains($file, '.json.meta.tmp-')) { + $this->native->write($file, $contents); + + return false; + } + + return $this->native->write($file, $contents); + } +} diff --git a/tests/support/DebugBar/History/PayloadMoveFailingHistoryFileOperations.php b/tests/support/DebugBar/History/PayloadMoveFailingHistoryFileOperations.php new file mode 100644 index 0000000..aeb3900 --- /dev/null +++ b/tests/support/DebugBar/History/PayloadMoveFailingHistoryFileOperations.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use Phalcon\DebugBar\History\HistoryFileOperations; +use Phalcon\DebugBar\History\NativeHistoryFileOperations; + +use function str_ends_with; + +final class PayloadMoveFailingHistoryFileOperations implements HistoryFileOperations +{ + private readonly NativeHistoryFileOperations $native; + + public function __construct() + { + $this->native = new NativeHistoryFileOperations(); + } + + public function move(string $source, string $target): bool + { + return !str_ends_with($target, '.json') && $this->native->move($source, $target); + } + + public function read(string $file): false | string + { + return $this->native->read($file); + } + + public function remove(string $file): bool + { + return $this->native->remove($file); + } + + public function removeDirectory(string $directory): bool + { + return $this->native->removeDirectory($directory); + } + + public function write(string $file, string $contents): bool + { + return $this->native->write($file, $contents); + } +} diff --git a/tests/support/DebugBar/History/PayloadReadTrackingHistoryFileOperations.php b/tests/support/DebugBar/History/PayloadReadTrackingHistoryFileOperations.php new file mode 100644 index 0000000..f6debc1 --- /dev/null +++ b/tests/support/DebugBar/History/PayloadReadTrackingHistoryFileOperations.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use Phalcon\DebugBar\History\HistoryFileOperations; +use Phalcon\DebugBar\History\NativeHistoryFileOperations; + +use function str_ends_with; + +final class PayloadReadTrackingHistoryFileOperations implements HistoryFileOperations +{ + public int $payloadReads = 0; + + private readonly NativeHistoryFileOperations $native; + + public function __construct() + { + $this->native = new NativeHistoryFileOperations(); + } + + public function move(string $source, string $target): bool + { + return $this->native->move($source, $target); + } + + public function read(string $file): false | string + { + if (str_ends_with($file, '.json')) { + $this->payloadReads++; + } + + return $this->native->read($file); + } + + public function remove(string $file): bool + { + return $this->native->remove($file); + } + + public function removeDirectory(string $directory): bool + { + return $this->native->removeDirectory($directory); + } + + public function write(string $file, string $contents): bool + { + return $this->native->write($file, $contents); + } +} diff --git a/tests/support/DebugBar/History/RenameFailingHistoryFileOperations.php b/tests/support/DebugBar/History/RenameFailingHistoryFileOperations.php new file mode 100644 index 0000000..db9c850 --- /dev/null +++ b/tests/support/DebugBar/History/RenameFailingHistoryFileOperations.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Support\DebugBar\History; + +use Phalcon\DebugBar\History\HistoryFileOperations; + +use function rmdir; + +final class RenameFailingHistoryFileOperations implements HistoryFileOperations +{ + public int $removeCalls = 0; + + public function move(string $source, string $target): bool + { + return false; + } + + public function read(string $file): false | string + { + return false; + } + + public function remove(string $file): bool + { + $this->removeCalls++; + + return true; + } + + public function removeDirectory(string $directory): bool + { + return @rmdir($directory); + } + + public function write(string $file, string $contents): bool + { + return true; + } +} From 220946b58312230b3b25fd60065d33e5fbadec8b Mon Sep 17 00:00:00 2001 From: Gabriele Propersi Date: Tue, 8 Sep 2026 14:37:34 +0200 Subject: [PATCH 05/12] Align request history with updated master Assisted-by: Codex --- src/DebugBar/Collector/HistoryCollector.php | 12 ---- .../Controllers/HistoryController.php | 10 --- src/DebugBar/History/FilesystemHistory.php | 66 +++++++++++-------- src/DebugBar/History/HistoryOptions.php | 16 ----- src/DebugBar/History/RequestMetadata.php | 7 -- src/DebugBar/Provider.php | 8 +-- src/DebugBar/ResponseListener.php | 6 +- .../Controllers/HistoryControllerTest.php | 12 ++-- .../History/FilesystemHistoryTest.php | 59 +++++++++++++++-- 9 files changed, 104 insertions(+), 92 deletions(-) diff --git a/src/DebugBar/Collector/HistoryCollector.php b/src/DebugBar/Collector/HistoryCollector.php index ef960b4..3441c14 100644 --- a/src/DebugBar/Collector/HistoryCollector.php +++ b/src/DebugBar/Collector/HistoryCollector.php @@ -21,24 +21,12 @@ final class HistoryCollector extends AbstractCollector { public const NAME = 'history'; - /** - * @var string - */ protected string $icon = 'icon-history'; - /** - * @var string - */ protected string $label = 'History'; - /** - * @var string - */ protected string $panel = 'history'; - /** - * @param string $url - */ public function __construct(private readonly string $url) { } diff --git a/src/DebugBar/Controllers/HistoryController.php b/src/DebugBar/Controllers/HistoryController.php index 8f24e2f..dbbc81d 100644 --- a/src/DebugBar/Controllers/HistoryController.php +++ b/src/DebugBar/Controllers/HistoryController.php @@ -33,9 +33,6 @@ */ final class HistoryController extends Controller { - /** - * @return ResponseInterface - */ public function clearAction(): ResponseInterface { return $this->handle( @@ -48,9 +45,6 @@ public function clearAction(): ResponseInterface ); } - /** - * @return ResponseInterface - */ public function indexAction(): ResponseInterface { return $this->handle( @@ -114,11 +108,7 @@ private function handle(string $expectedMethod, callable $action): ResponseInter } /** - * @param ResponseInterface $response * @param array $body - * @param int $status - * - * @return ResponseInterface */ private function json(ResponseInterface $response, array $body, int $status = 200): ResponseInterface { diff --git a/src/DebugBar/History/FilesystemHistory.php b/src/DebugBar/History/FilesystemHistory.php index 2d63e8a..88ffd29 100644 --- a/src/DebugBar/History/FilesystemHistory.php +++ b/src/DebugBar/History/FilesystemHistory.php @@ -19,11 +19,15 @@ use function array_slice; use function basename; use function bin2hex; +use function count; use function glob; use function hash; use function is_array; +use function is_bool; use function is_dir; use function is_file; +use function is_int; +use function is_string; use function json_decode; use function json_encode; use function min; @@ -68,9 +72,6 @@ final class FilesystemHistory private readonly HistoryFileOperations $fileOperations; private bool $garbageCollectionAttempted = false; - /** - * @param HistoryOptions $options - */ public function __construct( private readonly HistoryOptions $options, ?HistoryFileOperations $fileOperations = null @@ -127,7 +128,6 @@ public function find(): array } $files = $this->removeExpired($this->files($directory)); rsort($files, SORT_STRING); - $files = array_slice($files, 0, $this->options->maxRequests); $requests = []; foreach ($files as $file) { @@ -135,14 +135,15 @@ public function find(): array if (null !== $metadata) { $requests[] = $metadata; } + if (count($requests) >= $this->options->maxRequests) { + break; + } } return $requests; } /** - * @param string $id - * * @return array|null */ public function get(string $id): ?array @@ -171,10 +172,7 @@ public function get(string $id): ?array } /** - * @param payload $payload - * @param RequestMetadata $request - * - * @return string|null + * @param payload $payload */ public function save(array $payload, RequestMetadata $request): ?string { @@ -187,9 +185,9 @@ public function save(array $payload, RequestMetadata $request): ?string return null; } - $storedAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $storedAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); $requestedAt = $request->requestedAt ?? $storedAt; - $id = $storedAt->format('YmdHis-u-') . bin2hex(random_bytes(4)); + $id = $storedAt->format('YmdHis-u-') . bin2hex(random_bytes(4)); $entry = [ 'meta' => [ @@ -227,8 +225,6 @@ public function save(array $payload, RequestMetadata $request): ?string } /** - * @param string $directory - * * @return list */ private function files(string $directory): array @@ -298,10 +294,20 @@ private function metadataFiles(string $directory): array } /** - * @param string $directory - * - * @return void + * @param array $metadata */ + private function metadataIsValid(array $metadata, string $file): bool + { + return is_string($metadata['requested_at'] ?? null) + && is_string($metadata['method'] ?? null) + && is_string($metadata['uri'] ?? null) + && is_int($metadata['status'] ?? null) + && is_bool($metadata['ajax'] ?? null) + && is_string($metadata['id'] ?? null) + && basename($file, '.json') === $metadata['id'] + && is_string($metadata['stored_at'] ?? null); + } + private function prune(string $directory): void { $files = $this->removeExpired($this->files($directory)); @@ -312,8 +318,6 @@ private function prune(string $directory): void } /** - * @param string $file - * * @return array{meta: array, payload: array}|null */ private function read(string $file): ?array @@ -355,14 +359,19 @@ private function readMetadata(string $file): ?array $metadata = json_decode($json, true); if (is_array($metadata)) { /** @var array $metadata */ - return $metadata; + if ($this->metadataIsValid($metadata, $file)) { + return $metadata; + } } } } $entry = $this->read($file); + if (null === $entry || !$this->metadataIsValid($entry['meta'], $file)) { + return null; + } - return null === $entry ? null : $entry['meta']; + return $entry['meta']; } private function removeDirectoryIfEmpty(string $directory): void @@ -435,14 +444,17 @@ private function saveFiles(string $target, string $json, string $metadataJson): */ private function sessionDirectories(): array { - return @glob($this->options->path . '/*', GLOB_ONLYDIR) ?: []; + $directories = @glob($this->options->path . '/*', GLOB_ONLYDIR) ?: []; + $sessionDirectories = []; + foreach ($directories as $directory) { + if (1 === preg_match('/^[a-f0-9]{64}$/D', basename($directory))) { + $sessionDirectories[] = $directory; + } + } + + return $sessionDirectories; } - /** - * @param bool $create - * - * @return string|null - */ private function sessionDirectory(bool $create): ?string { $directory = $this->options->path . '/' . hash('sha256', (string) session_id()); diff --git a/src/DebugBar/History/HistoryOptions.php b/src/DebugBar/History/HistoryOptions.php index 76a81ba..2a358a6 100644 --- a/src/DebugBar/History/HistoryOptions.php +++ b/src/DebugBar/History/HistoryOptions.php @@ -23,28 +23,12 @@ */ final class HistoryOptions { - /** - * @var int - */ public readonly int $maxRequests; - /** - * @var string - */ public readonly string $path; - /** - * @var int - */ public readonly int $ttlSeconds; - /** - * @param bool $enabled - * @param string $url - * @param string $path - * @param int $maxRequests - * @param int $ttlSeconds - */ public function __construct( public readonly bool $enabled = false, public readonly string $url = '/_debugbar/open', diff --git a/src/DebugBar/History/RequestMetadata.php b/src/DebugBar/History/RequestMetadata.php index bae82ec..a0c6fce 100644 --- a/src/DebugBar/History/RequestMetadata.php +++ b/src/DebugBar/History/RequestMetadata.php @@ -20,13 +20,6 @@ */ final class RequestMetadata { - /** - * @param string $method - * @param string $uri - * @param int $status - * @param bool $ajax - * @param DateTimeImmutable|null $requestedAt - */ public function __construct( public readonly string $method, public readonly string $uri, diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index fcb4949..0cc437a 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -98,10 +98,10 @@ class Provider */ public function __construct(private readonly Application $app, array $config = []) { - $env = $config['env'] ?? []; - $assets = $config['assets'] ?? []; - $access = $config['access'] ?? []; - $redact = $config['redact'] ?? []; + $env = $config['env'] ?? []; + $assets = $config['assets'] ?? []; + $access = $config['access'] ?? []; + $redact = $config['redact'] ?? []; $history = $config['history'] ?? []; $this->envVar = $env['var'] ?? 'APP_ENV'; diff --git a/src/DebugBar/ResponseListener.php b/src/DebugBar/ResponseListener.php index e097fb8..629e53c 100644 --- a/src/DebugBar/ResponseListener.php +++ b/src/DebugBar/ResponseListener.php @@ -82,11 +82,7 @@ public function __invoke(EventInterface $event, mixed $source, mixed $response): } /** - * @param payload $collected - * @param ResponseInterface $response - * @param bool $isAjax - * - * @return void + * @param payload $collected */ private function record(array $collected, ResponseInterface $response, bool $isAjax): void { diff --git a/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php b/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php index 3dcb8c3..4b8cee9 100644 --- a/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php +++ b/tests/Unit/DebugBar/Controllers/HistoryControllerTest.php @@ -44,7 +44,7 @@ final class HistoryControllerTest extends AbstractUnitTestCase public function testActionsHideHistoryWhenAccessIsDenied(): void { $_SERVER['REMOTE_ADDR'] = '203.0.113.10'; - $history = new FilesystemHistory(new HistoryOptions()); + $history = new FilesystemHistory(new HistoryOptions()); foreach (['indexAction', 'clearAction'] as $action) { $response = $this->executeWithServices( @@ -155,8 +155,8 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void try { $_SERVER['REQUEST_METHOD'] = 'GET'; - $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); - $id = $history->save( + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path)); + $id = $history->save( ['data' => [], 'meta' => ['collectors' => 0]], new RequestMetadata('GET', '/orders', 200, false) ); @@ -171,7 +171,7 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void $this->assertIsArray($requests); $this->assertCount(1, $requests); - $_GET = ['id' => $id]; + $_GET = ['id' => $id]; $detail = $this->execute($history); $this->assertSame(200, $detail->getStatusCode()); $detailBody = json_decode($detail->getContent(), true); @@ -184,8 +184,8 @@ public function testListsAndLoadsRequestsFromTheCurrentSession(): void $this->assertSame('no-store, private', $detail->getHeaders()->get('Cache-Control')); $_SERVER['REQUEST_METHOD'] = 'DELETE'; - $_GET = []; - $clear = $this->execute($history, 'clear'); + $_GET = []; + $clear = $this->execute($history, 'clear'); $this->assertSame(200, $clear->getStatusCode()); $clearBody = json_decode($clear->getContent(), true); $this->assertIsArray($clearBody); diff --git a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php index 094326b..360f09a 100644 --- a/tests/Unit/DebugBar/History/FilesystemHistoryTest.php +++ b/tests/Unit/DebugBar/History/FilesystemHistoryTest.php @@ -45,6 +45,7 @@ use function sys_get_temp_dir; use function touch; use function unlink; +use function usleep; final class FilesystemHistoryTest extends AbstractUnitTestCase { @@ -182,6 +183,8 @@ public function testListingOnlyCollectsTheActiveSession(): void [$path, $firstSessionId] = $this->startSession(); $firstDirectory = $path . '/' . hash('sha256', $firstSessionId); $secondSessionId = 'debugbar-' . bin2hex(random_bytes(8)); + $unrelatedDirectory = $path . '/application-cache'; + $unrelatedFile = $unrelatedDirectory . '/response.json'; try { $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 1)); @@ -194,6 +197,9 @@ public function testListingOnlyCollectsTheActiveSession(): void $temporary = $firstDirectory . '/' . $id . '.json.tmp-deadbeef'; $this->assertIsInt(file_put_contents($temporary, '{}')); $this->assertTrue(touch($temporary, time() - 10)); + $this->assertTrue(mkdir($unrelatedDirectory)); + $this->assertIsInt(file_put_contents($unrelatedFile, '{}')); + $this->assertTrue(touch($unrelatedFile, time() - 10)); session_write_close(); session_id($secondSessionId); @@ -226,10 +232,14 @@ public function testListingOnlyCollectsTheActiveSession(): void $this->assertFalse(file_exists($firstDirectory . '/' . $id . '.json')); $this->assertFalse(file_exists($temporary)); $this->assertFalse(is_dir($firstDirectory)); + $this->assertTrue(file_exists($unrelatedFile)); } finally { session_write_close(); $this->removeHistory($path, $firstSessionId); $this->removeHistory($path, $secondSessionId); + @unlink($unrelatedFile); + @rmdir($unrelatedDirectory); + @rmdir($path); } } @@ -268,6 +278,45 @@ public function testMalformedAndExpiredEntriesAreIgnored(): void } } + #[RunInSeparateProcess] + public function testMalformedEntriesDoNotConsumeTheRequestLimit(): void + { + [$path, $sessionId] = $this->startSession(); + $directory = $path . '/' . hash('sha256', $sessionId); + + try { + $writer = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); + $ids = []; + for ($index = 0; $index < 3; $index++) { + $id = $writer->save( + ['data' => [], 'meta' => ['index' => $index]], + new RequestMetadata('GET', '/' . $index, 200, false) + ); + $this->assertIsString($id); + $ids[] = $id; + usleep(1000); + } + + $newest = $directory . '/' . $ids[2] . '.json'; + $this->assertIsInt(file_put_contents($newest . '.meta', '{"id":123}')); + + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 2, 60)); + $requests = $history->find(); + $this->assertCount(2, $requests); + $this->assertSame($ids[2], $requests[0]['id']); + $this->assertSame($ids[1], $requests[1]['id']); + + $this->assertIsInt(file_put_contents($newest, '{invalid')); + $requests = $history->find(); + $this->assertCount(2, $requests); + $this->assertSame($ids[1], $requests[0]['id']); + $this->assertSame($ids[0], $requests[1]['id']); + } finally { + session_write_close(); + $this->removeHistory($path, $sessionId); + } + } + #[RunInSeparateProcess] public function testMaximumRequestCountIsPruned(): void { @@ -293,7 +342,7 @@ public function testMaximumRequestCountIsPruned(): void public function testMetadataWriteFailureRemovesTemporaryFilesAndReturnsNull(): void { [$path, $sessionId] = $this->startSession(); - $history = new FilesystemHistory( + $history = new FilesystemHistory( new HistoryOptions(true, '/_debugbar/open', $path), new MetadataWriteFailingHistoryFileOperations() ); @@ -329,7 +378,7 @@ public function testNoActiveSessionStoresNothing(): void public function testPayloadMoveFailureRollsBackPublishedMetadata(): void { [$path, $sessionId] = $this->startSession(); - $history = new FilesystemHistory( + $history = new FilesystemHistory( new HistoryOptions(true, '/_debugbar/open', $path), new PayloadMoveFailingHistoryFileOperations() ); @@ -350,8 +399,8 @@ public function testPayloadMoveFailureRollsBackPublishedMetadata(): void public function testRenameFailureRemovesTemporaryFileAndReturnsNull(): void { [$path, $sessionId] = $this->startSession(); - $fileOperations = new RenameFailingHistoryFileOperations(); - $history = new FilesystemHistory( + $fileOperations = new RenameFailingHistoryFileOperations(); + $history = new FilesystemHistory( new HistoryOptions(true, '/_debugbar/open', $path), $fileOperations ); @@ -375,7 +424,7 @@ public function testSaveFindAndGetAreSessionScoped(): void $secondSessionId = 'debugbar-' . bin2hex(random_bytes(8)); try { - $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); + $history = new FilesystemHistory(new HistoryOptions(true, '/_debugbar/open', $path, 10, 60)); $requestedAt = new DateTimeImmutable('2000-01-02T03:04:05+00:00'); $id = $history->save( ['data' => [], 'meta' => ['collectors' => 0]], From 99bb2e4dafd56b0ca38dd87e76cdc1e6512df510 Mon Sep 17 00:00:00 2001 From: Gabriele Propersi Date: Tue, 8 Sep 2026 15:35:16 +0200 Subject: [PATCH 06/12] Polish request history internals Assisted-by: Codex --- src/DebugBar/Controllers/HistoryController.php | 14 +++++++------- src/DebugBar/History/FilesystemHistory.php | 2 -- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/DebugBar/Controllers/HistoryController.php b/src/DebugBar/Controllers/HistoryController.php index dbbc81d..0006996 100644 --- a/src/DebugBar/Controllers/HistoryController.php +++ b/src/DebugBar/Controllers/HistoryController.php @@ -77,11 +77,11 @@ function ( */ private function handle(string $expectedMethod, callable $action): ResponseInterface { - $container = $this->getDI() ?? throw new RuntimeException('The History controller requires a DI container.'); - $request = $container->getShared('request'); - $response = $container->getShared('response'); - $history = $container->getShared(Provider::HISTORY_SERVICE); - $access = $container->getShared(Provider::ACCESS_GATE_SERVICE); + $container = $this->getDI() ?? throw new RuntimeException('The History controller requires a DI container.'); + $request = $container->getShared('request'); + $response = $container->getShared('response'); + $history = $container->getShared(Provider::HISTORY_SERVICE); + $accessGate = $container->getShared(Provider::ACCESS_GATE_SERVICE); if (!$response instanceof ResponseInterface) { throw new RuntimeException('The response service must implement ResponseInterface.'); @@ -90,13 +90,13 @@ private function handle(string $expectedMethod, callable $action): ResponseInter if ( !$request instanceof RequestInterface || !$history instanceof FilesystemHistory - || !$access instanceof AccessGate + || !$accessGate instanceof AccessGate ) { return $this->json($response, ['error' => 'History is unavailable.'], 500); } $clientIp = $request->getClientAddress(); - if (!$access->allows(is_string($clientIp) ? $clientIp : null)) { + if (!$accessGate->allows(is_string($clientIp) ? $clientIp : null)) { return $this->json($response, ['error' => 'Not found.'], 404); } diff --git a/src/DebugBar/History/FilesystemHistory.php b/src/DebugBar/History/FilesystemHistory.php index 88ffd29..d6e6436 100644 --- a/src/DebugBar/History/FilesystemHistory.php +++ b/src/DebugBar/History/FilesystemHistory.php @@ -61,7 +61,6 @@ * id: string, * stored_at: string * } - * @phpstan-type history_entry array{meta: history_meta, payload: payload} */ final class FilesystemHistory { @@ -100,7 +99,6 @@ public function clear(): int if ($this->fileOperations->remove($file)) { $removed++; } - $this->fileOperations->remove($this->metadataFile($file)); } foreach ($this->metadataFiles($directory) as $file) { $this->fileOperations->remove($file); From 11b8040bd41b3d24e9ea0b81e4697be4316893cb Mon Sep 17 00:00:00 2001 From: Gabriele Propersi Date: Thu, 10 Sep 2026 08:44:59 +0200 Subject: [PATCH 07/12] Polish request history interface --- CHANGELOG.md | 16 +- docs/index.md | 40 +++- resources/assets/debugbar.css | 73 ++++++- resources/assets/debugbar.js | 205 ++++++++++++++---- src/Debug/Renderer/ValueDumper.php | 7 +- src/DebugBar/Collector/HistoryCollector.php | 22 +- src/DebugBar/Collector/MemoryCollector.php | 68 ++++++ src/DebugBar/History/FilesystemHistory.php | 3 + src/DebugBar/Provider.php | 21 +- tests/JavaScript/debugbar.test.js | 168 +++++++++++++- tests/JavaScript/support/dom.js | 12 + .../Collector/HistoryCollectorTest.php | 17 ++ .../Collector/MemoryCollectorTest.php | 73 +++++++ .../Controllers/HistoryControllerTest.php | 2 + .../History/FilesystemHistoryTest.php | 1 + tests/Unit/DebugBar/ProviderTest.php | 1 + tests/Unit/DebugBar/ResponseListenerTest.php | 49 +++++ 17 files changed, 695 insertions(+), 83 deletions(-) create mode 100644 src/DebugBar/Collector/MemoryCollector.php create mode 100644 tests/Unit/DebugBar/Collector/MemoryCollectorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f91072..d020e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,22 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas ### Added +- When request history is enabled, a memory collector reports current and peak + PHP request usage. Request time, current memory usage, and the HTTP method/URI + are displayed as compact status indicators on the right of the bar instead of + separate Time and Memory tabs. - Optional, extensible collector summaries rendered as headline metrics above a panel. The database collector uses them to report total queries, duplicate runs (executions after the first), and accumulated SQL time, and marks repeated statements with their execution count. - Optional, session-isolated request history with filesystem retention, an internal `GET/DELETE /_debugbar/open` controller, and a collapsible request browser with refresh and clear controls that swaps the bar payload without - leaving the current page. Retention cleanup covers abandoned session - directories without delaying history reads, and distinguishes request-start - and persistence timestamps. Metadata sidecars keep request listings independent - of collector payload size while preserving legacy stored entries. + leaving the current page. A dynamic request control combining a search icon, + method, and URI replaces a dedicated History tab. History and collector panels + are mutually exclusive; selecting a stored request closes history and updates + the entire bar. Retention + cleanup covers abandoned session directories without delaying history reads and + distinguishes request-start and persistence timestamps. Metadata sidecars keep + request listings independent of collector payload size while preserving legacy + stored entries. ## [0.4.0](https://github.com/phalcon/debugbar/releases/tag/v0.4.0) (2026-07-14) diff --git a/docs/index.md b/docs/index.md index 55f612b..b0011ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,7 +67,7 @@ The second argument to `Provider` is a nested array. Every key is optional. | `access.allow_ips` | `list` | `[]` (any client) | Client IP allowlist. Empty allows any address. | | `access.callback` | `(Closure(): bool)\|null` | `null` | Extra gate. When present, it must also return `true`. | | `assets.nonce` | `string\|null` | `null` | CSP nonce stamped on the injected `