Skip to content

Commit 06b5cc5

Browse files
committed
feat: check if a lower-case version of a URL exists
1 parent 7bf4746 commit 06b5cc5

3 files changed

Lines changed: 129 additions & 0 deletions

File tree

tbx/core/tests/test_middleware.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from urllib.parse import urlsplit
2+
3+
from wagtail.contrib.redirects.models import Redirect
4+
from wagtail.models import Site
5+
from wagtail.test.utils import WagtailPageTestCase
6+
7+
from tbx.blog.factories import BlogIndexPageFactory
8+
from tbx.core.factories import HomePageFactory
9+
from tbx.divisions.factories import DivisionPageFactory
10+
11+
12+
class TestMiddleware(WagtailPageTestCase):
13+
@classmethod
14+
def setUpTestData(cls):
15+
super().setUpTestData()
16+
17+
# Set up the site & homepage
18+
site = Site.objects.get(is_default_site=True)
19+
root = site.root_page.specific
20+
cls.home = HomePageFactory(parent=root)
21+
22+
site.root_page = cls.home
23+
site.save()
24+
25+
# Set up a division page
26+
cls.division_page = DivisionPageFactory(
27+
title="Charity",
28+
parent=cls.home,
29+
)
30+
31+
# Set up a blog page
32+
cls.blog_page = BlogIndexPageFactory(
33+
title="Blog",
34+
parent=cls.division_page,
35+
)
36+
37+
def test_page_accessible(self):
38+
response = self.client.get(self.blog_page.url)
39+
self.assertEqual(response.status_code, 200)
40+
41+
def test_redirects_to_lowercase_page(self):
42+
response = self.client.get(self.blog_page.url.upper())
43+
self.assertEqual(response.status_code, 301)
44+
self.assertEqual(response.url, self.blog_page.url)
45+
46+
def test_maintains_querystring(self):
47+
response = self.client.get(self.blog_page.url.upper(), {"foo": "BAR"})
48+
split_result = urlsplit(response.url)
49+
self.assertEqual(response.status_code, 301)
50+
self.assertEqual(split_result.query, "foo=BAR")
51+
52+
def test_404(self):
53+
response = self.client.get("/does-NOT-exist/")
54+
self.assertEqual(response.status_code, 404)
55+
56+
def test_redirect(self):
57+
Redirect.objects.create(
58+
old_path="/A-redirect", redirect_link="/destination", site=None
59+
)
60+
response = self.client.get("/A-redirect/")
61+
self.assertEqual(response.status_code, 301)
62+
63+
def test_double_slashed_url_for_missing_page(self):
64+
response = self.client.get(
65+
"http://testserver//evil.com" # Must be a fully-qualified, as parsing it bypasses the issue we're testing
66+
)
67+
self.assertEqual(response.status_code, 404)

tbx/core/utils/middleware.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import TYPE_CHECKING, Optional
2+
3+
from django.http import Http404, HttpResponsePermanentRedirect
4+
5+
from wagtail.models import Site
6+
7+
8+
if TYPE_CHECKING:
9+
from django.http import HttpRequest, HttpResponse
10+
11+
from wagtail.models import Page
12+
13+
14+
class URLCaseNormalizeMiddleware:
15+
"""
16+
If Wagtail can't find a page, this middleware checks whether a lower-case
17+
version of the page exists too, and redirects to it
18+
"""
19+
20+
def __init__(self, get_response):
21+
self.get_response = get_response
22+
23+
def get_page_for_path(self, request: "HttpRequest", path: str) -> Optional["Page"]:
24+
"""
25+
Implementation lifted from `wagtail.views.serve`.
26+
"""
27+
site = Site.find_for_request(request)
28+
29+
path_components = [component for component in path.split("/") if component]
30+
31+
try:
32+
page, _, _ = site.root_page.localized.specific.route(
33+
request, path_components
34+
)
35+
except Http404:
36+
return None
37+
38+
return page
39+
40+
def __call__(self, request: "HttpRequest") -> "HttpResponse":
41+
response = self.get_response(request)
42+
43+
if response.status_code != 404:
44+
return response
45+
46+
# If the path is already lower-case, do nothing.
47+
if request.path.islower():
48+
return response
49+
50+
# Pre-confirm there's a site for this URL
51+
site = Site.find_for_request(request)
52+
if not site:
53+
return response
54+
55+
if page := self.get_page_for_path(request, request.path.lower()):
56+
query_string = f"?{request.GET.urlencode()}" if request.GET else ""
57+
return HttpResponsePermanentRedirect(
58+
page.get_url(request=request) + query_string
59+
)
60+
61+
return response

tbx/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
# Clickjacking prevention. Default: X_FRAME_OPTIONS = 'DENY'
100100
# See https://docs.djangoproject.com/en/dev/ref/clickjacking/#preventing-clickjacking
101101
"django.middleware.clickjacking.XFrameOptionsMiddleware",
102+
"tbx.core.utils.middleware.URLCaseNormalizeMiddleware",
102103
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
103104
]
104105

0 commit comments

Comments
 (0)