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
150 lines
6 KiB
Python
150 lines
6 KiB
Python
"""Smaller copies of an uploaded image, made once and kept.
|
||
|
||
A question's stem image is 2–4 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)
|