fix(i18n): normalize setlang next path server-side

This commit is contained in:
2026-04-10 23:03:15 +02:00
parent 944e88d78d
commit 58139b08ff
5 changed files with 124 additions and 23 deletions

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
from __future__ import annotations from __future__ import annotations
import re
from urllib.parse import urlsplit, urlunsplit
from django import template from django import template
from django.conf import settings
from mandelstudio.i18n_utils import normalize_set_language_next
register = template.Library() register = template.Library()
@@ -30,20 +28,4 @@ def skip_to_content_text(context) -> str:
@register.filter(name="language_neutral_path") @register.filter(name="language_neutral_path")
def language_neutral_path(value: str | None) -> str: def language_neutral_path(value: str | None) -> str:
"""Normalize a path for set_language by removing any leading language prefix.""" """Normalize a path for set_language by removing any leading language prefix."""
if not value: return normalize_set_language_next(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, ""))

View File

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

View File

@@ -1,13 +1,14 @@
from django.urls import include, path from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from ocyan.main.urls import urlpatterns as ocyan_urlpatterns from ocyan.main.urls import urlpatterns as ocyan_urlpatterns
from ocyan.plugin.wagtail_oscar_integration.constants import CACHE_DURATION 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 from .sitemaps import robots_txt, sitemap_index, sitemap_section
urlpatterns = [ 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("robots.txt", robots_txt, name="robots-txt"),
path( path(
"sitemap.xml", "sitemap.xml",