From fe890525282a0a645c8df31c970fd86dc4c85567 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Fri, 16 Oct 2026 19:00:42 +0200
Subject: [PATCH v14 11/12] Migrate imagehash and serve badge images from it

badge_image() now answers If-None-Match from Badge.imagehash, so a
revalidation is answered with a single query and never reads imagedata
(previously it read the image out of TOAST and hashed it before doing the
check). The image itself is still fetched, lazily, for a 200.

The migration covers both new schema changes for the contributors app: the
imagehash column added two commits ago and the badge title length from the
previous one. Both are unreleased, so they share a single migration.
imagehash is database-generated, so there is nothing to backfill.
---
 .../0006_badge_imagehash_alter_badge_badge.py | 49 +++++++++++++++++++
 pgweb/contributors/views.py                   | 13 +++--
 2 files changed, 55 insertions(+), 7 deletions(-)
 create mode 100644 pgweb/contributors/migrations/0006_badge_imagehash_alter_badge_badge.py

diff --git a/pgweb/contributors/migrations/0006_badge_imagehash_alter_badge_badge.py b/pgweb/contributors/migrations/0006_badge_imagehash_alter_badge_badge.py
new file mode 100644
index 00000000..9ce08196
--- /dev/null
+++ b/pgweb/contributors/migrations/0006_badge_imagehash_alter_badge_badge.py
@@ -0,0 +1,49 @@
+# Generated by Django 5.2.17 on 2026-10-16 16:54
+
+import django.db.models.functions.text
+import django.db.models.lookups
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "contributors",
+            "0005_alter_contributor_ctype_alter_contributor_email_and_more",
+        ),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="badge",
+            name="imagehash",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        django.db.models.lookups.GreaterThan(
+                            django.db.models.functions.text.Length("imagedata"), 0
+                        ),
+                        then=models.Func(
+                            models.F("imagedata"),
+                            function="MD5",
+                            output_field=models.TextField(),
+                        ),
+                    ),
+                    default=models.Value(""),
+                    output_field=models.TextField(),
+                ),
+                output_field=models.TextField(),
+            ),
+        ),
+        migrations.AlterField(
+            model_name="badge",
+            name="badge",
+            field=models.CharField(
+                help_text='Title of this badge, e.g. "PGConf.EU 2025 Speaker".',
+                max_length=255,
+                unique=True,
+            ),
+        ),
+    ]
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index c0faf542..fbf6b315 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -1,5 +1,3 @@
-import hashlib
-
 from django.db.models import Prefetch
 from django.shortcuts import get_object_or_404
 from django.http import HttpResponse, HttpResponseNotModified, Http404
@@ -60,12 +58,13 @@ def profile(request, username):
 
 
 def badge_image(request, slug):
-    # defer(None) clears the manager's default defer: .only() on its own
-    # would leave an already-deferred field deferred and fetch it lazily.
-    badge = get_object_or_404(Badge.objects.defer(None).only('id', 'slug', 'imagedata'), slug=slug, approved=True)
-    if not badge.imagedata:
+    # imagedata is deferred: a revalidation (If-None-Match) is answered from the
+    # stored imagehash alone, without reading the image out of TOAST. imagedata
+    # is fetched lazily below, only when the image is actually sent.
+    badge = get_object_or_404(Badge.objects.only('id', 'slug', 'imagehash'), slug=slug, approved=True)
+    if not badge.imagehash:
         raise Http404
-    etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+    etag = '"%s"' % badge.imagehash
     # Tag the image so a badge change purges it through its xkey. The 304 does
     # not strictly need it (only the 200 is stored as a cache object), but it
     # is set on both responses to keep them identical.
-- 
2.53.0

