feat: add branded error pages
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
71
backend/workflows/error_views.py
Normal file
71
backend/workflows/error_views.py
Normal 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.'),
|
||||||
|
)
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check</code></pre>
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Preserve behavior while refactoring.</li>
|
<li>Preserve behavior while refactoring.</li>
|
||||||
<li>Prefer shared components over page-local special cases.</li>
|
<li>Prefer shared components over page-local special cases.</li>
|
||||||
<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>
|
||||||
|
|||||||
31
backend/workflows/templates/workflows/errors/error_page.html
Normal file
31
backend/workflows/templates/workflows/errors/error_page.html
Normal 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 %}
|
||||||
28
backend/workflows/tests/error_test_urls.py
Normal file
28
backend/workflows/tests/error_test_urls.py
Normal 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')),
|
||||||
|
]
|
||||||
62
backend/workflows/tests/test_error_pages.py
Normal file
62
backend/workflows/tests/test_error_pages.py
Normal 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)
|
||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user