From 94dd1eb801805ba12f50dbf188100514fb5166d8 Mon Sep 17 00:00:00 2001 From: Christopher Mavros Date: Fri, 23 Dec 2022 20:42:45 +0200 Subject: [PATCH] Maintain usable admin on system plugin errors --- administrator/language/en-GB/joomla.ini | 3 + administrator/templates/atum/error_full.php | 5 + libraries/src/Exception/ExceptionHandler.php | 138 +++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/administrator/language/en-GB/joomla.ini b/administrator/language/en-GB/joomla.ini index 1afcbb710aff5..80592cd05a32b 100644 --- a/administrator/language/en-GB/joomla.ini +++ b/administrator/language/en-GB/joomla.ini @@ -195,6 +195,9 @@ JERROR_NOLOGIN_BLOCKED="Login denied! Your account has either been blocked or yo JERROR_SAVE_FAILED="Could not save data. Error: %s" JERROR_SENDING_EMAIL="Email could not be sent." JERROR_SESSION_STARTUP="Error starting the session." +JERROR_CRASH_RECOVERY="We have detected that plugin \"%1$s\" is causing a system-wide error. Would you like to disable it?
Disable %1$s" +JERROR_PLUGIN_DISABLED="Plugin disabled." +JERROR_LAYOUT_DISABLE_PLUGIN="Disable %s" JFIELD_ACCESS_DESC="The access level group that is allowed to view this item." JFIELD_ACCESS_LABEL="Access" diff --git a/administrator/templates/atum/error_full.php b/administrator/templates/atum/error_full.php index d2c2381f4e8eb..d08d31fc1bd09 100644 --- a/administrator/templates/atum/error_full.php +++ b/administrator/templates/atum/error_full.php @@ -155,6 +155,11 @@ + getUserState('exceptionhandler.culprit_name', null)) && !is_null($app->getUserState('exceptionhandler.restore_link', null))) : ?> + + getUserState('exceptionhandler.culprit_name', null)); ?> + +

diff --git a/libraries/src/Exception/ExceptionHandler.php b/libraries/src/Exception/ExceptionHandler.php index 9caff3ca88802..c0f5adaf1abdd 100644 --- a/libraries/src/Exception/ExceptionHandler.php +++ b/libraries/src/Exception/ExceptionHandler.php @@ -13,6 +13,9 @@ use Joomla\CMS\Error\AbstractRenderer; use Joomla\CMS\Factory; use Joomla\CMS\Log\Log; +use Joomla\CMS\language\Text; +use Joomla\CMS\Uri\Uri; +use Joomla\CMS\Session\Session; // phpcs:disable PSR1.Files.SideEffects \defined('JPATH_PLATFORM') or die; @@ -69,6 +72,7 @@ public static function handleUserDeprecatedErrors(int $errorNumber, string $erro public static function handleException(\Throwable $error) { static::logException($error); + static::identifyCulprit($error); static::render($error); } @@ -214,4 +218,138 @@ protected static function logException(\Throwable $error) // Logging failed, don't make a stink about it though } } + + /** + * Tries to identify if the error is caused by a plugin that completely blocks the administrator interface. + * + * @param \Throwable $error An Exception or Throwable (PHP 7+) object to get error message from. + * + * @return void + * + * @since 4.2 + */ + protected static function identifyCulprit(\Throwable $error) + { + try { + $app = Factory::getApplication(); + if ($app->isClient('administrator') && !Factory::getUser()->guest) { + + // Clear previous detections + $app->setUserState('exceptionhandler.culprit_name', null); + $app->setUserState('exceptionhandler.restore_link', null); + + $traceFiles = array($error->getFile()); + foreach($error->getTrace() as $trace) { + $traceFiles[] = $trace['file']; + } + + $db = Factory::getDbo(); + $culprit = null; + foreach($traceFiles as $traceFile) { + $traceLoc = explode(DIRECTORY_SEPARATOR, str_replace(JPATH_ROOT.DIRECTORY_SEPARATOR, '', $traceFile)); + if ( + (count($traceLoc) > 3) + && ($traceLoc[0] == 'plugins') + && ($traceLoc[1] == 'system') + ) { + $query = $db->getQuery(true) + ->select( + $db->quoteName( + [ + 'type', + 'name', + 'element', + 'extension_id', + ] + ) + ) + ->from($db->quoteName('#__extensions')) + ->where( + [ + $db->quoteName('type') . ' = ' . $db->quote('plugin'), + $db->quoteName('folder') . ' = ' . $db->quote('system'), + $db->quoteName('element') . ' = ' . $db->quote($db->escape($traceLoc[2])) + ] + ); + $db->setQuery($query); + + $plugin = $db->loadObject(); + if ($plugin) { + $culprit = $plugin; + break; + } + } + } + + if ($culprit) { + // A plugin's directory has been found in the error log. + static::handleCulprit($culprit); + } + } + } catch(\Throwable $e) { + // Identifying failed, but we don't want to alter the existing error log. + } + } + + /** + * Handles the culprit plugin. + * + * @param \Throwable $error An Exception or Throwable (PHP 7+) object to get error message from. + * + * @return void + * + * @since 4.2 + */ + protected static function handleCulprit($culprit) { + try { + $app = Factory::getApplication(); + if ($app->isClient('administrator') && !Factory::getUser()->guest) { + if ($app->input->exists('disable_culprit') + && $app->input->exists('culprit_id') + && ($app->input->get('culprit_id', 0, 'int') == $culprit->extension_id) + ) { + Session::checkToken('get') or die(); + + // If the admin confirms to disable the culprit, go ahead. + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->update($db->quoteName('#__extensions')) + ->set($db->quoteName('enabled') . ' = 0') + ->where($db->quoteName('extension_id') . ' = ' . (int) $culprit->extension_id); + + $db->lockTable('#__extensions'); + + $result = $db->setQuery($query)->execute(); + + $db->unlockTables(); + + $app->enqueueMessage(Text::_('JERROR_PLUGIN_DISABLED')); + + // Where should we return to? + $returnUrl = $app->input->exists('return') ? base64_decode($app->input->getBase64('return')) : 'index.php'; + if (!Uri::isInternal($returnUrl)) { + $returnUrl = 'index.php'; + } + + $app->redirect($returnUrl); + } else { + // Present an option to the admin to disable the offending plugin. + $uri = (string) Uri::getInstance(); + $return = urlencode(base64_encode($uri)); + + $token = urlencode(Session::getFormToken()); + + $restoreLink = 'index.php?disable_culprit=1&culprit_id=' . (int) $culprit->extension_id . '&return=' . $return . '&' . $token .'=1'; + + $app->enqueueMessage(Text::sprintf('JERROR_CRASH_RECOVERY', $culprit->name, $restoreLink ), 'error'); + + // If that fails, we try to render the button through the template. + $app->setUserState('exceptionhandler.culprit_name', $culprit->name); + $app->setUserState('exceptionhandler.restore_link', $restoreLink); + } + } + } catch(\Throwable $e) { + // Handling failed, but we don't want to alter the existing error log. + } + } }