diff --git a/package/share/ahriman/templates/error.jinja2 b/package/share/ahriman/templates/error.jinja2
new file mode 100644
index 00000000..a7e627d7
--- /dev/null
+++ b/package/share/ahriman/templates/error.jinja2
@@ -0,0 +1,31 @@
+
+
+
+ Error
+
+
+
+
+
+ {% include "utils/style.jinja2" %}
+
+
+
+
+
+
+
+
+
{{ code }}
+
{{ reason }}
+
home
+
+
+
+
+
+ {% include "utils/bootstrap-scripts.jinja2" %}
+
+
+
+
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 8f6abcd5..74c84fa4 100644
--- a/setup.py
+++ b/setup.py
@@ -66,6 +66,7 @@ setup(
("share/ahriman/templates", [
"package/share/ahriman/templates/build-status.jinja2",
"package/share/ahriman/templates/email-index.jinja2",
+ "package/share/ahriman/templates/error.jinja2",
"package/share/ahriman/templates/repo-index.jinja2",
"package/share/ahriman/templates/shell",
"package/share/ahriman/templates/telegram-index.jinja2",
diff --git a/src/ahriman/web/middlewares/exception_handler.py b/src/ahriman/web/middlewares/exception_handler.py
index fbd4065c..ed5daec2 100644
--- a/src/ahriman/web/middlewares/exception_handler.py
+++ b/src/ahriman/web/middlewares/exception_handler.py
@@ -17,10 +17,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
+import aiohttp_jinja2
import logging
from aiohttp.web import middleware, Request
-from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError
+from aiohttp.web_exceptions import HTTPClientError, HTTPException, HTTPServerError, HTTPUnauthorized
from aiohttp.web_response import json_response, StreamResponse
from ahriman.web.middlewares import HandlerType, MiddlewareType
@@ -43,6 +44,11 @@ def exception_handler(logger: logging.Logger) -> MiddlewareType:
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
try:
return await handler(request)
+ except HTTPUnauthorized as e:
+ if is_templated_unauthorized(request):
+ context = {"code": e.status_code, "reason": e.reason}
+ return aiohttp_jinja2.render_template("error.jinja2", request, context, status=e.status_code)
+ return json_response(data={"error": e.reason}, status=e.status_code)
except HTTPClientError as e:
return json_response(data={"error": e.reason}, status=e.status_code)
except HTTPServerError as e:
@@ -55,3 +61,17 @@ def exception_handler(logger: logging.Logger) -> MiddlewareType:
return json_response(data={"error": str(e)}, status=500)
return handle
+
+
+def is_templated_unauthorized(request: Request) -> bool:
+ """
+ check if the request is eligible for rendering html template
+
+ Args:
+ request(Request): source request to check
+
+ Returns:
+ bool: True in case if response should be rendered as html and False otherwise
+ """
+ return request.path in ("/api/v1/login", "/api/v1/logout") \
+ and "application/json" not in request.headers.getall("accept", [])
diff --git a/tests/ahriman/core/test_util.py b/tests/ahriman/core/test_util.py
index 5e976d8d..d8c83ddd 100644
--- a/tests/ahriman/core/test_util.py
+++ b/tests/ahriman/core/test_util.py
@@ -335,6 +335,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
resource_path_root / "web" / "templates" / "build-status.jinja2",
resource_path_root / "web" / "templates" / "email-index.jinja2",
+ resource_path_root / "web" / "templates" / "error.jinja2",
resource_path_root / "web" / "templates" / "repo-index.jinja2",
resource_path_root / "web" / "templates" / "shell",
resource_path_root / "web" / "templates" / "telegram-index.jinja2",
diff --git a/tests/ahriman/web/middlewares/test_exception_handler.py b/tests/ahriman/web/middlewares/test_exception_handler.py
index 712c755a..f62af933 100644
--- a/tests/ahriman/web/middlewares/test_exception_handler.py
+++ b/tests/ahriman/web/middlewares/test_exception_handler.py
@@ -2,12 +2,12 @@ import json
import logging
import pytest
-from aiohttp.web_exceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent
+from aiohttp.web_exceptions import HTTPBadRequest, HTTPInternalServerError, HTTPNoContent, HTTPUnauthorized
from pytest_mock import MockerFixture
from typing import Any
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, MagicMock
-from ahriman.web.middlewares.exception_handler import exception_handler
+from ahriman.web.middlewares.exception_handler import exception_handler, is_templated_unauthorized
def _extract_body(response: Any) -> Any:
@@ -23,6 +23,37 @@ def _extract_body(response: Any) -> Any:
return json.loads(getattr(response, "body"))
+def test_is_templated_unauthorized() -> None:
+ """
+ must correct check if response should be rendered as template
+ """
+ response_mock = MagicMock()
+
+ response_mock.path = "/api/v1/login"
+ response_mock.headers.getall.return_value = ["*/*"]
+ assert is_templated_unauthorized(response_mock)
+
+ response_mock.path = "/api/v1/login"
+ response_mock.headers.getall.return_value = ["application/json"]
+ assert not is_templated_unauthorized(response_mock)
+
+ response_mock.path = "/api/v1/logout"
+ response_mock.headers.getall.return_value = ["*/*"]
+ assert is_templated_unauthorized(response_mock)
+
+ response_mock.path = "/api/v1/logout"
+ response_mock.headers.getall.return_value = ["application/json"]
+ assert not is_templated_unauthorized(response_mock)
+
+ response_mock.path = "/api/v1/status"
+ response_mock.headers.getall.return_value = ["*/*"]
+ assert not is_templated_unauthorized(response_mock)
+
+ response_mock.path = "/api/v1/status"
+ response_mock.headers.getall.return_value = ["application/json"]
+ assert not is_templated_unauthorized(response_mock)
+
+
async def test_exception_handler(mocker: MockerFixture) -> None:
"""
must pass success response
@@ -50,6 +81,36 @@ async def test_exception_handler_success(mocker: MockerFixture) -> None:
logging_mock.assert_not_called()
+async def test_exception_handler_unauthorized(mocker: MockerFixture) -> None:
+ """
+ must handle unauthorized exception as json response
+ """
+ request = pytest.helpers.request("", "", "")
+ request_handler = AsyncMock(side_effect=HTTPUnauthorized())
+ mocker.patch("ahriman.web.middlewares.exception_handler.is_templated_unauthorized", return_value=False)
+ render_mock = mocker.patch("aiohttp_jinja2.render_template")
+
+ handler = exception_handler(logging.getLogger())
+ response = await handler(request, request_handler)
+ assert _extract_body(response) == {"error": HTTPUnauthorized().reason}
+ render_mock.assert_not_called()
+
+
+async def test_exception_handler_unauthorized_templated(mocker: MockerFixture) -> None:
+ """
+ must handle unauthorized exception as json response
+ """
+ request = pytest.helpers.request("", "", "")
+ request_handler = AsyncMock(side_effect=HTTPUnauthorized())
+ mocker.patch("ahriman.web.middlewares.exception_handler.is_templated_unauthorized", return_value=True)
+ render_mock = mocker.patch("aiohttp_jinja2.render_template")
+
+ handler = exception_handler(logging.getLogger())
+ await handler(request, request_handler)
+ context = {"code": 401, "reason": "Unauthorized"}
+ render_mock.assert_called_once_with("error.jinja2", request, context, status=HTTPUnauthorized.status_code)
+
+
async def test_exception_handler_client_error(mocker: MockerFixture) -> None:
"""
must handle client exception