From 9a9f0d4cab4436ddaab6c3b79ef17f5b06b8dcf6 Mon Sep 17 00:00:00 2001
From: Quantum <quantum2048@gmail.com>
Date: Mon, 24 Jan 2022 02:43:49 -0500
Subject: [PATCH] Implement CDN cache backends

---
 qlinks/admin.py                      | 13 +++++++++----
 qlinks/cdn_cache/__init__.py         | 12 ++++++++++++
 qlinks/cdn_cache/base.py             |  9 +++++++++
 qlinks/cdn_cache/cloudflare_cache.py | 18 ++++++++++++++++++
 qlinks/models.py                     | 12 ++++++++++++
 qlinks/settings/base.py              |  1 +
 qlinks/settings/template.py          |  6 ++++++
 7 files changed, 67 insertions(+), 4 deletions(-)
 create mode 100644 qlinks/cdn_cache/__init__.py
 create mode 100644 qlinks/cdn_cache/base.py
 create mode 100644 qlinks/cdn_cache/cloudflare_cache.py

diff --git a/qlinks/admin.py b/qlinks/admin.py
index 8603ac9..4b8825e 100644
--- a/qlinks/admin.py
+++ b/qlinks/admin.py
@@ -17,17 +17,17 @@ class LinkAdmin(admin.ModelAdmin):
     search_fields = ('short', 'long')
 
     @admin.display(ordering='short', description=_('short slug'))
-    def short_slug(self, obj):
+    def short_slug(self, obj: Link):
         return obj.short or '/'
 
     @admin.display(ordering='long', description=_('long URL'))
-    def long_url(self, obj):
+    def long_url(self, obj: Link):
         return format_html('<a href="{0}">{1}</a>', obj.long, truncatechars(obj.long, 64))
 
     @admin.display(description=_('link'))
-    def short_url(self, obj):
+    def short_url(self, obj: Link):
         if settings.QLINKS_CANONICAL:
-            return format_html('<a href="{0}{1}">{2}</a>', settings.QLINKS_CANONICAL, obj.short, gettext('Link'))
+            return format_html('<a href="{0}">{1}</a>', obj.short_url, gettext('Link'))
 
     def save_model(self, request, obj: Link, form, change):
         obj.created_by = request.user
@@ -35,6 +35,11 @@ class LinkAdmin(admin.ModelAdmin):
         obj.is_working = check_url(obj.long)
         obj.last_check = timezone.now()
         super().save_model(request, obj, form, change)
+        obj.purge_cdn()
+
+    def delete_model(self, request, obj: Link):
+        super().delete_model(request, obj)
+        obj.purge_cdn()
 
 
 admin.site.register(Link, LinkAdmin)
diff --git a/qlinks/cdn_cache/__init__.py b/qlinks/cdn_cache/__init__.py
new file mode 100644
index 0000000..4b5681f
--- /dev/null
+++ b/qlinks/cdn_cache/__init__.py
@@ -0,0 +1,12 @@
+from typing import Optional
+
+from django.conf import settings
+from django.utils.module_loading import import_string
+
+from qlinks.cdn_cache.base import BaseCDNCache
+
+cdn_cache: Optional[BaseCDNCache]
+if settings.QLINKS_CDN_CACHE:
+    cdn_cache = import_string(settings.QLINKS_CDN_CACHE)()
+else:
+    cdn_cache = None
diff --git a/qlinks/cdn_cache/base.py b/qlinks/cdn_cache/base.py
new file mode 100644
index 0000000..6fe83f4
--- /dev/null
+++ b/qlinks/cdn_cache/base.py
@@ -0,0 +1,9 @@
+import abc
+
+
+class BaseCDNCache(abc.ABC):
+    @abc.abstractmethod
+    def __init__(self): ...
+
+    @abc.abstractmethod
+    def purge(self, url: str) -> None: ...
diff --git a/qlinks/cdn_cache/cloudflare_cache.py b/qlinks/cdn_cache/cloudflare_cache.py
new file mode 100644
index 0000000..18c23c0
--- /dev/null
+++ b/qlinks/cdn_cache/cloudflare_cache.py
@@ -0,0 +1,18 @@
+from CloudFlare import CloudFlare
+from django.conf import settings
+
+from qlinks.cdn_cache import BaseCDNCache
+
+
+class CloudflareCDNCache(BaseCDNCache):
+    cf: CloudFlare
+    zone: str
+
+    def __init__(self):
+        self.cf = CloudFlare(token=getattr(settings, 'QLINKS_CDN_CLOUDFLARE_API_TOKEN', None))
+        self.zone = settings.QLINKS_CDN_CLOUDFLARE_ZONE_ID
+
+    def purge(self, url: str) -> None:
+        self.cf.zones.purge_cache.post(self.zone, data={
+            'files': [url],
+        })
diff --git a/qlinks/models.py b/qlinks/models.py
index 1e00989..f06a3f6 100644
--- a/qlinks/models.py
+++ b/qlinks/models.py
@@ -1,8 +1,12 @@
+from functools import cached_property
+
 from django.conf import settings
 from django.db import models
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
+from qlinks.cdn_cache import cdn_cache
+
 
 class Link(models.Model):
     short = models.SlugField(max_length=64, verbose_name=_('short link slug'), unique=True, blank=True,
@@ -18,3 +22,11 @@ class Link(models.Model):
 
     def __str__(self):
         return self.short or '/'
+
+    @cached_property
+    def short_url(self):
+        return settings.QLINKS_CANONICAL + self.short
+
+    def purge_cdn(self):
+        if cdn_cache:
+            cdn_cache.purge(self.short_url)
diff --git a/qlinks/settings/base.py b/qlinks/settings/base.py
index e7c7951..09dd77c 100644
--- a/qlinks/settings/base.py
+++ b/qlinks/settings/base.py
@@ -86,3 +86,4 @@ QLINKS_CANONICAL = None
 QLINKS_SITE_HEADER = 'QLinks Admin'
 QLINKS_SITE_TITLE = 'QLinks Admin'
 QLINKS_INDEX_TITLE = 'Welcome to the QLinks admin interface!'
+QLINKS_CDN_CACHE = None
diff --git a/qlinks/settings/template.py b/qlinks/settings/template.py
index 6b7cd24..dd02744 100644
--- a/qlinks/settings/template.py
+++ b/qlinks/settings/template.py
@@ -27,3 +27,9 @@ QLINKS_ADMIN_HOST = r'admin'
 
 # Set to link prefix for short links, e.g. 'https://short.example.com/'
 QLINKS_CANONICAL = None
+
+# If you are using a CDN, you can optionally configure it to cache all the
+# redirects, and then use a CDN cache backend to purge the cache.
+# QLINKS_CDN_CACHE = 'qlinks.cdn_cache.cloudflare_cache.CloudflareCDNCache'
+# QLINKS_CDN_CLOUDFLARE_API_TOKEN = ...
+# QLINKS_CDN_CLOUDFLARE_API_ZONE_ID = ...