dnscms: version image URLs by file hash so media can be cached as immutable
This commit is contained in:
4 files changed
+118
-1
No files matched your search
@@ -59,13 +59,20 @@ not in any repo:
|
||||
|
||||
- `Caddyfile`, `default_redirect_503.caddy`: Coolify's own.
|
||||
- `studentfestivalen-redirects.caddy`: `studentfestivalen.no` and `studioweb.no` → `neuf.no/studio`.
|
||||
- `cms-neuf-no.subdirectives`:
|
||||
- `cms-neuf-no.subdirectives`. The GraphQL API versions image URLs with `?v=<file hash>`
|
||||
(`CustomImage.versioned_url`), so those are cached forever. Anything else under `/media/`
|
||||
(admin previews, renditions, old links) is revalidated on every use, instead of browsers
|
||||
guessing a lifetime from `Last-Modified`. `neuf-no.subdirectives` repeats the same headers:
|
||||
|
||||
```caddy
|
||||
rewrite / /admin/
|
||||
|
||||
handle_path /media/* {
|
||||
root * /site/cms.neuf.no/media
|
||||
@versioned query v=*
|
||||
header @versioned Cache-Control "public, max-age=31536000, immutable"
|
||||
@unversioned not query v=*
|
||||
header @unversioned Cache-Control "no-cache"
|
||||
file_server
|
||||
}
|
||||
```
|
||||
@@ -76,6 +83,10 @@ not in any repo:
|
||||
```caddy
|
||||
handle_path /media/* {
|
||||
root * /site/cms.neuf.no/media
|
||||
@versioned query v=*
|
||||
header @versioned Cache-Control "public, max-age=31536000, immutable"
|
||||
@unversioned not query v=*
|
||||
header @unversioned Cache-Control "no-cache"
|
||||
file_server
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import migrations
|
||||
from wagtail.utils.file import hash_filelike
|
||||
|
||||
|
||||
def backfill_file_hash_and_size(apps, schema_editor):
|
||||
"""
|
||||
Images from the 2024 bulk import were created without Wagtail's upload
|
||||
form, which is what sets file_hash and file_size. The hash versions image
|
||||
URLs (see CustomImage.versioned_url).
|
||||
"""
|
||||
CustomImage = apps.get_model("images", "CustomImage")
|
||||
images = CustomImage.objects.filter(file_hash="") | CustomImage.objects.filter(
|
||||
file_size__isnull=True
|
||||
)
|
||||
for image in images.iterator():
|
||||
try:
|
||||
with image.file.open("rb") as f:
|
||||
image.file_hash = hash_filelike(f)
|
||||
image.file_size = image.file.size
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
image.save(update_fields=["file_hash", "file_size"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("images", "0005_customimage_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_file_hash_and_size, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from grapple.models import (
|
||||
GraphQLString,
|
||||
)
|
||||
from grapple.utils import get_media_item_url
|
||||
from wagtail.images.models import AbstractImage, AbstractRendition, Image
|
||||
from wagtail.search import index
|
||||
|
||||
@@ -44,7 +45,20 @@ class CustomImage(AbstractImage):
|
||||
("choose_image", "Can choose image"),
|
||||
]
|
||||
|
||||
@property
|
||||
def versioned_url(self):
|
||||
# Original files keep their upload name, so deleting an image and
|
||||
# uploading another with the same name reuses the URL. The content hash
|
||||
# makes the URL change with the file, which lets the media proxy and
|
||||
# Next's image optimizer cache it as immutable.
|
||||
url = get_media_item_url(self)
|
||||
if not self.file_hash:
|
||||
return url
|
||||
return f"{url}?v={self.file_hash[:8]}"
|
||||
|
||||
graphql_fields = [
|
||||
# Replaces Grapple's own `url` field and resolver
|
||||
GraphQLString("url", source="versioned_url", required=True),
|
||||
GraphQLString("alt"),
|
||||
GraphQLString("attribution"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import importlib
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from images.models import CustomImage
|
||||
from tests.conftest import CustomImageFactory
|
||||
|
||||
backfill = importlib.import_module(
|
||||
"images.migrations.0006_backfill_file_hash_and_size"
|
||||
).backfill_file_hash_and_size
|
||||
|
||||
IMAGES_QUERY = "query { images { id url } }"
|
||||
|
||||
|
||||
def test_graphql_image_url_is_versioned_by_file_hash(db, graphql_post):
|
||||
image = CustomImageFactory()
|
||||
image.file_hash = "0123456789abcdef0123456789abcdef01234567"
|
||||
image.save(update_fields=["file_hash"])
|
||||
|
||||
response, body = graphql_post(IMAGES_QUERY)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "errors" not in body, body
|
||||
[data] = body["data"]["images"]
|
||||
assert data["url"].startswith("http")
|
||||
assert data["url"].endswith(f"{image.file.url}?v=01234567")
|
||||
|
||||
|
||||
def test_graphql_image_url_without_file_hash_is_unversioned(db, graphql_post):
|
||||
image = CustomImageFactory()
|
||||
CustomImage.objects.filter(pk=image.pk).update(file_hash="")
|
||||
|
||||
response, body = graphql_post(IMAGES_QUERY)
|
||||
|
||||
assert "errors" not in body, body
|
||||
[data] = body["data"]["images"]
|
||||
assert data["url"].endswith(image.file.url)
|
||||
|
||||
|
||||
def test_backfill_sets_file_hash_and_size(db):
|
||||
image = CustomImageFactory()
|
||||
CustomImage.objects.filter(pk=image.pk).update(file_hash="", file_size=None)
|
||||
|
||||
backfill(apps, None)
|
||||
|
||||
image.refresh_from_db()
|
||||
assert len(image.file_hash) == 40
|
||||
assert image.file_size == image.file.size
|
||||
|
||||
|
||||
def test_backfill_skips_images_whose_file_is_missing(db):
|
||||
image = CustomImageFactory()
|
||||
CustomImage.objects.filter(pk=image.pk).update(file_hash="", file_size=None)
|
||||
image.file.storage.delete(image.file.name)
|
||||
|
||||
backfill(apps, None)
|
||||
|
||||
# refresh_from_db() would read the missing file for its dimensions
|
||||
fields = CustomImage.objects.filter(pk=image.pk).values("file_hash", "file_size")
|
||||
assert fields.get() == {"file_hash": "", "file_size": None}
|
||||
Reference in new issue
Block a user