Skip to content

Commit 0622429

Browse files
authored
Proxito: pass unresolved domain in request (#9982)
Make use of the unresolved domain from the request instead of each of the attributes we are setting in the request from the middleware, we aren't using any of those on .com, so we are safe removing them here. This is on top of #9974.
1 parent 9b44214 commit 0622429

File tree

8 files changed

+112
-103
lines changed

8 files changed

+112
-103
lines changed

readthedocs/audit/models.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from readthedocs.acl.utils import get_auth_backend
99
from readthedocs.analytics.utils import get_client_ip
10-
from readthedocs.projects.models import Project
1110

1211

1312
class AuditLogManager(models.Manager):
@@ -50,9 +49,9 @@ def new(self, action, user=None, request=None, **kwargs):
5049

5150
# Fill the project from the request if available.
5251
# This is frequently on actions generated from a subdomain.
53-
project_slug = getattr(request, 'host_project_slug', None)
54-
if 'project' not in kwargs and project_slug:
55-
kwargs['project'] = Project.objects.filter(slug=project_slug).first()
52+
unresolved_domain = getattr(request, "unresolved_domain", None)
53+
if "project" not in kwargs and unresolved_domain:
54+
kwargs["project"] = unresolved_domain.project
5655

5756
return self.create(
5857
user=user,

readthedocs/core/unresolver.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ class UnresolvedDomain:
8181
domain: Domain = None
8282
external_version_slug: str = None
8383

84+
@property
85+
def is_from_custom_domain(self):
86+
return self.source == DomainSourceType.custom_domain
87+
88+
@property
89+
def is_from_public_domain(self):
90+
return self.source == DomainSourceType.public_domain
91+
92+
@property
93+
def is_from_http_header(self):
94+
return self.source == DomainSourceType.http_header
95+
96+
@property
97+
def is_from_external_domain(self):
98+
return self.source == DomainSourceType.external_domain
99+
84100

85101
class Unresolver:
86102
# This pattern matches:
@@ -136,7 +152,7 @@ def unresolve(self, url, append_indexhtml=True):
136152
)
137153

138154
# Make sure we are serving the external version from the subdomain.
139-
if unresolved_domain.source == DomainSourceType.external_domain and version:
155+
if unresolved_domain.is_from_external_domain and version:
140156
if unresolved_domain.external_version_slug != version.slug:
141157
log.warning(
142158
"Invalid version for external domain.",
@@ -166,7 +182,7 @@ def unresolve(self, url, append_indexhtml=True):
166182
filename=filename,
167183
parsed_url=parsed,
168184
domain=unresolved_domain.domain,
169-
external=unresolved_domain.source == DomainSourceType.external_domain,
185+
external=unresolved_domain.is_from_external_domain,
170186
)
171187

172188
@staticmethod

readthedocs/proxito/middleware.py

+30-45
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from django.utils.deprecation import MiddlewareMixin
1919

2020
from readthedocs.core.unresolver import (
21-
DomainSourceType,
2221
InvalidCustomDomainError,
2322
InvalidExternalDomainError,
2423
InvalidSubdomainError,
@@ -75,17 +74,13 @@ def add_proxito_headers(self, request, response):
7574
if cache_tags:
7675
response['Cache-Tag'] = ','.join(cache_tags)
7776

78-
if hasattr(request, 'rtdheader'):
79-
response['X-RTD-Project-Method'] = 'rtdheader'
80-
elif hasattr(request, 'subdomain'):
81-
response['X-RTD-Project-Method'] = 'subdomain'
82-
elif hasattr(request, 'cname'):
83-
response['X-RTD-Project-Method'] = 'cname'
84-
85-
if hasattr(request, 'external_domain'):
86-
response['X-RTD-Version-Method'] = 'domain'
87-
else:
88-
response['X-RTD-Version-Method'] = 'path'
77+
unresolved_domain = request.unresolved_domain
78+
if unresolved_domain:
79+
response["X-RTD-Project-Method"] = unresolved_domain.source.name
80+
if unresolved_domain.is_from_external_domain:
81+
response["X-RTD-Version-Method"] = "domain"
82+
else:
83+
response["X-RTD-Version-Method"] = "path"
8984

9085
def add_user_headers(self, request, response):
9186
"""
@@ -94,19 +89,21 @@ def add_user_headers(self, request, response):
9489
The headers added come from ``projects.models.HTTPHeader`` associated
9590
with the ``Domain`` object.
9691
"""
97-
if hasattr(request, 'domain'):
92+
unresolved_domain = request.unresolved_domain
93+
if unresolved_domain and unresolved_domain.is_from_custom_domain:
9894
response_headers = [header.lower() for header in response.headers.keys()]
99-
for http_header in request.domain.http_headers.all():
95+
domain = unresolved_domain.domain
96+
for http_header in domain.http_headers.all():
10097
if http_header.name.lower() in response_headers:
10198
log.error(
10299
'Overriding an existing response HTTP header.',
103100
http_header=http_header.name,
104-
domain=request.domain.domain,
101+
domain=domain.domain,
105102
)
106103
log.info(
107104
'Adding custom response HTTP header.',
108105
http_header=http_header.name,
109-
domain=request.domain.domain,
106+
domain=domain.domain,
110107
)
111108

112109
if http_header.only_if_secure_request and not request.is_secure():
@@ -129,17 +126,20 @@ def add_hsts_headers(self, request, response):
129126
# Only set the HSTS header if the request is over HTTPS
130127
return response
131128

132-
host = request.get_host().lower().split(':')[0]
133-
public_domain = settings.PUBLIC_DOMAIN.lower().split(':')[0]
134129
hsts_header_values = []
135-
if settings.PUBLIC_DOMAIN_USES_HTTPS and public_domain in host:
130+
unresolved_domain = request.unresolved_domain
131+
if (
132+
settings.PUBLIC_DOMAIN_USES_HTTPS
133+
and unresolved_domain
134+
and unresolved_domain.is_from_public_domain
135+
):
136136
hsts_header_values = [
137137
'max-age=31536000',
138138
'includeSubDomains',
139139
'preload',
140140
]
141-
elif hasattr(request, 'domain'):
142-
domain = request.domain
141+
elif unresolved_domain and unresolved_domain.is_from_custom_domain:
142+
domain = unresolved_domain.domain
143143
# TODO: migrate Domains with HSTS set using these fields to
144144
# ``HTTPHeader`` and remove this chunk of code from here.
145145
if domain.hsts_max_age:
@@ -169,10 +169,11 @@ def add_cache_headers(self, request, response):
169169
See https://developers.cloudflare.com/cache/about/cdn-cache-control.
170170
"""
171171
header = "CDN-Cache-Control"
172+
unresolved_domain = request.unresolved_domain
172173
# Never trust projects resolving from the X-RTD-Slug header,
173174
# we don't want to cache their content on domains from other
174175
# projects, see GHSA-mp38-vprc-7hf5.
175-
if hasattr(request, "rtdheader"):
176+
if unresolved_domain and unresolved_domain.is_from_http_header:
176177
response.headers[header] = "private"
177178

178179
if settings.ALLOW_PRIVATE_REPOS:
@@ -183,32 +184,18 @@ def _set_request_attributes(self, request, unresolved_domain):
183184
"""
184185
Set attributes in the request from the unresolved domain.
185186
186-
- If the project was extracted from the ``X-RTD-Slug`` header,
187-
we set ``request.rtdheader`` to `True`.
188-
- If the project was extracted from the public domain,
189-
we set ``request.subdomain`` to `True`.
190-
- If the project was extracted from a custom domain,
191-
we set ``request.cname`` to `True`.
187+
- Set ``request.unresolved_domain`` to the unresolved domain.
192188
- If the domain needs to redirect, set the canonicalize attribute accordingly.
193189
"""
194-
# TODO: Set the unresolved domain in the request instead of each of these attributes.
195-
source = unresolved_domain.source
190+
request.unresolved_domain = unresolved_domain
196191
project = unresolved_domain.project
197-
if source == DomainSourceType.http_header:
198-
request.rtdheader = True
199-
elif source == DomainSourceType.custom_domain:
192+
if unresolved_domain.is_from_custom_domain:
200193
domain = unresolved_domain.domain
201-
request.cname = True
202-
request.domain = domain
203194
if domain.https and not request.is_secure():
204195
# Redirect HTTP -> HTTPS (302) for this custom domain.
205196
log.debug("Proxito CNAME HTTPS Redirect.", domain=domain.domain)
206197
request.canonicalize = constants.REDIRECT_HTTPS
207-
elif source == DomainSourceType.external_domain:
208-
request.external_domain = True
209-
request.host_version_slug = unresolved_domain.external_version_slug
210-
elif source == DomainSourceType.public_domain:
211-
request.subdomain = True
198+
elif unresolved_domain.is_from_public_domain:
212199
canonical_domain = (
213200
Domain.objects.filter(project=project)
214201
.filter(canonical=True, https=True)
@@ -226,10 +213,11 @@ def _set_request_attributes(self, request, unresolved_domain):
226213
project_slug=project.slug,
227214
)
228215
request.canonicalize = constants.REDIRECT_SUBPROJECT_MAIN_DOMAIN
229-
else:
230-
raise NotImplementedError
231216

232217
def process_request(self, request): # noqa
218+
# Initialize our custom request attributes.
219+
request.unresolved_domain = None
220+
233221
skip = any(
234222
request.path.startswith(reverse(view))
235223
for view in self.skip_views
@@ -292,9 +280,6 @@ def process_request(self, request): # noqa
292280
project_slug=project.slug,
293281
)
294282

295-
# Otherwise set the slug on the request
296-
request.host_project_slug = request.slug = project.slug
297-
298283
# This is hacky because Django wants a module for the URLConf,
299284
# instead of also accepting string
300285
if project.urlconf:

readthedocs/proxito/tests/test_headers.py

+32-28
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,25 @@ def test_redirect_headers(self):
2121
self.assertEqual(
2222
r['Location'], 'https://project.dev.readthedocs.io/en/latest/',
2323
)
24-
self.assertEqual(r['Cache-Tag'], 'project')
25-
self.assertEqual(r['X-RTD-Project'], 'project')
26-
self.assertEqual(r['X-RTD-Project-Method'], 'subdomain')
27-
self.assertEqual(r['X-RTD-Domain'], 'project.dev.readthedocs.io')
28-
self.assertIsNone(r.get('X-RTD-Version'))
29-
self.assertIsNone(r.get('X-RTD-Path'))
24+
self.assertEqual(r["Cache-Tag"], "project")
25+
self.assertEqual(r["X-RTD-Project"], "project")
26+
self.assertEqual(r["X-RTD-Project-Method"], "public_domain")
27+
self.assertEqual(r["X-RTD-Domain"], "project.dev.readthedocs.io")
28+
self.assertIsNone(r.get("X-RTD-Version"))
29+
self.assertIsNone(r.get("X-RTD-Path"))
3030

3131
def test_serve_headers(self):
3232
r = self.client.get('/en/latest/', HTTP_HOST='project.dev.readthedocs.io')
3333
self.assertEqual(r.status_code, 200)
34-
self.assertEqual(r['Cache-Tag'], 'project,project:latest')
35-
self.assertEqual(r['X-RTD-Domain'], 'project.dev.readthedocs.io')
36-
self.assertEqual(r['X-RTD-Project'], 'project')
37-
self.assertEqual(r['X-RTD-Project-Method'], 'subdomain')
38-
self.assertEqual(r['X-RTD-Version'], 'latest')
39-
self.assertEqual(r['X-RTD-version-Method'], 'path')
40-
self.assertEqual(r['X-RTD-Path'], '/proxito/media/html/project/latest/index.html')
34+
self.assertEqual(r["Cache-Tag"], "project,project:latest")
35+
self.assertEqual(r["X-RTD-Domain"], "project.dev.readthedocs.io")
36+
self.assertEqual(r["X-RTD-Project"], "project")
37+
self.assertEqual(r["X-RTD-Project-Method"], "public_domain")
38+
self.assertEqual(r["X-RTD-Version"], "latest")
39+
self.assertEqual(r["X-RTD-version-Method"], "path")
40+
self.assertEqual(
41+
r["X-RTD-Path"], "/proxito/media/html/project/latest/index.html"
42+
)
4143

4244
def test_subproject_serve_headers(self):
4345
r = self.client.get('/projects/subproject/en/latest/', HTTP_HOST='project.dev.readthedocs.io')
@@ -49,7 +51,7 @@ def test_subproject_serve_headers(self):
4951
# I think it's not accurate saying that it's `subdomain` the method
5052
# that we use to get the project slug here, since it was in fact the
5153
# URL's path but we don't have that feature built
52-
self.assertEqual(r['X-RTD-Project-Method'], 'subdomain')
54+
self.assertEqual(r["X-RTD-Project-Method"], "public_domain")
5355

5456
self.assertEqual(r['X-RTD-Version'], 'latest')
5557
self.assertEqual(r['X-RTD-version-Method'], 'path')
@@ -58,13 +60,13 @@ def test_subproject_serve_headers(self):
5860
def test_404_headers(self):
5961
r = self.client.get('/foo/bar.html', HTTP_HOST='project.dev.readthedocs.io')
6062
self.assertEqual(r.status_code, 404)
61-
self.assertEqual(r['Cache-Tag'], 'project')
62-
self.assertEqual(r['X-RTD-Domain'], 'project.dev.readthedocs.io')
63-
self.assertEqual(r['X-RTD-Project'], 'project')
64-
self.assertEqual(r['X-RTD-Project-Method'], 'subdomain')
65-
self.assertEqual(r['X-RTD-version-Method'], 'path')
66-
self.assertIsNone(r.get('X-RTD-Version'))
67-
self.assertIsNone(r.get('X-RTD-Path'))
63+
self.assertEqual(r["Cache-Tag"], "project")
64+
self.assertEqual(r["X-RTD-Domain"], "project.dev.readthedocs.io")
65+
self.assertEqual(r["X-RTD-Project"], "project")
66+
self.assertEqual(r["X-RTD-Project-Method"], "public_domain")
67+
self.assertEqual(r["X-RTD-version-Method"], "path")
68+
self.assertIsNone(r.get("X-RTD-Version"))
69+
self.assertIsNone(r.get("X-RTD-Path"))
6870

6971
def test_custom_domain_headers(self):
7072
hostname = 'docs.random.com'
@@ -75,13 +77,15 @@ def test_custom_domain_headers(self):
7577
)
7678
r = self.client.get("/en/latest/", HTTP_HOST=hostname)
7779
self.assertEqual(r.status_code, 200)
78-
self.assertEqual(r['Cache-Tag'], 'project,project:latest')
79-
self.assertEqual(r['X-RTD-Domain'], self.domain.domain)
80-
self.assertEqual(r['X-RTD-Project'], self.project.slug)
81-
self.assertEqual(r['X-RTD-Project-Method'], 'cname')
82-
self.assertEqual(r['X-RTD-Version'], 'latest')
83-
self.assertEqual(r['X-RTD-version-Method'], 'path')
84-
self.assertEqual(r['X-RTD-Path'], '/proxito/media/html/project/latest/index.html')
80+
self.assertEqual(r["Cache-Tag"], "project,project:latest")
81+
self.assertEqual(r["X-RTD-Domain"], self.domain.domain)
82+
self.assertEqual(r["X-RTD-Project"], self.project.slug)
83+
self.assertEqual(r["X-RTD-Project-Method"], "custom_domain")
84+
self.assertEqual(r["X-RTD-Version"], "latest")
85+
self.assertEqual(r["X-RTD-version-Method"], "path")
86+
self.assertEqual(
87+
r["X-RTD-Path"], "/proxito/media/html/project/latest/index.html"
88+
)
8589

8690
def test_footer_headers(self):
8791
version = self.project.versions.get(slug=LATEST)

readthedocs/proxito/tests/test_middleware.py

+12-12
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ def test_proper_cname(self):
4141
request = self.request(method='get', path=self.url, HTTP_HOST=domain)
4242
res = self.run_middleware(request)
4343
self.assertIsNone(res)
44-
self.assertEqual(request.cname, True)
45-
self.assertEqual(request.host_project_slug, 'pip')
44+
self.assertTrue(request.unresolved_domain.is_from_custom_domain)
45+
self.assertEqual(request.unresolved_domain.project, self.pip)
4646

4747
def test_proper_cname_https_upgrade(self):
4848
cname = 'docs.random.com'
@@ -138,8 +138,8 @@ def test_proper_cname_uppercase(self):
138138
get(Domain, project=self.pip, domain='docs.random.com')
139139
request = self.request(method='get', path=self.url, HTTP_HOST='docs.RANDOM.COM')
140140
self.run_middleware(request)
141-
self.assertEqual(request.cname, True)
142-
self.assertEqual(request.host_project_slug, 'pip')
141+
self.assertTrue(request.unresolved_domain.is_from_custom_domain)
142+
self.assertEqual(request.unresolved_domain.project, self.pip)
143143

144144
def test_invalid_cname(self):
145145
self.assertFalse(Domain.objects.filter(domain='my.host.com').exists())
@@ -151,17 +151,17 @@ def test_invalid_cname(self):
151151
def test_proper_subdomain(self):
152152
request = self.request(method='get', path=self.url, HTTP_HOST='pip.dev.readthedocs.io')
153153
self.run_middleware(request)
154-
self.assertEqual(request.subdomain, True)
155-
self.assertEqual(request.host_project_slug, 'pip')
154+
self.assertTrue(request.unresolved_domain.is_from_public_domain)
155+
self.assertEqual(request.unresolved_domain.project, self.pip)
156156

157157
@override_settings(PUBLIC_DOMAIN='foo.bar.readthedocs.io')
158158
def test_subdomain_different_length(self):
159159
request = self.request(
160160
method='get', path=self.url, HTTP_HOST='pip.foo.bar.readthedocs.io'
161161
)
162162
self.run_middleware(request)
163-
self.assertEqual(request.subdomain, True)
164-
self.assertEqual(request.host_project_slug, 'pip')
163+
self.assertTrue(request.unresolved_domain.is_from_public_domain)
164+
self.assertEqual(request.unresolved_domain.project, self.pip)
165165

166166
def test_request_header(self):
167167
get(
@@ -171,8 +171,8 @@ def test_request_header(self):
171171
method='get', path=self.url, HTTP_HOST='some.random.com', HTTP_X_RTD_SLUG='pip'
172172
)
173173
self.run_middleware(request)
174-
self.assertEqual(request.rtdheader, True)
175-
self.assertEqual(request.host_project_slug, 'pip')
174+
self.assertTrue(request.unresolved_domain.is_from_http_header)
175+
self.assertEqual(request.unresolved_domain.project, self.pip)
176176

177177
def test_request_header_uppercase(self):
178178
get(
@@ -183,8 +183,8 @@ def test_request_header_uppercase(self):
183183
)
184184
self.run_middleware(request)
185185

186-
self.assertEqual(request.rtdheader, True)
187-
self.assertEqual(request.host_project_slug, 'pip')
186+
self.assertTrue(request.unresolved_domain.is_from_http_header)
187+
self.assertEqual(request.unresolved_domain.project, self.pip)
188188

189189
def test_request_header_not_allowed(self):
190190
request = self.request(

0 commit comments

Comments
 (0)