From 9000cb67ab7a4e9661e695a6d37731529130f5c2 Mon Sep 17 00:00:00 2001 From: Andreu Vallbona Date: Mon, 23 Feb 2015 17:50:42 +0100 Subject: [PATCH 1/2] Redirecting the root URL of a project if APPEND_SLASH is set (following work from old pr https://github.com/django/django/pull/7663) --- django/core/handlers/asgi.py | 1 + django/core/handlers/wsgi.py | 11 ++++++++- django/http/request.py | 1 + django/middleware/common.py | 33 ++++++++++++++++---------- tests/handlers/tests.py | 46 ++++++++++++++++++++++++++++++++++++ tests/handlers/urls.py | 3 ++- tests/handlers/views.py | 4 ++++ tests/middleware/tests.py | 32 +++++++++++++++++++++++++ tests/middleware/urls.py | 1 + 9 files changed, 117 insertions(+), 15 deletions(-) diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index b4056ca042..abd05fdc0d 100644 --- a/django/core/handlers/asgi.py +++ b/django/core/handlers/asgi.py @@ -57,6 +57,7 @@ class ASGIRequest(HttpRequest): self.path_info = scope["path"].removeprefix(self.script_name) else: self.path_info = scope["path"] + self.path_info_is_empty = not bool(self.path_info) # HTTP basics. self.method = self.scope["method"].upper() # Ensure query string is encoded correctly. diff --git a/django/core/handlers/wsgi.py b/django/core/handlers/wsgi.py index aab9fe0c49..1b41ca1a15 100644 --- a/django/core/handlers/wsgi.py +++ b/django/core/handlers/wsgi.py @@ -58,7 +58,16 @@ class WSGIRequest(HttpRequest): script_name = get_script_name(environ) # If PATH_INFO is empty (e.g. accessing the SCRIPT_NAME URL without a # trailing slash), operate as if '/' was requested. - path_info = get_path_info(environ) or "/" + path_info = get_path_info(environ) + if not path_info: + # Sometimes PATH_INFO exists, but is empty (e.g. accessing + # the SCRIPT_NAME URL without a trailing slash). We really need to + # operate as if they'd requested '/'. Not amazingly nice to force + # the path like this, but should be harmless. + self.path_info_is_empty = True + path_info = "/" + else: + self.path_info_is_empty = False self.environ = environ self.path_info = path_info # be careful to only replace the first slash in the path because of diff --git a/django/http/request.py b/django/http/request.py index c8adde768d..36241c993f 100644 --- a/django/http/request.py +++ b/django/http/request.py @@ -70,6 +70,7 @@ class HttpRequest: self.path = "" self.path_info = "" + self.path_info_is_empty = True self.method = None self.resolver_match = None self.content_type = None diff --git a/django/middleware/common.py b/django/middleware/common.py index 870d462e6d..c4cf9a7347 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -46,28 +46,35 @@ class CommonMiddleware(MiddlewareMixin): # Check for a redirect based on settings.PREPEND_WWW host = request.get_host() + must_prepend = settings.PREPEND_WWW and host and not host.startswith("www.") + redirect_url = ("%s://www.%s" % (request.scheme, host)) if must_prepend else "" - if settings.PREPEND_WWW and host and not host.startswith("www."): - # Check if we also need to append a slash so we can do it all - # with a single redirect. (This check may be somewhat expensive, - # so we only do it if we already know we're sending a redirect, - # or in process_response if we get a 404.) - if self.should_redirect_with_slash(request): - path = self.get_full_path_with_slash(request) - else: - path = request.get_full_path() + # Check if a slash should be appended to the URL + should_redirect_with_slash = self.should_redirect_with_slash(request) - return self.response_redirect_class(f"{request.scheme}://www.{host}{path}") + # If a slash should be appended, use the full path with a slash. + # Otherwise, just get the full path without forcing a slash. + if should_redirect_with_slash: + path = self.get_full_path_with_slash(request) + else: + path = request.get_full_path() + + # If it's needed to redirect either based on settings.PREPEND_WWW + # or to append a slash, do so. + if redirect_url or should_redirect_with_slash: + redirect_url += path + return self.response_redirect_class(redirect_url) def should_redirect_with_slash(self, request): """ Return True if settings.APPEND_SLASH is True and appending a slash to the request path turns an invalid path into a valid one. """ - if settings.APPEND_SLASH and not request.path_info.endswith("/"): + path_info = "" if request.path_info_is_empty else request.path_info + if settings.APPEND_SLASH and not path_info.endswith("/"): urlconf = getattr(request, "urlconf", None) - if not is_valid_path(request.path_info, urlconf): - match = is_valid_path("%s/" % request.path_info, urlconf) + if not is_valid_path(path_info, urlconf): + match = is_valid_path("%s/" % path_info, urlconf) if match: view = match.func return getattr(view, "should_append_slash", True) diff --git a/tests/handlers/tests.py b/tests/handlers/tests.py index 83dfd95713..d5697047a9 100644 --- a/tests/handlers/tests.py +++ b/tests/handlers/tests.py @@ -93,6 +93,52 @@ class HandlerTests(SimpleTestCase): # Expect "bad request" response self.assertEqual(response.status_code, 400) + @override_settings(ROOT_URLCONF="handlers.urls") + def test_root_path_info_with_slash(self): + """ + If PATH_INFO is '/' and APPEND_SLASH is True, and a URL pattern + is defined for '^/$', then Django should render a response + from the corresponding view. + """ + environ = RequestFactory().get("/").environ + handler = WSGIHandler() + response = handler(environ, lambda *a, **k: None) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"Index") + + @override_settings(ROOT_URLCONF="handlers.urls") + def test_root_path_info_without_slash(self): + """ + If PATH_INFO is empty and APPEND_SLASH is True, and a url pattern + is defined for '^/$' but not for '^$', then CommonMiddleware should + issue a redirect. + """ + environ = RequestFactory().get("").environ + handler = WSGIHandler() + response = handler(environ, lambda *a, **k: None) + self.assertEqual(response.status_code, 301) + self.assertEqual(response.url, "/") + + @override_settings(APPEND_SLASH=False, ROOT_URLCONF="handlers.urls") + def test_root_path_info_nonempty_script_name_no_append_slash(self): + environ = RequestFactory().get("").environ + environ["SCRIPT_NAME"] = "site-root" + environ["PATH_INFO"] = "" + handler = WSGIHandler() + response = handler(environ, lambda *a, **k: None) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"Index") + + @override_settings(APPEND_SLASH=True, ROOT_URLCONF="handlers.urls") + def test_root_path_info_nonempty_script_name_with_append_slash(self): + environ = RequestFactory().get("").environ + environ["SCRIPT_NAME"] = "site-root" + environ["PATH_INFO"] = "" + handler = WSGIHandler() + response = handler(environ, lambda *a, **k: None) + self.assertEqual(response.status_code, 301) + self.assertEqual(response.url, "site-root/") + @override_settings(ROOT_URLCONF="handlers.urls", MIDDLEWARE=[]) class TransactionsPerRequestTests(TransactionTestCase): diff --git a/tests/handlers/urls.py b/tests/handlers/urls.py index a0efece602..8fb8f6feb1 100644 --- a/tests/handlers/urls.py +++ b/tests/handlers/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from . import views @@ -18,4 +18,5 @@ urlpatterns = [ path("malformed_post/", views.malformed_post), path("httpstatus_enum/", views.httpstatus_enum), path("unawaited/", views.async_unawaited), + re_path("^$", views.index), ] diff --git a/tests/handlers/views.py b/tests/handlers/views.py index 95d663323d..f1f5dac634 100644 --- a/tests/handlers/views.py +++ b/tests/handlers/views.py @@ -7,6 +7,10 @@ from django.http import HttpResponse, StreamingHttpResponse from django.views.decorators.csrf import csrf_exempt +def index(request): + return HttpResponse(b"Index") + + def regular(request): return HttpResponse(b"regular content") diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index c4aac0552b..d84a9e8d0d 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -319,6 +319,38 @@ class CommonMiddlewareTest(SimpleTestCase): self.assertEqual(r.status_code, 301) self.assertEqual(r.url, "http://www.testserver/customurlconf/slash/") + @override_settings(APPEND_SLASH=True) + def test_empty_path_info_not_found_with_append_slash(self): + req = HttpRequest() + req.urlconf = "middleware.urls" + res = HttpResponseNotFound() + middleware_res = CommonMiddleware(get_response_empty).process_response(req, res) + self.assertEqual(middleware_res.status_code, 301) + + @override_settings(APPEND_SLASH=False) + def test_empty_path_info_not_found_without_append_slash(self): + req = HttpRequest() + req.urlconf = "middleware.urls" + res = HttpResponseNotFound() + middleware_res = CommonMiddleware(get_response_empty).process_response(req, res) + self.assertEqual(middleware_res.status_code, 404) + + @override_settings(APPEND_SLASH=True) + def test_empty_path_info_200_with_append_slash(self): + req = HttpRequest() + req.urlconf = "middleware.urls" + res = HttpResponse("content") + middleware_res = CommonMiddleware(get_response_empty).process_response(req, res) + self.assertEqual(middleware_res.status_code, 200) + + @override_settings(APPEND_SLASH=False) + def test_empty_path_info_200_without_append_slash(self): + req = HttpRequest() + req.urlconf = "middleware.urls" + res = HttpResponse("content") + middleware_res = CommonMiddleware(get_response_empty).process_response(req, res) + self.assertEqual(middleware_res.status_code, 200) + # Tests for the Content-Length header def test_content_length_header_added(self): diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py index bbd68d2050..e66095fb17 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -24,4 +24,5 @@ urlpatterns = [ path("csp-override-enforced/", views.csp_override_enforced), path("csp-override-report-only/", views.csp_override_report_only), path("csp-500/", views.csp_500), + re_path(r"^$", views.empty_view), ] From 25352e72ba379eb200afb3d6edd55cdeb58845eb Mon Sep 17 00:00:00 2001 From: Andreu Vallbona Date: Fri, 10 Oct 2025 13:38:02 +0200 Subject: [PATCH 2/2] fixing tests --- django/core/handlers/wsgi.py | 4 ++-- django/middleware/common.py | 4 ++-- tests/middleware/tests.py | 24 ++++++++++++------------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/django/core/handlers/wsgi.py b/django/core/handlers/wsgi.py index 1b41ca1a15..e2b015e9cb 100644 --- a/django/core/handlers/wsgi.py +++ b/django/core/handlers/wsgi.py @@ -58,7 +58,7 @@ class WSGIRequest(HttpRequest): script_name = get_script_name(environ) # If PATH_INFO is empty (e.g. accessing the SCRIPT_NAME URL without a # trailing slash), operate as if '/' was requested. - path_info = get_path_info(environ) + path_info = environ_path_info = get_path_info(environ) if not path_info: # Sometimes PATH_INFO exists, but is empty (e.g. accessing # the SCRIPT_NAME URL without a trailing slash). We really need to @@ -75,7 +75,7 @@ class WSGIRequest(HttpRequest): # stated in RFC 3986. self.path = "%s/%s" % (script_name.rstrip("/"), path_info.replace("/", "", 1)) self.META = environ - self.META["PATH_INFO"] = path_info + self.META["PATH_INFO"] = environ_path_info self.META["SCRIPT_NAME"] = script_name self.method = environ["REQUEST_METHOD"].upper() # Set content_type, content_params, and encoding. diff --git a/django/middleware/common.py b/django/middleware/common.py index c4cf9a7347..9bc390a546 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -47,7 +47,7 @@ class CommonMiddleware(MiddlewareMixin): # Check for a redirect based on settings.PREPEND_WWW host = request.get_host() must_prepend = settings.PREPEND_WWW and host and not host.startswith("www.") - redirect_url = ("%s://www.%s" % (request.scheme, host)) if must_prepend else "" + redirect_url = f"{request.scheme}://www.{host}" if must_prepend else "" # Check if a slash should be appended to the URL should_redirect_with_slash = self.should_redirect_with_slash(request) @@ -62,7 +62,7 @@ class CommonMiddleware(MiddlewareMixin): # If it's needed to redirect either based on settings.PREPEND_WWW # or to append a slash, do so. if redirect_url or should_redirect_with_slash: - redirect_url += path + redirect_url = f"{redirect_url}{path}" return self.response_redirect_class(redirect_url) def should_redirect_with_slash(self, request): diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index d84a9e8d0d..ae580fa6d9 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -170,15 +170,15 @@ class CommonMiddlewareTest(SimpleTestCase): """ # Use 4 slashes because of RequestFactory behavior. request = self.rf.get("////evil.com/security") - r = CommonMiddleware(get_response_404).process_request(request) - self.assertIsNone(r) + res = CommonMiddleware(get_response_404).process_request(request) + self.assertIsNone(res) response = HttpResponseNotFound() - r = CommonMiddleware(get_response_404).process_response(request, response) - self.assertEqual(r.status_code, 301) - self.assertEqual(r.url, "/%2Fevil.com/security/") - r = CommonMiddleware(get_response_404)(request) - self.assertEqual(r.status_code, 301) - self.assertEqual(r.url, "/%2Fevil.com/security/") + res = CommonMiddleware(get_response_404).process_response(request, response) + self.assertEqual(res.status_code, 301) + self.assertEqual(res.url, "/%2Fevil.com/security/") + res = CommonMiddleware(get_response_404)(request) + self.assertEqual(res.status_code, 301) + self.assertEqual(res.url, "/%2Fevil.com/security/") @override_settings(APPEND_SLASH=False, PREPEND_WWW=True) def test_prepend_www(self): @@ -408,11 +408,11 @@ class CommonMiddlewareTest(SimpleTestCase): """Regression test for #15152""" request = self.rf.get("/slash") request.META["QUERY_STRING"] = "drink=café" - r = CommonMiddleware(get_response_empty).process_request(request) - self.assertIsNone(r) + res = CommonMiddleware(get_response_empty).process_request(request) + self.assertEqual(res.status_code, 301) response = HttpResponseNotFound() - r = CommonMiddleware(get_response_empty).process_response(request, response) - self.assertEqual(r.status_code, 301) + res = CommonMiddleware(get_response_empty).process_response(request, response) + self.assertEqual(res.status_code, 301) def test_response_redirect_class(self): request = self.rf.get("/slash")