* @license GNU General Public License version 2 or later; see LICENSE.txt * @phpcs:disable PSR1.Classes.ClassDeclaration.MissingNamespace */ use DebugBar\DataCollector\MemoryCollector; use DebugBar\DataCollector\MessagesCollector; use DebugBar\DataCollector\RequestDataCollector; use DebugBar\DebugBar; use DebugBar\OpenHandler; use Joomla\Application\ApplicationEvents; use Joomla\CMS\Application\CMSApplicationInterface; use Joomla\CMS\Document\HtmlDocument; use Joomla\CMS\Log\Log; use Joomla\CMS\Log\LogEntry; use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Profiler\Profiler; use Joomla\CMS\Session\Session; use Joomla\CMS\Uri\Uri; use Joomla\Database\DatabaseDriver; use Joomla\Database\Event\ConnectionEvent; use Joomla\Event\DispatcherInterface; use Joomla\Event\SubscriberInterface; use Joomla\Plugin\System\Debug\DataCollector\InfoCollector; use Joomla\Plugin\System\Debug\DataCollector\LanguageErrorsCollector; use Joomla\Plugin\System\Debug\DataCollector\LanguageFilesCollector; use Joomla\Plugin\System\Debug\DataCollector\LanguageStringsCollector; use Joomla\Plugin\System\Debug\DataCollector\ProfileCollector; use Joomla\Plugin\System\Debug\DataCollector\QueryCollector; use Joomla\Plugin\System\Debug\DataCollector\SessionCollector; use Joomla\Plugin\System\Debug\JavascriptRenderer; use Joomla\Plugin\System\Debug\JoomlaHttpDriver; use Joomla\Plugin\System\Debug\Storage\FileStorage; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * Joomla! Debug plugin. * * @since 1.5 */ class PlgSystemDebug extends CMSPlugin implements SubscriberInterface { /** * True if debug lang is on. * * @var boolean * @since 3.0 */ private $debugLang = false; /** * Holds log entries handled by the plugin. * * @var LogEntry[] * @since 3.1 */ private $logEntries = []; /** * Holds SHOW PROFILES of queries. * * @var array * @since 3.1.2 */ private $sqlShowProfiles = []; /** * Holds all SHOW PROFILE FOR QUERY n, indexed by n-1. * * @var array * @since 3.1.2 */ private $sqlShowProfileEach = []; /** * Holds all EXPLAIN EXTENDED for all queries. * * @var array * @since 3.1.2 */ private $explains = []; /** * Holds total amount of executed queries. * * @var int * @since 3.2 */ private $totalQueries = 0; /** * Application object. * * @var CMSApplicationInterface * @since 3.3 */ protected $app; /** * Database object. * * @var DatabaseDriver * @since 3.8.0 */ protected $db; /** * @var DebugBar * @since 4.0.0 */ private $debugBar; /** * The query monitor. * * @var \Joomla\Database\Monitor\DebugMonitor * @since 4.0.0 */ private $queryMonitor; /** * AJAX marker * * @var bool * @since 4.0.0 */ protected $isAjax = false; /** * Whether displaing a logs is enabled * * @var bool * @since 4.0.0 */ protected $showLogs = false; /** * Returns an array of events this subscriber will listen to. * * @return array * * @since 4.1.3 */ public static function getSubscribedEvents(): array { return [ 'onBeforeCompileHead' => 'onBeforeCompileHead', 'onAjaxDebug' => 'onAjaxDebug', 'onBeforeRespond' => 'onBeforeRespond', 'onAfterRespond' => 'onAfterRespond', ApplicationEvents::AFTER_RESPOND => 'onAfterRespond', 'onAfterDisconnect' => 'onAfterDisconnect', ]; } /** * Constructor. * * @param DispatcherInterface &$subject The object to observe. * @param array $config An optional associative array of configuration settings. * * @since 1.5 */ public function __construct(&$subject, $config) { parent::__construct($subject, $config); $this->debugLang = $this->app->get('debug_lang'); // Skip the plugin if debug is off if (!$this->debugLang && !$this->app->get('debug')) { return; } $this->app->getConfig()->set('gzip', false); ob_start(); ob_implicit_flush(false); /** @var \Joomla\Database\Monitor\DebugMonitor */ $this->queryMonitor = $this->db->getMonitor(); if (!$this->params->get('queries', 1)) { // Remove the database driver monitor $this->db->setMonitor(null); } $storagePath = JPATH_CACHE . '/plg_system_debug_' . $this->app->getName(); $this->debugBar = new DebugBar(); $this->debugBar->setStorage(new FileStorage($storagePath)); $this->debugBar->setHttpDriver(new JoomlaHttpDriver($this->app)); $this->isAjax = $this->app->input->get('option') === 'com_ajax' && $this->app->input->get('plugin') === 'debug' && $this->app->input->get('group') === 'system'; $this->showLogs = (bool) $this->params->get('logs', true); // Log deprecated class aliases if ($this->showLogs && $this->app->get('log_deprecated')) { foreach (JLoader::getDeprecatedAliases() as $deprecation) { Log::add( sprintf( '%1$s has been aliased to %2$s and the former class name is deprecated. The alias will be removed in %3$s.', $deprecation['old'], $deprecation['new'], $deprecation['version'] ), Log::WARNING, 'deprecation-notes' ); } } } /** * Add an assets for debugger. * * @return void * * @since 4.0.0 */ public function onBeforeCompileHead() { // Only if debugging or language debug is enabled. if ((JDEBUG || $this->debugLang) && $this->isAuthorisedDisplayDebug() && $this->app->getDocument() instanceof HtmlDocument) { // Use our own jQuery and fontawesome instead of the debug bar shipped version $assetManager = $this->app->getDocument()->getWebAssetManager(); $assetManager->registerAndUseStyle( 'plg.system.debug', 'plg_system_debug/debug.css', [], [], ['fontawesome'] ); $assetManager->registerAndUseScript( 'plg.system.debug', 'plg_system_debug/debug.min.js', [], ['defer' => true], ['jquery'] ); } // Disable asset media version if needed. if (JDEBUG && (int) $this->params->get('refresh_assets', 1) === 0) { $this->app->getDocument()->setMediaVersion(null); } } /** * Show the debug info. * * @return void * * @since 1.6 */ public function onAfterRespond() { // Do not collect data if debugging or language debug is not enabled. if (!JDEBUG && !$this->debugLang || $this->isAjax) { return; } // User has to be authorised to see the debug information. if (!$this->isAuthorisedDisplayDebug()) { return; } // Load language. $this->loadLanguage(); $this->debugBar->addCollector(new InfoCollector($this->params, $this->debugBar->getCurrentRequestId())); if (JDEBUG) { if ($this->params->get('memory', 1)) { $this->debugBar->addCollector(new MemoryCollector()); } if ($this->params->get('request', 1)) { $this->debugBar->addCollector(new RequestDataCollector()); } if ($this->params->get('session', 1)) { $this->debugBar->addCollector(new SessionCollector($this->params)); } if ($this->params->get('profile', 1)) { $this->debugBar->addCollector(new ProfileCollector($this->params)); } if ($this->params->get('queries', 1)) { // Call $db->disconnect() here to trigger the onAfterDisconnect() method here in this class! $this->db->disconnect(); $this->debugBar->addCollector(new QueryCollector($this->params, $this->queryMonitor, $this->sqlShowProfileEach, $this->explains)); } if ($this->showLogs) { $this->collectLogs(); } } if ($this->debugLang) { $this->debugBar->addCollector(new LanguageFilesCollector($this->params)); $this->debugBar->addCollector(new LanguageStringsCollector($this->params)); $this->debugBar->addCollector(new LanguageErrorsCollector($this->params)); } // Only render for HTML output. if (!($this->app->getDocument() instanceof HtmlDocument)) { $this->debugBar->stackData(); return; } $debugBarRenderer = new JavascriptRenderer($this->debugBar, Uri::root(true) . '/media/vendor/debugbar/'); $openHandlerUrl = Uri::base(true) . '/index.php?option=com_ajax&plugin=debug&group=system&format=raw&action=openhandler'; $openHandlerUrl .= '&' . Session::getFormToken() . '=1'; $debugBarRenderer->setOpenHandlerUrl($openHandlerUrl); /** * @todo disable highlightjs from the DebugBar, import it through NPM * and deliver it through Joomla's API * Also every DebugBar script and stylesheet needs to use Joomla's API * $debugBarRenderer->disableVendor('highlightjs'); */ // Capture output. $contents = ob_get_contents(); if ($contents) { ob_end_clean(); } // No debug for Safari and Chrome redirection. if ( strpos($contents, 'loadResult(); if ($hasProfiling) { // Run a SHOW PROFILE query. $db->setQuery('SHOW PROFILES'); $this->sqlShowProfiles = $db->loadAssocList(); if ($this->sqlShowProfiles) { foreach ($this->sqlShowProfiles as $qn) { // Run SHOW PROFILE FOR QUERY for each query where a profile is available (max 100). $db->setQuery('SHOW PROFILE FOR QUERY ' . (int) $qn['Query_ID']); $this->sqlShowProfileEach[(int) ($qn['Query_ID'] - 1)] = $db->loadAssocList(); } } } else { $this->sqlShowProfileEach[0] = [['Error' => 'MySql have_profiling = off']]; } } catch (Exception $e) { $this->sqlShowProfileEach[0] = [['Error' => $e->getMessage()]]; } } if ($this->params->get('query_explains') && in_array($db->getServerType(), ['mysql', 'postgresql'], true)) { $logs = $this->queryMonitor->getLogs(); $boundParams = $this->queryMonitor->getBoundParams(); foreach ($logs as $k => $query) { $dbVersion56 = $db->getServerType() === 'mysql' && version_compare($db->getVersion(), '5.6', '>='); $dbVersion80 = $db->getServerType() === 'mysql' && version_compare($db->getVersion(), '8.0', '>='); if ($dbVersion80) { $dbVersion56 = false; } if ((stripos($query, 'select') === 0) || ($dbVersion56 && ((stripos($query, 'delete') === 0) || (stripos($query, 'update') === 0)))) { try { $queryInstance = $db->getQuery(true); $queryInstance->setQuery('EXPLAIN ' . ($dbVersion56 ? 'EXTENDED ' : '') . $query); if ($boundParams[$k]) { foreach ($boundParams[$k] as $key => $obj) { $queryInstance->bind($key, $obj->value, $obj->dataType, $obj->length, $obj->driverOptions); } } $this->explains[$k] = $db->setQuery($queryInstance)->loadAssocList(); } catch (Exception $e) { $this->explains[$k] = [['error' => $e->getMessage()]]; } } } } } /** * Store log messages so they can be displayed later. * This function is passed log entries by JLogLoggerCallback. * * @param LogEntry $entry A log entry. * * @return void * * @since 3.1 * * @deprecated 5.0 Use Log::add(LogEntry $entry); */ public function logger(LogEntry $entry) { if (!$this->showLogs) { return; } $this->logEntries[] = $entry; } /** * Collect log messages. * * @return $this * * @since 4.0.0 */ private function collectLogs(): self { $loggerOptions = ['group' => 'default']; $logger = new Joomla\CMS\Log\Logger\InMemoryLogger($loggerOptions); $logEntries = $logger->getCollectedEntries(); if (!$this->logEntries && !$logEntries) { return $this; } if ($this->logEntries) { $logEntries = array_merge($logEntries, $this->logEntries); } $logDeprecated = $this->app->get('log_deprecated', 0); $logDeprecatedCore = $this->params->get('log-deprecated-core', 0); $this->debugBar->addCollector(new MessagesCollector('log')); if ($logDeprecated) { $this->debugBar->addCollector(new MessagesCollector('deprecated')); $this->debugBar->addCollector(new MessagesCollector('deprecation-notes')); } if ($logDeprecatedCore) { $this->debugBar->addCollector(new MessagesCollector('deprecated-core')); } foreach ($logEntries as $entry) { switch ($entry->category) { case 'deprecation-notes': if ($logDeprecated) { $this->debugBar[$entry->category]->addMessage($entry->message); } break; case 'deprecated': if (!$logDeprecated && !$logDeprecatedCore) { break; } $file = ''; $line = ''; // Find the caller, skip Log methods and trigger_error function foreach ($entry->callStack as $stackEntry) { if ( !empty($stackEntry['class']) && ($stackEntry['class'] === 'Joomla\CMS\Log\LogEntry' || $stackEntry['class'] === 'Joomla\CMS\Log\Log') ) { continue; } if ( empty($stackEntry['class']) && !empty($stackEntry['function']) && $stackEntry['function'] === 'trigger_error' ) { continue; } $file = $stackEntry['file'] ?? ''; $line = $stackEntry['line'] ?? ''; break; } $category = $entry->category; $relative = $file ? str_replace(JPATH_ROOT, '', $file) : ''; if ($relative && 0 === strpos($relative, '/libraries/src')) { if (!$logDeprecatedCore) { break; } $category .= '-core'; } elseif (!$logDeprecated) { break; } $message = [ 'message' => $entry->message, 'caller' => $file . ':' . $line, // @todo 'stack' => $entry->callStack; ]; $this->debugBar[$category]->addMessage($message, 'warning'); break; case 'databasequery': // Should be collected by its own collector break; default: switch ($entry->priority) { case Log::EMERGENCY: case Log::ALERT: case Log::CRITICAL: case Log::ERROR: $level = 'error'; break; case Log::WARNING: $level = 'warning'; break; default: $level = 'info'; } $this->debugBar['log']->addMessage($entry->category . ' - ' . $entry->message, $level); break; } } return $this; } /** * Add server timing headers when profile is activated. * * @return void * * @since 4.1.0 */ public function onBeforeRespond(): void { if (!JDEBUG || !$this->params->get('profile', 1)) { return; } $metrics = ''; $moduleTime = 0; $accessTime = 0; foreach (Profiler::getInstance('Application')->getMarks() as $index => $mark) { // Ignore the before mark as the after one contains the timing of the action if (stripos($mark->label, 'before') !== false) { continue; } // Collect the module render time if (strpos($mark->label, 'mod_') !== false) { $moduleTime += $mark->time; continue; } // Collect the access render time if (strpos($mark->label, 'Access:') !== false) { $accessTime += $mark->time; continue; } $desc = str_ireplace('after', '', $mark->label); $name = preg_replace('/[^\da-z]/i', '', $desc); $metrics .= sprintf('%s;dur=%f;desc="%s", ', $index . $name, $mark->time, $desc); // Do not create too large headers, some web servers don't love them if (strlen($metrics) > 3000) { $metrics .= 'System;dur=0;desc="Data truncated to 3000 characters", '; break; } } // Add the module entry $metrics .= 'Modules;dur=' . $moduleTime . ';desc="Modules", '; // Add the access entry $metrics .= 'Access;dur=' . $accessTime . ';desc="Access"'; $this->app->setHeader('Server-Timing', $metrics); } }