From cff6376129ceeb1e07ff418e8aadbefb2ac91857 Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Thu, 15 Oct 2026 01:43:10 +0200 Subject: [PATCH] dnscms: version image URLs by file hash so media can be cached as immutable --- README.md | 21 +++++-- .../0006_backfill_file_hash_and_size.py | 32 ++++++++++ dnscms/images/models.py | 14 +++++ dnscms/tests/test_image_urls.py | 60 +++++++++++++++++++ 4 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 dnscms/images/migrations/0006_backfill_file_hash_and_size.py create mode 100644 dnscms/tests/test_image_urls.py diff --git a/README.md b/README.md index 7cb5603..094bd71 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,21 @@ 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=` + (`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 - file_server + 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 } ``` @@ -75,8 +82,12 @@ not in any repo: ```caddy handle_path /media/* { - root * /site/cms.neuf.no/media - file_server + 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 } @pages not path /_next/static/* /_next/image* /assets/* /media/* diff --git a/dnscms/images/migrations/0006_backfill_file_hash_and_size.py b/dnscms/images/migrations/0006_backfill_file_hash_and_size.py new file mode 100644 index 0000000..3ff24a4 --- /dev/null +++ b/dnscms/images/migrations/0006_backfill_file_hash_and_size.py @@ -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), + ] diff --git a/dnscms/images/models.py b/dnscms/images/models.py index b677095..08a5306 100644 --- a/dnscms/images/models.py +++ b/dnscms/images/models.py @@ -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"), ] diff --git a/dnscms/tests/test_image_urls.py b/dnscms/tests/test_image_urls.py new file mode 100644 index 0000000..50d45dc --- /dev/null +++ b/dnscms/tests/test_image_urls.py @@ -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}