From 58139b08ff0279a5af10c2332eaa95af1f3568c9 Mon Sep 17 00:00:00 2001 From: Mandel Olaiya Date: Fri, 10 Apr 2026 23:03:15 +0200 Subject: [PATCH] fix(i18n): normalize setlang next path server-side --- mandelstudio/i18n_utils.py | 31 +++++++++ mandelstudio/i18n_views.py | 24 +++++++ .../templatetags/mandelstudio_i18n.py | 24 +------ .../tests/test_i18n_set_language_view.py | 63 +++++++++++++++++++ mandelstudio/urls.py | 5 +- 5 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 mandelstudio/i18n_utils.py create mode 100644 mandelstudio/i18n_views.py create mode 100644 mandelstudio/tests/test_i18n_set_language_view.py diff --git a/mandelstudio/i18n_utils.py b/mandelstudio/i18n_utils.py new file mode 100644 index 0000000..528c026 --- /dev/null +++ b/mandelstudio/i18n_utils.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import re +from urllib.parse import urlsplit, urlunsplit + +from django.conf import settings + + +def normalize_set_language_next(value: str | None) -> str: + """ + Normalize the `next` path used by Django's set_language view. + + Removes any leading language prefix from the path so switching from one + locale to another cannot produce duplicated prefixes like `/de/en/...`. + """ + if not value: + return "/" + + parsed = urlsplit(str(value)) + path = parsed.path or "/" + if not path.startswith("/"): + path = f"/{path}" + + language_codes = [code for code, _ in settings.LANGUAGES] + if language_codes: + pattern = ( + rf"^/(?:{'|'.join(re.escape(code) for code in language_codes)})(?=/|$)" + ) + path = re.sub(pattern, "", path, count=1) or "/" + + return urlunsplit(("", "", path, parsed.query, "")) diff --git a/mandelstudio/i18n_views.py b/mandelstudio/i18n_views.py new file mode 100644 index 0000000..9dccc5c --- /dev/null +++ b/mandelstudio/i18n_views.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from django.http import HttpRequest, HttpResponse +from django.views.i18n import set_language as django_set_language + +from .i18n_utils import normalize_set_language_next + + +def set_language_normalized(request: HttpRequest) -> HttpResponse: + """Normalize `next` before delegating to Django's set_language view.""" + if request.method == "POST": + next_value = request.POST.get("next") + if next_value is not None: + post_data = request.POST.copy() + post_data["next"] = normalize_set_language_next(next_value) + request.POST = post_data + else: + next_value = request.GET.get("next") + if next_value is not None: + get_data = request.GET.copy() + get_data["next"] = normalize_set_language_next(next_value) + request.GET = get_data + + return django_set_language(request) diff --git a/mandelstudio/templatetags/mandelstudio_i18n.py b/mandelstudio/templatetags/mandelstudio_i18n.py index 17e44f2..f68d167 100644 --- a/mandelstudio/templatetags/mandelstudio_i18n.py +++ b/mandelstudio/templatetags/mandelstudio_i18n.py @@ -1,10 +1,8 @@ from __future__ import annotations -import re -from urllib.parse import urlsplit, urlunsplit - from django import template -from django.conf import settings + +from mandelstudio.i18n_utils import normalize_set_language_next register = template.Library() @@ -30,20 +28,4 @@ def skip_to_content_text(context) -> str: @register.filter(name="language_neutral_path") def language_neutral_path(value: str | None) -> str: """Normalize a path for set_language by removing any leading language prefix.""" - if not value: - return "/" - - parsed = urlsplit(str(value)) - path = parsed.path or "/" - if not path.startswith("/"): - path = f"/{path}" - - language_codes = [code for code, _ in settings.LANGUAGES] - if language_codes: - # Strip the first language segment, e.g. /en/manage -> /manage. - pattern = ( - rf"^/(?:{'|'.join(re.escape(code) for code in language_codes)})(?=/|$)" - ) - path = re.sub(pattern, "", path, count=1) or "/" - - return urlunsplit(("", "", path, parsed.query, "")) + return normalize_set_language_next(value) diff --git a/mandelstudio/tests/test_i18n_set_language_view.py b/mandelstudio/tests/test_i18n_set_language_view.py new file mode 100644 index 0000000..3fd3f56 --- /dev/null +++ b/mandelstudio/tests/test_i18n_set_language_view.py @@ -0,0 +1,63 @@ +from unittest.mock import patch + +from django.http import HttpResponseRedirect +from django.test import RequestFactory, SimpleTestCase, override_settings + +from mandelstudio.i18n_utils import normalize_set_language_next +from mandelstudio.i18n_views import set_language_normalized + + +@override_settings( + LANGUAGES=( + ("nl", "Dutch"), + ("en", "English"), + ("de", "German"), + ("fr", "French"), + ("es", "Spanish"), + ("it", "Italian"), + ("pt", "Portuguese"), + ("ru", "Russian"), + ) +) +class SetLanguageNormalizationTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + + def test_normalize_set_language_next_strips_single_prefix(self): + self.assertEqual( + normalize_set_language_next("/en/manage/checkout/paymentmethod/"), + "/manage/checkout/paymentmethod/", + ) + + @patch("mandelstudio.i18n_views.django_set_language") + def test_post_next_is_normalized_before_delegate(self, django_set_language): + django_set_language.side_effect = lambda request: HttpResponseRedirect( + request.POST["next"] + ) + request = self.factory.post( + "/i18n/setlang/", + data={"language": "de", "next": "/en/manage/checkout/paymentmethod/"}, + ) + + response = set_language_normalized(request) + + self.assertEqual(response.status_code, 302) + self.assertEqual( + django_set_language.call_args.args[0].POST["next"], + "/manage/checkout/paymentmethod/", + ) + + @patch("mandelstudio.i18n_views.django_set_language") + def test_get_next_is_normalized_before_delegate(self, django_set_language): + django_set_language.side_effect = lambda request: HttpResponseRedirect( + request.GET["next"] + ) + request = self.factory.get( + "/i18n/setlang/", + data={"language": "fr", "next": "/de/manage/"}, + ) + + response = set_language_normalized(request) + + self.assertEqual(response.status_code, 302) + self.assertEqual(django_set_language.call_args.args[0].GET["next"], "/manage/") diff --git a/mandelstudio/urls.py b/mandelstudio/urls.py index be4b6d4..0bf4fa8 100644 --- a/mandelstudio/urls.py +++ b/mandelstudio/urls.py @@ -1,13 +1,14 @@ -from django.urls import include, path +from django.urls import path from django.views.decorators.cache import cache_page from ocyan.main.urls import urlpatterns as ocyan_urlpatterns from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION +from .i18n_views import set_language_normalized from .sitemaps import robots_txt, sitemap_index, sitemap_section urlpatterns = [ - path("i18n/", include("django.conf.urls.i18n")), + path("i18n/setlang/", set_language_normalized, name="set_language"), path("robots.txt", robots_txt, name="robots-txt"), path( "sitemap.xml",