feat: add branded error pages
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled

This commit is contained in:
Md Bayazid Bostame
2026-04-01 17:34:27 +02:00
parent baf53a3274
commit 6b305e930d
11 changed files with 233 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ APP_DOMAIN=workdock.example.com
APP_BASE_URL=https://workdock.example.com APP_BASE_URL=https://workdock.example.com
DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=0 DJANGO_DEBUG=0
FORCE_BRANDED_ERROR_PAGES=0
DJANGO_ALLOWED_HOSTS=workdock.example.com DJANGO_ALLOWED_HOSTS=workdock.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com
DJANGO_SECURE_COOKIES=1 DJANGO_SECURE_COOKIES=1

View File

@@ -2,6 +2,7 @@ APP_DOMAIN=
APP_BASE_URL= APP_BASE_URL=
DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_SECRET_KEY=change-me-long-random-value
DJANGO_DEBUG=1 DJANGO_DEBUG=1
FORCE_BRANDED_ERROR_PAGES=1
DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088
DJANGO_SECURE_COOKIES=0 DJANGO_SECURE_COOKIES=0

View File

@@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str:
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key') SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key')
DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' 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_DOMAIN = os.getenv('APP_DOMAIN', '').strip()
APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/') APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/')
ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1') ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1')
@@ -84,6 +85,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'workflows.middleware.FriendlyExceptionMiddleware',
'workflows.middleware.RequestIDMiddleware', 'workflows.middleware.RequestIDMiddleware',
'workflows.middleware.RateLimitMiddleware', 'workflows.middleware.RateLimitMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -95,6 +97,7 @@ MIDDLEWARE = [
] ]
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = 'config.urls'
CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure'
TEMPLATES = [ TEMPLATES = [
{ {

View File

@@ -7,6 +7,11 @@ from django.urls import include, path
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
from workflows import views as workflow_views 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 = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')),

View File

@@ -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.'),
)

View File

@@ -5,17 +5,37 @@ from django.contrib import messages
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.messages.api import MessageFailure from django.contrib.messages.api import MessageFailure
from django.core.cache import cache 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.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from .branding import is_trial_expired, is_trial_mode_enabled 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 .logging_utils import clear_request_id, set_request_id
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key 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: class RequestIDMiddleware:
HEADER_NAME = 'X-Request-ID' HEADER_NAME = 'X-Request-ID'

View File

@@ -188,6 +188,7 @@ docker compose exec -T web python manage.py check</code></pre>
<li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li> <li>Do not overwrite environment-specific runtime config as a side effect of code deploys.</li>
<li>Keep code-driven behavior and data-driven behavior mentally separate.</li> <li>Keep code-driven behavior and data-driven behavior mentally separate.</li>
<li>Update documentation in the same branch when operational workflow changes.</li> <li>Update documentation in the same branch when operational workflow changes.</li>
<li>Keep branded error handling wired through the root URL handlers so production does not fall back to Django default error pages.</li>
</ol> </ol>
</div> </div>
<div class="box"> <div class="box">
@@ -557,6 +558,9 @@ make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS</code></pre>
<div class="note"> <div class="note">
The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> 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 <code>DEBUG=0</code>. The LAN test deployment intentionally uses <code>DJANGO_DEBUG=1</code> in <code>.env.test</code> because the security checks correctly reject insecure cookie settings when <code>DEBUG=0</code> 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 <code>DEBUG=0</code>.
</div> </div>
<div class="note">
If you still want branded wrong-URL and permission pages on the LAN test server while keeping <code>DJANGO_DEBUG=1</code>, enable <code>FORCE_BRANDED_ERROR_PAGES=1</code> in <code>.env.test</code>. Full branded <code>500</code> behavior still requires <code>DEBUG=0</code>, which remains the correct production-style setup.
</div>
<h2 id="deploy">18) Deployment</h2> <h2 id="deploy">18) Deployment</h2>
<h3>Test server stack</h3> <h3>Test server stack</h3>

View File

@@ -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 %}
<div class="page-stack">
<section class="surface-card" style="max-width: 760px; margin: 0 auto;">
<div class="surface-head">
<div>
<div class="eyebrow">{% trans "Fehlerseite" %}</div>
<h1>{{ error_heading }}</h1>
<p class="sub">{{ error_message }}</p>
</div>
<div class="app-stat-card" style="min-width: 120px; text-align: center;">
<strong style="font-size: 2rem; line-height: 1;">{{ error_code }}</strong>
<span>{% trans "Status" %}</span>
</div>
</div>
<div class="app-action-row">
<a class="btn btn-primary" href="/">{% trans "Zur Startseite" %}</a>
{% if request.user.is_authenticated %}
<a class="btn btn-secondary" href="/requests/">{% trans "Zum Dashboard" %}</a>
{% else %}
<a class="btn btn-secondary" href="/accounts/login/">{% trans "Anmelden" %}</a>
{% endif %}
</div>
</section>
</div>
{% endblock %}

View File

@@ -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')),
]

View File

@@ -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)

View File

@@ -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 = [ urlpatterns = [
path('healthz/', views.healthz, name='healthz'), path('healthz/', views.healthz, name='healthz'),
@@ -65,4 +65,5 @@ urlpatterns = [
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'), path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'), path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'), path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
re_path(r'^.*$', error_views.not_found, name='not_found_fallback'),
] ]