From 092f1de585cff0e168571aaff85f390bd0977b84 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 28 Aug 2026 20:53:37 -0600 Subject: [PATCH] Fix stale custom emoji image after delete-and-reupload (#18) The image-serve endpoint's Cache-Control: max-age=300 let a browser keep serving an already-cached image for up to 5 minutes after a delete-and-reupload swapped in a different file under the same shortcode URL. Switched to no-cache, which forces revalidation on every use -- still cheap, since FileResponse's own ETag/Last-Modified make an unchanged file a 304, not a full re-transfer. Co-Authored-By: Claude Sonnet 5 --- backend/app/routers/custom_emoji.py | 18 ++++++++--- backend/tests/test_custom_emoji.py | 48 +++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/backend/app/routers/custom_emoji.py b/backend/app/routers/custom_emoji.py index f581c72..8a2cdea 100644 --- a/backend/app/routers/custom_emoji.py +++ b/backend/app/routers/custom_emoji.py @@ -123,9 +123,17 @@ async def get_custom_emoji_image_endpoint( return FileResponse( UPLOADS_DIR / emoji.storage_filename, media_type=emoji.content_type, - # Site-wide and rarely changed, but a shortcode can be deleted and - # re-uploaded with different image data -- short-cache like the - # avatar endpoint, not `immutable` like content-addressed message - # images. - headers={"Cache-Control": "private, max-age=300"}, + # #18 follow-up: `max-age=300` (the avatar endpoint's own + # convention) meant a browser that had already fetched this + # shortcode's image kept serving it from cache for up to 5 minutes + # after a delete-and-reupload under the same name swapped in a + # genuinely different file underneath the same URL -- confirmed + # live, re-adding an emoji with a just-deleted shortcode showed the + # old image. `no-cache` (despite the name, still cacheable) forces + # a revalidation round trip on every use instead of trusting a + # timed cache -- FileResponse already sets ETag/Last-Modified from + # the file's own mtime+size (see Starlette's set_stat_headers), so + # an unchanged file still gets served as a cheap 304 and only an + # actually-different one (any re-upload) returns fresh bytes. + headers={"Cache-Control": "private, no-cache"}, ) diff --git a/backend/tests/test_custom_emoji.py b/backend/tests/test_custom_emoji.py index 428b8cd..4dd17d2 100644 --- a/backend/tests/test_custom_emoji.py +++ b/backend/tests/test_custom_emoji.py @@ -11,9 +11,9 @@ def _unique(prefix: str) -> str: return f"{prefix}-{uuid.uuid4().hex[:8]}" -def _png_bytes(size: tuple[int, int] = (10, 10)) -> bytes: +def _png_bytes(size: tuple[int, int] = (10, 10), color: tuple[int, int, int] = (255, 0, 0)) -> bytes: buf = io.BytesIO() - Image.new("RGB", size, color=(255, 0, 0)).save(buf, format="PNG") + Image.new("RGB", size, color=color).save(buf, format="PNG") return buf.getvalue() @@ -123,6 +123,50 @@ async def test_serve_custom_emoji_image_by_shortcode(client, db_session): assert resp.headers["content-type"] == "image/png" +async def test_serve_custom_emoji_image_forces_revalidation(client, db_session): + # A timed cache (the original `max-age=300`) meant a browser that had + # already fetched a shortcode's image kept serving those bytes for up + # to 5 minutes after a delete-and-reupload swapped in a different file + # under the same URL -- confirmed live: re-adding an emoji under a + # just-deleted shortcode showed the old image. `no-cache` forces + # revalidation on every use instead (still cheap: FileResponse's own + # ETag/Last-Modified make an actually-unchanged file a 304, not a full + # re-transfer). + await register_and_login(client, db_session, username=_unique("alice")) + shortcode = _unique("revalidated") + await client.post( + "/api/custom-emoji", + data={"shortcode": shortcode}, + files={"file": ("a.png", _png_bytes(), "image/png")}, + ) + resp = await client.get(f"/api/custom-emoji/{shortcode}/image") + assert "no-cache" in resp.headers["cache-control"] + assert "max-age" not in resp.headers["cache-control"] + + +async def test_reuploading_a_deleted_shortcode_serves_the_new_image(client, db_session): + await register_and_login(client, db_session, username=_unique("alice")) + shortcode = _unique("reused") + first = await client.post( + "/api/custom-emoji", + data={"shortcode": shortcode}, + files={"file": ("red.png", _png_bytes(color=(255, 0, 0)), "image/png")}, + ) + assert first.status_code == 201 + await client.delete(f"/api/custom-emoji/{first.json()['id']}") + + second = await client.post( + "/api/custom-emoji", + data={"shortcode": shortcode}, + files={"file": ("blue.png", _png_bytes(color=(0, 0, 255)), "image/png")}, + ) + assert second.status_code == 201 + + resp = await client.get(f"/api/custom-emoji/{shortcode}/image") + served = Image.open(io.BytesIO(resp.content)).convert("RGB") + assert served.getpixel((0, 0)) == (0, 0, 255) + + async def test_serve_unknown_shortcode_404s(client, db_session): await register_and_login(client, db_session, username=_unique("alice")) resp = await client.get("/api/custom-emoji/no-such-emoji/image")