diff --git a/django/middleware/common.py b/django/middleware/common.py index e6f30f44ad..3af4759109 100644 --- a/django/middleware/common.py +++ b/django/middleware/common.py @@ -67,10 +67,11 @@ class CommonMiddleware(MiddlewareMixin): """ if settings.APPEND_SLASH and not request.path_info.endswith('/'): urlconf = getattr(request, 'urlconf', None) - return ( - not is_valid_path(request.path_info, urlconf) and - is_valid_path('%s/' % request.path_info, urlconf) - ) + if not is_valid_path(request.path_info, urlconf): + match = is_valid_path('%s/' % request.path_info, urlconf) + if match: + view = match.func + return getattr(view, 'should_append_slash', True) return False def get_full_path_with_slash(self, request): diff --git a/django/urls/base.py b/django/urls/base.py index 3899feeefb..6cf75d3a3f 100644 --- a/django/urls/base.py +++ b/django/urls/base.py @@ -145,13 +145,12 @@ def get_urlconf(default=None): def is_valid_path(path, urlconf=None): """ - Return True if the given path resolves against the default URL resolver, - False otherwise. This is a convenience method to make working with "is - this a match?" cases easier, avoiding try...except blocks. + Return the ResolverMatch if the given path resolves against the default URL + resolver, False otherwise. This is a convenience method to make working + with "is this a match?" cases easier, avoiding try...except blocks. """ try: - resolve(path, urlconf) - return True + return resolve(path, urlconf) except Resolver404: return False diff --git a/django/views/decorators/common.py b/django/views/decorators/common.py new file mode 100644 index 0000000000..34b0e5a50e --- /dev/null +++ b/django/views/decorators/common.py @@ -0,0 +1,14 @@ +from functools import wraps + + +def no_append_slash(view_func): + """ + Mark a view function as excluded from CommonMiddleware's APPEND_SLASH + redirection. + """ + # view_func.should_append_slash = False would also work, but decorators are + # nicer if they don't have side effects, so return a new function. + def wrapped_view(*args, **kwargs): + return view_func(*args, **kwargs) + wrapped_view.should_append_slash = False + return wraps(view_func)(wrapped_view) diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index c52bcc5d18..0078c716c0 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -61,6 +61,22 @@ Adds a few conveniences for perfectionists: indexer would treat them as separate URLs -- so it's best practice to normalize URLs. + If necessary, individual views may be excluded from the ``APPEND_SLASH`` + behavior using the :func:`~django.views.decorators.common.no_append_slash` + decorator:: + + from django.views.decorators.common import no_append_slash + + @no_append_slash + def sensitive_fbv(request, *args, **kwargs): + """View to be excluded from APPEND_SLASH.""" + return HttpResponse() + + .. versionchanged:: 3.2 + + Support for the :func:`~django.views.decorators.common.no_append_slash` + decorator was added. + * Sets the ``Content-Length`` header for non-streaming responses. .. attribute:: CommonMiddleware.response_redirect_class diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 2f0f2f5a6d..18d63786d2 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -185,6 +185,13 @@ CSRF * ... +Decorators +~~~~~~~~~~ + +* The new :func:`~django.views.decorators.common.no_append_slash` decorator + allows individual views to be excluded from :setting:`APPEND_SLASH` URL + normalization. + Email ~~~~~ diff --git a/docs/topics/http/decorators.txt b/docs/topics/http/decorators.txt index fe91d0cc98..cabdd4f01a 100644 --- a/docs/topics/http/decorators.txt +++ b/docs/topics/http/decorators.txt @@ -121,3 +121,18 @@ client-side caching. This decorator adds a ``Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private`` header to a response to indicate that a page should never be cached. + +.. module:: django.views.decorators.common + +Common +====== + +.. versionadded:: 3.2 + +The decorators in :mod:`django.views.decorators.common` allow per-view +customization of :class:`~django.middleware.common.CommonMiddleware` behavior. + +.. function:: no_append_slash() + + This decorator allows individual views to be excluded from + :setting:`APPEND_SLASH` URL normalization. diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index 4b49858cd9..c7a007b821 100644 --- a/tests/middleware/tests.py +++ b/tests/middleware/tests.py @@ -127,6 +127,17 @@ class CommonMiddlewareTest(SimpleTestCase): request = self.rf.get('/slash') self.assertEqual(CommonMiddleware(get_response_404)(request).status_code, 404) + @override_settings(APPEND_SLASH=True) + def test_append_slash_opt_out(self): + """ + Views marked with @no_append_slash should be left alone. + """ + request = self.rf.get('/sensitive_fbv') + self.assertEqual(CommonMiddleware(get_response_404)(request).status_code, 404) + + request = self.rf.get('/sensitive_cbv') + self.assertEqual(CommonMiddleware(get_response_404)(request).status_code, 404) + @override_settings(APPEND_SLASH=True) def test_append_slash_quoted(self): """ diff --git a/tests/middleware/urls.py b/tests/middleware/urls.py index 8411d87b5a..e76f4ac771 100644 --- a/tests/middleware/urls.py +++ b/tests/middleware/urls.py @@ -8,4 +8,7 @@ urlpatterns = [ path('needsquoting#/', views.empty_view), # Accepts paths with two leading slashes. re_path(r'^(.+)/security/$', views.empty_view), + # Should not append slash. + path('sensitive_fbv/', views.sensitive_fbv), + path('sensitive_cbv/', views.SensitiveCBV.as_view()), ] diff --git a/tests/middleware/views.py b/tests/middleware/views.py index 3f8e055a53..ee36f418f2 100644 --- a/tests/middleware/views.py +++ b/tests/middleware/views.py @@ -1,5 +1,19 @@ from django.http import HttpResponse +from django.utils.decorators import method_decorator +from django.views.decorators.common import no_append_slash +from django.views.generic import View def empty_view(request, *args, **kwargs): return HttpResponse() + + +@no_append_slash +def sensitive_fbv(request, *args, **kwargs): + return HttpResponse() + + +@method_decorator(no_append_slash, name='dispatch') +class SensitiveCBV(View): + def get(self, *args, **kwargs): + return HttpResponse()