Skip to content

Commit 9300676

Browse files
committed
Proxito: simplify caching logic
I'm kind of still playing around how to do this.. I like that we are explicitly asking to not cache things, but I don't like that we are still depending on the `ALLOW_PRIVATE_REPOS` setting.
1 parent c0c3f02 commit 9300676

File tree

12 files changed

+109
-80
lines changed

12 files changed

+109
-80
lines changed

readthedocs/analytics/proxied_api.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88

99
from readthedocs.analytics.models import PageView
1010
from readthedocs.api.v2.permissions import IsAuthorizedToViewVersion
11+
from readthedocs.core.mixins import CDNCacheControlMixin
1112
from readthedocs.core.unresolver import UnresolverError, unresolve
1213
from readthedocs.core.utils.extend import SettingsOverrideObject
1314
from readthedocs.projects.models import Project
1415

1516

16-
class BaseAnalyticsView(APIView):
17+
class BaseAnalyticsView(CDNCacheControlMixin, APIView):
1718

1819
"""
1920
Track page views.
@@ -25,6 +26,8 @@ class BaseAnalyticsView(APIView):
2526
- absolute_uri: Full path with domain.
2627
"""
2728

29+
# Never cache this view, we always want to hit this endpoint.
30+
cache_response = False
2831
http_method_names = ['get']
2932
permission_classes = [IsAuthorizedToViewVersion]
3033

readthedocs/analytics/tests.py

+5
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ def test_invalid_uri(self):
128128
self.client.get(url, HTTP_HOST=self.host)
129129
assert PageView.objects.all().count() == 0
130130

131+
def test_cache_headers(self):
132+
resp = self.client.get(self.url, HTTP_HOST=self.host)
133+
self.assertEqual(resp.status_code, 200)
134+
self.assertEqual(resp["CDN-Cache-Control"], "private")
135+
131136
def test_increase_page_view_count(self):
132137
assert (
133138
PageView.objects.all().count() == 0

readthedocs/builds/models.py

+5
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ def is_private(self):
217217
return self.project.external_builds_privacy_level == PRIVATE
218218
return self.privacy_level == PRIVATE
219219

220+
@property
221+
def is_public(self):
222+
"""Check if the version is public (taking external versions into consideration)."""
223+
return not self.is_private
224+
220225
@property
221226
def is_external(self):
222227
return self.type == EXTERNAL

readthedocs/core/mixins.py

+13-24
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
"""Common mixin classes for views."""
2-
from functools import lru_cache
3-
4-
from django.conf import settings
52
from django.contrib.auth.mixins import LoginRequiredMixin
63
from vanilla import ListView
74

8-
from readthedocs.projects.models import Feature
9-
from readthedocs.subscriptions.models import PlanFeature
10-
115

126
class ListViewWithForm(ListView):
137

@@ -34,41 +28,36 @@ class ProxiedAPIMixin:
3428
class CDNCacheControlMixin:
3529

3630
"""
37-
Allow to cache views at the CDN level when privacy levels are enabled.
31+
Explicitly cache or not a view at the CDN level.
3832
39-
The cache control header is only used when privacy levels
40-
are enabled (otherwise everything is public by default).
33+
The cache control header is used to mark the response as public/private,
34+
so it can be cached or not.
4135
4236
Views that can be cached should always return the same response for all
4337
users (anonymous and authenticated users), like when the version attached
4438
to the request is public.
4539
46-
To cache a view you can either set the `cache_request` attribute to `True`,
40+
To explicitly cache a view you can either set the `cache_response` attribute to `True`/`False`,
4741
or override the `can_be_cached` method.
42+
If set to `None` (default), the cache header won't be set,
43+
so the default value can be set by our middleware.
4844
4945
We use ``CDN-Cache-Control``, to control caching at the CDN level only.
5046
This doesn't affect caching at the browser level (``Cache-Control``).
5147
5248
See https://developers.cloudflare.com/cache/about/cdn-cache-control.
5349
"""
5450

55-
cache_request = False
51+
cache_response = None
5652

5753
def dispatch(self, request, *args, **kwargs):
5854
response = super().dispatch(request, *args, **kwargs)
59-
if settings.ALLOW_PRIVATE_REPOS and self.can_be_cached(request):
60-
response.headers['CDN-Cache-Control'] = 'public'
55+
cache_response = self.can_be_cached(request)
56+
if cache_response is not None:
57+
response.headers["CDN-Cache-Control"] = (
58+
"public" if cache_response else "private"
59+
)
6160
return response
6261

6362
def can_be_cached(self, request):
64-
return self.cache_request
65-
66-
@lru_cache(maxsize=1)
67-
def _is_cache_enabled(self, project):
68-
"""Helper function to check if CDN is enabled for a project."""
69-
plan_has_cdn = PlanFeature.objects.get_feature(
70-
obj=project, type=PlanFeature.TYPE_CDN
71-
)
72-
return settings.ALLOW_PRIVATE_REPOS and (
73-
plan_has_cdn or project.has_feature(Feature.CDN_ENABLED)
74-
)
63+
return self.cache_response

readthedocs/core/views/__init__.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from django.shortcuts import render
1212
from django.views.generic import TemplateView, View
1313

14-
from readthedocs.core.mixins import PrivateViewMixin
14+
from readthedocs.core.mixins import CDNCacheControlMixin, PrivateViewMixin
1515
from readthedocs.projects.models import Project
1616

1717
log = structlog.get_logger(__name__)
@@ -21,7 +21,11 @@ class NoProjectException(Exception):
2121
pass
2222

2323

24-
class HealthCheckView(View):
24+
class HealthCheckView(CDNCacheControlMixin, View):
25+
26+
# Never cache this view, we always want to get the live response from the server.
27+
cache_response = False
28+
2529
def get(self, request, *args, **kwargs):
2630
return JsonResponse({'status': 200}, status=200)
2731

readthedocs/projects/views/public.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from readthedocs.builds.constants import BUILD_STATE_FINISHED, LATEST
2323
from readthedocs.builds.models import Version
2424
from readthedocs.builds.views import BuildTriggerMixin
25+
from readthedocs.core.mixins import CDNCacheControlMixin
2526
from readthedocs.core.permissions import AdminPermission
2627
from readthedocs.core.utils.extend import SettingsOverrideObject
2728
from readthedocs.projects.filters import ProjectVersionListFilterSet
@@ -302,7 +303,7 @@ def project_downloads(request, project_slug):
302303
)
303304

304305

305-
class ProjectDownloadMediaBase(ServeDocsMixin, View):
306+
class ProjectDownloadMediaBase(CDNCacheControlMixin, ServeDocsMixin, View):
306307

307308
# Use new-style URLs (same domain as docs) or old-style URLs (dashboard URL)
308309
same_domain_url = False
@@ -361,6 +362,9 @@ def get(
361362
slug=version_slug,
362363
)
363364

365+
if version.is_public:
366+
self.cache_response = True
367+
364368
return self._serve_dowload(
365369
request=request,
366370
project=version.project,

readthedocs/proxito/middleware.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -156,27 +156,28 @@ def add_cache_headers(self, request, response):
156156
"""
157157
Add Cache-Control headers.
158158
159-
If privacy levels are enabled and the header isn't already present,
160-
set the cache level to private.
161-
Or if the request was from the `X-RTD-Slug` header,
162-
we don't cache the response, since we could be caching a response in another domain.
159+
If the `CDN-Cache-Control` header isn't already present, set the cache
160+
level to public or private, depending if we allow private repos or not.
161+
Or if the request was from the `X-RTD-Slug` header, we don't cache the
162+
response, since we could be caching a response in another domain.
163163
164164
We use ``CDN-Cache-Control``, to control caching at the CDN level only.
165165
This doesn't affect caching at the browser level (``Cache-Control``).
166166
167167
See https://developers.cloudflare.com/cache/about/cdn-cache-control.
168168
"""
169-
header = "CDN-Cache-Control"
169+
cache_header = "CDN-Cache-Control"
170170
unresolved_domain = request.unresolved_domain
171171
# Never trust projects resolving from the X-RTD-Slug header,
172172
# we don't want to cache their content on domains from other
173173
# projects, see GHSA-mp38-vprc-7hf5.
174174
if unresolved_domain and unresolved_domain.is_from_http_header:
175-
response.headers[header] = "private"
175+
response.headers[cache_header] = "private"
176+
return
176177

177-
if settings.ALLOW_PRIVATE_REPOS:
178-
# Set the key to private only if it hasn't already been set by the view.
179-
response.headers.setdefault(header, "private")
178+
# Set the key to private only if it hasn't already been set by the view.
179+
default_cache_level = "private" if settings.ALLOW_PRIVATE_REPOS else "public"
180+
response.headers.setdefault(cache_header, default_cache_level)
180181

181182
def _set_request_attributes(self, request, unresolved_domain):
182183
"""

readthedocs/proxito/tests/base.py

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.core.files.storage import get_storage_class
88
from django.test import TestCase
99

10+
from readthedocs.builds.constants import LATEST
1011
from readthedocs.projects.constants import PUBLIC
1112
from readthedocs.projects.models import Domain, Project
1213
from readthedocs.proxito.views import serve
@@ -33,6 +34,7 @@ def setUp(self):
3334
main_language_project=None,
3435
)
3536
self.project.versions.update(privacy_level=PUBLIC)
37+
self.version = self.project.versions.get(slug=LATEST)
3638

3739
self.subproject = fixture.get(
3840
Project,

readthedocs/proxito/tests/test_full.py

+35-11
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ def test_health_check(self):
5757
host = '127.0.0.1'
5858
resp = self.client.get(url, HTTP_HOST=host)
5959
self.assertEqual(resp.status_code, 200)
60-
self.assertEqual(resp.json(), {'status': 200})
60+
self.assertEqual(resp.json(), {"status": 200})
61+
self.assertEqual(resp["CDN-Cache-Control"], "private")
6162

6263
def test_subproject_serving(self):
6364
url = '/projects/subproject/en/latest/awesome.html'
@@ -366,7 +367,24 @@ def test_project_nginx_serving_unicode_filename(self):
366367
)
367368

368369
@override_settings(PYTHON_MEDIA=False)
369-
def test_download_files(self):
370+
def test_download_files_public_version(self):
371+
for type_ in DOWNLOADABLE_MEDIA_TYPES:
372+
resp = self.client.get(
373+
f"/_/downloads/en/latest/{type_}/",
374+
HTTP_HOST="project.dev.readthedocs.io",
375+
)
376+
self.assertEqual(resp.status_code, 200)
377+
extension = "zip" if type_ == MEDIA_TYPE_HTMLZIP else type_
378+
self.assertEqual(
379+
resp["X-Accel-Redirect"],
380+
f"/proxito/media/{type_}/project/latest/project.{extension}",
381+
)
382+
self.assertEqual(resp["CDN-Cache-Control"], "public")
383+
384+
@override_settings(PYTHON_MEDIA=False, ALLOW_PRIVATE_REPOS=True)
385+
def test_download_files_private_version(self):
386+
self.version.privacy_level = PRIVATE
387+
self.version.save()
370388
for type_ in DOWNLOADABLE_MEDIA_TYPES:
371389
resp = self.client.get(
372390
f"/_/downloads/en/latest/{type_}/",
@@ -378,6 +396,7 @@ def test_download_files(self):
378396
resp["X-Accel-Redirect"],
379397
f"/proxito/media/{type_}/project/latest/project.{extension}",
380398
)
399+
self.assertEqual(resp["CDN-Cache-Control"], "private")
381400

382401
@override_settings(PYTHON_MEDIA=False)
383402
def test_invalid_download_files(self):
@@ -1395,11 +1414,13 @@ def test_cache_on_private_versions_custom_domain(self):
13951414
self.domain.save()
13961415
self._test_cache_control_header_project(expected_value='private', host=self.domain.domain)
13971416

1398-
# HTTPS redirect respects the privacy level of the version.
1399-
resp = self.client.get('/en/latest/', secure=False, HTTP_HOST=self.domain.domain)
1400-
self.assertEqual(resp['Location'], f'https://{self.domain.domain}/en/latest/')
1401-
self.assertEqual(resp.headers['CDN-Cache-Control'], 'private')
1402-
self.assertEqual(resp.headers['Cache-Tag'], 'project,project:latest')
1417+
# HTTPS redirect can always be cached.
1418+
resp = self.client.get(
1419+
"/en/latest/", secure=False, HTTP_HOST=self.domain.domain
1420+
)
1421+
self.assertEqual(resp["Location"], f"https://{self.domain.domain}/en/latest/")
1422+
self.assertEqual(resp.headers["CDN-Cache-Control"], "public")
1423+
self.assertEqual(resp.headers["Cache-Tag"], "project,project:latest")
14031424

14041425
def test_cache_public_versions(self):
14051426
self.project.versions.update(privacy_level=PUBLIC)
@@ -1427,15 +1448,18 @@ def test_cache_on_private_versions_custom_domain_subproject(self):
14271448
self.domain.save()
14281449
self._test_cache_control_header_subproject(expected_value='private', host=self.domain.domain)
14291450

1430-
# HTTPS redirect respects the privacy level of the version.
1451+
# HTTPS redirect can always be cached.
14311452
resp = self.client.get(
14321453
'/projects/subproject/en/latest/',
14331454
secure=False,
14341455
HTTP_HOST=self.domain.domain,
14351456
)
1436-
self.assertEqual(resp['Location'], f'https://{self.domain.domain}/projects/subproject/en/latest/')
1437-
self.assertEqual(resp.headers['CDN-Cache-Control'], 'private')
1438-
self.assertEqual(resp.headers['Cache-Tag'], 'subproject,subproject:latest')
1457+
self.assertEqual(
1458+
resp["Location"],
1459+
f"https://{self.domain.domain}/projects/subproject/en/latest/",
1460+
)
1461+
self.assertEqual(resp.headers["CDN-Cache-Control"], "public")
1462+
self.assertEqual(resp.headers["Cache-Tag"], "subproject,subproject:latest")
14391463

14401464
def test_cache_public_versions_subproject(self):
14411465
self.subproject.versions.update(privacy_level=PUBLIC)

readthedocs/proxito/tests/test_headers.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ def test_user_domain_headers(self):
133133
self.assertEqual(r[http_header_secure], http_header_value)
134134

135135
@override_settings(ALLOW_PRIVATE_REPOS=False)
136-
def test_cache_headers_public_projects(self):
136+
def test_cache_headers_public_version_with_private_projects_not_allowed(self):
137137
r = self.client.get('/en/latest/', HTTP_HOST='project.dev.readthedocs.io')
138138
self.assertEqual(r.status_code, 200)
139-
self.assertNotIn('CDN-Cache-Control', r)
139+
self.assertEqual(r["CDN-Cache-Control"], "public")
140140

141141
@override_settings(ALLOW_PRIVATE_REPOS=True)
142-
def test_cache_headers_private_projects(self):
142+
def test_cache_headers_public_version_with_private_projects_allowed(self):
143143
r = self.client.get('/en/latest/', HTTP_HOST='project.dev.readthedocs.io')
144144
self.assertEqual(r.status_code, 200)
145-
self.assertEqual(r['CDN-Cache-Control'], 'private')
145+
self.assertEqual(r["CDN-Cache-Control"], "public")

readthedocs/proxito/views/mixins.py

+4
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ def system_redirect(
323323
log.debug(
324324
"System Redirect.", host=request.get_host(), from_url=filename, to_url=to
325325
)
326+
# All system redirects can be cached, since the final URL will check for authz.
327+
self.cache_response = True
326328
resp = HttpResponseRedirect(to)
327329
resp["X-RTD-Redirect"] = RedirectType.system.name
328330
return resp
@@ -392,6 +394,8 @@ def canonical_redirect(
392394
raise InfiniteRedirectException()
393395

394396
log.info('Canonical Redirect.', host=request.get_host(), from_url=filename, to_url=to)
397+
# All canonical redirects can be cached, since the final URL will check for authz.
398+
self.cache_response = True
395399
resp = HttpResponseRedirect(to)
396400
resp["X-RTD-Redirect"] = redirect_type.name
397401
return resp

0 commit comments

Comments
 (0)