From 358baa9f62b87617992602d3e3a80586ad98c444 Mon Sep 17 00:00:00 2001 From: Hypolite Petovan Date: Wed, 1 May 2019 21:33:33 -0400 Subject: [PATCH] Add themed error pages - Module init, post and rawContent-triggered HTTPException generate the classic bare HTTP status page - Module content-triggered HTTPException generate themed error pages - Trim System::httpExit to the bare minimum --- src/App.php | 286 ++++++++++++++------------- src/Core/System.php | 55 +----- src/Module/PageNotFound.php | 15 ++ src/Module/Special/HTTPException.php | 91 +++++++++ view/templates/exception.tpl | 4 + 5 files changed, 262 insertions(+), 189 deletions(-) create mode 100644 src/Module/PageNotFound.php create mode 100644 src/Module/Special/HTTPException.php create mode 100644 view/templates/exception.tpl diff --git a/src/App.php b/src/App.php index 328a1a152..adcb5d7d5 100644 --- a/src/App.php +++ b/src/App.php @@ -14,7 +14,7 @@ use Friendica\Core\Hook; use Friendica\Core\Theme; use Friendica\Database\DBA; use Friendica\Model\Profile; -use Friendica\Network\HTTPException\InternalServerErrorException; +use Friendica\Network\HTTPException; use Friendica\Util\BaseURL; use Friendica\Util\Config\ConfigFileLoader; use Friendica\Util\HTTPSignature; @@ -572,7 +572,7 @@ class App * @param string $origURL * * @return string The cleaned url - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function removeBaseURL($origURL) { @@ -593,7 +593,7 @@ class App * Returns the current UserAgent as a String * * @return string the UserAgent as a String - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function getUserAgent() { @@ -723,7 +723,7 @@ class App * @brief Checks if the minimal memory is reached * * @return bool Is the memory limit reached? - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function isMinMemoryReached() { @@ -768,7 +768,7 @@ class App * @brief Checks if the maximum load is reached * * @return bool Is the load reached? - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function isMaxLoadReached() { @@ -801,7 +801,7 @@ class App * * @param string $command The command to execute * @param array $args Arguments to pass to the command ( [ 'key' => value, 'key2' => value2, ... ] - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function proc_run($command, $args) { @@ -842,7 +842,7 @@ class App * Generates the site's default sender email address * * @return string - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function getSenderEmailAddress() { @@ -863,7 +863,7 @@ class App * Returns the current theme name. * * @return string the name of the current theme - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function getCurrentTheme() { @@ -944,7 +944,7 @@ class App * Provide a sane default if nothing is chosen or the specified theme does not exist. * * @return string - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function getCurrentThemeStylesheetPath() { @@ -1010,7 +1010,10 @@ class App { // Missing DB connection: ERROR if ($this->getMode()->has(App\Mode::LOCALCONFIGPRESENT) && !$this->getMode()->has(App\Mode::DBAVAILABLE)) { - Core\System::httpExit(500, ['title' => 'Error 500 - Internal Server Error', 'description' => 'Apologies but the website is unavailable at the moment.']); + echo Module\Special\HTTPException::rawContent( + new HTTPException\InternalServerErrorException('Apologies but the website is unavailable at the moment.') + ); + exit; } // Max Load Average reached: ERROR @@ -1018,11 +1021,17 @@ class App header('Retry-After: 120'); header('Refresh: 120; url=' . $this->getBaseURL() . "/" . $this->query_string); - Core\System::httpExit(503, ['title' => 'Error 503 - Service Temporarily Unavailable', 'description' => 'Core\System is currently overloaded. Please try again later.']); + echo Module\Special\HTTPException::rawContent( + new HTTPException\ServiceUnavaiableException('The node is currently overloaded. Please try again later.') + ); + exit; } if (strstr($this->query_string, '.well-known/host-meta') && ($this->query_string != '.well-known/host-meta')) { - Core\System::httpExit(404); + echo Module\Special\HTTPException::rawContent( + new HTTPException\NotFoundException() + ); + exit; } if (!$this->getMode()->isInstall()) { @@ -1073,7 +1082,10 @@ class App // Someone came with an invalid parameter, maybe as a DDoS attempt // We simply stop processing here Core\Logger::log("Invalid ZRL parameter " . $_GET['zrl'], Core\Logger::DEBUG); - Core\System::httpExit(403, ['title' => '403 Forbidden']); + echo Module\Special\HTTPException::rawContent( + new HTTPException\ForbiddenException() + ); + exit; } } } @@ -1126,123 +1138,115 @@ class App 'title' => '' ]; - if (strlen($this->module)) { - // Compatibility with the Android Diaspora client - if ($this->module == 'stream') { - $this->internalRedirect('network?f=&order=post'); - } + // Compatibility with the Android Diaspora client + if ($this->module == 'stream') { + $this->internalRedirect('network?f=&order=post'); + } - if ($this->module == 'conversations') { - $this->internalRedirect('message'); - } + if ($this->module == 'conversations') { + $this->internalRedirect('message'); + } - if ($this->module == 'commented') { - $this->internalRedirect('network?f=&order=comment'); - } + if ($this->module == 'commented') { + $this->internalRedirect('network?f=&order=comment'); + } - if ($this->module == 'liked') { - $this->internalRedirect('network?f=&order=comment'); - } + if ($this->module == 'liked') { + $this->internalRedirect('network?f=&order=comment'); + } - if ($this->module == 'activity') { - $this->internalRedirect('network/?f=&conv=1'); - } + if ($this->module == 'activity') { + $this->internalRedirect('network/?f=&conv=1'); + } - if (($this->module == 'status_messages') && ($this->cmd == 'status_messages/new')) { - $this->internalRedirect('bookmarklet'); - } + if (($this->module == 'status_messages') && ($this->cmd == 'status_messages/new')) { + $this->internalRedirect('bookmarklet'); + } - if (($this->module == 'user') && ($this->cmd == 'user/edit')) { - $this->internalRedirect('settings'); - } + if (($this->module == 'user') && ($this->cmd == 'user/edit')) { + $this->internalRedirect('settings'); + } - if (($this->module == 'tag_followings') && ($this->cmd == 'tag_followings/manage')) { - $this->internalRedirect('search'); - } + if (($this->module == 'tag_followings') && ($this->cmd == 'tag_followings/manage')) { + $this->internalRedirect('search'); + } - // Compatibility with the Firefox App - if (($this->module == "users") && ($this->cmd == "users/sign_in")) { - $this->module = "login"; - } + // Compatibility with the Firefox App + if (($this->module == "users") && ($this->cmd == "users/sign_in")) { + $this->module = "login"; + } - /* - * ROUTING - * - * From the request URL, routing consists of obtaining the name of a BaseModule-extending class of which the - * post() and/or content() static methods can be respectively called to produce a data change or an output. - */ + /* + * ROUTING + * + * From the request URL, routing consists of obtaining the name of a BaseModule-extending class of which the + * post() and/or content() static methods can be respectively called to produce a data change or an output. + */ - // First we try explicit routes defined in App\Router - $this->router->collectRoutes(); + // First we try explicit routes defined in App\Router + $this->router->collectRoutes(); - $data = $this->router->getRouteCollector(); - Hook::callAll('route_collection', $data); + $data = $this->router->getRouteCollector(); + Hook::callAll('route_collection', $data); - $this->module_class = $this->router->getModuleClass($this->cmd); + $this->module_class = $this->router->getModuleClass($this->cmd); - // Then we try addon-provided modules that we wrap in the LegacyModule class - if (!$this->module_class && Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { - //Check if module is an app and if public access to apps is allowed or not - $privateapps = $this->config->get('config', 'private_addons', false); - if ((!local_user()) && Core\Hook::isAddonApp($this->module) && $privateapps) { - info(Core\L10n::t("You must be logged in to use addons. ")); - } else { - include_once "addon/{$this->module}/{$this->module}.php"; - if (function_exists($this->module . '_module')) { - LegacyModule::setModuleFile("addon/{$this->module}/{$this->module}.php"); - $this->module_class = 'Friendica\\LegacyModule'; - } + // Then we try addon-provided modules that we wrap in the LegacyModule class + if (!$this->module_class && Core\Addon::isEnabled($this->module) && file_exists("addon/{$this->module}/{$this->module}.php")) { + //Check if module is an app and if public access to apps is allowed or not + $privateapps = $this->config->get('config', 'private_addons', false); + if ((!local_user()) && Core\Hook::isAddonApp($this->module) && $privateapps) { + info(Core\L10n::t("You must be logged in to use addons. ")); + } else { + include_once "addon/{$this->module}/{$this->module}.php"; + if (function_exists($this->module . '_module')) { + LegacyModule::setModuleFile("addon/{$this->module}/{$this->module}.php"); + $this->module_class = LegacyModule::class; } } - - // Then we try name-matching a Friendica\Module class - if (!$this->module_class && class_exists('Friendica\\Module\\' . ucfirst($this->module))) { - $this->module_class = 'Friendica\\Module\\' . ucfirst($this->module); - } - - /* Finally, we look for a 'standard' program module in the 'mod' directory - * We emulate a Module class through the LegacyModule class - */ - if (!$this->module_class && file_exists("mod/{$this->module}.php")) { - LegacyModule::setModuleFile("mod/{$this->module}.php"); - $this->module_class = 'Friendica\\LegacyModule'; - } - - /* The URL provided does not resolve to a valid module. - * - * On Dreamhost sites, quite often things go wrong for no apparent reason and they send us to '/internal_error.html'. - * We don't like doing this, but as it occasionally accounts for 10-20% or more of all site traffic - - * we are going to trap this and redirect back to the requested page. As long as you don't have a critical error on your page - * this will often succeed and eventually do the right thing. - * - * Otherwise we are going to emit a 404 not found. - */ - if (!$this->module_class) { - // Stupid browser tried to pre-fetch our Javascript img template. Don't log the event or return anything - just quietly exit. - if (!empty($_SERVER['QUERY_STRING']) && preg_match('/{[0-9]}/', $_SERVER['QUERY_STRING']) !== 0) { - exit(); - } - - if (!empty($_SERVER['QUERY_STRING']) && ($_SERVER['QUERY_STRING'] === 'q=internal_error.html') && isset($dreamhost_error_hack)) { - Core\Logger::log('index.php: dreamhost_error_hack invoked. Original URI =' . $_SERVER['REQUEST_URI']); - $this->internalRedirect($_SERVER['REQUEST_URI']); - } - - Core\Logger::log('index.php: page not found: ' . $_SERVER['REQUEST_URI'] . ' ADDRESS: ' . $_SERVER['REMOTE_ADDR'] . ' QUERY: ' . $_SERVER['QUERY_STRING'], Core\Logger::DEBUG); - - header($_SERVER["SERVER_PROTOCOL"] . ' 404 ' . Core\L10n::t('Not Found')); - $tpl = Core\Renderer::getMarkupTemplate("404.tpl"); - $this->page['content'] = Core\Renderer::replaceMacros($tpl, [ - '$message' => Core\L10n::t('Page not found.') - ]); - } } - $content = ''; + // Then we try name-matching a Friendica\Module class + if (!$this->module_class && class_exists('Friendica\\Module\\' . ucfirst($this->module))) { + $this->module_class = 'Friendica\\Module\\' . ucfirst($this->module); + } + + /* Finally, we look for a 'standard' program module in the 'mod' directory + * We emulate a Module class through the LegacyModule class + */ + if (!$this->module_class && file_exists("mod/{$this->module}.php")) { + LegacyModule::setModuleFile("mod/{$this->module}.php"); + $this->module_class = LegacyModule::class; + } + + /* The URL provided does not resolve to a valid module. + * + * On Dreamhost sites, quite often things go wrong for no apparent reason and they send us to '/internal_error.html'. + * We don't like doing this, but as it occasionally accounts for 10-20% or more of all site traffic - + * we are going to trap this and redirect back to the requested page. As long as you don't have a critical error on your page + * this will often succeed and eventually do the right thing. + * + * Otherwise we are going to emit a 404 not found. + */ + if (!$this->module_class) { + // Stupid browser tried to pre-fetch our Javascript img template. Don't log the event or return anything - just quietly exit. + if (!empty($_SERVER['QUERY_STRING']) && preg_match('/{[0-9]}/', $_SERVER['QUERY_STRING']) !== 0) { + exit(); + } + + if (!empty($_SERVER['QUERY_STRING']) && ($_SERVER['QUERY_STRING'] === 'q=internal_error.html') && isset($dreamhost_error_hack)) { + Core\Logger::log('index.php: dreamhost_error_hack invoked. Original URI =' . $_SERVER['REQUEST_URI']); + $this->internalRedirect($_SERVER['REQUEST_URI']); + } + + Core\Logger::log('index.php: page not found: ' . $_SERVER['REQUEST_URI'] . ' ADDRESS: ' . $_SERVER['REMOTE_ADDR'] . ' QUERY: ' . $_SERVER['QUERY_STRING'], Core\Logger::DEBUG); + + $this->module_class = Module\PageNotFound::class; + } // Initialize module that can set the current theme in the init() method, either directly or via App->profile_uid - if ($this->module_class) { - $this->page['page_title'] = $this->module; + $this->page['page_title'] = $this->module; + try { $placeholder = ''; Core\Hook::callAll($this->module . '_mod_init', $placeholder); @@ -1251,35 +1255,42 @@ class App // "rawContent" is especially meant for technical endpoints. // This endpoint doesn't need any theme initialization or other comparable stuff. - call_user_func([$this->module_class, 'rawContent']); - } + call_user_func([$this->module_class, 'rawContent']); - // Load current theme info after module has been initialized as theme could have been set in module - $theme_info_file = 'view/theme/' . $this->getCurrentTheme() . '/theme.php'; - if (file_exists($theme_info_file)) { - require_once $theme_info_file; - } + // Load current theme info after module has been initialized as theme could have been set in module + $theme_info_file = 'view/theme/' . $this->getCurrentTheme() . '/theme.php'; + if (file_exists($theme_info_file)) { + require_once $theme_info_file; + } - if (function_exists(str_replace('-', '_', $this->getCurrentTheme()) . '_init')) { - $func = str_replace('-', '_', $this->getCurrentTheme()) . '_init'; - $func($this); - } + if (function_exists(str_replace('-', '_', $this->getCurrentTheme()) . '_init')) { + $func = str_replace('-', '_', $this->getCurrentTheme()) . '_init'; + $func($this); + } - if ($this->module_class) { if ($_SERVER['REQUEST_METHOD'] === 'POST') { Core\Hook::callAll($this->module . '_mod_post', $_POST); call_user_func([$this->module_class, 'post']); } - Core\Hook::callAll($this->module . '_mod_afterpost', $placeholder); - call_user_func([$this->module_class, 'afterpost']); + Core\Hook::callAll($this->module . '_mod_afterpost', $placeholder); + call_user_func([$this->module_class, 'afterpost']); + } catch(HTTPException $e) { + echo Module\Special\HTTPException::rawContent($e); + exit; + } - $arr = ['content' => $content]; - Core\Hook::callAll($this->module . '_mod_content', $arr); - $content = $arr['content']; - $arr = ['content' => call_user_func([$this->module_class, 'content'])]; - Core\Hook::callAll($this->module . '_mod_aftercontent', $arr); - $content .= $arr['content']; + $content = ''; + + try { + $arr = ['content' => $content]; + Core\Hook::callAll($this->module . '_mod_content', $arr); + $content = $arr['content']; + $arr = ['content' => call_user_func([$this->module_class, 'content'])]; + Core\Hook::callAll($this->module . '_mod_aftercontent', $arr); + $content .= $arr['content']; + } catch(HTTPException $e) { + $content = Module\Special\HTTPException::content($e); } // initialise content region @@ -1303,13 +1314,6 @@ class App */ $this->initFooter(); - /* now that we've been through the module content, see if the page reported - * a permission problem and if so, a 403 response would seem to be in order. - */ - if (stristr(implode("", $_SESSION['sysmsg']), Core\L10n::t('Permission denied'))) { - header($_SERVER["SERVER_PROTOCOL"] . ' 403 ' . Core\L10n::t('Permission denied.')); - } - if (!$this->isAjax()) { Core\Hook::callAll('page_end', $this->page['content']); } @@ -1401,12 +1405,12 @@ class App * @param string $toUrl The destination URL (Default is empty, which is the default page of the Friendica node) * @param bool $ssl if true, base URL will try to get called with https:// (works just for relative paths) * - * @throws InternalServerErrorException In Case the given URL is not relative to the Friendica node + * @throws HTTPException\InternalServerErrorException In Case the given URL is not relative to the Friendica node */ public function internalRedirect($toUrl = '', $ssl = false) { if (!empty(parse_url($toUrl, PHP_URL_SCHEME))) { - throw new InternalServerErrorException("'$toUrl is not a relative path, please use System::externalRedirectTo"); + throw new HTTPException\InternalServerErrorException("'$toUrl is not a relative path, please use System::externalRedirectTo"); } $redirectTo = $this->getBaseURL($ssl) . '/' . ltrim($toUrl, '/'); @@ -1418,7 +1422,7 @@ class App * Should only be used if it isn't clear if the URL is either internal or external * * @param string $toUrl The target URL - * @throws InternalServerErrorException + * @throws HTTPException\InternalServerErrorException */ public function redirect($toUrl) { diff --git a/src/Core/System.php b/src/Core/System.php index 83c3dc908..e2966a9b0 100644 --- a/src/Core/System.php +++ b/src/Core/System.php @@ -120,58 +120,17 @@ class System extends BaseObject /** * @brief Send HTTP status header and exit. * - * @param integer $val HTTP status result value - * @param array $description optional message - * 'title' => header title - * 'description' => optional message - * @throws InternalServerErrorException + * @param integer $val HTTP status result value + * @param string $message Error message. Optional. + * @param string $content Response body. Optional. + * @throws \Exception */ - public static function httpExit($val, $description = []) + public static function httpExit($val, $message = '', $content = '') { - $err = ''; - if ($val >= 400) { - if (!empty($description['title'])) { - $err = $description['title']; - } else { - $title = [ - '400' => L10n::t('Error 400 - Bad Request'), - '401' => L10n::t('Error 401 - Unauthorized'), - '403' => L10n::t('Error 403 - Forbidden'), - '404' => L10n::t('Error 404 - Not Found'), - '500' => L10n::t('Error 500 - Internal Server Error'), - '503' => L10n::t('Error 503 - Service Unavailable'), - ]; - $err = defaults($title, $val, 'Error ' . $val); - $description['title'] = $err; - } - if (empty($description['description'])) { - // Explanations are taken from https://en.wikipedia.org/wiki/List_of_HTTP_status_codes - $explanation = [ - '400' => L10n::t('The server cannot or will not process the request due to an apparent client error.'), - '401' => L10n::t('Authentication is required and has failed or has not yet been provided.'), - '403' => L10n::t('The request was valid, but the server is refusing action. The user might not have the necessary permissions for a resource, or may need an account.'), - '404' => L10n::t('The requested resource could not be found but may be available in the future.'), - '500' => L10n::t('An unexpected condition was encountered and no more specific message is suitable.'), - '503' => L10n::t('The server is currently unavailable (because it is overloaded or down for maintenance). Please try again later.'), - ]; - if (!empty($explanation[$val])) { - $description['description'] = $explanation[$val]; - } - } - } - - if ($val >= 200 && $val < 300) { - $err = 'OK'; - } - Logger::log('http_status_exit ' . $val); - header($_SERVER["SERVER_PROTOCOL"] . ' ' . $val . ' ' . $err); + header($_SERVER["SERVER_PROTOCOL"] . ' ' . $val . ' ' . $message); - if (isset($description["title"])) { - $tpl = Renderer::getMarkupTemplate('http_status.tpl'); - echo Renderer::replaceMacros($tpl, ['$title' => $description["title"], - '$description' => defaults($description, 'description', '')]); - } + echo $content; exit(); } diff --git a/src/Module/PageNotFound.php b/src/Module/PageNotFound.php new file mode 100644 index 000000000..764903c2a --- /dev/null +++ b/src/Module/PageNotFound.php @@ -0,0 +1,15 @@ + ..., '$description' => ...] + */ + private static function getVars(\Friendica\Network\HTTPException $e) + { + $message = $e->getMessage(); + + $titles = [ + 200 => 'OK', + 400 => L10n::t('Bad Request'), + 401 => L10n::t('Unauthorized'), + 403 => L10n::t('Forbidden'), + 404 => L10n::t('Not Found'), + 500 => L10n::t('Internal Server Error'), + 503 => L10n::t('Service Unavailable'), + ]; + $title = defaults($titles, $e->getCode(), 'Error ' . $e->getCode()); + + if (empty($message)) { + // Explanations are taken from https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + $explanation = [ + 400 => L10n::t('The server cannot or will not process the request due to an apparent client error.'), + 401 => L10n::t('Authentication is required and has failed or has not yet been provided.'), + 403 => L10n::t('The request was valid, but the server is refusing action. The user might not have the necessary permissions for a resource, or may need an account.'), + 404 => L10n::t('The requested resource could not be found but may be available in the future.'), + 500 => L10n::t('An unexpected condition was encountered and no more specific message is suitable.'), + 503 => L10n::t('The server is currently unavailable (because it is overloaded or down for maintenance). Please try again later.'), + ]; + + $message = defaults($explanation, $e->getCode(), ''); + } + + return ['$title' => $title, '$description' => $message]; + } + + /** + * Displays a bare message page with no theming at all. + * + * @param \Friendica\Network\HTTPException $e + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function rawContent(\Friendica\Network\HTTPException $e) + { + $content = ''; + + if ($e->getCode() >= 400) { + $tpl = Renderer::getMarkupTemplate('http_status.tpl'); + $content = Renderer::replaceMacros($tpl, self::getVars($e)); + } + + System::httpExit($e->getCode(), $e->httpdesc, $content); + } + + /** + * Returns a content string that can be integrated in the current theme. + * + * @param \Friendica\Network\HTTPException $e + * @return string + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + */ + public static function content(\Friendica\Network\HTTPException $e) + { + header($_SERVER["SERVER_PROTOCOL"] . ' ' . $e->getCode() . ' ' . $e->httpdesc); + + $tpl = Renderer::getMarkupTemplate('exception.tpl'); + + return Renderer::replaceMacros($tpl, self::getVars($e)); + } +} diff --git a/view/templates/exception.tpl b/view/templates/exception.tpl new file mode 100644 index 000000000..cc4e6167d --- /dev/null +++ b/view/templates/exception.tpl @@ -0,0 +1,4 @@ +
+

{{$title}}

+

{{$message}}

+