diff --git a/cloudflare/__init__.py b/cloudflare/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudflare/tests/__init__.py b/cloudflare/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudflare/tests/test_cache_tag_purge.py b/cloudflare/tests/test_cache_tag_purge.py new file mode 100644 index 000000000..8fdfb7563 --- /dev/null +++ b/cloudflare/tests/test_cache_tag_purge.py @@ -0,0 +1,52 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings + +from cloudflare.utils import purge_tags_from_cache, purge_all_from_cache + + +@override_settings(WAGTAILFRONTENDCACHE={ + 'cloudflare': { + 'BACKEND': 'wagtail.contrib.wagtailfrontendcache.backends.CloudflareBackend', + 'EMAIL': 'CLOUDFLARE_FAKE_EMAIL', + 'TOKEN': 'CLOUDFLARE_FAKE_TOKEN', + 'ZONEID': 'CLOUDFLARE_FAKE_ZONE', + } +}) +class TestCacheTags(TestCase): + + @patch('cloudflare.utils.requests.delete') + def test_cache_tag_purge(self, requests_delete): + """ + Should fire an appropriate looking HTTP request to Cloudflare's + purge API endpoint + """ + purge_tags_from_cache(['tag-1', 'tag-2']) + requests_delete.assert_called_with( + 'https://api.cloudflare.com/client/v4/zones/CLOUDFLARE_FAKE_ZONE/purge_cache', + json={ + 'tags': ['tag-1', 'tag-2'] + }, + headers={ + 'X-Auth-Email': 'CLOUDFLARE_FAKE_EMAIL', + 'Content-Type': 'application/json', + 'X-Auth-Key': 'CLOUDFLARE_FAKE_TOKEN' + } + ) + + @patch('cloudflare.utils.requests.delete') + def test_cache_purge_all(self, requests_delete): + """ + Should fire an appropriate looking HTTP request to Cloudflare's + purge API endpoint + """ + purge_all_from_cache() + requests_delete.assert_called_with( + 'https://api.cloudflare.com/client/v4/zones/CLOUDFLARE_FAKE_ZONE/purge_cache', + json={}, + headers={ + 'X-Auth-Email': 'CLOUDFLARE_FAKE_EMAIL', + 'Content-Type': 'application/json', + 'X-Auth-Key': 'CLOUDFLARE_FAKE_TOKEN' + } + ) diff --git a/cloudflare/utils.py b/cloudflare/utils.py new file mode 100644 index 000000000..e2868c152 --- /dev/null +++ b/cloudflare/utils.py @@ -0,0 +1,72 @@ +import logging +import requests +import json + +from typing import Iterable + +from wagtail.contrib.wagtailfrontendcache.utils import get_backends +from wagtail.contrib.wagtailfrontendcache.backends import CloudflareBackend + + +logger = logging.getLogger('wagtail.frontendcache') + + +def _for_every_cloudflare_backend(func: callable) -> callable: + "Decorator to run a function once for every Cloudflare backend" + + def inner(*args, backend_settings=None, backends=None, **kwargs): + for backend_name, backend in get_backends(backend_settings=backend_settings, backends=backends).items(): + if not isinstance(backend, CloudflareBackend): + continue + func(*args, backend=backend, **kwargs) + + return inner + + +def _purge(backend: CloudflareBackend, data={}) -> None: + "Send a delete request to the Cloudflare API" + purge_url = 'https://api.cloudflare.com/client/v4/zones/{0}/purge_cache'.format(backend.cloudflare_zoneid) + string_data = json.dumps(data) + + response = requests.delete( + purge_url, + json=data, + headers={ + "X-Auth-Email": backend.cloudflare_email, + "X-Auth-Key": backend.cloudflare_token, + "Content-Type": "application/json", + }, + ) + + try: + response_json = response.json() + except ValueError: + if response.status_code != 200: + response.raise_for_status() + else: + logger.error("Couldn't purge from Cloudflare with data: %s. Unexpected JSON parse error.", string_data) + + except requests.exceptions.HTTPError as e: + logger.error("Couldn't purge from Cloudflare with data: %s. HTTPError: %d %s", string_data, e.response.status_code, e.message) + return + + except requests.exceptions.InvalidURL as e: + logger.error("Couldn't purge from Cloudflare with data: %s. URLError: %s", string_data, e.message) + return + + if response_json['success'] is False: + error_messages = ', '.join([str(err['message']) for err in response_json['errors']]) + logger.error("Couldn't purge from Cloudflare with data: %s. Cloudflare errors '%s'", string_data, error_messages) + return + + +@_for_every_cloudflare_backend +def purge_tags_from_cache(tags: Iterable, backend: CloudflareBackend) -> None: + "Purge tags by list. Requires an enterprise Cloudflare subscription" + _purge(backend=backend, data={"tags": tags}) + + +@_for_every_cloudflare_backend +def purge_all_from_cache(backend: CloudflareBackend) -> None: + "Purge an entire zone" + _purge(backend=backend) diff --git a/securedrop/settings/base.py b/securedrop/settings/base.py index 9af3ba71c..8892b6f22 100644 --- a/securedrop/settings/base.py +++ b/securedrop/settings/base.py @@ -32,6 +32,7 @@ 'accounts', 'autocomplete', 'blog', + 'cloudflare', 'common', 'home', 'marketing',