Add HandleExceptions middleware with request handlers to generate error responses
authorTim Düsterhus <duesterhus@woltlab.com>
Wed, 12 Apr 2023 13:25:13 +0000 (15:25 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Thu, 13 Apr 2023 12:18:49 +0000 (14:18 +0200)
This allows controllers to still leverage Exception, but prevents the
Exceptions from skipping the response direction of the middleware stack.

com.woltlab.wcf/templates/error.tpl [new file with mode: 0644]
wcfsetup/install/files/acp/templates/error.tpl [new file with mode: 0644]
wcfsetup/install/files/lib/http/error/ErrorDetail.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/http/error/HtmlErrorRenderer.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/http/error/NotFoundHandler.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/http/error/PermissionDeniedHandler.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/http/middleware/HandleExceptions.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/http/middleware/HandleValinorMappingErrors.class.php
wcfsetup/install/files/lib/system/request/RequestHandler.class.php

diff --git a/com.woltlab.wcf/templates/error.tpl b/com.woltlab.wcf/templates/error.tpl
new file mode 100644 (file)
index 0000000..15bd9f4
--- /dev/null
@@ -0,0 +1,44 @@
+{if $title !== ''}
+       {capture assign='pageTitle'}{$title}{/capture}
+       {capture assign='contentTitle'}{$title}{/capture}
+{else}
+       {capture assign='pageTitle'}{lang}wcf.global.error.title{/lang}{/capture}
+       {capture assign='contentTitle'}{lang}wcf.global.error.title{/lang}{/capture}
+{/if}
+
+{include file='header' __disableAds=true}
+
+{if ENABLE_DEBUG_MODE}
+{if $exception !== null}
+<!--
+{* A comment may not contain double dashes. *}
+{@'--'|str_replace:'- -':$exception}
+-->
+{/if}
+{/if}
+
+<div class="section">
+       <div class="box64 userException">
+               {icon size=64 name='circle-exclamation'}
+               <p>
+                       {@$message}
+               </p>
+       </div>
+</div>
+
+{if $showLogin}
+<section class="section">
+       <h2 class="sectionTitle">{lang}wcf.user.login{/lang}</h2>
+       
+       <p>{lang}wcf.page.error.loginAvailable{/lang}</p>
+       <p style="margin-top: 20px">
+               <a
+                       href="{link controller='Login' url=$__wcf->getRequestURI()}{/link}"
+                       class="button"
+                       rel="nofollow"
+               >{icon name='key'} {lang}wcf.user.loginOrRegister{/lang}</a>
+       </p>
+</section>
+{/if}
+
+{include file='footer' __disableAds=true}
diff --git a/wcfsetup/install/files/acp/templates/error.tpl b/wcfsetup/install/files/acp/templates/error.tpl
new file mode 100644 (file)
index 0000000..d8b625a
--- /dev/null
@@ -0,0 +1,27 @@
+{include file='header' pageTitle=$title templateName='error' templateNameApplication='wcf'}
+
+{if ENABLE_DEBUG_MODE}
+{if $exception !== null}
+<!--
+{* A comment may not contain double dashes. *}
+{@'--'|str_replace:'- -':$exception}
+-->
+{/if}
+{/if}
+
+<header class="contentHeader">
+       <div class="contentHeaderTitle">
+               <h1 class="contentTitle">{$title}</h1>
+       </div>
+</header>
+
+<div class="section">
+       <div class="box64 userException">
+               {icon size=64 name='circle-exclamation'}
+               <p>
+                       {@$message}
+               </p>
+       </div>
+</div>
+
+{include file='footer'}
diff --git a/wcfsetup/install/files/lib/http/error/ErrorDetail.class.php b/wcfsetup/install/files/lib/http/error/ErrorDetail.class.php
new file mode 100644 (file)
index 0000000..a0558b1
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace wcf\http\error;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Stores additional metadata for response generation for erroneous requests.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2023 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.0
+ */
+final class ErrorDetail
+{
+    private const ATTRIBUTE = self::class;
+
+    private function __construct(
+        private ?string $message = null,
+        private ?\Throwable $throwable = null,
+    ) {
+    }
+
+    public function getMessage(): ?string
+    {
+        return $this->message;
+    }
+
+    public function getThrowable(): ?\Throwable
+    {
+        return $this->throwable;
+    }
+
+    /**
+     * Creates a new ErrorDetail object with the Throwable's message
+     * as the message.
+     */
+    public static function fromThrowable(\Throwable $e): self
+    {
+        return self::fromMessageWithThrowable($e->getMessage(), $e);
+    }
+
+    /**
+     * Creates a new ErrorDetail object with the given message.
+     */
+    public static function fromMessage(string $message): self
+    {
+        return new self(
+            $message,
+        );
+    }
+
+    /**
+     * Creates a new ErrorDetail object with the given message and the
+     * Throwable as context.
+     */
+    public static function fromMessageWithThrowable(string $message, \Throwable $e): self
+    {
+        return new self(
+            $message,
+            $e
+        );
+    }
+
+    public function attachToRequest(ServerRequestInterface $request): ServerRequestInterface
+    {
+        return $request->withAttribute(self::ATTRIBUTE, $this);
+    }
+
+    public static function fromRequest(ServerRequestInterface $request): ?self
+    {
+        return $request->getAttribute(self::ATTRIBUTE);
+    }
+}
diff --git a/wcfsetup/install/files/lib/http/error/HtmlErrorRenderer.class.php b/wcfsetup/install/files/lib/http/error/HtmlErrorRenderer.class.php
new file mode 100644 (file)
index 0000000..5c61e76
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+
+namespace wcf\http\error;
+
+use Psr\Http\Message\StreamInterface;
+use wcf\system\WCF;
+use wcf\util\HeaderUtil;
+use wcf\util\StringUtil;
+
+/**
+ * Renders an nice HTML error page.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2023 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.0
+ */
+final class HtmlErrorRenderer
+{
+    public function render(
+        string $title,
+        string $message,
+        ?\Throwable $exception = null,
+        bool $showLogin = false
+    ): StreamInterface {
+        return $this->renderHtmlMessage(
+            $title,
+            StringUtil::encodeHTML($message),
+            $exception,
+            $showLogin
+        );
+    }
+
+    public function renderHtmlMessage(
+        string $title,
+        string $message,
+        ?\Throwable $exception = null,
+        bool $showLogin = false
+    ): StreamInterface {
+        return HeaderUtil::parseOutputStream(WCF::getTPL()->fetchStream(
+            'error',
+            'wcf',
+            [
+                'title' => $title,
+                'message' => $message,
+                'exception' => $exception,
+                'showLogin' => $showLogin,
+                'templateName' => 'error',
+                'templateNameApplication' => 'wcf',
+            ]
+        ));
+    }
+}
diff --git a/wcfsetup/install/files/lib/http/error/NotFoundHandler.class.php b/wcfsetup/install/files/lib/http/error/NotFoundHandler.class.php
new file mode 100644 (file)
index 0000000..b53d746
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace wcf\http\error;
+
+use Laminas\Diactoros\Response\HtmlResponse;
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\http\Helper;
+use wcf\system\box\BoxHandler;
+use wcf\system\request\RequestHandler;
+use wcf\system\session\SessionHandler;
+use wcf\system\WCF;
+
+/**
+ * Returns a "Not Found" response.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2023 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.0
+ */
+final class NotFoundHandler implements RequestHandlerInterface
+{
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $errorDetail = ErrorDetail::fromRequest($request);
+        $message = $errorDetail?->getMessage() ?? WCF::getLanguage()->getDynamicVariable('wcf.page.error.illegalLink');
+
+        if (!RequestHandler::getInstance()->isACPRequest()) {
+            BoxHandler::disablePageLayout();
+        }
+        SessionHandler::getInstance()->disableTracking();
+
+        $preferredType = Helper::getPreferredContentType($request, [
+            'text/html',
+            'application/json',
+        ]);
+
+        return match ($preferredType) {
+            'application/json' => new JsonResponse(
+                [
+                    'message' => $message,
+                ],
+                404,
+                [],
+                \JSON_PRETTY_PRINT
+            ),
+            'text/html' => new HtmlResponse(
+                (new HtmlErrorRenderer())->render(
+                    WCF::getLanguage()->getDynamicVariable('wcf.page.error.illegalLink.title'),
+                    $message,
+                    $errorDetail?->getThrowable()
+                ),
+                404
+            ),
+        };
+    }
+}
diff --git a/wcfsetup/install/files/lib/http/error/PermissionDeniedHandler.class.php b/wcfsetup/install/files/lib/http/error/PermissionDeniedHandler.class.php
new file mode 100644 (file)
index 0000000..33fcfc6
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace wcf\http\error;
+
+use Laminas\Diactoros\Response\HtmlResponse;
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\http\Helper;
+use wcf\system\box\BoxHandler;
+use wcf\system\notice\NoticeHandler;
+use wcf\system\request\RequestHandler;
+use wcf\system\session\SessionHandler;
+use wcf\system\WCF;
+
+/**
+ * Returns a "Permission Denied" response.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2023 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.0
+ */
+final class PermissionDeniedHandler implements RequestHandlerInterface
+{
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $errorDetail = ErrorDetail::fromRequest($request);
+        $message = $errorDetail?->getMessage() ?? WCF::getLanguage()->getDynamicVariable('wcf.page.error.permissionDenied');
+
+        if (!RequestHandler::getInstance()->isACPRequest()) {
+            BoxHandler::disablePageLayout();
+            NoticeHandler::disableNotices();
+        }
+        SessionHandler::getInstance()->disableTracking();
+
+        $preferredType = Helper::getPreferredContentType($request, [
+            'text/html',
+            'application/json',
+        ]);
+
+        return match ($preferredType) {
+            'application/json' => new JsonResponse(
+                [
+                    'message' => $message,
+                ],
+                403,
+                [],
+                \JSON_PRETTY_PRINT
+            ),
+            'text/html' => new HtmlResponse(
+                (new HtmlErrorRenderer())->render(
+                    WCF::getLanguage()->getDynamicVariable('wcf.page.error.permissionDenied.title'),
+                    $message,
+                    $errorDetail?->getThrowable(),
+                    !WCF::getUser()->userID,
+                ),
+                403
+            ),
+        };
+    }
+}
diff --git a/wcfsetup/install/files/lib/http/middleware/HandleExceptions.class.php b/wcfsetup/install/files/lib/http/middleware/HandleExceptions.class.php
new file mode 100644 (file)
index 0000000..ae7f4dd
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace wcf\http\middleware;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\http\error\ErrorDetail;
+use wcf\http\error\NotFoundHandler;
+use wcf\http\error\PermissionDeniedHandler;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+
+/**
+ * Catches PermissionDeniedException and IllegalLinkException and delegates
+ * to appropriate handlers.
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2023 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.0
+ */
+final class HandleExceptions implements MiddlewareInterface
+{
+    /**
+     * @inheritDoc
+     */
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        try {
+            return $handler->handle($request);
+        } catch (PermissionDeniedException|IllegalLinkException $e) {
+            if ($e instanceof PermissionDeniedException) {
+                $handler = new PermissionDeniedHandler();
+            } elseif ($e instanceof IllegalLinkException) {
+                $handler = new NotFoundHandler();
+            } else {
+                throw new \LogicException('Unreachable');
+            }
+
+            return $handler->handle(ErrorDetail::fromThrowable($e)->attachToRequest($request));
+        }
+    }
+}
index d47f23d1e73a985daa6c93e4ea97a0779e93435d..15a63f6e1b958fed41b48e4140c0ce1af4fbaeda 100644 (file)
@@ -11,9 +11,11 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\MiddlewareInterface;
 use Psr\Http\Server\RequestHandlerInterface;
+use wcf\http\error\HtmlErrorRenderer;
 use wcf\http\Helper;
 use wcf\system\valinor\formatter\PrependPath;
 use wcf\system\WCF;
+use wcf\util\StringUtil;
 
 /**
  * Catches Valinor's MappingErrors and returns a HTTP 400 Bad Request.
@@ -56,20 +58,10 @@ final class HandleValinorMappingErrors implements MiddlewareInterface
                     \JSON_PRETTY_PRINT
                 ),
                 'text/html' => new HtmlResponse(
-                    // TODO: Create a more generically reusable template for this type of error message.
-                    WCF::getTPL()->fetchStream(
-                        'userException',
-                        'wcf',
-                        [
-                            'name' => $e::class,
-                            'file' => $e->getFile(),
-                            'line' => $e->getLine(),
-                            'message' => $message,
-                            'stacktrace' => $e->getTraceAsString(),
-                            'templateName' => 'userException',
-                            'templateNameApplication' => 'wcf',
-                            'exceptionClassName' => $e::class,
-                        ]
+                    (new HtmlErrorRenderer())->render(
+                        WCF::getLanguage()->getDynamicVariable('wcf.global.error.title'),
+                        $message,
+                        $e
                     ),
                     400
                 ),
index 83433b1a6d28aa427635bfa090291f5cd88d2426..d16a9ff4a4478d99a08d691058b8482249cc4bb9 100644 (file)
@@ -21,6 +21,7 @@ use wcf\http\middleware\EnforceAcpAuthentication;
 use wcf\http\middleware\EnforceCacheControlPrivate;
 use wcf\http\middleware\EnforceFrameOptions;
 use wcf\http\middleware\EnforceNoCacheForTemporaryRedirects;
+use wcf\http\middleware\HandleExceptions;
 use wcf\http\middleware\HandleStartupErrors;
 use wcf\http\middleware\HandleValinorMappingErrors;
 use wcf\http\middleware\JsonBody;
@@ -128,6 +129,7 @@ final class RequestHandler extends SingletonFactory
                     new CheckForOfflineMode(),
                     new JsonBody(),
                     new TriggerBackgroundQueue(),
+                    new HandleExceptions(),
                     new HandleValinorMappingErrors(),
                 ]);