dnscms: version image URLs by file hash so media can be cached as immutable
dnscms / ci (push) Successful in 1m10s
web / ci (push) Successful in 2m38s

This commit is contained in:
ponas committed 2026-10-15 01:43:10 +02:00
1 parent fb1453b419
commit cff6376129
4 files changed
+122 -5

No files matched your search

@@ -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),
]
+14
View File
@@ -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"),
]
+60
View File
@@ -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}