Skip to content

Commit 81768fc

Browse files
Perform redirects at DB level (#6398)
Perform redirects at DB level Co-authored-by: Eric Holscher <[email protected]>
2 parents d90f37d + aaaa584 commit 81768fc

File tree

4 files changed

+150
-12
lines changed

4 files changed

+150
-12
lines changed

readthedocs/proxito/views/mixins.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import logging
22
import mimetypes
3+
from urllib.parse import urlparse, urlunparse
34

45
from django.conf import settings
56
from django.core.files.storage import get_storage_class
6-
from django.http import HttpResponse
7+
from django.http import (
8+
HttpResponse,
9+
HttpResponseRedirect,
10+
HttpResponsePermanentRedirect,
11+
)
712
from django.shortcuts import render
813
from django.utils.encoding import iri_to_uri
914
from django.views.static import serve
@@ -79,3 +84,47 @@ def _serve_401(self, request, project):
7984
res.status_code = 401
8085
log.debug('Unauthorized access to %s documentation', project.slug)
8186
return res
87+
88+
89+
class ServeRedirectMixin:
90+
91+
def get_redirect(self, project, lang_slug, version_slug, filename, full_path):
92+
"""
93+
Check for a redirect for this project that matches ``full_path``.
94+
95+
:returns: the path to redirect the request and its status code
96+
:rtype: tuple
97+
"""
98+
redirect_path, http_status = project.redirects.get_redirect_path_with_status(
99+
language=lang_slug,
100+
version_slug=version_slug,
101+
path=filename,
102+
full_path=full_path,
103+
)
104+
return redirect_path, http_status
105+
106+
def get_redirect_response(self, request, redirect_path, http_status):
107+
"""
108+
Build the response for the ``redirect_path`` and its ``http_status``.
109+
110+
:returns: redirect respose with the correct path
111+
:rtype: HttpResponseRedirect or HttpResponsePermanentRedirect
112+
"""
113+
schema, netloc, path, params, query, fragments = urlparse(request.path)
114+
new_path = urlunparse((schema, netloc, redirect_path, params, query, fragments))
115+
116+
# Re-use the domain and protocol used in the current request.
117+
# Redirects shouldn't change the domain, version or language.
118+
# However, if the new_path is already an absolute URI, just use it
119+
new_path = request.build_absolute_uri(new_path)
120+
log.info(
121+
'Redirecting: from=%s to=%s http_status=%s',
122+
request.build_absolute_uri(),
123+
new_path,
124+
http_status,
125+
)
126+
127+
if http_status and http_status == 301:
128+
return HttpResponsePermanentRedirect(new_path)
129+
130+
return HttpResponseRedirect(new_path)

readthedocs/proxito/views/serve.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from readthedocs.projects import constants
2121
from readthedocs.projects.templatetags.projects_tags import sort_version_aware
2222

23-
from .mixins import ServeDocsMixin
23+
from .mixins import ServeDocsMixin, ServeRedirectMixin
2424

2525
from .decorators import map_project_slug
2626
from .redirects import redirect_project_slug
@@ -30,7 +30,7 @@
3030
log = logging.getLogger(__name__) # noqa
3131

3232

33-
class ServeDocsBase(ServeDocsMixin, View):
33+
class ServeDocsBase(ServeRedirectMixin, ServeDocsMixin, View):
3434

3535
def get(self,
3636
request,
@@ -77,11 +77,16 @@ def get(self,
7777
)
7878
raise Http404('Invalid URL for project with versions')
7979

80-
# TODO: Redirects need to be refactored before we can turn them on
81-
# They currently do 1 request per redirect that exists for the project
82-
# path, http_status = final_project.redirects.get_redirect_path_with_status(
83-
# language=lang_slug, version_slug=version_slug, path=filename
80+
# TODO: un-comment when ready to perform redirect here
81+
# redirect_path, http_status = self.get_redirect(
82+
# final_project,
83+
# lang_slug,
84+
# version_slug,
85+
# filename,
86+
# request.path,
8487
# )
88+
# if redirect_path and http_status:
89+
# return self.get_redirect_response(request, redirect_path, http_status)
8590

8691
# Check user permissions and return an unauthed response if needed
8792
if not self.allowed_user(request, final_project, version_slug):
@@ -107,7 +112,7 @@ class ServeDocs(SettingsOverrideObject):
107112
_default_class = ServeDocsBase
108113

109114

110-
class ServeError404Base(View):
115+
class ServeError404Base(ServeRedirectMixin, View):
111116

112117
def get(self, request, proxito_path, template_name='404.html'):
113118
"""
@@ -138,6 +143,17 @@ def get(self, request, proxito_path, template_name='404.html'):
138143
filename=kwargs.get('filename', ''),
139144
)
140145

146+
# Check and perform redirects on 404 handler
147+
redirect_path, http_status = self.get_redirect(
148+
final_project,
149+
lang_slug,
150+
version_slug,
151+
filename,
152+
request.path,
153+
)
154+
if redirect_path and http_status:
155+
return self.get_redirect_response(request, redirect_path, http_status)
156+
141157
storage_root_path = final_project.get_storage_path(
142158
type_='html',
143159
version_slug=version_slug,

readthedocs/redirects/querysets.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Queryset for the redirects app."""
22

33
from django.db import models
4+
from django.db.models import Value, CharField, IntegerField, Q, F, ExpressionWrapper
5+
from django.db.models.functions import Substr, Length
46

57
from readthedocs.core.utils.extend import SettingsOverrideObject
6-
from readthedocs.projects import constants
78

89

910
class RedirectQuerySetBase(models.QuerySet):
@@ -25,8 +26,80 @@ def api(self, user=None, detail=True):
2526
queryset = self._add_user_repos(queryset, user)
2627
return queryset
2728

28-
def get_redirect_path_with_status(self, path, language=None, version_slug=None):
29-
for redirect in self.select_related('project'):
29+
def get_redirect_path_with_status(self, path, full_path=None, language=None, version_slug=None):
30+
# add extra fields with the ``path`` and ``full_path`` to perform a
31+
# filter at db level instead with Python
32+
queryset = self.annotate(
33+
path=Value(
34+
path,
35+
output_field=CharField(),
36+
),
37+
full_path=Value(
38+
full_path,
39+
output_field=CharField(),
40+
),
41+
42+
from_url_length=ExpressionWrapper(
43+
Length('from_url'),
44+
output_field=IntegerField(),
45+
),
46+
47+
# 1-indexed
48+
from_url_without_rest=Substr(
49+
'from_url',
50+
1,
51+
F('from_url_length') - 5, # Strip "$rest"
52+
output_field=CharField(),
53+
),
54+
55+
# 1-indexed
56+
full_path_without_rest=Substr(
57+
'full_path',
58+
1,
59+
F('from_url_length') - 5, # Strip "$rest"
60+
output_field=CharField(),
61+
),
62+
)
63+
prefix = Q(
64+
redirect_type='prefix',
65+
path__startswith=F('from_url'),
66+
)
67+
page = Q(
68+
redirect_type='page',
69+
path__iexact=F('from_url'),
70+
)
71+
exact = (
72+
Q(
73+
redirect_type='exact',
74+
from_url__endswith='$rest',
75+
# This works around a bug in Django doing a substr and an endswith,
76+
# so instead we do 2 substrs and an exact
77+
# https://code.djangoproject.com/ticket/29155
78+
full_path_without_rest=F('from_url_without_rest'),
79+
) | Q(
80+
redirect_type='exact',
81+
full_path__iexact=F('from_url'),
82+
)
83+
)
84+
sphinx_html = (
85+
Q(
86+
redirect_type='sphinx_html',
87+
path__endswith='/',
88+
) | Q(
89+
redirect_type='sphinx_html',
90+
path__endswith='/index.html',
91+
)
92+
)
93+
sphinx_htmldir = Q(
94+
redirect_type='sphinx_htmldir',
95+
path__endswith='.html',
96+
)
97+
98+
# There should be one and only one redirect returned by this query. I
99+
# can't think in a case where there can be more at this point. I'm
100+
# leaving the loop just in case for now
101+
queryset = queryset.filter(prefix | page | exact | sphinx_html | sphinx_htmldir)
102+
for redirect in queryset.select_related('project'):
30103
new_path = redirect.get_redirect_path(
31104
path=path,
32105
language=language,

readthedocs/redirects/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def get_redirect_response(request, full_path):
8484
language, version_slug, path = language_and_version_from_path(path)
8585

8686
path, http_status = project.redirects.get_redirect_path_with_status(
87-
path=path, language=language, version_slug=version_slug
87+
path=path, full_path=full_path, language=language, version_slug=version_slug
8888
)
8989

9090
if path is None:

0 commit comments

Comments
 (0)