pdf-quiz-generator/backend/app/services/thumbnails.py
Daniel 6203c3a92f fix: figures a browser will actually draw, and two requests that could hang
Twenty-one stem figures are JPEG 2000. Chrome dropped it in 2015, Firefox and
Edge never had it, and the slim base image ships no MIME table — so
`guess_type` returned nothing, the fallback was `application/octet-stream`, and
`nosniff` finished the job. Those figures rendered nowhere but Safari.

The bytes were never the problem: Pillow decodes JP2 here perfectly well. Only
the delivery had to change, so it changes the way everything else already does
— through the thumbnail machinery, as a cached WebP derivative, stored beside
the original. A format no browser draws now asks for conversion whatever size
it was requested at, decided by the file's own magic rather than by the query
string. The 41 KB original comes back as an 83 KB full-size WebP or a 5 KB
thumbnail, and the stored file is untouched.

`.jp2`, `.jpx`, `.jpf` and `.webp` are registered at import, because a
container with no `/etc/mime.types` is a container that mislabels every one of
them. `.webp` had no figures behind it yet and would have failed the same way.

Two calls could hang for ten minutes. The SDK reads for that long by default
and this client retries nothing, so a stalled connection is a stalled request —
three of them in extraction, which does its own retrying. Both now pass an
explicit two-minute timeout.

Also removed: `EMBEDDING_PROVIDER`, which looks like a switch between a local
encoder and a remote one and is read nowhere, with a comment claiming
embeddings run locally when they have always gone over the network to the
proxy; and a `.replace("openai/", "")` that existed only to undo a prefix
nothing adds any more. The JPEG 2000 comment named the wrong mechanism — the
filename is no guide because there is no MIME table, not because it lies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 16:29:45 +02:00

150 lines
6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Smaller copies of an uploaded image, made once and kept.
A question's stem image is 24 MB of scanned radiograph and a grid of them is
twenty of those, so a browse page was pulling forty megabytes to draw forty
postage stamps. This makes a 256px and a 640px WebP of each and serves those
where a small one will do.
Three rules, all of them about not turning this into a way to spend the
server's afternoon:
* **Allow-listed widths only.** 256 and 640, and any other `?w=` is refused
rather than honoured. An endpoint that resizes to whatever the query string
asks for is a CPU sink anybody can point at.
* **Never enlarged.** A 180px image asked for at 640 is served as it is.
Scaling up invents detail and costs bytes to do it.
* **Best effort.** A derivative that cannot be made is not an error — the
caller falls back to the original. A failed preview must never fail an
upload, and a corrupt file must never take a page down with it.
Derivatives live in the same bucket under `thumbs/{width}/{key}`, so
credentials, lifecycle and backup are unchanged and nothing new has to be
configured for them to be backed up or thrown away.
Pillow rather than sharp: sharp is Node, this backend is Python, and Pillow is
already here and does the same three things.
"""
import io
import logging
import mimetypes
from app.services import storage_service
logger = logging.getLogger(__name__)
#: The only widths that exist. See the module docstring.
WIDTHS = (256, 640)
#: What is worth resizing. A PDF or an SVG is not: one is not an image and the
#: other is already small and resolution-independent.
RESIZABLE = {"image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp",
"image/tiff", "image/jp2"}
#: Formats a browser will not draw. JPEG 2000 is the live case: Chrome dropped
#: it in 2015, Firefox and Edge never had it, and 21 stem figures in this bank
#: are `.jpx`. Pillow decodes it here, so the file is fine — it is only the
#: delivery that has to change, and it changes to WebP like everything else.
UNDISPLAYABLE = {"image/jp2", "image/tiff", "image/bmp"}
#: Registered at import, because the slim base image ships no `/etc/mime.types`
#: and `guess_type` therefore returns None for these. That is how a JPEG 2000
#: figure came to be served as `application/octet-stream` under `nosniff`,
#: which no browser will render however capable it is.
for _suffix, _type in ((".jp2", "image/jp2"), (".jpx", "image/jp2"),
(".jpf", "image/jp2"), (".webp", "image/webp")):
mimetypes.add_type(_type, _suffix)
QUALITY = 82
def thumb_key(key: str, width: int | None) -> str:
return f"thumbs/{width or 'full'}/{key}"
def media_type(key: str) -> str:
return mimetypes.guess_type(key)[0] or ""
def is_resizable(key: str) -> bool:
return media_type(key) in RESIZABLE
def needs_converting(key: str) -> bool:
"""Whether a browser would refuse to draw this even at full size."""
return media_type(key) in UNDISPLAYABLE
def render(data: bytes, width: int | None) -> bytes | None:
"""One derivative, or None if it should not or cannot be made.
A width of None means "convert, do not resize" — the whole picture, in a
format a browser will draw.
"""
try:
from PIL import Image, ImageOps
except ImportError: # pragma: no cover - Pillow is a hard dependency here
logger.warning("Pillow is not installed; serving originals")
return None
try:
image = Image.open(io.BytesIO(data))
# Before anything measures it: a photograph from a phone is stored
# sideways with a rotation flag, and a thumbnail made without reading
# that flag is a sideways thumbnail of a portrait image.
image = ImageOps.exif_transpose(image)
if width is not None:
if image.width <= width:
return None
height = max(1, round(image.height * width / image.width))
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
image = image.resize((width, height), Image.LANCZOS)
else:
image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB")
out = io.BytesIO()
image.save(out, format="WEBP", quality=QUALITY, method=4)
return out.getvalue()
except Exception:
# A file that is not the image its name claims, a truncated upload, a
# decompression bomb Pillow refuses to open. None of those is a reason
# to fail the request that asked for a preview.
logger.info("Could not make a %spx thumbnail", width, exc_info=True)
return None
def get(key: str, width: int | None) -> bytes | None:
"""The derivative, making it on first ask. None means "serve the original".
Not a cache that can be turned off: the second reader of a page gets the
stored copy, and the first pays for it once.
"""
if not is_resizable(key):
return None
# `None` is the convert-only case and is not a width anybody may ask for;
# it is decided here by the file's own format, never by the query string.
if width is not None and width not in WIDTHS:
return None
derived = thumb_key(key, width)
existing = storage_service.load(derived)
if existing:
return existing
source = storage_service.load(key)
if not source:
return None
made = render(source, width)
if not made:
return None
try:
storage_service.save(derived, made, "image/webp")
except Exception:
# Serve it anyway. Storing is an optimisation for the next reader; this
# one already has the bytes in hand.
logger.info("Could not store the %spx thumbnail for %s", width, key, exc_info=True)
return made
def forget(key: str) -> None:
"""Drop every derivative of a key, for when the original changes or goes."""
for width in (*WIDTHS, None):
try:
storage_service.delete(thumb_key(key, width))
except Exception:
logger.info("Could not remove the %spx thumbnail for %s", width, key, exc_info=True)