From 358baa9f62b87617992602d3e3a80586ad98c444 Mon Sep 17 00:00:00 2001
From: Hypolite Petovan <hypolite@mrpetovan.com>
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 328a1a1522..adcb5d7d54 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 83c3dc9081..e2966a9b0e 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 0000000000..764903c2a6
--- /dev/null
+++ b/src/Module/PageNotFound.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Friendica\Module;
+
+use Friendica\BaseModule;
+use Friendica\Core\L10n;
+use Friendica\Network\HTTPException;
+
+class PageNotFound extends BaseModule
+{
+	public static function content()
+	{
+		throw new HTTPException\NotFoundException(L10n::t('Page not found.'));
+	}
+}
diff --git a/src/Module/Special/HTTPException.php b/src/Module/Special/HTTPException.php
new file mode 100644
index 0000000000..5c6ff79ae6
--- /dev/null
+++ b/src/Module/Special/HTTPException.php
@@ -0,0 +1,91 @@
+<?php
+
+
+namespace Friendica\Module\Special;
+
+use Friendica\Core\L10n;
+use Friendica\Core\Logger;
+use Friendica\Core\Renderer;
+use Friendica\Core\System;
+
+/**
+ * This special module displays HTTPException when they are thrown in modules.
+ *
+ * @package Friendica\Module\Special
+ */
+class HTTPException
+{
+	/**
+	 * Generates the necessary template variables from the caught HTTPException.
+	 *
+	 * Fills in the blanks if title or descriptions aren't provided by the exception.
+	 *
+	 * @param \Friendica\Network\HTTPException $e
+	 * @return array ['$title' => ..., '$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 0000000000..cc4e6167d0
--- /dev/null
+++ b/view/templates/exception.tpl
@@ -0,0 +1,4 @@
+<div id="exception" class="generic-page-wrapper">
+    <h1>{{$title}}</h1>
+    <p>{{$message}}</p>
+</div>