"""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)