Browse Source

Fixed #32124 -- Added per-view opt-out for APPEND_SLASH behavior.

Carlton Gibson 4 years ago
parent
commit
ad11f5b8c9

+ 5 - 4
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):

+ 4 - 5
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
 

+ 14 - 0
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)

+ 16 - 0
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

+ 7 - 0
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
 ~~~~~
 

+ 15 - 0
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.

+ 11 - 0
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):
         """

+ 3 - 0
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()),
 ]

+ 14 - 0
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()