From 7846791642c5417136e81fb43f02fedabb6f02f0 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:09:08 -0600 Subject: [PATCH 1/6] [#62] - extracting the request context and HTML check Assisted-by: Claude Code --- src/DebugBar/ADR/DebugPageListener.php | 10 ++--- src/DebugBar/History/HistoryHandler.php | 4 +- src/DebugBar/Injector.php | 21 ++++++--- src/DebugBar/RequestContext.php | 47 +++++++++++++++++++ tests/Unit/DebugBar/InjectorTest.php | 30 +++++++++++++ tests/Unit/DebugBar/RequestContextTest.php | 52 ++++++++++++++++++++++ 6 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 src/DebugBar/RequestContext.php create mode 100644 tests/Unit/DebugBar/RequestContextTest.php diff --git a/src/DebugBar/ADR/DebugPageListener.php b/src/DebugBar/ADR/DebugPageListener.php index 601ce00..1a0313f 100644 --- a/src/DebugBar/ADR/DebugPageListener.php +++ b/src/DebugBar/ADR/DebugPageListener.php @@ -16,6 +16,7 @@ use Phalcon\Debug; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\Injector; +use Phalcon\DebugBar\RequestContext; use Phalcon\DebugBar\ResponseListener; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Events\EventInterface; @@ -23,8 +24,6 @@ use Phalcon\Http\ResponseInterface; use Throwable; -use function is_string; - /** * Shows the debug page for a server error in an ADR application. The application * catches each exception before `Debug::listen()` can see it, so this class reads @@ -65,10 +64,11 @@ public function __invoke(EventInterface $event, mixed $source, mixed $response): */ private function applies(ResponseInterface $response): bool { - $clientIp = $this->request?->getClientAddress(); + $context = RequestContext::fromRequest($this->request); return ($response->getStatusCode() ?? 0) >= 500 - && $this->accessGate->allows(is_string($clientIp) ? $clientIp : null) - && (new Injector())->shouldInject($response, $this->request?->isAjax() ?? false); + && $this->accessGate->allows($context->clientIp) + && !$context->isAjax + && (new Injector())->isHtml($response); } } diff --git a/src/DebugBar/History/HistoryHandler.php b/src/DebugBar/History/HistoryHandler.php index 56a5920..7114be7 100644 --- a/src/DebugBar/History/HistoryHandler.php +++ b/src/DebugBar/History/HistoryHandler.php @@ -14,6 +14,7 @@ namespace Phalcon\DebugBar\History; use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\RequestContext; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Http\RequestInterface; use Phalcon\Http\ResponseInterface; @@ -81,8 +82,7 @@ public function open(): ResponseInterface */ private function handle(string $expectedMethod, callable $action): ResponseInterface { - $clientIp = $this->request->getClientAddress(); - if (!$this->accessGate->allows(is_string($clientIp) ? $clientIp : null)) { + if (!$this->accessGate->allows(RequestContext::fromRequest($this->request)->clientIp)) { return $this->json(['error' => 'Not found.'], 404); } diff --git a/src/DebugBar/Injector.php b/src/DebugBar/Injector.php index 4ac8d74..c310b76 100644 --- a/src/DebugBar/Injector.php +++ b/src/DebugBar/Injector.php @@ -50,6 +50,20 @@ public function inject(ResponseInterface $response, string $head, string $body): return $response; } + /** + * Whether the response is an HTML page. A response with no content type counts + * as HTML. + */ + public function isHtml(ResponseInterface $response): bool + { + $contentType = $response->getHeaders()->get('Content-Type'); + if (!is_string($contentType)) { + return true; + } + + return str_contains(mb_strtolower($contentType), 'text/html'); + } + /** * Whether the bar may be injected: HTML content, not a redirect, not AJAX. */ @@ -64,11 +78,6 @@ public function shouldInject(ResponseInterface $response, bool $isAjax = false): return false; } - $contentType = $response->getHeaders()->get('Content-Type'); - if (!is_string($contentType)) { - return true; - } - - return str_contains(mb_strtolower($contentType), 'text/html'); + return $this->isHtml($response); } } diff --git a/src/DebugBar/RequestContext.php b/src/DebugBar/RequestContext.php new file mode 100644 index 0000000..98c3977 --- /dev/null +++ b/src/DebugBar/RequestContext.php @@ -0,0 +1,47 @@ + + * + * 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; + +use Phalcon\Http\RequestInterface; + +use function is_string; + +/** + * The request values that the bar uses to make decisions. Create this object for + * each decision: do not keep it at boot. + */ +final class RequestContext +{ + public function __construct( + public readonly ?string $clientIp, + public readonly bool $isAjax, + public readonly bool $isSecure + ) { + } + + public static function fromRequest(?RequestInterface $request): self + { + if (null === $request) { + return new self(null, false, false); + } + + $clientIp = $request->getClientAddress(); + + return new self( + is_string($clientIp) ? $clientIp : null, + $request->isAjax(), + $request->isSecure() + ); + } +} diff --git a/tests/Unit/DebugBar/InjectorTest.php b/tests/Unit/DebugBar/InjectorTest.php index 3680a3d..f7123a1 100644 --- a/tests/Unit/DebugBar/InjectorTest.php +++ b/tests/Unit/DebugBar/InjectorTest.php @@ -39,6 +39,36 @@ public function testInjectSplicesBeforeTheLastBodyTag(): void $this->assertSame('hi', $response->getContent()); } + public function testIsHtmlForAnHtmlContentType(): void + { + $response = new Response(); + $response->setContentType('text/html', 'UTF-8'); + + $this->assertTrue((new Injector())->isHtml($response)); + } + + public function testIsHtmlForARedirectThatTheBarSkips(): void + { + $response = new Response(); + $response->setStatusCode(302); + $response->setContentType('text/html'); + + $this->assertTrue((new Injector())->isHtml($response)); + } + + public function testIsHtmlWhenThereIsNoContentType(): void + { + $this->assertTrue((new Injector())->isHtml(new Response())); + } + + public function testIsNotHtmlForJson(): void + { + $response = new Response(); + $response->setContentType('application/json'); + + $this->assertFalse((new Injector())->isHtml($response)); + } + public function testShouldInjectForClientErrorStatus(): void { $response = new Response(); diff --git a/tests/Unit/DebugBar/RequestContextTest.php b/tests/Unit/DebugBar/RequestContextTest.php new file mode 100644 index 0000000..a4c5fb9 --- /dev/null +++ b/tests/Unit/DebugBar/RequestContextTest.php @@ -0,0 +1,52 @@ + + * + * 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; + +use Phalcon\DebugBar\RequestContext; +use Phalcon\Http\RequestInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class RequestContextTest extends AbstractUnitTestCase +{ + public function testClientAddressThatIsNotAStringIsNull(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getClientAddress')->willReturn(false); + + $this->assertNull(RequestContext::fromRequest($request)->clientIp); + } + + public function testNoRequestHasNoClientAndNoFlags(): void + { + $context = RequestContext::fromRequest(null); + + $this->assertNull($context->clientIp); + $this->assertFalse($context->isAjax); + $this->assertFalse($context->isSecure); + } + + public function testValuesComeFromTheRequest(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getClientAddress')->willReturn('203.0.113.10'); + $request->method('isAjax')->willReturn(true); + $request->method('isSecure')->willReturn(true); + + $context = RequestContext::fromRequest($request); + + $this->assertSame('203.0.113.10', $context->clientIp); + $this->assertTrue($context->isAjax); + $this->assertTrue($context->isSecure); + } +} From 585007ab588548fc520b04ca7630f56e4b272e3f Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:10:28 -0600 Subject: [PATCH 2/6] [#62] - reading the active bar at log time in the logger adapters Assisted-by: Claude Code --- src/DebugBar/Logger/Adapter.php | 20 +++--- src/DebugBar/Logger/ExceptionAdapter.php | 11 ++-- tests/Unit/DebugBar/Logger/AdapterTest.php | 64 +++++++++++++------ .../DebugBar/Logger/ExceptionAdapterTest.php | 49 +++++++++++--- tests/support/DebugBar/ADR/Front.php | 3 +- 5 files changed, 99 insertions(+), 48 deletions(-) diff --git a/src/DebugBar/Logger/Adapter.php b/src/DebugBar/Logger/Adapter.php index c0cc3d0..c2167b3 100644 --- a/src/DebugBar/Logger/Adapter.php +++ b/src/DebugBar/Logger/Adapter.php @@ -13,7 +13,7 @@ namespace Phalcon\DebugBar\Logger; -use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Debug; use Phalcon\Logger\Adapter\AbstractAdapter; use Phalcon\Logger\Item; @@ -23,21 +23,17 @@ * captured in the bar's "Logs" tab: * * ```php - * $logger->addAdapter('debugbar', new Adapter(Debug::getBar())); + * $logger->addAdapter('debugbar', new Adapter()); * ``` * - * The bar is nullable because `Debug::getBar()` returns `null` until the bar - * boots, so the adapter can be attached unconditionally. Forwarding goes - * through `DebugBar::addLog()`, so it also no-ops when the logger collector is - * disabled. Only `process()` is overridden; the inherited `commit()` replays - * queued items through it, covering transactional logging. + * The adapter reads the active bar each time the logger writes, so you can create + * it before the bar boots. Items that the logger writes before the boot are lost. + * Forwarding goes through `DebugBar::addLog()`, so it also no-ops when the logger + * collector is disabled. Only `process()` is overridden; the inherited `commit()` + * replays queued items through it, covering transactional logging. */ final class Adapter extends AbstractAdapter { - public function __construct(private readonly ?DebugBar $bar = null) - { - } - public function close(): bool { return true; @@ -45,6 +41,6 @@ public function close(): bool public function process(Item $item): void { - $this->bar?->addLog($item->getMessage(), $item->getLevelName(), $item->getContext()); + Debug::getBar()?->addLog($item->getMessage(), $item->getLevelName(), $item->getContext()); } } diff --git a/src/DebugBar/Logger/ExceptionAdapter.php b/src/DebugBar/Logger/ExceptionAdapter.php index f65c5ab..a85fc72 100644 --- a/src/DebugBar/Logger/ExceptionAdapter.php +++ b/src/DebugBar/Logger/ExceptionAdapter.php @@ -13,7 +13,7 @@ namespace Phalcon\DebugBar\Logger; -use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Debug; use Phalcon\Logger\Adapter\AbstractAdapter; use Phalcon\Logger\Item; use Throwable; @@ -23,14 +23,11 @@ * to the debug bar's `ExceptionsCollector`. `Phalcon\ADR\ErrorResponder` logs each * exception in this form and fires no event. Keep this class separate from * `Adapter`: an MVC application that logs an exception would record it two times. - * The bar is nullable because `Debug::getBar()` returns `null` until the bar boots. + * The adapter reads the active bar each time the logger writes, so you can create + * it before the bar boots. */ final class ExceptionAdapter extends AbstractAdapter { - public function __construct(private readonly ?DebugBar $bar = null) - { - } - public function close(): bool { return true; @@ -40,7 +37,7 @@ public function process(Item $item): void { $exception = $item->getContext()['exception'] ?? null; if ($exception instanceof Throwable) { - $this->bar?->addException($exception); + Debug::getBar()?->addException($exception); } } } diff --git a/tests/Unit/DebugBar/Logger/AdapterTest.php b/tests/Unit/DebugBar/Logger/AdapterTest.php index c5e634c..bd4698f 100644 --- a/tests/Unit/DebugBar/Logger/AdapterTest.php +++ b/tests/Unit/DebugBar/Logger/AdapterTest.php @@ -15,8 +15,10 @@ use DateTimeImmutable; use Phalcon\DebugBar\Collector\LoggerCollector; +use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\Logger\Adapter; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Logger\Item; use Phalcon\Logger\Logger; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; @@ -25,20 +27,42 @@ final class AdapterTest extends AbstractUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + } + + protected function tearDown(): void + { + Debug::setBar(null); + + parent::tearDown(); + } + + public function testAdapterCreatedBeforeTheBarBootsForwardsToTheBar(): void + { + $logger = new Logger('debug', ['debugbar' => new Adapter()]); + + $collector = $this->bootBar(); + $logger->info('after boot'); + + $this->assertSame('after boot', $collector->collect()['panel'][0]['message']); + } + public function testCloseReturnsTrue(): void { - $adapter = new Adapter(new DebugBar()); + $adapter = new Adapter(); $this->assertTrue($adapter->close()); } public function testLoggerContextIsForwarded(): void { - $collector = new LoggerCollector(); - $bar = new DebugBar(); - $bar->addCollector($collector); + $collector = $this->bootBar(); - $logger = new Logger('debug', ['debugbar' => new Adapter($bar)]); + $logger = new Logger('debug', ['debugbar' => new Adapter()]); $logger->info('user login', ['user_id' => 7]); $row = $collector->collect()['panel'][0]; @@ -49,11 +73,9 @@ public function testLoggerContextIsForwarded(): void public function testLoggingThroughRealLoggerPopulatesTheCollector(): void { - $collector = new LoggerCollector(); - $bar = new DebugBar(); - $bar->addCollector($collector); + $collector = $this->bootBar(); - $logger = new Logger('debug', ['debugbar' => new Adapter($bar)]); + $logger = new Logger('debug', ['debugbar' => new Adapter()]); $logger->info('hello'); $logger->error('nope'); @@ -67,13 +89,20 @@ public function testLoggingThroughRealLoggerPopulatesTheCollector(): void $this->assertSame('nope', $panel[1]['message']); } + public function testProcessDoesNothingWhenNoBarIsActive(): void + { + $adapter = new Adapter(); + + $adapter->process(new Item('boom', 'error', 3, new DateTimeImmutable())); + + $this->assertNull(Debug::getBar()); + } + public function testProcessForwardsMessageLevelAndContext(): void { - $collector = new LoggerCollector(); - $bar = new DebugBar(); - $bar->addCollector($collector); + $collector = $this->bootBar(); - $adapter = new Adapter($bar); + $adapter = new Adapter(); $adapter->process( new Item('boom', 'error', 3, new DateTimeImmutable(), ['request_id' => 'abc']) ); @@ -85,12 +114,11 @@ public function testProcessForwardsMessageLevelAndContext(): void $this->assertSame(['request_id' => 'abc'], json_decode($row['context'], true)); } - public function testProcessNoOpsWhenBarIsNull(): void + private function bootBar(): LoggerCollector { - $adapter = new Adapter(null); + $collector = new LoggerCollector(new Redactor()); + Debug::setBar((new DebugBar())->addCollector($collector)); - $adapter->process(new Item('boom', 'error', 3, new DateTimeImmutable())); - - $this->assertTrue($adapter->close()); + return $collector; } } diff --git a/tests/Unit/DebugBar/Logger/ExceptionAdapterTest.php b/tests/Unit/DebugBar/Logger/ExceptionAdapterTest.php index 1b6b5ac..3f3da34 100644 --- a/tests/Unit/DebugBar/Logger/ExceptionAdapterTest.php +++ b/tests/Unit/DebugBar/Logger/ExceptionAdapterTest.php @@ -16,8 +16,10 @@ use DateTimeImmutable; use Phalcon\DebugBar\Collector\ExceptionsCollector; use Phalcon\DebugBar\Collector\LoggerCollector; +use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\Logger\ExceptionAdapter; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Logger\Item; use Phalcon\Logger\Logger; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; @@ -25,9 +27,37 @@ final class ExceptionAdapterTest extends AbstractUnitTestCase { + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + } + + protected function tearDown(): void + { + Debug::setBar(null); + + parent::tearDown(); + } + + public function testAdapterCreatedBeforeTheBarBootsForwardsToTheBar(): void + { + $logger = new Logger('debug', ['debugbar' => new ExceptionAdapter()]); + + $collector = new ExceptionsCollector(); + $bar = (new DebugBar())->addCollector($collector); + Debug::setBar($bar); + $exception = new RuntimeException('boom'); + $logger->error('failed', ['exception' => $exception]); + + $this->assertSame(1, $collector->collect()['badge']); + $this->assertSame($exception, $bar->getLastException()); + } + public function testCloseReturnsTrue(): void { - $adapter = new ExceptionAdapter(new DebugBar()); + $adapter = new ExceptionAdapter(); $this->assertTrue($adapter->close()); } @@ -35,7 +65,8 @@ public function testCloseReturnsTrue(): void public function testContextWithNoThrowableIsIgnored(): void { $collector = new ExceptionsCollector(); - $adapter = new ExceptionAdapter((new DebugBar())->addCollector($collector)); + Debug::setBar((new DebugBar())->addCollector($collector)); + $adapter = new ExceptionAdapter(); $adapter->process(new Item('one', 'error', 3, new DateTimeImmutable())); $adapter->process(new Item('two', 'error', 3, new DateTimeImmutable(), ['exception' => 'text'])); @@ -46,8 +77,8 @@ public function testContextWithNoThrowableIsIgnored(): void public function testExceptionLoggedThroughRealLoggerReachesTheCollector(): void { $collector = new ExceptionsCollector(); - $bar = (new DebugBar())->addCollector($collector); - $logger = new Logger('debug', ['debugbar' => new ExceptionAdapter($bar)]); + Debug::setBar((new DebugBar())->addCollector($collector)); + $logger = new Logger('debug', ['debugbar' => new ExceptionAdapter()]); $logger->error('failed', ['exception' => new RuntimeException('boom')]); @@ -60,17 +91,17 @@ public function testExceptionLoggedThroughRealLoggerReachesTheCollector(): void public function testExceptionShowsOnlyInTheExceptionsCollector(): void { - $logs = new LoggerCollector(); - $bar = (new DebugBar())->addCollector($logs)->addCollector(new ExceptionsCollector()); + $logs = new LoggerCollector(new Redactor()); + Debug::setBar((new DebugBar())->addCollector($logs)->addCollector(new ExceptionsCollector())); - (new ExceptionAdapter($bar))->process( + (new ExceptionAdapter())->process( new Item('failed', 'error', 3, new DateTimeImmutable(), ['exception' => new RuntimeException('boom')]) ); $this->assertSame([], $logs->collect()['panel']); } - public function testProcessDoesNothingWhenBarIsNull(): void + public function testProcessDoesNothingWhenNoBarIsActive(): void { $adapter = new ExceptionAdapter(); @@ -78,6 +109,6 @@ public function testProcessDoesNothingWhenBarIsNull(): void new Item('failed', 'error', 3, new DateTimeImmutable(), ['exception' => new RuntimeException('boom')]) ); - $this->assertTrue($adapter->close()); + $this->assertNull(Debug::getBar()); } } diff --git a/tests/support/DebugBar/ADR/Front.php b/tests/support/DebugBar/ADR/Front.php index 1fbc4e1..c7b59ab 100644 --- a/tests/support/DebugBar/ADR/Front.php +++ b/tests/support/DebugBar/ADR/Front.php @@ -28,7 +28,6 @@ use Phalcon\Contracts\View\Renderer; use Phalcon\DataMapper\Pdo\Connection; use Phalcon\DebugBar\ADR\Provider; -use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\DebugBar\Logger\ExceptionAdapter; use Phalcon\Events\ManagerInterface; @@ -133,7 +132,7 @@ private function giveEventsManager(Container $container, Cache | Connection | Si private function newLogger(): Logger { - return new Logger('adr', ['debugbar' => new ExceptionAdapter(Debug::getBar())]); + return new Logger('adr', ['debugbar' => new ExceptionAdapter()]); } private function newRenderer(Container $container): Simple From 8e1c549adccb8d8ce197d60fedb26b5f67909d9a Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:12:10 -0600 Subject: [PATCH 3/6] [#62] - moving the history workflow to HistoryRecorder and Core Assisted-by: Claude Code --- src/DebugBar/ADR/Provider.php | 54 ++---- src/DebugBar/Core.php | 73 ++++++-- src/DebugBar/History/HistoryEndpoint.php | 2 - src/DebugBar/History/HistoryOptions.php | 2 +- src/DebugBar/History/HistoryRecorder.php | 100 +++++++++++ src/DebugBar/Mvc/HistoryListener.php | 4 +- src/DebugBar/Provider.php | 61 ++----- src/DebugBar/ResponseListener.php | 85 +-------- .../Unit/DebugBar/ADR/ProviderHistoryTest.php | 14 +- tests/Unit/DebugBar/CoreTest.php | 146 ++++++++++++++- .../DebugBar/History/HistoryRecorderTest.php | 167 ++++++++++++++++++ .../Unit/DebugBar/Mvc/HistoryListenerTest.php | 2 +- tests/Unit/DebugBar/ProviderTest.php | 16 +- tests/Unit/DebugBar/ResponseListenerTest.php | 32 ++-- 14 files changed, 555 insertions(+), 203 deletions(-) create mode 100644 src/DebugBar/History/HistoryRecorder.php create mode 100644 tests/Unit/DebugBar/History/HistoryRecorderTest.php diff --git a/src/DebugBar/ADR/Provider.php b/src/DebugBar/ADR/Provider.php index 915906a..396f4b6 100644 --- a/src/DebugBar/ADR/Provider.php +++ b/src/DebugBar/ADR/Provider.php @@ -19,17 +19,11 @@ use Phalcon\Contracts\Events\Manager as ManagerContract; use Phalcon\Contracts\Http\AttributeRequest; use Phalcon\Debug as DebugPage; -use Phalcon\DebugBar\Collector\HistoryCollector; -use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\Core; -use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; -use Phalcon\DebugBar\History\FilesystemHistory; -use Phalcon\DebugBar\History\HistoryCookie; -use Phalcon\DebugBar\History\HistoryEndpoint; -use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\ResponseListener; use Phalcon\Events\ManagerInterface; use Phalcon\Http\RequestInterface; @@ -51,20 +45,13 @@ final class Provider implements ProviderContract private bool | DebugPage $debugPage; - private ?HistoryCookie $historyCookie = null; - - private ?HistoryEndpoint $historyEndpoint = null; - - private HistoryOptions $historyOptions; - /** * @param provider_config $config */ public function __construct(array $config = []) { - $this->core = new Core($config); - $this->debugPage = $config['debug_page'] ?? false; - $this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); + $this->core = new Core($config); + $this->debugPage = $config['debug_page'] ?? false; } /** @@ -80,16 +67,13 @@ public function provide(Collection $services): void $eventsManager = $eventsManager instanceof ManagerInterface ? $eventsManager : null; $request = $this->resolve($services, AttributeRequest::class); $request = $request instanceof RequestInterface ? $request : null; - $history = $this->registerHistory($services, $eventsManager, $request); $listener = $this->core->boot( $eventsManager, $request, null, - $history, - $this->historyEndpoint, - $this->historyCookie + $this->registerHistory($services, $eventsManager, $request) ); - $bar = Debug::getBar(); + $bar = $this->core->getBar(); if (null === $eventsManager || null === $listener || null === $bar) { return; @@ -107,31 +91,19 @@ private function registerHistory( Collection $services, ?ManagerInterface $eventsManager, ?RequestInterface $request - ): ?History { - if ( - !$this->historyOptions->enabled - || !$this->core->isCollectorEnabled(HistoryCollector::NAME) - ) { + ): ?HistoryRecorder { + if (null === $eventsManager || !$services->has(Router::class)) { return null; } - $this->historyOptions->validate(); - - if (null === $eventsManager || !$services->has(Router::class)) { + $recorder = $this->core->createHistory(fn (): ?RequestInterface => $request); + if (null === $recorder) { return null; } - $this->historyCookie = HistoryCookie::fromGlobals(); - $history = new FilesystemHistory($this->historyOptions, null, $this->historyCookie); - $history->validateStorage(); - $accessGate = $this->core->getAccessGate(); - $baseUri = $this->historyOptions->baseUri; - $endpoint = new HistoryEndpoint( - $this->historyOptions, - fn (): ?RequestInterface => $request, - fn (): string => $baseUri - ); - $this->historyEndpoint = $endpoint; + $accessGate = $this->core->getAccessGate(); + $endpoint = $recorder->endpoint(); + $history = $recorder->history(); $services->set( HistoryAction::class, @@ -142,7 +114,7 @@ private function registerHistory( fn (Router $router): Router => new HistoryRouter($router, $endpoint) ); - return $history; + return $recorder; } private function resolve(Collection $services, string $name): mixed diff --git a/src/DebugBar/Core.php b/src/DebugBar/Core.php index b69d381..262a1ce 100644 --- a/src/DebugBar/Core.php +++ b/src/DebugBar/Core.php @@ -13,6 +13,8 @@ namespace Phalcon\DebugBar; +use Closure; +use InvalidArgumentException; use Phalcon\Config\ConfigInterface; use Phalcon\DebugBar\Collector\CacheCollector; use Phalcon\DebugBar\Collector\ConfigCollector; @@ -29,15 +31,18 @@ use Phalcon\DebugBar\Collector\VersionCollector; use Phalcon\DebugBar\Collector\ViewCollector; use Phalcon\DebugBar\Contracts\Collector; -use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\DebugBar\History\FilesystemHistory; use Phalcon\DebugBar\History\HistoryCookie; use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\DebugBar\Security\Redactor; use Phalcon\Events\ManagerInterface; use Phalcon\Http\RequestInterface; use Phalcon\Http\ResponseInterface; +use RuntimeException; use function getenv; use function in_array; @@ -56,6 +61,8 @@ final class Core { private AccessGate $accessGate; + private ?DebugBar $bar = null; + /** * @var list */ @@ -72,6 +79,8 @@ final class Core private bool $headers; + private HistoryOptions $historyOptions; + private ?ResponseListener $listener = null; private ?string $nonce; @@ -98,6 +107,7 @@ public function __construct(array $config = []) $this->accessGate = new AccessGate($access['allow_ips'] ?? [], $access['callback'] ?? null); $this->collectorsConfig = $config['collectors'] ?? []; $this->headers = $config['headers'] ?? true; + $this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); $this->redactor = new Redactor( [...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])], $redact['hidden'] ?? [] @@ -115,9 +125,7 @@ public function boot( ?ManagerInterface $eventsManager = null, ?RequestInterface $request = null, ?ConfigInterface $config = null, - ?History $history = null, - ?HistoryEndpoint $historyEndpoint = null, - ?HistoryCookie $historyCookie = null + ?HistoryRecorder $history = null ): ?ResponseListener { if (true !== $this->canBoot()) { return null; @@ -127,10 +135,11 @@ public function boot( foreach ($this->buildCollectors($request, $config) as $collector) { $bar->addCollector($collector); } - if (null !== $historyEndpoint) { - $bar->addCollector(new HistoryCollector($historyEndpoint)); + if (null !== $history) { + $bar->addCollector(new HistoryCollector($history->endpoint())); } + $this->bar = $bar; Debug::setBar($bar); if (null !== $eventsManager) { @@ -144,9 +153,7 @@ public function boot( $this->accessGate, $request, new BarOptions($this->headers, $this->nonce), - $history, - $historyEndpoint, - $historyCookie + $history ); return $this->listener; @@ -173,11 +180,57 @@ public function canBoot(): bool return $this->enabled; } + /** + * Creates the history recorder from the `history` config. It returns null when + * the bar cannot boot or history is off. Each integration gives the closures + * that read the request and the base URI. With no base URI closure, the recorder + * uses `history.base_uri`. The integration must route the endpoint. + * + * @param Closure(): ?RequestInterface $request + * @param (Closure(): string)|null $baseUri + * + * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. + * @throws InvalidArgumentException When the `history` config is not valid. + * @throws RuntimeException When the web-server user cannot write to `history.path`. + */ + public function createHistory(Closure $request, ?Closure $baseUri = null): ?HistoryRecorder + { + if ( + true !== $this->canBoot() + || !$this->historyOptions->enabled + || !$this->isCollectorEnabled(HistoryCollector::NAME) + ) { + return null; + } + + $options = $this->historyOptions; + $options->validate(); + + $cookie = HistoryCookie::fromGlobals(); + $history = new FilesystemHistory($options, null, $cookie); + $history->validateStorage(); + + return new HistoryRecorder( + $history, + new HistoryEndpoint($options, $request, $baseUri ?? fn (): string => $options->baseUri), + $cookie, + $this->redactor + ); + } + public function getAccessGate(): AccessGate { return $this->accessGate; } + /** + * The bar that `boot()` created, or null when the bar did not boot. + */ + public function getBar(): ?DebugBar + { + return $this->bar; + } + /** * Runs the response step. It does nothing when the bar did not boot. */ @@ -222,7 +275,7 @@ private function buildCollectors(?RequestInterface $request, ?ConfigInterface $c } if ($this->isCollectorEnabled(LoggerCollector::NAME)) { - $collectors[] = new LoggerCollector(); + $collectors[] = new LoggerCollector($this->redactor); } if ($this->isCollectorEnabled(ExceptionsCollector::NAME)) { diff --git a/src/DebugBar/History/HistoryEndpoint.php b/src/DebugBar/History/HistoryEndpoint.php index 15f03b9..f11fc2c 100644 --- a/src/DebugBar/History/HistoryEndpoint.php +++ b/src/DebugBar/History/HistoryEndpoint.php @@ -30,8 +30,6 @@ */ final class HistoryEndpoint { - public const CONTROLLER_NAMESPACE = 'Phalcon\\DebugBar\\Controllers'; - /** * @param Closure(): ?RequestInterface $request * @param Closure(): string $baseUri diff --git a/src/DebugBar/History/HistoryOptions.php b/src/DebugBar/History/HistoryOptions.php index 7c053a5..3f10188 100644 --- a/src/DebugBar/History/HistoryOptions.php +++ b/src/DebugBar/History/HistoryOptions.php @@ -30,7 +30,7 @@ use const PHP_URL_SCHEME; /** - * Immutable request-history configuration shared by the provider and storage. + * Immutable request-history configuration shared by `Core` and storage. * * @phpstan-import-type history_config from DebugBarTypes */ diff --git a/src/DebugBar/History/HistoryRecorder.php b/src/DebugBar/History/HistoryRecorder.php new file mode 100644 index 0000000..d318028 --- /dev/null +++ b/src/DebugBar/History/HistoryRecorder.php @@ -0,0 +1,100 @@ + + * + * 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 Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\DebugBarTypes; +use Phalcon\DebugBar\RequestContext; +use Phalcon\DebugBar\Security\Redactor; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; + +use function is_float; +use function sprintf; + +/** + * Holds the request history workflow: it finds the endpoint request, stores the + * collected payload and queues the browser cookie. `Core::createHistory()` creates + * it, and the routes of each integration read its storage and its endpoint. + * + * @phpstan-import-type payload from DebugBarTypes + */ +final class HistoryRecorder +{ + public function __construct( + private readonly History $history, + private readonly HistoryEndpoint $endpoint, + private readonly HistoryCookie $cookie, + private readonly Redactor $redactor + ) { + } + + public function endpoint(): HistoryEndpoint + { + return $this->endpoint; + } + + public function history(): History + { + return $this->history; + } + + public function isEndpointRequest(): bool + { + return $this->endpoint->matches(); + } + + /** + * @param payload $payload + */ + public function record( + array $payload, + ResponseInterface $response, + ?RequestInterface $request, + RequestContext $context + ): void { + if (null !== $request) { + $this->history->save( + $payload, + new RequestMetadata( + $request->getMethod(), + $this->redactor->redactUri($request->getURI()), + $response->getStatusCode() ?? 200, + $context->isAjax, + $this->requestedAt() + ) + ); + } + + $this->cookie->queue($this->endpoint->cookiePath(), $context->isSecure); + } + + 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/src/DebugBar/Mvc/HistoryListener.php b/src/DebugBar/Mvc/HistoryListener.php index 0223387..84014b8 100644 --- a/src/DebugBar/Mvc/HistoryListener.php +++ b/src/DebugBar/Mvc/HistoryListener.php @@ -23,6 +23,8 @@ */ final class HistoryListener { + public const CONTROLLER_NAMESPACE = 'Phalcon\\DebugBar\\Controllers'; + public function __construct(private readonly HistoryEndpoint $endpoint) { } @@ -33,7 +35,7 @@ public function __invoke(EventInterface $event, mixed $source, mixed $dispatcher return; } - $dispatcher->setNamespaceName(HistoryEndpoint::CONTROLLER_NAMESPACE); + $dispatcher->setNamespaceName(self::CONTROLLER_NAMESPACE); $dispatcher->setControllerName('history'); $dispatcher->setControllerSuffix('Controller'); $dispatcher->setActionSuffix('Action'); diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index 6938b45..2f50972 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -14,18 +14,13 @@ namespace Phalcon\DebugBar; use Phalcon\Config\ConfigInterface; -use Phalcon\DebugBar\Collector\HistoryCollector; -use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\Controllers\HistoryController; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; -use Phalcon\DebugBar\History\FilesystemHistory; -use Phalcon\DebugBar\History\HistoryCookie; -use Phalcon\DebugBar\History\HistoryEndpoint; -use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\Mvc\Bridge; use Phalcon\DebugBar\Mvc\HistoryListener; -use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Di\DiInterface; +use Phalcon\Events\ManagerInterface; use Phalcon\Http\Request; use Phalcon\Http\RequestInterface; use Phalcon\Http\Response; @@ -44,19 +39,12 @@ class Provider { private Core $core; - private ?HistoryCookie $historyCookie = null; - - private ?HistoryEndpoint $historyEndpoint = null; - - private HistoryOptions $historyOptions; - /** * @param provider_config $config */ public function __construct(private readonly Application $app, array $config = []) { - $this->core = new Core($config); - $this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); + $this->core = new Core($config); } /** @@ -74,18 +62,15 @@ public function boot(): void /** @var DiInterface $container */ $container = $this->app->getDI(); $request = $this->resolveRequest($container); - $history = $this->registerHistory($container, $this->core->getAccessGate()); $eventsManager = $this->app->getEventsManager(); $listener = $this->core->boot( $eventsManager, $request, $this->resolveConfig($container), - $history, - $this->historyEndpoint, - $this->historyCookie + $this->registerHistory($container, $eventsManager) ); - $bar = Debug::getBar(); + $bar = $this->core->getBar(); if (null === $eventsManager || null === $listener || null === $bar) { return; @@ -106,36 +91,20 @@ public function isAllowed(): bool /** * Wires the internal endpoint without reading or mutating the app router. */ - private function registerHistory( - ?DiInterface $container, - AccessGate $accessGate - ): ?History { - if ( - !$this->historyOptions->enabled - || !$this->core->isCollectorEnabled(HistoryCollector::NAME) - ) { + private function registerHistory(?DiInterface $container, ?ManagerInterface $eventsManager): ?HistoryRecorder + { + if (null === $container || null === $eventsManager) { return null; } - $this->historyOptions->validate(); - - $eventsManager = $this->app->getEventsManager(); - if ( - null === $container - || null === $eventsManager - ) { + $request = fn (): ?RequestInterface => $this->resolveSharedRequest($container); + $recorder = $this->core->createHistory($request, fn (): string => $this->resolveBaseUri($container)); + if (null === $recorder) { return null; } - $this->historyCookie = HistoryCookie::fromGlobals(); - $history = new FilesystemHistory($this->historyOptions, null, $this->historyCookie); - $history->validateStorage(); - $request = fn (): ?RequestInterface => $this->resolveSharedRequest($container); - $this->historyEndpoint = new HistoryEndpoint( - $this->historyOptions, - $request, - fn (): string => $this->resolveBaseUri($container) - ); + $accessGate = $this->core->getAccessGate(); + $history = $recorder->history(); // The DI binds this closure to the container, so `$this` is not the provider here. $container->setShared( HistoryController::class, @@ -148,9 +117,9 @@ function () use ($accessGate, $history, $request): HistoryController { ); } ); - $eventsManager->attach('application:beforeHandleRequest', new HistoryListener($this->historyEndpoint)); + $eventsManager->attach('application:beforeHandleRequest', new HistoryListener($recorder->endpoint())); - return $history; + return $recorder; } private function resolveBaseUri(DiInterface $container): string diff --git a/src/DebugBar/ResponseListener.php b/src/DebugBar/ResponseListener.php index 23f2d34..17a5b29 100644 --- a/src/DebugBar/ResponseListener.php +++ b/src/DebugBar/ResponseListener.php @@ -13,21 +13,13 @@ namespace Phalcon\DebugBar; -use DateTimeImmutable; -use DateTimeZone; -use Phalcon\DebugBar\Contracts\History; -use Phalcon\DebugBar\History\HistoryCookie; -use Phalcon\DebugBar\History\HistoryEndpoint; -use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Events\EventInterface; use Phalcon\Http\RequestInterface; use Phalcon\Http\ResponseInterface; use function count; -use function is_float; -use function is_string; -use function sprintf; /** * The response listener: MVC attaches it to `application:beforeSendResponse` and @@ -35,9 +27,6 @@ * aggregates the bar, sets the diagnostic headers, and - for an injectable HTML * response - renders and splices the bar in. `handle()` does the same work with * no event. - * - * @phpstan-import-type request_context from DebugBarTypes - * @phpstan-import-type payload from DebugBarTypes */ final class ResponseListener { @@ -48,9 +37,7 @@ public function __construct( private readonly AccessGate $accessGate, private readonly ?RequestInterface $request, private readonly BarOptions $options, - private readonly ?History $history = null, - private readonly ?HistoryEndpoint $historyEndpoint = null, - private readonly ?HistoryCookie $historyCookie = null + private readonly ?HistoryRecorder $history = null ) { } @@ -65,30 +52,24 @@ public function __invoke(EventInterface $event, mixed $source, mixed $response): public function handle(ResponseInterface $response): void { - if ($this->historyEndpoint?->matches()) { + if ($this->history?->isEndpointRequest()) { return; } - [$clientIp, $isAjax] = $this->requestContext(); - if (true !== $this->accessGate->allows($clientIp)) { + $context = RequestContext::fromRequest($this->request); + if (true !== $this->accessGate->allows($context->clientIp)) { return; } $collected = $this->bar->collect(); - $this->record($collected, $response, $isAjax); - if (null !== $this->history) { - $this->historyCookie?->queue( - $this->historyEndpoint?->cookiePath() ?? '/', - $this->request?->isSecure() ?? false - ); - } + $this->history?->record($collected, $response, $this->request, $context); if (true === $this->options->headers) { $response->setHeader('X-Debug-Bar', (string) count($collected['data'])); } - if (true === $this->injector->shouldInject($response, $isAjax)) { + if (true === $this->injector->shouldInject($response, $context->isAjax)) { $this->injector->inject( $response, $this->renderer->renderHead($this->options->nonce), @@ -96,56 +77,4 @@ public function handle(ResponseInterface $response): void ); } } - - /** - * @param payload $collected - */ - private function record(array $collected, ResponseInterface $response, bool $isAjax): void - { - if (null === $this->history || null === $this->request) { - return; - } - - $uri = $this->request->getURI(); - $this->history->save( - $collected, - new RequestMetadata( - $this->request->getMethod(), - $uri, - $response->getStatusCode() ?? 200, - $isAjax, - $this->requestedAt() - ) - ); - } - - /** - * @return request_context - */ - private function requestContext(): array - { - if (null === $this->request) { - return [null, false]; - } - - $clientIp = $this->request->getClientAddress(); - - 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/Unit/DebugBar/ADR/ProviderHistoryTest.php b/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php index 1c9bf9f..51bc0d3 100644 --- a/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php +++ b/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php @@ -26,6 +26,7 @@ use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\ResponseListener; use Phalcon\Events\Manager; use Phalcon\Http\ResponseInterface; @@ -167,7 +168,14 @@ public function testEnabledHistoryWithNoPathThrows(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('history.path is required'); + $this->container(['env' => ['var' => self::ENV_VAR], 'history' => ['enabled' => true]]); + } + + public function testEventsManagerMissingDoesNotValidateTheOptions(): void + { (new Provider(['env' => ['var' => self::ENV_VAR], 'history' => ['enabled' => true]]))->provide(new Container()); + + $this->assertFalse($this->bar()->hasCollector('history')); } public function testHistoryRequestIsNotRecorded(): void @@ -187,7 +195,7 @@ public function testHistoryResponseIsJsonWithNoBar(): void $this->assertFalse($response->getHeaders()->has('X-Debug-Bar')); } - public function testListenerReceivesTheHistoryCookie(): void + public function testListenerReceivesTheHistoryRecorder(): void { $provider = new Provider($this->config()); $container = new Container(); @@ -201,8 +209,8 @@ public function testListenerReceivesTheHistoryCookie(): void $this->assertInstanceOf(ResponseListener::class, $listener); $this->assertInstanceOf( - HistoryCookie::class, - (new ReflectionProperty(ResponseListener::class, 'historyCookie'))->getValue($listener) + HistoryRecorder::class, + (new ReflectionProperty(ResponseListener::class, 'history'))->getValue($listener) ); } diff --git a/tests/Unit/DebugBar/CoreTest.php b/tests/Unit/DebugBar/CoreTest.php index 5cb8f3e..b7027a0 100644 --- a/tests/Unit/DebugBar/CoreTest.php +++ b/tests/Unit/DebugBar/CoreTest.php @@ -13,7 +13,9 @@ namespace Phalcon\Tests\Unit\DebugBar; +use InvalidArgumentException; use Phalcon\Config\Config; +use Phalcon\DebugBar\Collector\LoggerCollector; use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\Core; use Phalcon\DebugBar\Debug; @@ -22,6 +24,8 @@ use Phalcon\DebugBar\History\HistoryCookie; use Phalcon\DebugBar\History\HistoryEndpoint; use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Events\Manager; use Phalcon\Http\Request; use Phalcon\Http\RequestInterface; @@ -32,7 +36,13 @@ use RuntimeException; use function array_keys; +use function bin2hex; +use function dirname; use function is_array; +use function is_dir; +use function json_decode; +use function random_bytes; +use function rmdir; use function sys_get_temp_dir; #[BackupGlobals(true)] @@ -42,16 +52,22 @@ final class CoreTest extends AbstractUnitTestCase private const HTML = 'hi'; + private string $storage = ''; + protected function setUp(): void { parent::setUp(); Debug::setBar(null); + $this->storage = dirname(__DIR__, 2) . '/_output/debugbar-core-' . bin2hex(random_bytes(8)); } protected function tearDown(): void { Debug::setBar(null); + if (is_dir($this->storage)) { + rmdir($this->storage); + } parent::tearDown(); } @@ -188,6 +204,75 @@ public function testConfigSourceReachesTheCollectorRedacted(): void $this->assertArrayNotHasKey('section.dropme', $panel); } + public function testCreateHistoryIsNullInABlockedEnvironment(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + $recorder = $this->core(['history' => ['enabled' => true, 'path' => $this->storage]]) + ->createHistory(fn (): ?RequestInterface => null); + + $this->assertNull($recorder); + $this->assertDirectoryDoesNotExist($this->storage); + } + + public function testCreateHistoryIsNullWhenHistoryIsOff(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->assertNull($this->core()->createHistory(fn (): ?RequestInterface => null)); + } + + public function testCreateHistoryIsNullWhenTheCollectorIsOff(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core( + ['collectors' => ['history' => false], 'history' => ['enabled' => true]] + ); + + $this->assertNull($core->createHistory(fn (): ?RequestInterface => null)); + } + + public function testCreateHistoryRejectsStorageThatCannotBeWritten(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('history.path must be writable'); + + $this->core(['history' => ['enabled' => true, 'path' => __FILE__]]) + ->createHistory(fn (): ?RequestInterface => null); + } + + public function testCreateHistoryRejectsTheOptionsWithNoPath(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('history.path is required'); + + $this->core(['history' => ['enabled' => true]])->createHistory(fn (): ?RequestInterface => null); + } + + public function testCreateHistoryUsesTheConfiguredBaseUriByDefault(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $recorder = $this->core(['history' => ['enabled' => true, 'path' => $this->storage, 'base_uri' => '/app1/']]) + ->createHistory(fn (): ?RequestInterface => null); + + $this->assertSame('/app1/_debugbar/open', $recorder?->endpoint()->url()); + } + + public function testCreateHistoryUsesTheGivenBaseUriOverTheConfiguredOne(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $recorder = $this->core(['history' => ['enabled' => true, 'path' => $this->storage, 'base_uri' => '/app1/']]) + ->createHistory(fn (): ?RequestInterface => null, fn (): string => '/app2/'); + + $this->assertSame('/app2/_debugbar/open', $recorder?->endpoint()->url()); + } + public function testDefaultBootRegistersEveryCollectorInOrder(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -250,6 +335,29 @@ public function testEnvironmentFollowsTheConfig(): void $this->assertTrue($core->isAllowed()); } + public function testGetBarIsNullWhenTheBarDidNotBoot(): void + { + $_ENV[self::ENV_VAR] = 'production'; + $core = $this->core(); + + $core->boot(); + + $this->assertNull($core->getBar()); + } + + public function testGetBarReturnsTheBarOfThisCore(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(); + + $core->boot(); + $bar = $core->getBar(); + Debug::setBar(null); + + $this->assertInstanceOf(DebugBar::class, $bar); + $this->assertSame($bar, $core->getBar()); + } + public function testHandleBeforeBootDoesNothing(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -285,13 +393,23 @@ public function testHistoryEndpointRequestIsNotInjected(): void $core = $this->core(); $response = $this->response(); - $core->boot(null, $request, null, null, $this->endpoint($request)); + $core->boot( + null, + $request, + null, + new HistoryRecorder( + $this->createMock(History::class), + $this->endpoint($request), + new HistoryCookie(null), + new Redactor() + ) + ); $core->handle($response); $this->assertSame(self::HTML, $response->getContent()); } - public function testHistoryPartsAddTheCollectorAndRecordTheRequest(): void + public function testHistoryRecorderAddsTheCollectorAndRecordsTheRequest(): void { unset($_COOKIE[HistoryCookie::NAME]); $_ENV[self::ENV_VAR] = 'dev'; @@ -309,7 +427,12 @@ function () use (&$queued): bool { ); $core = $this->core(); - $core->boot(null, $request, null, $history, $this->endpoint($request), $cookie); + $core->boot( + null, + $request, + null, + new HistoryRecorder($history, $this->endpoint($request), $cookie, new Redactor()) + ); $core->handle($this->response()); $this->assertTrue($this->bootedBar()->hasCollector('history')); @@ -340,6 +463,22 @@ public function testNoSourcesLeaveTheirPanelsEmpty(): void $this->assertSame([], $data['request']['panel']); } + public function testRedactOptionsReachTheLogContext(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme']]])->boot(); + + $bar = $this->bootedBar(); + $bar->addLog('login', 'info', ['maskme' => 'mask-value', 'dropme' => 'drop-value']); + $collector = $bar->getCollector('logger'); + if (!$collector instanceof LoggerCollector) { + $this->fail('Expected the logger collector.'); + } + + $this->assertSame(['maskme' => '***'], json_decode($collector->collect()['panel'][0]['context'], true)); + } + public function testRequestReachesTheCollector(): void { $_ENV[self::ENV_VAR] = 'dev'; @@ -392,6 +531,7 @@ private function bootedBar(): DebugBar * access?: array{allow_ips?: list}, * collectors?: array, * headers?: bool, + * history?: array{enabled?: bool, path?: string, base_uri?: string}, * redact?: array{mask?: list, hidden?: list} * } $config */ diff --git a/tests/Unit/DebugBar/History/HistoryRecorderTest.php b/tests/Unit/DebugBar/History/HistoryRecorderTest.php new file mode 100644 index 0000000..2e5ecb4 --- /dev/null +++ b/tests/Unit/DebugBar/History/HistoryRecorderTest.php @@ -0,0 +1,167 @@ + + * + * 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\Contracts\History; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; +use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\DebugBar\RequestContext; +use Phalcon\DebugBar\Security\Redactor; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\Response; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class HistoryRecorderTest extends AbstractUnitTestCase +{ + private const PAYLOAD = ['data' => [], 'meta' => ['collectors' => 0]]; + + /** + * @var list}> + */ + private array $queued = []; + + /** + * @var list, metadata: RequestMetadata}> + */ + private array $saved = []; + + public function testEndpointRequestIsDetected(): void + { + $endpointRequest = $this->request('/app1/_debugbar/open?id=entry'); + $otherRequest = $this->request('/app1/orders'); + + $this->assertTrue($this->recorder($endpointRequest)->isEndpointRequest()); + $this->assertFalse($this->recorder($otherRequest)->isEndpointRequest()); + } + + public function testPartsAreAvailableForTheRoutes(): void + { + $history = $this->history(); + $recorder = new HistoryRecorder($history, $this->endpoint(null), $this->cookie(), new Redactor()); + + $this->assertSame($history, $recorder->history()); + $this->assertSame('/app1/_debugbar/open', $recorder->endpoint()->url()); + } + + public function testRecordQueuesTheCookieWithTheEndpointPathAndTheSecureFlag(): void + { + $request = $this->request('/app1/orders'); + + $context = new RequestContext(null, false, true); + + $this->recorder($request)->record(self::PAYLOAD, new Response(), $request, $context); + + $this->assertCount(1, $this->queued); + $this->assertSame(HistoryCookie::NAME, $this->queued[0]['name']); + $this->assertSame('/app1/', $this->queued[0]['options']['path']); + $this->assertTrue($this->queued[0]['options']['secure']); + } + + public function testRecordSavesThePayloadAndTheRequestValues(): void + { + $request = $this->request('/app1/orders'); + $response = new Response(); + $response->setStatusCode(202); + + $this->recorder($request)->record(self::PAYLOAD, $response, $request, new RequestContext(null, true, false)); + + $this->assertCount(1, $this->saved); + $this->assertSame(self::PAYLOAD, $this->saved[0]['payload']); + $this->assertSame('PATCH', $this->saved[0]['metadata']->method); + $this->assertSame('/app1/orders', $this->saved[0]['metadata']->uri); + $this->assertSame(202, $this->saved[0]['metadata']->status); + $this->assertTrue($this->saved[0]['metadata']->ajax); + } + + public function testRecordStoresARedactedUri(): void + { + $request = $this->request('/app1/reset?token=abc&page=2'); + $context = new RequestContext(null, false, false); + + $this->recorder($request)->record(self::PAYLOAD, new Response(), $request, $context); + + $this->assertSame('/app1/reset?token=***&page=2', $this->saved[0]['metadata']->uri); + } + + public function testRecordStoresAResponseWithNoStatusAsOk(): void + { + $request = $this->request('/app1/orders'); + + $context = new RequestContext(null, false, false); + + $this->recorder($request)->record(self::PAYLOAD, new Response(), $request, $context); + + $this->assertSame(200, $this->saved[0]['metadata']->status); + } + + public function testRecordWithNoRequestSavesNothingAndStillQueuesTheCookie(): void + { + $this->recorder(null)->record(self::PAYLOAD, new Response(), null, new RequestContext(null, false, false)); + + $this->assertSame([], $this->saved); + $this->assertCount(1, $this->queued); + } + + private function cookie(): HistoryCookie + { + return new HistoryCookie( + null, + function (string $name, string $value, array $options): bool { + $this->queued[] = ['name' => $name, 'options' => $options]; + + return true; + } + ); + } + + private function endpoint(?RequestInterface $request): HistoryEndpoint + { + return new HistoryEndpoint( + new HistoryOptions(true), + fn (): ?RequestInterface => $request, + fn (): string => '/app1/' + ); + } + + private function history(): History + { + $history = $this->createMock(History::class); + $history->method('save')->willReturnCallback( + function (array $payload, RequestMetadata $metadata): ?string { + $this->saved[] = ['payload' => $payload, 'metadata' => $metadata]; + + return null; + } + ); + + return $history; + } + + private function recorder(?RequestInterface $request): HistoryRecorder + { + return new HistoryRecorder($this->history(), $this->endpoint($request), $this->cookie(), new Redactor()); + } + + private function request(string $uri): RequestInterface + { + $request = $this->createMock(RequestInterface::class); + $request->method('getURI')->willReturn($uri); + $request->method('getMethod')->willReturn('PATCH'); + + return $request; + } +} diff --git a/tests/Unit/DebugBar/Mvc/HistoryListenerTest.php b/tests/Unit/DebugBar/Mvc/HistoryListenerTest.php index 1a4bc72..3b8c509 100644 --- a/tests/Unit/DebugBar/Mvc/HistoryListenerTest.php +++ b/tests/Unit/DebugBar/Mvc/HistoryListenerTest.php @@ -36,7 +36,7 @@ public function testInternalRequestsUseThePackageControllerAndDiscardRouteParame (new HistoryListener($endpoint))(new Event('beforeHandleRequest', $this), null, $dispatcher); $this->assertTrue($endpoint->matches()); - $this->assertSame(HistoryEndpoint::CONTROLLER_NAMESPACE, $dispatcher->getNamespaceName()); + $this->assertSame(HistoryListener::CONTROLLER_NAMESPACE, $dispatcher->getNamespaceName()); $this->assertSame('Phalcon\\DebugBar\\Controllers\\HistoryController', $dispatcher->getControllerClass()); $this->assertSame('open', $dispatcher->getActionName()); $this->assertSame('Action', $dispatcher->getActionSuffix()); diff --git a/tests/Unit/DebugBar/ProviderTest.php b/tests/Unit/DebugBar/ProviderTest.php index 7a6aae8..4229af9 100644 --- a/tests/Unit/DebugBar/ProviderTest.php +++ b/tests/Unit/DebugBar/ProviderTest.php @@ -19,7 +19,7 @@ use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; -use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\Mvc\HistoryListener; use Phalcon\DebugBar\Provider; use Phalcon\Di\Di; use Phalcon\Di\DiInterface; @@ -534,7 +534,7 @@ public function testHistoryUsesTheFinalRequestServiceRegisteredAfterBoot(): void $container->setShared('request', $finalRequest); $em->fire('application:beforeHandleRequest', $app, $dispatcher); - $this->assertSame(HistoryEndpoint::CONTROLLER_NAMESPACE, $dispatcher->getNamespaceName()); + $this->assertSame(HistoryListener::CONTROLLER_NAMESPACE, $dispatcher->getNamespaceName()); } public function testHistoryUsesTheFinalUrlServiceRegisteredAfterBoot(): void @@ -619,6 +619,18 @@ public function testHistoryWithoutEventsManagerDoesNotRegisterAnUnreachableEndpo $this->assertFalse($this->bootedBar()->hasCollector('history')); } + public function testHistoryWithoutEventsManagerDoesNotValidateTheOptions(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + (new Provider(new Application(new FactoryDefault()), [ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true], + ]))->boot(); + + $this->assertFalse($this->bootedBar()->hasCollector('history')); + } + public function testIsAllowedTracksTheEnvironment(): void { $provider = $this->provider($this->application(new Manager())); diff --git a/tests/Unit/DebugBar/ResponseListenerTest.php b/tests/Unit/DebugBar/ResponseListenerTest.php index d866f5c..2b7de8d 100644 --- a/tests/Unit/DebugBar/ResponseListenerTest.php +++ b/tests/Unit/DebugBar/ResponseListenerTest.php @@ -14,15 +14,18 @@ namespace Phalcon\Tests\Unit\DebugBar; use Phalcon\DebugBar\BarOptions; +use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\DebugBar; use Phalcon\DebugBar\History\FilesystemHistory; use Phalcon\DebugBar\History\HistoryCookie; use Phalcon\DebugBar\History\HistoryEndpoint; use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\HistoryRecorder; use Phalcon\DebugBar\Injector; use Phalcon\DebugBar\Renderer; use Phalcon\DebugBar\ResponseListener; use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Events\Event; use Phalcon\Http\Request; use Phalcon\Http\RequestInterface; @@ -81,9 +84,11 @@ function () use (&$called): bool { new AccessGate(['127.0.0.1'], null), $request, new BarOptions(false, null), - new FilesystemHistory($options, null, $cookie), - $this->endpoint($options, $request), - $cookie + $this->recorder( + new FilesystemHistory($options, null, $cookie), + $this->endpoint($options, $request), + $cookie + ) ); $response = new Response(); @@ -120,9 +125,7 @@ function (string $name, string $value, array $options) use (&$call): bool { new AccessGate([], null), $request, new BarOptions(false, null), - $history, - $this->endpoint($options, $request, '/app1/'), - $cookie + $this->recorder($history, $this->endpoint($options, $request, '/app1/'), $cookie) ); $response = new Response(); $response->setContent('accepted'); @@ -208,9 +211,7 @@ public function testInternalHistoryRequestsAreNotRecorded(): void new AccessGate([], null), $request, new BarOptions(true, null), - $history, - $this->endpoint($options, $request, '/app1/'), - HistoryCookie::fromGlobals() + $this->recorder($history, $this->endpoint($options, $request, '/app1/'), HistoryCookie::fromGlobals()) ); $response = new Response(); $response->setContent('endpoint error'); @@ -272,9 +273,7 @@ public function testRecordsRequestStartTimeFromNativeRequest(): void new AccessGate([], null), new Request(), new BarOptions(false, null), - $history, - $this->endpoint($options, new Request()), - HistoryCookie::fromGlobals() + $this->recorder($history, $this->endpoint($options, new Request()), HistoryCookie::fromGlobals()) ); $response = new Response(); $response->setStatusCode(202); @@ -337,9 +336,7 @@ public function testRecordsTheCollectedResponseInRequestHistory(): void new AccessGate([], null), $request, new BarOptions(false, null), - $history, - $this->endpoint($options, $request, '/app1/'), - HistoryCookie::fromGlobals() + $this->recorder($history, $this->endpoint($options, $request, '/app1/'), HistoryCookie::fromGlobals()) ); $response = new Response(); $response->setStatusCode(202); @@ -437,4 +434,9 @@ private function listener(bool $headers, array $allowedIps = []): ResponseListen new BarOptions($headers, null) ); } + + private function recorder(History $history, HistoryEndpoint $endpoint, HistoryCookie $cookie): HistoryRecorder + { + return new HistoryRecorder($history, $endpoint, $cookie, new Redactor()); + } } From b318618130599a475aca02c2eb135949a7d0c525 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:14:00 -0600 Subject: [PATCH 4/6] [#62] - redacting the request URI and the log contxt Assisted-by: Claude Code --- src/DebugBar/Collector/LoggerCollector.php | 13 +- src/DebugBar/Collector/RequestCollector.php | 4 +- src/DebugBar/Security/Redactor.php | 32 ++++- .../Collector/LoggerCollectorTest.php | 24 +++- .../Collector/RequestCollectorTest.php | 18 +++ .../Unit/DebugBar/DebugBarConvenienceTest.php | 3 +- tests/Unit/DebugBar/RedactionLeakTest.php | 136 ++++++++++++++++++ tests/Unit/DebugBar/Security/RedactorTest.php | 25 ++++ 8 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/DebugBar/RedactionLeakTest.php diff --git a/src/DebugBar/Collector/LoggerCollector.php b/src/DebugBar/Collector/LoggerCollector.php index 1edb0f1..80a7796 100644 --- a/src/DebugBar/Collector/LoggerCollector.php +++ b/src/DebugBar/Collector/LoggerCollector.php @@ -15,6 +15,7 @@ use Phalcon\DebugBar\Contracts\LoggerAware; use Phalcon\DebugBar\DebugBarTypes; +use Phalcon\DebugBar\Security\Redactor; use function count; use function is_string; @@ -30,8 +31,8 @@ * tagged application log, not the manual `Debug::info()`/`debug()`/… messages * the developer drops in by hand. * - * Each entry carries the log level, the message, and the PSR-3 context, which - * the `logs` panel shows as a collapsible detail per row. + * Each entry carries the log level, the message, and the redacted PSR-3 context, + * which the `logs` panel shows as a collapsible detail per row. * * @phpstan-import-type log_envelope from DebugBarTypes * @phpstan-import-type log_panel from DebugBarTypes @@ -40,8 +41,6 @@ final class LoggerCollector extends AbstractCollector implements LoggerAware { public const NAME = 'logger'; - protected string $icon = 'icon-messages'; - protected string $label = 'Logs'; protected string $panel = 'logs'; @@ -51,6 +50,10 @@ final class LoggerCollector extends AbstractCollector implements LoggerAware */ private array $logs = []; + public function __construct(private readonly Redactor $redactor) + { + } + /** * @param array $context */ @@ -59,7 +62,7 @@ public function addLog(string $message, string $level, array $context = []): voi $this->logs[] = [ 'label' => $level, 'message' => $message, - 'context' => $this->stringifyContext($context), + 'context' => $this->stringifyContext($this->redactor->redact($context)), ]; } diff --git a/src/DebugBar/Collector/RequestCollector.php b/src/DebugBar/Collector/RequestCollector.php index eacf9bd..51f8df9 100644 --- a/src/DebugBar/Collector/RequestCollector.php +++ b/src/DebugBar/Collector/RequestCollector.php @@ -37,8 +37,6 @@ final class RequestCollector extends AbstractCollector public const NAME = 'request'; - protected string $icon = 'icon-request'; - protected string $label = 'Request'; protected string $panel = 'grid'; @@ -62,7 +60,7 @@ public function collect(): array } $method = $this->request->getMethod(); - $uri = $this->request->getURI(); + $uri = $this->redactor->redactUri($this->request->getURI()); $grid = [ 'Method' => $method, 'URI' => $uri, diff --git a/src/DebugBar/Security/Redactor.php b/src/DebugBar/Security/Redactor.php index 7bdd9bf..0e05fa4 100644 --- a/src/DebugBar/Security/Redactor.php +++ b/src/DebugBar/Security/Redactor.php @@ -13,15 +13,22 @@ namespace Phalcon\DebugBar\Security; +use function http_build_query; use function in_array; use function is_array; use function mb_strtolower; +use function parse_str; +use function rawurlencode; +use function str_replace; +use function strpos; +use function substr; /** * Guards data before it leaves PHP, matching keys case-insensitively through * nested arrays. Sensitive keys are masked (key shown, value replaced); "hidden" * keys are dropped entirely - mirroring the blacklist `Debug` already applies. - * Applied by the Request/Session/Config collectors. + * Applied by the Request/Session/Config/Logger collectors, and to the URI that + * request history stores. */ final class Redactor { @@ -86,6 +93,29 @@ public function redact(array $data): array return $result; } + /** + * Applies the same rule to the query of a URI. A URI that has nothing to redact + * stays unchanged. A rebuilt query can encode other values in a different way. + */ + public function redactUri(string $uri): string + { + $position = strpos($uri, '?'); + if (false === $position) { + return $uri; + } + + parse_str(substr($uri, $position + 1), $values); + $redacted = $this->redact($values); + if ($redacted === $values) { + return $uri; + } + + $path = substr($uri, 0, $position); + $query = str_replace(rawurlencode(self::MASK), self::MASK, http_build_query($redacted)); + + return '' === $query ? $path : $path . '?' . $query; + } + /** * @param list $keys * diff --git a/tests/Unit/DebugBar/Collector/LoggerCollectorTest.php b/tests/Unit/DebugBar/Collector/LoggerCollectorTest.php index 06bc787..ca76383 100644 --- a/tests/Unit/DebugBar/Collector/LoggerCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/LoggerCollectorTest.php @@ -14,6 +14,7 @@ namespace Phalcon\Tests\Unit\DebugBar\Collector; use Phalcon\DebugBar\Collector\LoggerCollector; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; @@ -25,7 +26,7 @@ final class LoggerCollectorTest extends AbstractUnitTestCase public function testContextIsCapturedAsJson(): void { - $collector = new LoggerCollector(); + $collector = new LoggerCollector(new Redactor()); $collector->addLog('user login', 'info', ['user_id' => 42, 'ip' => '10.0.0.1']); $context = $collector->collect()['panel'][0]['context']; @@ -33,9 +34,24 @@ public function testContextIsCapturedAsJson(): void $this->assertSame(['user_id' => 42, 'ip' => '10.0.0.1'], json_decode($context, true)); } + public function testContextIsRedacted(): void + { + $collector = new LoggerCollector(new Redactor(['password'], ['internal'])); + $collector->addLog( + 'user login', + 'info', + ['user' => 'sarah', 'password' => 'hunter2', 'internal' => 'gone', 'nested' => ['password' => 'hunter3']] + ); + + $this->assertSame( + ['user' => 'sarah', 'password' => '***', 'nested' => ['password' => '***']], + json_decode($collector->collect()['panel'][0]['context'], true) + ); + } + public function testEmptyContextIsAnEmptyString(): void { - $collector = new LoggerCollector(); + $collector = new LoggerCollector(new Redactor()); $collector->addLog('ping', 'debug'); $this->assertSame('', $collector->collect()['panel'][0]['context']); @@ -43,7 +59,7 @@ public function testEmptyContextIsAnEmptyString(): void public function testLogsAccumulateWithLevelAsLabel(): void { - $collector = new LoggerCollector(); + $collector = new LoggerCollector(new Redactor()); $collector->addLog('user created', 'info'); $collector->addLog('disk full', 'error'); @@ -58,7 +74,7 @@ public function testLogsAccumulateWithLevelAsLabel(): void public function testNameAndPanelContract(): void { - $collector = new LoggerCollector(); + $collector = new LoggerCollector(new Redactor()); $collector->addLog('x', 'debug', ['k' => 'v']); $this->assertSame('logger', $collector->getName()); diff --git a/tests/Unit/DebugBar/Collector/RequestCollectorTest.php b/tests/Unit/DebugBar/Collector/RequestCollectorTest.php index 1e99730..6921888 100644 --- a/tests/Unit/DebugBar/Collector/RequestCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/RequestCollectorTest.php @@ -19,6 +19,8 @@ use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\PanelContractTrait; +use function is_array; + final class RequestCollectorTest extends AbstractUnitTestCase { use PanelContractTrait; @@ -54,6 +56,22 @@ public function testNonArrayQueryOrPostIsIgnored(): void $this->assertArrayNotHasKey('Query.0', $panel); } + public function testQueryValuesInTheUriAreRedacted(): void + { + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getURI')->willReturn('/reset?token=abc&page=2'); + $request->method('getHeaders')->willReturn([]); + + $collected = (new RequestCollector($request, new Redactor()))->collect(); + if (!is_array($collected['panel']) || !isset($collected['metrics'])) { + $this->fail('Expected a grid panel and the request metrics.'); + } + + $this->assertSame('/reset?token=***&page=2', $collected['panel']['URI']); + $this->assertSame('/reset?token=***&page=2', $collected['metrics'][RequestCollector::METRIC_URI]); + } + public function testRequestDataIsFlattenedAndRedacted(): void { $request = $this->createMock(RequestInterface::class); diff --git a/tests/Unit/DebugBar/DebugBarConvenienceTest.php b/tests/Unit/DebugBar/DebugBarConvenienceTest.php index 9970fe5..5260074 100644 --- a/tests/Unit/DebugBar/DebugBarConvenienceTest.php +++ b/tests/Unit/DebugBar/DebugBarConvenienceTest.php @@ -15,6 +15,7 @@ use Phalcon\DebugBar\Collector\LoggerCollector; use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Security\Redactor; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; use Phalcon\Tests\Support\DebugBar\Fixtures\RecordingExceptionsCollector; use Phalcon\Tests\Support\DebugBar\Fixtures\RecordingMessagesCollector; @@ -38,7 +39,7 @@ public function testAddExceptionDelegatesToExceptionsCollector(): void public function testAddLogDelegatesToLoggerCollector(): void { - $logger = new LoggerCollector(); + $logger = new LoggerCollector(new Redactor()); $bar = new DebugBar(); $bar->addCollector($logger); diff --git a/tests/Unit/DebugBar/RedactionLeakTest.php b/tests/Unit/DebugBar/RedactionLeakTest.php new file mode 100644 index 0000000..851059f --- /dev/null +++ b/tests/Unit/DebugBar/RedactionLeakTest.php @@ -0,0 +1,136 @@ + + * + * 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; + +use Phalcon\DebugBar\Core; +use Phalcon\DebugBar\Debug; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\Logger\Adapter; +use Phalcon\Http\Request; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\Response; +use Phalcon\Logger\Logger; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function array_map; +use function bin2hex; +use function dirname; +use function file_get_contents; +use function glob; +use function implode; +use function is_array; +use function is_dir; +use function is_file; +use function random_bytes; +use function rmdir; +use function str_repeat; +use function unlink; + +/** + * Checks the two exits of a request: the bar in the response and the history files + * on disk. A value under a masked key must not reach either one. + */ +#[BackupGlobals(true)] +final class RedactionLeakTest extends AbstractUnitTestCase +{ + private const ENV_VAR = 'DEBUGBAR_LEAK_TEST_ENV'; + + private const SECRET_IN_LOG = 'secret-in-the-log-context'; + + private const SECRET_IN_QUERY = 'secret-in-the-query'; + + private string $storage = ''; + + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + $this->storage = dirname(__DIR__, 2) . '/_output/debugbar-leak-' . bin2hex(random_bytes(8)); + + $_COOKIE[HistoryCookie::NAME] = str_repeat('a', 64); + $_ENV[self::ENV_VAR] = 'dev'; + $_GET = ['token' => self::SECRET_IN_QUERY, 'page' => '2']; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['REQUEST_URI'] = '/reset?token=' . self::SECRET_IN_QUERY . '&page=2'; + } + + protected function tearDown(): void + { + Debug::setBar(null); + foreach ($this->storedFiles() as $file) { + unlink($file); + } + foreach ([$this->storage . '/.gc', $this->storage . '/.lock'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + foreach ($this->matching($this->storage . '/*') as $directory) { + rmdir($directory); + } + if (is_dir($this->storage)) { + rmdir($this->storage); + } + + parent::tearDown(); + } + + public function testSecretsDoNotReachTheBarOrTheStoredFiles(): void + { + $core = new Core([ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true, 'path' => $this->storage], + ]); + $request = new Request(); + $response = new Response(); + $response->setContent('page'); + + $core->boot(null, $request, null, $core->createHistory(fn (): RequestInterface => $request)); + (new Logger('app', ['debugbar' => new Adapter()]))->info('signed in', ['password' => self::SECRET_IN_LOG]); + $core->handle($response); + + $files = $this->storedFiles(); + $stored = implode("\n", array_map(fn (string $file): string => (string) file_get_contents($file), $files)); + + $this->assertCount(2, $files); + $this->assertStringContainsString('signed in', $response->getContent()); + $this->assertStringContainsString('signed in', $stored); + $this->assertStringContainsString('/reset?token=***', $stored); + foreach ([$response->getContent(), $stored] as $exit) { + $this->assertStringNotContainsString(self::SECRET_IN_LOG, $exit); + $this->assertStringNotContainsString(self::SECRET_IN_QUERY, $exit); + } + } + + /** + * @return list + */ + private function matching(string $pattern): array + { + $matches = glob($pattern); + + return is_array($matches) ? $matches : []; + } + + /** + * @return list + */ + private function storedFiles(): array + { + return $this->matching($this->storage . '/*/*'); + } +} diff --git a/tests/Unit/DebugBar/Security/RedactorTest.php b/tests/Unit/DebugBar/Security/RedactorTest.php index 3d74593..463c9a1 100644 --- a/tests/Unit/DebugBar/Security/RedactorTest.php +++ b/tests/Unit/DebugBar/Security/RedactorTest.php @@ -15,9 +15,28 @@ use Phalcon\DebugBar\Security\Redactor; use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; final class RedactorTest extends AbstractUnitTestCase { + /** + * @return array + */ + public static function uris(): array + { + return [ + 'no query' => ['/orders/42', '/orders/42'], + 'empty query' => ['/orders?', '/orders?'], + 'no sensitive key' => ['/orders?a[]=1&b=x%20y', '/orders?a[]=1&b=x%20y'], + 'sensitive key' => ['/reset?token=abc&page=2', '/reset?token=***&page=2'], + 'key in another case' => ['/reset?TOKEN=abc', '/reset?TOKEN=***'], + 'absolute URL' => ['https://example.test/a?password=abc', 'https://example.test/a?password=***'], + 'nested sensitive key' => ['/a?filter[token]=abc', '/a?filter%5Btoken%5D=***'], + 'hidden key' => ['/a?internal=abc&page=2', '/a?page=2'], + 'only a hidden key' => ['/a?internal=abc', '/a'], + ]; + } + public function testConfiguredMultibyteKeysAreLowered(): void { $redacted = (new Redactor(['ПАРОЛЬ']))->redact([ @@ -72,6 +91,12 @@ public function testRedactsNestedKeys(): void $this->assertSame(['host' => 'localhost', 'secret' => '***'], $redacted['db']); } + #[DataProvider('uris')] + public function testRedactUri(string $uri, string $expected): void + { + $this->assertSame($expected, (new Redactor(Redactor::DEFAULT_KEYS, ['internal']))->redactUri($uri)); + } + public function testUsesCustomKeys(): void { $redacted = (new Redactor(['pin']))->redact([ From fb14219157350beee16b7dcca8fe1b09d3bf6f44 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:15:36 -0600 Subject: [PATCH 5/6] [#62] - remocing icon - one panel contract for ui/code Assisted-by: Claude Code --- src/DebugBar/Collector/AbstractCollector.php | 11 +- src/DebugBar/Collector/CacheCollector.php | 2 - src/DebugBar/Collector/ConfigCollector.php | 2 - src/DebugBar/Collector/DatabaseCollector.php | 4 +- .../Collector/ExceptionsCollector.php | 2 - src/DebugBar/Collector/HistoryCollector.php | 2 - src/DebugBar/Collector/MemoryCollector.php | 2 - src/DebugBar/Collector/MessagesCollector.php | 2 - src/DebugBar/Collector/RouteCollector.php | 2 - src/DebugBar/Collector/SessionCollector.php | 2 - src/DebugBar/Collector/TimeCollector.php | 4 +- src/DebugBar/Collector/VersionCollector.php | 2 - src/DebugBar/Collector/ViewCollector.php | 6 +- src/DebugBar/Contracts/Renderable.php | 4 +- src/DebugBar/DebugBarTypes.php | 13 +- tests/JavaScript/debugbar.test.js | 103 +++++++ .../Collector/AbstractCollectorTest.php | 6 +- .../DebugBar/PanelContractFixtureTest.php | 207 ++++++++++++++ .../DebugBar/Fixtures/GridCollector.php | 2 - .../assets/DebugBar/panel-contract.json | 252 ++++++++++++++++++ 20 files changed, 580 insertions(+), 50 deletions(-) create mode 100644 tests/Unit/DebugBar/PanelContractFixtureTest.php create mode 100644 tests/support/assets/DebugBar/panel-contract.json diff --git a/src/DebugBar/Collector/AbstractCollector.php b/src/DebugBar/Collector/AbstractCollector.php index 2c2aba0..67734a2 100644 --- a/src/DebugBar/Collector/AbstractCollector.php +++ b/src/DebugBar/Collector/AbstractCollector.php @@ -18,18 +18,17 @@ /** * Optional base for collectors. Supplies the `NAME` wiring and `getWidget()` - * scaffolding from three protected members, so a subclass only sets its icon, - * label, panel type, and `collect()`. + * scaffolding from protected members, so a subclass only sets its label, panel + * type, and `collect()`. * * @phpstan-import-type widget from DebugBarTypes * @phpstan-import-type indicator from DebugBarTypes + * @phpstan-import-type panel_type from DebugBarTypes */ abstract class AbstractCollector implements Renderable { public const NAME = ''; - protected string $icon = ''; - /** * @var indicator|null */ @@ -37,6 +36,9 @@ abstract class AbstractCollector implements Renderable protected string $label = ''; + /** + * @var panel_type + */ protected string $panel = 'grid'; public function getName(): string @@ -54,7 +56,6 @@ public function getWidget(): array { $widget = [ 'label' => ('' !== $this->label) ? $this->label : $this->getName(), - 'icon' => $this->icon, 'panel' => $this->panel, ]; diff --git a/src/DebugBar/Collector/CacheCollector.php b/src/DebugBar/Collector/CacheCollector.php index b2da4b3..f7b37fc 100644 --- a/src/DebugBar/Collector/CacheCollector.php +++ b/src/DebugBar/Collector/CacheCollector.php @@ -27,8 +27,6 @@ final class CacheCollector extends AbstractCollector { public const NAME = 'cache'; - protected string $icon = 'icon-cache'; - protected string $label = 'Cache'; protected string $panel = 'list'; diff --git a/src/DebugBar/Collector/ConfigCollector.php b/src/DebugBar/Collector/ConfigCollector.php index da96e39..b1f1d10 100644 --- a/src/DebugBar/Collector/ConfigCollector.php +++ b/src/DebugBar/Collector/ConfigCollector.php @@ -30,8 +30,6 @@ final class ConfigCollector extends AbstractCollector public const NAME = 'config'; - protected string $icon = 'icon-config'; - protected string $label = 'Config'; protected string $panel = 'grid'; diff --git a/src/DebugBar/Collector/DatabaseCollector.php b/src/DebugBar/Collector/DatabaseCollector.php index 2b65c11..c96bd6e 100644 --- a/src/DebugBar/Collector/DatabaseCollector.php +++ b/src/DebugBar/Collector/DatabaseCollector.php @@ -25,8 +25,8 @@ * Records SQL queries. `startQuery()` starts the time, and `finishQuery()` takes * the statement and the bindings. * - * @phpstan-import-type db_query from DebugBarTypes * @phpstan-import-type database_envelope from DebugBarTypes + * @phpstan-type db_query array{sql: string, bindings: array, time: int|float} */ final class DatabaseCollector extends AbstractCollector { @@ -35,8 +35,6 @@ final class DatabaseCollector extends AbstractCollector public const NAME = 'database'; - protected string $icon = 'icon-database'; - protected string $label = 'Database'; protected string $panel = 'list'; diff --git a/src/DebugBar/Collector/ExceptionsCollector.php b/src/DebugBar/Collector/ExceptionsCollector.php index 08bf5ac..4b79c87 100644 --- a/src/DebugBar/Collector/ExceptionsCollector.php +++ b/src/DebugBar/Collector/ExceptionsCollector.php @@ -33,8 +33,6 @@ final class ExceptionsCollector extends AbstractCollector implements ExceptionAw { public const NAME = 'exceptions'; - protected string $icon = 'icon-exception'; - protected string $label = 'Exceptions'; protected string $panel = 'exceptions'; diff --git a/src/DebugBar/Collector/HistoryCollector.php b/src/DebugBar/Collector/HistoryCollector.php index 736ecd2..49ef813 100644 --- a/src/DebugBar/Collector/HistoryCollector.php +++ b/src/DebugBar/Collector/HistoryCollector.php @@ -23,8 +23,6 @@ final class HistoryCollector extends AbstractCollector { public const NAME = 'history'; - protected string $icon = 'icon-history'; - protected string $label = 'History'; protected string $panel = 'history'; diff --git a/src/DebugBar/Collector/MemoryCollector.php b/src/DebugBar/Collector/MemoryCollector.php index 953dcb9..dbf236b 100644 --- a/src/DebugBar/Collector/MemoryCollector.php +++ b/src/DebugBar/Collector/MemoryCollector.php @@ -31,8 +31,6 @@ final class MemoryCollector extends AbstractCollector public const NAME = 'memory'; - protected string $icon = 'icon-memory'; - protected ?array $indicator = [ 'icon' => 'cogs', 'label' => 'Current memory usage', diff --git a/src/DebugBar/Collector/MessagesCollector.php b/src/DebugBar/Collector/MessagesCollector.php index fcfe5c4..7510b0f 100644 --- a/src/DebugBar/Collector/MessagesCollector.php +++ b/src/DebugBar/Collector/MessagesCollector.php @@ -34,8 +34,6 @@ final class MessagesCollector extends AbstractCollector implements MessageAware public const NAME = 'messages'; - protected string $icon = 'icon-messages'; - protected string $label = 'Messages'; protected string $panel = 'list'; diff --git a/src/DebugBar/Collector/RouteCollector.php b/src/DebugBar/Collector/RouteCollector.php index b49b3e8..a0d7ceb 100644 --- a/src/DebugBar/Collector/RouteCollector.php +++ b/src/DebugBar/Collector/RouteCollector.php @@ -27,8 +27,6 @@ final class RouteCollector extends AbstractCollector { public const NAME = 'route'; - protected string $icon = 'icon-route'; - protected string $label = 'Route'; protected string $panel = 'grid'; diff --git a/src/DebugBar/Collector/SessionCollector.php b/src/DebugBar/Collector/SessionCollector.php index 4484c89..e5a93a1 100644 --- a/src/DebugBar/Collector/SessionCollector.php +++ b/src/DebugBar/Collector/SessionCollector.php @@ -37,8 +37,6 @@ final class SessionCollector extends AbstractCollector public const NAME = 'session'; - protected string $icon = 'icon-session'; - protected string $label = 'Session'; protected string $panel = 'grid'; diff --git a/src/DebugBar/Collector/TimeCollector.php b/src/DebugBar/Collector/TimeCollector.php index 5e976ed..56775dd 100644 --- a/src/DebugBar/Collector/TimeCollector.php +++ b/src/DebugBar/Collector/TimeCollector.php @@ -26,7 +26,7 @@ * * @phpstan-import-type list_envelope from DebugBarTypes * @phpstan-import-type list_row from DebugBarTypes - * @phpstan-import-type time_measure from DebugBarTypes + * @phpstan-type time_measure array{label: string, start: int|float, end: int|float|null} */ final class TimeCollector extends AbstractCollector implements TimeAware { @@ -34,8 +34,6 @@ final class TimeCollector extends AbstractCollector implements TimeAware public const NAME = 'time'; - protected string $icon = 'icon-time'; - protected ?array $indicator = [ 'icon' => 'clock', 'label' => 'Request time', diff --git a/src/DebugBar/Collector/VersionCollector.php b/src/DebugBar/Collector/VersionCollector.php index 41e3ef9..0edd88a 100644 --- a/src/DebugBar/Collector/VersionCollector.php +++ b/src/DebugBar/Collector/VersionCollector.php @@ -27,8 +27,6 @@ final class VersionCollector extends AbstractCollector { public const NAME = 'version'; - protected string $icon = 'icon-version'; - protected string $label = 'Version'; protected string $panel = 'grid'; diff --git a/src/DebugBar/Collector/ViewCollector.php b/src/DebugBar/Collector/ViewCollector.php index 3106c78..1f551cd 100644 --- a/src/DebugBar/Collector/ViewCollector.php +++ b/src/DebugBar/Collector/ViewCollector.php @@ -25,8 +25,8 @@ * partials are timed correctly. * * @phpstan-import-type list_envelope from DebugBarTypes - * @phpstan-import-type view_pending from DebugBarTypes - * @phpstan-import-type view_render from DebugBarTypes + * @phpstan-type view_pending array{path: string, start: int|float} + * @phpstan-type view_render array{path: string, time: int|float} */ final class ViewCollector extends AbstractCollector { @@ -34,8 +34,6 @@ final class ViewCollector extends AbstractCollector public const NAME = 'view'; - protected string $icon = 'icon-view'; - protected string $label = 'View'; protected string $panel = 'list'; diff --git a/src/DebugBar/Contracts/Renderable.php b/src/DebugBar/Contracts/Renderable.php index adb83e1..db4b83c 100644 --- a/src/DebugBar/Contracts/Renderable.php +++ b/src/DebugBar/Contracts/Renderable.php @@ -17,8 +17,8 @@ /** * A collector that also describes its tab/panel. The `panel` value selects the - * client renderer ('grid' | 'list' | 'code' | 'html'); the tab count comes from - * `collect()['badge']`, not from here. + * client renderer (`panel_type` in `DebugBarTypes` lists the values); the tab count + * comes from `collect()['badge']`, not from here. * * @phpstan-import-type widget from DebugBarTypes */ diff --git a/src/DebugBar/DebugBarTypes.php b/src/DebugBar/DebugBarTypes.php index 24ce96c..afb0573 100644 --- a/src/DebugBar/DebugBarTypes.php +++ b/src/DebugBar/DebugBarTypes.php @@ -14,9 +14,12 @@ namespace Phalcon\DebugBar; /** - * Central home for the phpdoc array-shape aliases shared across the debug bar. + * Central home for the phpdoc array-shape aliases shared across the debug bar: the + * payload that the browser client reads and the configuration. A shape that only + * one class uses stays in that class. * Pull what you need with `@phpstan-import-type from \Phalcon\DebugBar\DebugBarTypes`. * + * @phpstan-type panel_type 'code'|'exceptions'|'grid'|'history'|'html'|'list'|'logs' * @phpstan-type list_row array{label: string, message: string} * @phpstan-type list_panel list * @phpstan-type grid_panel array @@ -28,8 +31,7 @@ * @phpstan-type request_widget array{method: list, uri: list} * @phpstan-type widget array{ * label: string, - * icon: string, - * panel: string, + * panel: panel_type, * indicator?: indicator, * request?: request_widget * } @@ -52,14 +54,9 @@ * @phpstan-type exception_envelope array{panel: exception_panel, badge: scalar|null, summary?: collector_summary} * @phpstan-type log_envelope array{panel: log_panel, badge: scalar|null, summary?: collector_summary} * @phpstan-type payload array{data: array, meta: array} - * @phpstan-type db_query array{sql: string, bindings: array, time: int|float} * @phpstan-type database_row array{label: string, message: string, occurrences: int|null} * @phpstan-type database_panel list * @phpstan-type database_envelope array{panel: database_panel, badge: int, summary: collector_summary} - * @phpstan-type time_measure array{label: string, start: int|float, end: int|float|null} - * @phpstan-type view_pending array{path: string, start: int|float} - * @phpstan-type view_render array{path: string, time: int|float} - * @phpstan-type request_context array{0: string|null, 1: bool} * @phpstan-type history_config array{ * enabled?: bool, * url?: string, diff --git a/tests/JavaScript/debugbar.test.js b/tests/JavaScript/debugbar.test.js index 35f218c..bd4d35c 100644 --- a/tests/JavaScript/debugbar.test.js +++ b/tests/JavaScript/debugbar.test.js @@ -38,6 +38,10 @@ function errorResponse(status) { return {ok: false, status: status}; } +function panelContract() { + return JSON.parse(fs.readFileSync(require.resolve('../support/assets/DebugBar/panel-contract.json'), 'utf8')); +} + function deferred() { var reject; var resolve; @@ -516,6 +520,105 @@ test('indicator collectors render once on the right and open their panels', func assert.equal(dom.findByClass(mount, 'phalcon-debugbar-body').style.display, 'none'); }); +test('the client renders the output of the real collectors', function () { + var dataNode = new dom.TestElement('script'); + var mount = new dom.TestElement('div'); + dataNode.textContent = JSON.stringify(panelContract().payload); + global.document = dom.createDocument({ + 'phalcon-debugbar': mount, + 'phalcon-debugbar-data': dataNode + }); + global.window = { + localStorage: { + getItem: function () { + return null; + }, + setItem: function () {} + } + }; + + require(debugbarPath); + + var tabs = mount.querySelectorAll('.phalcon-debugbar-tab'); + var labels = tabs.map(function (tab) { + return dom.findByClass(tab, 'phalcon-debugbar-tab-label').textContent; + }); + var body = dom.findByClass(mount, 'phalcon-debugbar-body'); + var metrics = mount.querySelectorAll('.phalcon-debugbar-metric'); + function panelText(label) { + tabs[labels.indexOf(label)].click(); + + return body.textContent; + } + + assert.deepEqual( + labels, + ['Version', 'Messages', 'Logs', 'Exceptions', 'Database', 'View', 'Route', 'Cache', 'Request', 'Config'] + ); + assert.equal(dom.findByClass(mount, 'phalcon-debugbar-indicators').textContent, '1ms1MBGET/orders?page=2'); + assert.ok(dom.findByClass(mount, 'is-history-trigger')); + assert.equal(panelText('Version'), 'PHP8.0.0Phalcon6.0.0'); + assert.equal(panelText('Messages'), 'infouser resolved'); + assert.equal(panelText('Logs'), 'infouser login{\n "user_id": 7\n}debugping'); + assert.equal(panelText('Exceptions'), 'RuntimeExceptionboom (file.php:1)#0 {main}'); + assert.equal( + panelText('Database'), + 'Queries3Duplicate Runs1Total Time1ms' + + '1msSELECT * FROM orders WHERE id = :id {"id":42}Executed 2 times' + + '1msSELECT * FROM orders WHERE id = :id {"id":42}Executed 2 times' + + '1msSELECT 1' + ); + assert.equal(panelText('View'), '1msorders/index'); + assert.equal(panelText('Route'), 'ActionGetOrdersAttributes{"id":"42"}'); + assert.equal(panelText('Cache'), 'Getorders'); + assert.equal( + panelText('Request'), + 'MethodGETURI/orders?page=2Query.page2Headers.Accepttext/html' + ); + assert.equal(panelText('Config'), 'app.namedemo'); + metrics[0].click(); + assert.equal(body.textContent, 'Request1msRender1ms'); + metrics[1].click(); + assert.equal(body.textContent, 'Current usage1MBPeak usage1MB'); +}); + +test('the client accepts the stored entry of the real history code', async function () { + var contract = panelContract(); + var selected = null; + global.document = dom.createDocument(); + global.window = { + fetch: function (url) { + return Promise.resolve(response( + url.includes('?id=') + ? {request: contract.storedEntry} + : {requests: [contract.storedEntry.meta]} + )); + } + }; + var mount = new dom.TestElement('div'); + + renderHistoryBrowser(mount, contract.payload.data.history.panel, '', function (payload, metadata, id) { + selected = {payload: payload, metadata: metadata, id: id}; + }, function () {}, function () { + return true; + }); + await dom.flushPromises(); + + var request = dom.findByClass(mount, 'phalcon-debugbar-history-request'); + assert.equal( + request.textContent, + 'GET/orders?page=2200' + contract.storedEntry.meta.requested_at + ); + request.click(); + await dom.flushPromises(); + + assert.deepEqual(selected, { + payload: contract.storedEntry.payload, + metadata: contract.storedEntry.meta, + id: contract.storedEntry.meta.id + }); +}); + test('request metrics use widget metadata when the collector has a different name', function () { var dataNode = new dom.TestElement('script'); var mount = new dom.TestElement('div'); diff --git a/tests/Unit/DebugBar/Collector/AbstractCollectorTest.php b/tests/Unit/DebugBar/Collector/AbstractCollectorTest.php index 96d17cf..3bf30d1 100644 --- a/tests/Unit/DebugBar/Collector/AbstractCollectorTest.php +++ b/tests/Unit/DebugBar/Collector/AbstractCollectorTest.php @@ -43,11 +43,7 @@ public function testGetWidgetAssemblesFromMembers(): void { $collector = new GridCollector(); - $widget = $collector->getWidget(); - - $this->assertSame('Grid', $widget['label']); - $this->assertSame('icon-grid', $widget['icon']); - $this->assertSame('grid', $widget['panel']); + $this->assertSame(['label' => 'Grid', 'panel' => 'grid'], $collector->getWidget()); } public function testImplementsTheContracts(): void diff --git a/tests/Unit/DebugBar/PanelContractFixtureTest.php b/tests/Unit/DebugBar/PanelContractFixtureTest.php new file mode 100644 index 0000000..a7431bb --- /dev/null +++ b/tests/Unit/DebugBar/PanelContractFixtureTest.php @@ -0,0 +1,207 @@ + + * + * 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; + +use DateTimeImmutable; +use Phalcon\Config\Config; +use Phalcon\DebugBar\Collector\CacheCollector; +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\MemoryCollector; +use Phalcon\DebugBar\Collector\MessagesCollector; +use Phalcon\DebugBar\Collector\RequestCollector; +use Phalcon\DebugBar\Collector\RouteCollector; +use Phalcon\DebugBar\Collector\TimeCollector; +use Phalcon\DebugBar\Collector\VersionCollector; +use Phalcon\DebugBar\Collector\ViewCollector; +use Phalcon\DebugBar\Contracts\Collector; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryEntry; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\DebugBar\History\RequestMetadata; +use Phalcon\DebugBar\Security\Redactor; +use Phalcon\Http\RequestInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use RuntimeException; + +use function file_put_contents; +use function getenv; +use function json_encode; +use function preg_replace; + +use const JSON_PRETTY_PRINT; +use const JSON_THROW_ON_ERROR; +use const JSON_UNESCAPED_SLASHES; + +/** + * Keeps the fixture that `tests/JavaScript/debugbar.test.js` reads equal to the + * output of the real collectors and of `HistoryEntry`, so that each suite tests the + * same contract. The session collector is not in the fixture because it needs an + * active session, and the `config` panel covers the same grid shape. + * + * To accept a change to the contract, refresh the fixture and examine the diff: + * `DEBUGBAR_REFRESH_PANEL_FIXTURE=1 vendor/bin/phpunit -c resources/phpunit.xml.dist + * --filter PanelContractFixtureTest` + */ +final class PanelContractFixtureTest extends AbstractUnitTestCase +{ + private const FIXTURE = __DIR__ . '/../../support/assets/DebugBar/panel-contract.json'; + + private const REFRESH = 'DEBUGBAR_REFRESH_PANEL_FIXTURE'; + + public function testFixtureEqualsTheOutputOfTheCollectors(): void + { + $bar = new DebugBar(); + foreach ($this->collectors() as $collector) { + $bar->addCollector($collector); + } + + $json = $this->withStableValues( + json_encode( + ['payload' => $bar->collect(), 'storedEntry' => $this->storedEntry()], + JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES + ) + ) . "\n"; + + if (false !== getenv(self::REFRESH)) { + file_put_contents(self::FIXTURE, $json); + } + + $this->assertStringEqualsFile(self::FIXTURE, $json); + } + + /** + * @return list + */ + private function collectors(): array + { + $redactor = new Redactor(); + + $messages = new MessagesCollector(); + $messages->addMessage('user resolved', 'info'); + + $logger = new LoggerCollector($redactor); + $logger->addLog('user login', 'info', ['user_id' => 7]); + $logger->addLog('ping', 'debug'); + + $exceptions = new ExceptionsCollector(); + $exceptions->addThrowable(new RuntimeException('boom')); + + $time = new TimeCollector(); + $time->startMeasure('render', 'Render'); + $time->stopMeasure('render'); + + $database = new DatabaseCollector(); + foreach (['SELECT * FROM orders WHERE id = :id', 'SELECT * FROM orders WHERE id = :id'] as $sql) { + $database->startQuery(); + $database->finishQuery($sql, ['id' => 42]); + } + $database->startQuery(); + $database->finishQuery('SELECT 1'); + + $view = new ViewCollector(); + $view->startRender('orders/index'); + $view->finishRender(); + + $route = new RouteCollector(); + $route->setRoute(['Action' => 'GetOrders', 'Attributes' => '{"id":"42"}']); + + $cache = new CacheCollector(); + $cache->addOperation('Get', 'orders'); + + return [ + new VersionCollector(), + $messages, + $logger, + $exceptions, + $time, + new MemoryCollector(), + $database, + $view, + $route, + $cache, + new RequestCollector($this->request(), $redactor), + new ConfigCollector(new Config(['app' => ['name' => 'demo']]), $redactor), + new HistoryCollector( + new HistoryEndpoint( + new HistoryOptions(true), + fn (): ?RequestInterface => null, + fn (): string => '/' + ) + ), + ]; + } + + private function request(): RequestInterface + { + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getURI')->willReturn('/orders?page=2'); + $request->method('getQuery')->willReturn(['page' => '2']); + $request->method('getPost')->willReturn([]); + $request->method('getHeaders')->willReturn(['Accept' => 'text/html']); + + return $request; + } + + /** + * @return array + */ + private function storedEntry(): array + { + return HistoryEntry::create( + ['data' => [], 'meta' => []], + new RequestMetadata( + 'GET', + '/orders?page=2', + 200, + false, + new DateTimeImmutable('2026-01-02T03:04:05+00:00') + ), + '20260102030405-000000-deadbeef', + new DateTimeImmutable('2026-01-02T03:04:06+00:00') + )->toArray(); + } + + /** + * Replaces the values that change on each run: durations, memory, versions, and + * the file and the trace of the exception. + */ + private function withStableValues(string $json): string + { + return (string) preg_replace( + [ + '/"\d+(\.\d+)?ms"/', + '/"\d+(\.\d+)?(B|KB|MB|GB|TB)"/', + '/"PHP": "[^"]*"/', + '/"Phalcon": "[^"]*"/', + '/boom \([^"]*"/', + '/"trace": "(?:[^"\\\\]|\\\\.)*"/', + ], + [ + '"1ms"', + '"1MB"', + '"PHP": "8.0.0"', + '"Phalcon": "6.0.0"', + 'boom (file.php:1)"', + '"trace": "#0 {main}"', + ], + $json + ); + } +} diff --git a/tests/support/DebugBar/Fixtures/GridCollector.php b/tests/support/DebugBar/Fixtures/GridCollector.php index cf30766..fe57281 100644 --- a/tests/support/DebugBar/Fixtures/GridCollector.php +++ b/tests/support/DebugBar/Fixtures/GridCollector.php @@ -22,8 +22,6 @@ final class GridCollector extends AbstractCollector { public const NAME = 'grid_fixture'; - protected string $icon = 'icon-grid'; - protected string $label = 'Grid'; protected string $panel = 'grid'; diff --git a/tests/support/assets/DebugBar/panel-contract.json b/tests/support/assets/DebugBar/panel-contract.json new file mode 100644 index 0000000..8482b64 --- /dev/null +++ b/tests/support/assets/DebugBar/panel-contract.json @@ -0,0 +1,252 @@ +{ + "payload": { + "data": { + "version": { + "panel": { + "PHP": "8.0.0", + "Phalcon": "6.0.0" + }, + "badge": null + }, + "messages": { + "panel": [ + { + "label": "info", + "message": "user resolved" + } + ], + "badge": 1 + }, + "logger": { + "panel": [ + { + "label": "info", + "message": "user login", + "context": "{\n \"user_id\": 7\n}" + }, + { + "label": "debug", + "message": "ping", + "context": "" + } + ], + "badge": 2 + }, + "exceptions": { + "panel": [ + { + "label": "RuntimeException", + "message": "boom (file.php:1)", + "trace": "#0 {main}" + } + ], + "badge": 1 + }, + "time": { + "panel": [ + { + "label": "Request", + "message": "1ms" + }, + { + "label": "Render", + "message": "1ms" + } + ], + "badge": "1ms" + }, + "memory": { + "panel": { + "Current usage": "1MB", + "Peak usage": "1MB" + }, + "badge": "1MB", + "metrics": { + "current_usage": "1MB" + } + }, + "database": { + "panel": [ + { + "label": "1ms", + "message": "SELECT * FROM orders WHERE id = :id {\"id\":42}", + "occurrences": 2 + }, + { + "label": "1ms", + "message": "SELECT * FROM orders WHERE id = :id {\"id\":42}", + "occurrences": 2 + }, + { + "label": "1ms", + "message": "SELECT 1", + "occurrences": null + } + ], + "badge": 3, + "summary": [ + { + "label": "queries", + "value": 3 + }, + { + "label": "duplicate_runs", + "value": 1 + }, + { + "label": "total_time", + "value": "1ms" + } + ] + }, + "view": { + "panel": [ + { + "label": "1ms", + "message": "orders/index" + } + ], + "badge": 1 + }, + "route": { + "panel": { + "Action": "GetOrders", + "Attributes": "{\"id\":\"42\"}" + }, + "badge": null + }, + "cache": { + "panel": [ + { + "label": "Get", + "message": "orders" + } + ], + "badge": 1 + }, + "request": { + "panel": { + "Method": "GET", + "URI": "/orders?page=2", + "Query.page": "2", + "Headers.Accept": "text/html" + }, + "badge": null, + "metrics": { + "method": "GET", + "uri": "/orders?page=2" + } + }, + "config": { + "panel": { + "app.name": "demo" + }, + "badge": null + }, + "history": { + "panel": { + "url": "/_debugbar/open" + }, + "badge": null + } + }, + "meta": { + "collectors": 13, + "widgets": { + "version": { + "label": "Version", + "panel": "grid" + }, + "messages": { + "label": "Messages", + "panel": "list" + }, + "logger": { + "label": "Logs", + "panel": "logs" + }, + "exceptions": { + "label": "Exceptions", + "panel": "exceptions" + }, + "time": { + "label": "Time", + "panel": "list", + "indicator": { + "icon": "clock", + "label": "Request time", + "path": [ + "badge" + ] + } + }, + "memory": { + "label": "Memory", + "panel": "grid", + "indicator": { + "icon": "cogs", + "label": "Current memory usage", + "path": [ + "metrics", + "current_usage" + ] + } + }, + "database": { + "label": "Database", + "panel": "list" + }, + "view": { + "label": "View", + "panel": "list" + }, + "route": { + "label": "Route", + "panel": "grid" + }, + "cache": { + "label": "Cache", + "panel": "list" + }, + "request": { + "label": "Request", + "panel": "grid", + "request": { + "method": [ + "metrics", + "method" + ], + "uri": [ + "metrics", + "uri" + ] + } + }, + "config": { + "label": "Config", + "panel": "grid" + }, + "history": { + "label": "History", + "panel": "history" + } + } + } + }, + "storedEntry": { + "version": 1, + "meta": { + "requested_at": "2026-01-02T03:04:05+00:00", + "method": "GET", + "uri": "/orders?page=2", + "status": 200, + "ajax": false, + "id": "20260102030405-000000-deadbeef", + "stored_at": "2026-01-02T03:04:06+00:00" + }, + "payload": { + "data": [], + "meta": [] + } + } +} From 4994dd5ce6fe77b3a994ab9f1377e3cf93a501bd Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Thu, 5 Nov 2026 08:17:01 -0600 Subject: [PATCH 6/6] [#62] - updating docs and changelog Asssisted-by: Claude Code --- CHANGELOG.md | 15 +++++++++++++++ docs/index.md | 35 ++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7879bff..8a30ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,15 +23,30 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - The `debug_page` option shows the debug page for a server error in ADR applications. [#48](https://github.com/phalcon/debugbar/issues/48) - `Mvc\Bridge` and `ServiceBridge` feed the built-in collectors, which no longer hold framework event names. [#15](https://github.com/phalcon/debugbar/issues/15) - `DebugBar::addCollector()` subscribes a collector that implements `Contracts\Subscriber`. [#15](https://github.com/phalcon/debugbar/issues/15) +- `Core::createHistory()` creates the request history from the `history` config, and `History\HistoryRecorder` holds its workflow. [#62](https://github.com/phalcon/debugbar/issues/62) +- `Core::getBar()` returns the bar that `boot()` created. [#62](https://github.com/phalcon/debugbar/issues/62) +- `RequestContext` holds the client address and the AJAX and HTTPS flags for each decision, and `Injector::isHtml()` tells if a response is an HTML page. [#62](https://github.com/phalcon/debugbar/issues/62) +- `Security\Redactor::redactUri()` applies the redaction rule to the query of a URI. [#62](https://github.com/phalcon/debugbar/issues/62) +- A shared fixture, `tests/support/assets/DebugBar/panel-contract.json`, makes the PHP and JavaScript tests use the same payload. [#62](https://github.com/phalcon/debugbar/issues/62) ### Changed - **Breaking:** the built-in collectors no longer implement `Contracts\Subscriber` and have no `subscribe()` method. [#15](https://github.com/phalcon/debugbar/issues/15) +- **Breaking:** `Logger\Adapter` and `Logger\ExceptionAdapter` have no constructor argument. They read the active bar each time the logger writes, so you can create them before the bar boots. Replace `new Adapter(Debug::getBar())` with `new Adapter()`. [#62](https://github.com/phalcon/debugbar/issues/62) +- **Breaking:** `LoggerCollector` takes a `Security\Redactor`, and it redacts the log context. [#62](https://github.com/phalcon/debugbar/issues/62) +- `Core::boot()` and `ResponseListener` take one `?History\HistoryRecorder` in place of the history, endpoint and cookie arguments. [#62](https://github.com/phalcon/debugbar/issues/62) +- `History\HistoryEndpoint::CONTROLLER_NAMESPACE` is now `Mvc\HistoryListener::CONTROLLER_NAMESPACE`. [#62](https://github.com/phalcon/debugbar/issues/62) +- The providers validate the `history` config after they check for the events manager, and for the container or the router. An invalid `history.path` does not throw when history cannot run. [#62](https://github.com/phalcon/debugbar/issues/62) +- A collector no longer sends `widget.icon` and `AbstractCollector` has no `$icon` property, because the browser client never read them. `indicator.icon` does not change. [#62](https://github.com/phalcon/debugbar/issues/62) +- `DebugBarTypes` holds the wire contract and the config shapes, and it lists the panel names, so static analysis rejects an unknown panel name. [#62](https://github.com/phalcon/debugbar/issues/62) +- The debug page in ADR applications uses `Injector::isHtml()` and no longer depends on the injection rules of the bar. [#62](https://github.com/phalcon/debugbar/issues/62) ### Fixed - History: check storage at boot, remove empty browser directories, fix glob paths on Windows, add `nosniff` to JSON responses. - The browser asset exports test helpers only outside a DOM, and no longer hard-codes the request collector name. +- The `request` panel and request history no longer show or store a query value under a masked key in the URI. [#62](https://github.com/phalcon/debugbar/issues/62) +- The documentation states the effect of a long-lived worker on request history. [#62](https://github.com/phalcon/debugbar/issues/62) ## [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 ff0e9f8..ea3d501 100644 --- a/docs/index.md +++ b/docs/index.md @@ -162,7 +162,6 @@ use Phalcon\ADR\Responder\StatusMapper; use Phalcon\ADR\Responder\ViewResponder; use Phalcon\Contracts\Logger\Logger as LoggerContract; use Phalcon\Contracts\View\Renderer; -use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\Logger\ExceptionAdapter; use Phalcon\Logger\Adapter\Stream; use Phalcon\Logger\Logger; @@ -174,7 +173,7 @@ $container->set( 'app', [ 'main' => new Stream('/var/log/app.log'), - 'debugbar' => new ExceptionAdapter(Debug::getBar()), + 'debugbar' => new ExceptionAdapter(), ] ); } @@ -191,7 +190,7 @@ $container->set( ); ``` -The closure calls `Debug::getBar()` when the application first uses the logger. This is after the provider boots the bar. The adapter does not change the response and does not keep the exception from the error responder. The panel also shows `RouteNotFound` for a URL with no route, because the error responder logs this too. If the error responder itself fails, the application returns a bare 500 response and logs nothing, so the panel does not show the exception. +The adapter reads the active bar each time the logger writes, so you can create the logger before the provider boots the bar. An exception that the logger writes before the boot is lost. The adapter does not change the response and does not keep the exception from the error responder. The panel also shows `RouteNotFound` for a URL with no route, because the error responder logs this too. If the error responder itself fails, the application returns a bare 500 response and logs nothing, so the panel does not show the exception. The `history` keys turn on request history. You do not have to add a route. Set `history.base_uri` when the application is not at the root of the host, because an ADR application has no `url` service by default. @@ -244,7 +243,7 @@ $debug = (new Debug())->setBlacklist(['server' => ['DB_PASSWORD', 'APP_CRYPT_SAL These limits apply in an ADR application: -- **One request for each process.** `Phalcon\ADR\Application::handle()` supports a reused request in a long-lived worker, but collectors have no reset method. In a worker, their data grows from request to request. `HistoryCookie::fromGlobals()` also reads only at boot. +- **One request for each process.** `Phalcon\ADR\Application::handle()` supports a reused request in a long-lived worker, but collectors have no reset method. In a worker, their data grows from request to request. `HistoryCookie::fromGlobals()` also reads only at boot, so history uses the cookie of the first browser for all requests in the worker. The request data of one browser can then appear in the history of another browser. - **Events manager.** The events manager must implement `Phalcon\Events\ManagerInterface`. The default manager does. A manager that implements only `Phalcon\Contracts\Events\Manager` gets no listener, so the bar is not injected. - **Empty panels.** The `database`, `view` and `cache` panels get data only when the application gives the events manager to these services. The `exceptions` panel gets data only when the application adds `ExceptionAdapter` to its logger. The provider reads no `config` service, so the `config` panel is empty. @@ -279,9 +278,18 @@ if (null !== $listener) { } ``` +To use request history, create the recorder from the `history` keys and give it to `boot()`. `createHistory()` returns `null` when the bar cannot boot or history is off. It does not route the URL: send `history.url` to `Phalcon\DebugBar\History\HistoryHandler` in your host, with `$recorder->history()` and `$core->getAccessGate()`. + +```php +createHistory(fn () => $request); +$core->boot(null, $request, null, $recorder); +``` + ## Configuration -The second argument to `Provider` is a nested array. Every key is optional. `Phalcon\DebugBar\ADR\Provider` and `Phalcon\DebugBar\Core` take the same array as their only argument. `Core` does not read the `debug_page` and `history` keys. +The second argument to `Provider` is a nested array. Every key is optional. `Phalcon\DebugBar\ADR\Provider` and `Phalcon\DebugBar\Core` take the same array as their only argument. `Core` does not read the `debug_page` key. | Key | Type | Default | Purpose | |--------------------|---------------------------|--------------------------|------------------------------------------------------------------| @@ -348,9 +356,9 @@ before applying application controller rules: ```php getNamespaceName()) { +if (HistoryListener::CONTROLLER_NAMESPACE === $dispatcher->getNamespaceName()) { return true; } ``` @@ -486,6 +494,8 @@ Debug::getBar()?->addCollector(new OrdersCollector()); `addCollector()` calls `subscribe()` when the bar has an events manager. A collector with no events does not implement `Subscriber`, and you add it the same way. A collector with the name of a built-in collector replaces it. The bridges do not feed the replacement, so it must attach its own listeners. +The bar does not redact the data of a custom collector. Run sensitive data through `Phalcon\DebugBar\Security\Redactor` in the collector. The collector does not receive the `redact.mask` and `redact.hidden` values, so give them to its `Redactor`. + ## Sharing the Events Manager Streamed collectors only receive events from components that fire on the same events manager the bar is subscribed to. Phalcon components fire events only on an events manager set on them explicitly; they do not read one from the container. Register a single events manager as a shared service and hand it to the application and to each component whose events you want. @@ -551,18 +561,17 @@ The `exceptions` collector also captures throwables automatically. In an MVC app - Phalcon 5 (the C extension) exposes the `Phalcon\Logger` adapter contract the adapter builds on. - Phalcon 6 (`phalcon/phalcon`) exposes the same contract. The adapter attaches the same way on either runtime. -The adapter forwards each `Phalcon\Logger\Item` to the active bar through `Phalcon\DebugBar\Debug::getBar()`, reading the item's level name, message, and PSR-3 context. Forwarding no-ops when the `logger` collector is absent or disabled, so the adapter is safe to leave attached. The adapter also accepts a `null` bar - which `Debug::getBar()` returns until the bar boots - so it can be attached unconditionally. +The adapter forwards each `Phalcon\Logger\Item` to the active bar through `Phalcon\DebugBar\Debug::getBar()`, reading the item's level name, message, and PSR-3 context. Forwarding no-ops when the `logger` collector is absent or disabled, so the adapter is safe to leave attached. The adapter reads the active bar each time the logger writes, so you can attach it before the bar boots. Items that the logger writes before the boot are lost. ```php new Stream('/var/log/app.log')]); -$logger->addAdapter('debugbar', new Adapter(Debug::getBar())); +$logger->addAdapter('debugbar', new Adapter()); $logger->info('user login', ['user_id' => 42]); $logger->error('payment declined', ['order_id' => 1001]); @@ -589,7 +598,11 @@ use Phalcon\DebugBar\Provider; - **Masked** keys keep their key but replace the value with `***`. - **Hidden** keys are dropped entirely. -The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. The `request`, `config`, and `session` collectors run their data through the redactor. The `session` collector also omits the raw session id and every `$PHALCON/*` internal key, including the CSRF token. +The default masked keys are `authorization`, `cookie`, `csrf`, `key`, `password`, `secret`, and `token`. The redactor matches whole key names, so `token` does not match `reset_token`. Add other keys to `redact.mask`. + +The `request`, `config`, `session`, and `logger` collectors run their data through the redactor. The `request` panel and request history also redact the query of the URI. When the redactor changes a value in a query, it rebuilds the query, so the encoding of the other values can change. The `session` collector also omits the raw session id and every `$PHALCON/*` internal key, including the CSRF token. + +The `database` panel and request history show and store query bindings as they are, because generated names such as `:APL0` do not match a key. The redactor does not change log messages, exception messages, or stack traces. `Phalcon\DebugBar\Security\AccessGate` decides who receives the bar within an allowed environment. An empty IP allowlist admits any client. When both an allowlist and a callback are given, both must pass.