fix(i18n): normalize setlang next path server-side
This commit is contained in:
31
mandelstudio/i18n_utils.py
Normal file
31
mandelstudio/i18n_utils.py
Normal 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, ""))
|
||||||
24
mandelstudio/i18n_views.py
Normal file
24
mandelstudio/i18n_views.py
Normal 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)
|
||||||
@@ -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, ""))
|
|
||||||
|
|||||||
63
mandelstudio/tests/test_i18n_set_language_view.py
Normal file
63
mandelstudio/tests/test_i18n_set_language_view.py
Normal 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/")
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user