A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.
Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
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, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.
Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.
EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.
Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.
Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""Smaller copies of an uploaded image: which widths exist, and what refuses.
|
|
|
|
Disposable temp directory for the upload root; Pillow does the real work, so
|
|
these are real images in and real WebP out rather than mocks agreeing with
|
|
themselves.
|
|
"""
|
|
import io
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
|
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
|
|
|
from PIL import Image
|
|
|
|
from app.config import settings
|
|
from app.services import storage_service, thumbnails
|
|
|
|
|
|
def png(width, height, colour=(200, 30, 40)):
|
|
buffer = io.BytesIO()
|
|
Image.new("RGB", (width, height), colour).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
class ThumbnailTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.root = tempfile.TemporaryDirectory()
|
|
self.previous = (settings.UPLOAD_DIR, settings.STORAGE_BACKEND)
|
|
settings.UPLOAD_DIR = self.root.name
|
|
# Pinned local, so the test is about resizing rather than about which
|
|
# bucket this container happens to be pointed at.
|
|
settings.STORAGE_BACKEND = "local"
|
|
|
|
def tearDown(self):
|
|
settings.UPLOAD_DIR, settings.STORAGE_BACKEND = self.previous
|
|
self.root.cleanup()
|
|
|
|
def put(self, key, data):
|
|
storage_service.save(key, data)
|
|
return key
|
|
|
|
def test_a_wide_image_comes_back_narrower_and_as_webp(self):
|
|
key = self.put("questions/big.png", png(1600, 900))
|
|
small = thumbnails.get(key, 256)
|
|
self.assertIsNotNone(small)
|
|
image = Image.open(io.BytesIO(small))
|
|
self.assertEqual(image.width, 256)
|
|
self.assertEqual(image.format, "WEBP")
|
|
# The aspect ratio is kept rather than the height being guessed.
|
|
self.assertEqual(image.height, round(900 * 256 / 1600))
|
|
self.assertLess(len(small), len(storage_service.load(key)))
|
|
|
|
def test_it_is_made_once_and_kept_beside_the_original(self):
|
|
key = self.put("questions/kept.png", png(1200, 600))
|
|
first = thumbnails.get(key, 640)
|
|
self.assertIsNotNone(first)
|
|
stored = storage_service.load(thumbnails.thumb_key(key, 640))
|
|
self.assertEqual(stored, first)
|
|
# Removing the original must not change what a stored derivative
|
|
# returns: the second reader is served from the copy, not from a resize.
|
|
storage_service.delete(key)
|
|
self.assertEqual(thumbnails.get(key, 640), first)
|
|
|
|
def test_a_small_image_is_never_enlarged(self):
|
|
key = self.put("questions/small.png", png(180, 120))
|
|
# None means "there is nothing smaller worth serving", and the caller
|
|
# sends the original. Scaling up invents detail and costs bytes to do it.
|
|
self.assertIsNone(thumbnails.get(key, 256))
|
|
self.assertIsNone(thumbnails.get(key, 640))
|
|
|
|
def test_only_the_two_widths_exist(self):
|
|
key = self.put("questions/widths.png", png(1600, 900))
|
|
self.assertEqual(thumbnails.WIDTHS, (256, 640))
|
|
for width in (1, 255, 512, 641, 4000):
|
|
self.assertIsNone(thumbnails.get(key, width))
|
|
|
|
def test_what_is_not_an_image_is_left_alone(self):
|
|
self.assertFalse(thumbnails.is_resizable("documents/handbook.pdf"))
|
|
self.assertFalse(thumbnails.is_resizable("figures/diagram.svg"))
|
|
key = self.put("documents/handbook.pdf", b"%PDF-1.4 not really")
|
|
self.assertIsNone(thumbnails.get(key, 256))
|
|
|
|
def test_a_file_that_is_not_the_image_it_claims_is_not_an_error(self):
|
|
key = self.put("questions/broken.png", b"this is not a PNG")
|
|
self.assertIsNone(thumbnails.get(key, 256))
|
|
|
|
def test_forgetting_removes_every_derivative(self):
|
|
key = self.put("questions/gone.png", png(1600, 900))
|
|
for width in thumbnails.WIDTHS:
|
|
self.assertIsNotNone(thumbnails.get(key, width))
|
|
thumbnails.forget(key)
|
|
for width in thumbnails.WIDTHS:
|
|
self.assertIsNone(storage_service.load(thumbnails.thumb_key(key, width)))
|
|
|
|
def test_a_sideways_photograph_is_turned_the_right_way_up(self):
|
|
# Orientation 6 is "rotate 90° clockwise to view", which is how every
|
|
# phone stores a portrait photograph. A thumbnail made without reading
|
|
# it is a sideways thumbnail.
|
|
buffer = io.BytesIO()
|
|
image = Image.new("RGB", (1200, 600), (10, 20, 30))
|
|
exif = image.getexif()
|
|
exif[274] = 6
|
|
image.save(buffer, format="JPEG", exif=exif)
|
|
key = self.put("questions/sideways.jpg", buffer.getvalue())
|
|
small = Image.open(io.BytesIO(thumbnails.get(key, 256)))
|
|
self.assertEqual(small.width, 256)
|
|
self.assertGreater(small.height, small.width)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|