From 6b305e930dab889e5882d0163d7d7fff150174e2 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Wed, 1 Apr 2026 17:34:27 +0200 Subject: [PATCH] feat: add branded error pages --- .env.prod.example | 1 + .env.test.example | 1 + backend/config/settings.py | 3 + backend/config/urls.py | 5 ++ backend/workflows/error_views.py | 71 +++++++++++++++++++ backend/workflows/middleware.py | 22 +++++- .../workflows/developer_handbook.html | 10 ++- .../workflows/errors/error_page.html | 31 ++++++++ backend/workflows/tests/error_test_urls.py | 28 ++++++++ backend/workflows/tests/test_error_pages.py | 62 ++++++++++++++++ backend/workflows/urls.py | 5 +- 11 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 backend/workflows/error_views.py create mode 100644 backend/workflows/templates/workflows/errors/error_page.html create mode 100644 backend/workflows/tests/error_test_urls.py create mode 100644 backend/workflows/tests/test_error_pages.py diff --git a/.env.prod.example b/.env.prod.example index 5604b8a..c54637a 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -2,6 +2,7 @@ APP_DOMAIN=workdock.example.com APP_BASE_URL=https://workdock.example.com DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_DEBUG=0 +FORCE_BRANDED_ERROR_PAGES=0 DJANGO_ALLOWED_HOSTS=workdock.example.com DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com DJANGO_SECURE_COOKIES=1 diff --git a/.env.test.example b/.env.test.example index b021ffb..aa5c6cf 100644 --- a/.env.test.example +++ b/.env.test.example @@ -2,6 +2,7 @@ APP_DOMAIN= APP_BASE_URL= DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_DEBUG=1 +FORCE_BRANDED_ERROR_PAGES=1 DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 DJANGO_SECURE_COOKIES=0 diff --git a/backend/config/settings.py b/backend/config/settings.py index 3a0db7a..c64ae8c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str: SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key') DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' +FORCE_BRANDED_ERROR_PAGES = os.getenv('FORCE_BRANDED_ERROR_PAGES', '0') == '1' APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip() APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/') ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1') @@ -84,6 +85,7 @@ MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', + 'workflows.middleware.FriendlyExceptionMiddleware', 'workflows.middleware.RequestIDMiddleware', 'workflows.middleware.RateLimitMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -95,6 +97,7 @@ MIDDLEWARE = [ ] ROOT_URLCONF = 'config.urls' +CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure' TEMPLATES = [ { diff --git a/backend/config/urls.py b/backend/config/urls.py index b783ae7..7ff67f2 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -7,6 +7,11 @@ from django.urls import include, path from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm from workflows import views as workflow_views +handler400 = 'workflows.error_views.bad_request' +handler403 = 'workflows.error_views.permission_denied' +handler404 = 'workflows.error_views.not_found' +handler500 = 'workflows.error_views.server_error' + urlpatterns = [ path('admin/', admin.site.urls), path('i18n/', include('django.conf.urls.i18n')), diff --git a/backend/workflows/error_views.py b/backend/workflows/error_views.py new file mode 100644 index 0000000..9923f27 --- /dev/null +++ b/backend/workflows/error_views.py @@ -0,0 +1,71 @@ +from django.shortcuts import render +from django.utils.translation import gettext as _ + + +def _render_error(request, *, status: int, title: str, code: str, heading: str, message: str): + return render( + request, + 'workflows/errors/error_page.html', + { + 'error_title': title, + 'error_code': code, + 'error_heading': heading, + 'error_message': message, + }, + status=status, + ) + + +def bad_request(request, exception=None): + return _render_error( + request, + status=400, + title=_('Ungültige Anfrage'), + code='400', + heading=_('Die Anfrage konnte nicht verarbeitet werden'), + message=_('Die übermittelten Daten waren unvollständig oder ungültig. Bitte gehen Sie zurück und versuchen Sie es erneut.'), + ) + + +def permission_denied(request, exception=None): + return _render_error( + request, + status=403, + title=_('Kein Zugriff'), + code='403', + heading=_('Für diese Seite fehlt die Berechtigung'), + message=_('Sie sind angemeldet, aber für diesen Bereich nicht freigeschaltet. Wenn das nicht erwartet ist, wenden Sie sich an die Administration.'), + ) + + +def not_found(request, exception=None): + return _render_error( + request, + status=404, + title=_('Seite nicht gefunden'), + code='404', + heading=_('Diese Seite gibt es nicht'), + message=_('Die gewünschte Adresse ist nicht vorhanden oder wurde verschoben. Nutzen Sie die Startseite oder das Dashboard, um weiterzugehen.'), + ) + + +def server_error(request): + return _render_error( + request, + status=500, + title=_('Serverfehler'), + code='500', + heading=_('Etwas ist schiefgelaufen'), + message=_('Der Fehler wurde nicht sauber verarbeitet. Bitte laden Sie die Seite neu. Wenn das Problem bleibt, prüfen Sie die Server-Logs.'), + ) + + +def csrf_failure(request, reason=''): + return _render_error( + request, + status=400, + title=_('Sicherheitsprüfung fehlgeschlagen'), + code='400', + heading=_('Die Sitzung konnte nicht bestätigt werden'), + message=_('Bitte laden Sie die Seite neu und senden Sie das Formular erneut. Wenn das weiter passiert, prüfen Sie Host-, HTTPS- und CSRF-Einstellungen.'), + ) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index f378f73..46563ad 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -5,17 +5,37 @@ from django.contrib import messages from django.contrib.auth import logout from django.contrib.messages.api import MessageFailure from django.core.cache import cache -from django.http import HttpResponse +from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.http import Http404, HttpResponse from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from .branding import is_trial_expired, is_trial_mode_enabled +from .error_views import bad_request, not_found, permission_denied, server_error from .logging_utils import clear_request_id, set_request_id from .roles import ROLE_PLATFORM_OWNER, get_user_role_key +class FriendlyExceptionMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not settings.FORCE_BRANDED_ERROR_PAGES: + return self.get_response(request) + + try: + return self.get_response(request) + except Http404 as exc: + return not_found(request, exc) + except PermissionDenied as exc: + return permission_denied(request, exc) + except SuspiciousOperation as exc: + return bad_request(request, exc) + + class RequestIDMiddleware: HEADER_NAME = 'X-Request-ID' diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 6ab00b6..4e0d8ad 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check
  1. Preserve behavior while refactoring.
  2. Prefer shared components over page-local special cases.
  3. -
  4. Do not overwrite environment-specific runtime config as a side effect of code deploys.
  5. -
  6. Keep code-driven behavior and data-driven behavior mentally separate.
  7. -
  8. Update documentation in the same branch when operational workflow changes.
  9. +
  10. Do not overwrite environment-specific runtime config as a side effect of code deploys.
  11. +
  12. Keep code-driven behavior and data-driven behavior mentally separate.
  13. +
  14. Update documentation in the same branch when operational workflow changes.
  15. +
  16. Keep branded error handling wired through the root URL handlers so production does not fall back to Django default error pages.
@@ -557,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS
The LAN test deployment intentionally uses DJANGO_DEBUG=1 in .env.test because the security checks correctly reject insecure cookie settings when DEBUG=0 and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and DEBUG=0.
+
+ If you still want branded wrong-URL and permission pages on the LAN test server while keeping DJANGO_DEBUG=1, enable FORCE_BRANDED_ERROR_PAGES=1 in .env.test. Full branded 500 behavior still requires DEBUG=0, which remains the correct production-style setup. +

18) Deployment

Test server stack

diff --git a/backend/workflows/templates/workflows/errors/error_page.html b/backend/workflows/templates/workflows/errors/error_page.html new file mode 100644 index 0000000..c3340c2 --- /dev/null +++ b/backend/workflows/templates/workflows/errors/error_page.html @@ -0,0 +1,31 @@ +{% extends 'workflows/base_shell.html' %} +{% load i18n %} + +{% block title %}{{ error_title }}{% endblock %} + +{% block shell_body %} + {% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} +
+
+
+
+
{% trans "Fehlerseite" %}
+

{{ error_heading }}

+

{{ error_message }}

+
+
+ {{ error_code }} + {% trans "Status" %} +
+
+
+ {% trans "Zur Startseite" %} + {% if request.user.is_authenticated %} + {% trans "Zum Dashboard" %} + {% else %} + {% trans "Anmelden" %} + {% endif %} +
+
+
+{% endblock %} diff --git a/backend/workflows/tests/error_test_urls.py b/backend/workflows/tests/error_test_urls.py new file mode 100644 index 0000000..512345a --- /dev/null +++ b/backend/workflows/tests/error_test_urls.py @@ -0,0 +1,28 @@ +from django.core.exceptions import PermissionDenied +from django.http import HttpResponse +from django.urls import include, path + + +def ok_view(request): + return HttpResponse('ok') + + +def deny_view(request): + raise PermissionDenied('forbidden') + + +def explode_view(request): + raise RuntimeError('boom') + + +handler400 = 'workflows.error_views.bad_request' +handler403 = 'workflows.error_views.permission_denied' +handler404 = 'workflows.error_views.not_found' +handler500 = 'workflows.error_views.server_error' + +urlpatterns = [ + path('healthz/', ok_view, name='healthz'), + path('raise-403/', deny_view, name='raise_403'), + path('raise-500/', explode_view, name='raise_500'), + path('', include('workflows.urls')), +] diff --git a/backend/workflows/tests/test_error_pages.py b/backend/workflows/tests/test_error_pages.py new file mode 100644 index 0000000..eebd2b8 --- /dev/null +++ b/backend/workflows/tests/test_error_pages.py @@ -0,0 +1,62 @@ +from django.test import Client, RequestFactory, TestCase, override_settings + +from workflows.error_views import csrf_failure + + +@override_settings( + DEBUG=False, + ROOT_URLCONF='workflows.tests.error_test_urls', + ALLOWED_HOSTS=['testserver'], +) +class ErrorPageTests(TestCase): + def setUp(self): + self.client = Client() + self.client.raise_request_exception = False + + def test_custom_404_page_is_rendered(self): + response = self.client.get('/missing-page/') + + self.assertEqual(response.status_code, 404) + self.assertTemplateUsed(response, 'workflows/errors/error_page.html') + self.assertContains(response, '404', status_code=404) + + def test_custom_403_page_is_rendered(self): + response = self.client.get('/raise-403/') + + self.assertEqual(response.status_code, 403) + self.assertTemplateUsed(response, 'workflows/errors/error_page.html') + self.assertContains(response, '403', status_code=403) + + def test_custom_500_page_is_rendered(self): + response = self.client.get('/raise-500/') + + self.assertEqual(response.status_code, 500) + self.assertTemplateUsed(response, 'workflows/errors/error_page.html') + self.assertContains(response, '500', status_code=500) + + def test_csrf_failure_view_uses_custom_400_page(self): + request = RequestFactory().post('/test/') + + response = csrf_failure(request) + + self.assertEqual(response.status_code, 400) + self.assertContains(response, '400', status_code=400) + + +@override_settings( + DEBUG=True, + FORCE_BRANDED_ERROR_PAGES=True, + ROOT_URLCONF='workflows.tests.error_test_urls', + ALLOWED_HOSTS=['testserver'], +) +class ForcedBrandedErrorPageTests(TestCase): + def setUp(self): + self.client = Client() + self.client.raise_request_exception = False + + def test_missing_url_uses_branded_404_even_with_debug_enabled(self): + response = self.client.get('/missing-page/') + + self.assertEqual(response.status_code, 404) + self.assertTemplateUsed(response, 'workflows/errors/error_page.html') + self.assertContains(response, '404', status_code=404) diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index ad0aa73..7971322 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -1,6 +1,6 @@ -from django.urls import path +from django.urls import path, re_path -from . import views +from . import error_views, views urlpatterns = [ path('healthz/', views.healthz, name='healthz'), @@ -65,4 +65,5 @@ urlpatterns = [ path('requests/delete///', views.delete_request_from_dashboard, name='delete_request_from_dashboard'), path('requests/retry///', views.retry_request_from_dashboard, name='retry_request_from_dashboard'), path('requests/timeline///', views.request_timeline_page, name='request_timeline_page'), + re_path(r'^.*$', error_views.not_found, name='not_found_fallback'), ]