The heid bug-hunt panel onc2b1454(4/4 arms, five seat-executed probes). The new size rules governed only cache MISSES; the hit path trusted a name and an mtime, inside a directory any fleet session can write into. - A cache hit is a REGULAR file (lstat) carrying its source's EXACT mtime (4/4). A planted directory at the cache path was returned as the thumbnail, and a source replaced by `cp -p` or an archive extract kept an older stamp that `>=` served forever. The encoder now stamps the thumbnail with the source's mtime, so any change to the source is a miss. - The cache directories are made component by component and never through a link (seat P4). A `.thumbs` planted as a link put the cache outside the booth, beyond the sweep. The booth-mtime restore now keys on creating `.thumbs` itself. - The temp file is mkstemp (4/4, seat P5). The old `<out>.<pid>.tmp` was predictable, and a link planted there made the encoder overwrite its target (600 B became 316,400 B). - Palette transparency survives (3/4, seat-executed, and INTRODUCED byc2b1454). The fits-but-heavy branch newly re-encoded palette PNGs, and getbands() of mode P has no A even with tRNS. - EXIF orientation is honoured for sizing and for the saved image (groa, seat-verified). A camera portrait stored sideways was sized and tiled as a landscape. - A 64 MP decode budget (2/4). A header claims any size, and a failure is not cached, so every request re-decoded it. - The cache name carries the whole rule: width, height cap, quality and an encoding version (groa). The width alone would have served stale bytes after a quality change. Declined: the utime-restore failing on a foreign-owned booth (booths are the service user's), and regin's two solos (the THUMB_MAX export is not imported anywhere; the live fixture is function-scoped). thumbs.toml: 14/14 proved.
377 lines
15 KiB
Python
377 lines
15 KiB
Python
"""Thumbnails — the fix for a gallery that shipped 77 MB to render 250px tiles.
|
|
|
|
The operator found this in about a minute of using the live Desk. ROADMAP had
|
|
parked it on "the largest gallery is 66 images", which counted IMAGES and never
|
|
weighed BYTES; 66 is a fine count sitting on a terrible payload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import pathlib
|
|
import sys
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
|
|
|
from booth.app import create_app # noqa: E402
|
|
from booth.items import booth_items # noqa: E402
|
|
from booth.thumbs import ( # noqa: E402
|
|
THUMB_DIR,
|
|
THUMB_HEIGHT_MAX,
|
|
THUMB_LIGHT_BYTES,
|
|
THUMB_WIDTH,
|
|
ensure_thumb,
|
|
thumb_path,
|
|
wants_thumb,
|
|
)
|
|
|
|
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
|
|
|
|
|
|
def _img(path: pathlib.Path, w: int, h: int, fmt="PNG"):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
PIL.new("RGB", (w, h), (120, 30, 90)).save(path, fmt)
|
|
return path
|
|
|
|
|
|
def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path):
|
|
"""The whole point, asserted in BYTES rather than in existence — a thumbnail
|
|
that is not dramatically smaller has not fixed anything."""
|
|
b = tmp_path / "g"
|
|
src = _img(b / "big.png", 1024, 1024)
|
|
out = ensure_thumb(b, "big.png")
|
|
assert out is not None and out.is_file()
|
|
w, h = PIL.open(out).size
|
|
assert w <= THUMB_WIDTH and h <= THUMB_HEIGHT_MAX
|
|
assert out.stat().st_size * 4 < src.stat().st_size, (
|
|
f"thumb {out.stat().st_size}B vs source {src.stat().st_size}B — not worth the cache"
|
|
)
|
|
|
|
|
|
def test_an_already_small_image_gets_no_thumbnail(tmp_path):
|
|
"""Serving the original is correct when it is already tile-sized AND already
|
|
light. A cache entry that saves nothing is pure cost.
|
|
|
|
Defeating change: generating unconditionally."""
|
|
b = tmp_path / "g"
|
|
_img(b / "small.png", 200, 200)
|
|
assert ensure_thumb(b, "small.png") is None
|
|
|
|
|
|
def test_the_cache_lives_inside_the_booth_and_is_invisible(tmp_path):
|
|
"""`.thumbs/` is inside the booth so it is swept with it — a cache that
|
|
outlives what it describes is a leak. And it must not become gallery items
|
|
or zip entries: both skip every dot-prefixed path COMPONENT, which they did
|
|
not do until this module needed them to."""
|
|
import zipfile
|
|
|
|
from booth.app import zip_booth
|
|
|
|
b = tmp_path / "g"
|
|
_img(b / "big.png", 1024, 1024)
|
|
ensure_thumb(b, "big.png")
|
|
assert (b / THUMB_DIR).is_dir(), "the cache is not inside the booth"
|
|
assert [i.rel for i in booth_items(b)] == ["big.png"]
|
|
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["big.png"]
|
|
|
|
|
|
def test_a_damaged_image_costs_its_own_tile_not_the_page(tmp_path):
|
|
"""NEVER RAISES. A thumbnail is an optimisation; a page that will not load
|
|
is worse than one that loads slowly.
|
|
|
|
Defeating change: letting the Pillow exception out."""
|
|
b = tmp_path / "g"
|
|
b.mkdir()
|
|
(b / "lies.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"not an image at all" * 20)
|
|
assert ensure_thumb(b, "lies.png") is None
|
|
|
|
|
|
def test_a_stale_thumbnail_is_rebuilt(tmp_path):
|
|
"""Editing a file in place must not leave the old thumbnail forever."""
|
|
import os
|
|
|
|
b = tmp_path / "g"
|
|
_img(b / "x.png", 1024, 1024)
|
|
first = ensure_thumb(b, "x.png")
|
|
before = first.stat().st_mtime_ns
|
|
_img(b / "x.png", 900, 900)
|
|
os.utime(b / "x.png", None)
|
|
again = ensure_thumb(b, "x.png")
|
|
assert again.stat().st_mtime_ns != before, "the stale thumbnail survived an edit"
|
|
|
|
|
|
def test_only_thumbable_types_are_candidates():
|
|
"""SVG is vector and Pillow cannot read it; a video is not an image."""
|
|
assert wants_thumb("a.png") and wants_thumb("A.JPG") and wants_thumb("a.webp")
|
|
assert not wants_thumb("a.svg") and not wants_thumb("a.webm") and not wants_thumb("a.txt")
|
|
|
|
|
|
def test_the_item_record_carries_the_thumb_url(tmp_path):
|
|
"""INV-1: the resolver decides whether an item has a thumbnail. No template
|
|
appends `?thumb=1` by reasoning about `kind` itself."""
|
|
b = tmp_path / "g"
|
|
_img(b / "big.png", 1024, 1024)
|
|
_img(b / "vec.svg", 10, 10) if False else (b / "vec.svg").write_text("<svg/>")
|
|
by = {i.rel: i for i in booth_items(b)}
|
|
assert by["big.png"].thumb == "big.png?thumb=1"
|
|
assert by["vec.svg"].thumb is None
|
|
|
|
|
|
def test_the_route_serves_the_thumbnail_and_the_original(tmp_path):
|
|
"""?thumb=1 rides the EXISTING file route, so it inherits that route's
|
|
traversal guard rather than growing a second one."""
|
|
b = tmp_path / "g"
|
|
src = _img(b / "big.png", 1024, 1024)
|
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
|
|
|
full = c.get("/b/g/big.png")
|
|
thumb = c.get("/b/g/big.png?thumb=1")
|
|
assert full.status_code == thumb.status_code == 200
|
|
assert len(thumb.content) * 4 < len(full.content), "the route served the full image"
|
|
assert len(full.content) == src.stat().st_size
|
|
|
|
|
|
def test_the_gallery_tile_requests_the_thumbnail(tmp_path):
|
|
"""The operator's actual complaint: the grid pulled full-resolution files."""
|
|
b = tmp_path / "g"
|
|
for n in ("a.png", "b.png"):
|
|
_img(b / n, 1024, 1024)
|
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
|
html = c.get("/b/g/").text
|
|
assert 'src="a.png?thumb=1"' in html, "the tile still asks for the full image"
|
|
|
|
|
|
def test_a_thumb_request_for_a_traversal_path_is_still_refused(tmp_path):
|
|
"""The guard is the file route's, and it must not be weakened by the new
|
|
query parameter."""
|
|
b = tmp_path / "g"
|
|
_img(b / "big.png", 1024, 1024)
|
|
(tmp_path / "secret.txt").write_text("nope")
|
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
|
assert c.get("/b/g/../secret.txt?thumb=1").status_code in (404, 400)
|
|
|
|
|
|
def test_the_filmstrip_and_tray_use_thumbnails_but_the_stage_does_not(tmp_path):
|
|
"""The same 77 MB in a different place. The filmstrip shows EVERY ring item
|
|
at a few dozen pixels, so full-resolution frames there are worse than the
|
|
grid was — while the stage is the full-size review and must stay full size.
|
|
|
|
Defeating change: `x.url` in the filmstrip, or `it.thumb` on the stage."""
|
|
b = tmp_path / "g"
|
|
for n in ("a.png", "b.png", "c.png"):
|
|
_img(b / n, 1024, 1024)
|
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
|
html = c.get("/b/g/view?f=a.png").text
|
|
|
|
assert 'id="vimg" src="a.png"' in html, "the stage must serve the full image"
|
|
assert 'src="b.png?thumb=1"' in html, "the filmstrip still pulls full images"
|
|
assert 'src="b.png"' not in html.replace('src="b.png?thumb=1"', ""), \
|
|
"a full-size frame survived in the strip"
|
|
|
|
|
|
def test_the_desk_preview_strip_uses_thumbnails(tmp_path):
|
|
"""The heaviest surface in the service, on the page he opens FIRST: four
|
|
small images per booth, across every booth. design-dev measured 28
|
|
originals / 24.1 MB on a 12-booth copy; live has 28 booths.
|
|
|
|
Defeating change: `it.url` in the preview tuple."""
|
|
for name in ("one", "two"):
|
|
b = tmp_path / name
|
|
for n in ("a.png", "b.png"):
|
|
_img(b / n, 1024, 1024)
|
|
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
|
|
html = c.get("/").text
|
|
assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images"
|
|
assert 'src="/b/one/a.png"' not in html
|
|
|
|
|
|
# ---- sized for the tile's WIDTH, at 2x density ---------------------------------
|
|
#
|
|
# The operator, on sindra-nude-final: "the images look blurry until they're
|
|
# selected and blown up". The cap was 512 on the LONGEST side, but a tile is sized
|
|
# by its WIDTH, so a 704x1408 portrait got a 256px-wide thumbnail stretched into
|
|
# a 361px tile: 1.4x at 1x density, 2.8x on a 2x screen.
|
|
|
|
|
|
def _noise(path: pathlib.Path, w: int, h: int):
|
|
"""A photographic-weight image: incompressible, so its bytes are realistic.
|
|
A flat colour compresses to almost nothing and would take the light path."""
|
|
import os
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
PIL.frombytes("RGB", (w, h), os.urandom(w * h * 3)).save(path, "PNG")
|
|
return path
|
|
|
|
|
|
def test_a_portrait_keeps_its_full_width(tmp_path):
|
|
"""Defeating change: bounding the longest side, which gave this image 256px
|
|
of width for a tile that shows 361."""
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
out = ensure_thumb(b, "p.png")
|
|
assert out is not None
|
|
assert PIL.open(out).size == (704, 1408)
|
|
|
|
|
|
def test_a_wide_image_is_bounded_by_width(tmp_path):
|
|
b = tmp_path / "g"
|
|
_noise(b / "w.png", 2048, 1024)
|
|
out = ensure_thumb(b, "w.png")
|
|
assert PIL.open(out).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
|
|
|
|
|
|
def test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail(tmp_path):
|
|
"""Fitting the tile in PIXELS is not being light in BYTES: a 704x1408 PNG is
|
|
about a megabyte, and serving it as its own thumbnail would undo the cache.
|
|
Defeating change: skipping every image that fits the bounds."""
|
|
b = tmp_path / "g"
|
|
src = _noise(b / "p.png", 704, 1408)
|
|
assert src.stat().st_size > THUMB_LIGHT_BYTES
|
|
out = ensure_thumb(b, "p.png")
|
|
assert out is not None and out.stat().st_size < src.stat().st_size
|
|
|
|
|
|
def test_an_extremely_tall_image_is_bounded_by_height_too(tmp_path):
|
|
"""Width alone would let a long screenshot through at full height."""
|
|
b = tmp_path / "g"
|
|
_noise(b / "t.png", 300, THUMB_HEIGHT_MAX * 2)
|
|
w, h = PIL.open(ensure_thumb(b, "t.png")).size
|
|
assert h <= THUMB_HEIGHT_MAX and w <= 150
|
|
|
|
|
|
def test_an_animated_gif_that_fits_is_served_as_itself(tmp_path):
|
|
"""A thumbnail is one frame. A heavy GIF that already fits the tile used to
|
|
be served whole (it was under the old cap) and must stay animated.
|
|
Defeating change: dropping the animation guard on the fits-but-heavy path."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
b.mkdir()
|
|
frames = [PIL.frombytes("RGB", (300, 300), os.urandom(300 * 300 * 3)) for _ in range(3)]
|
|
frames[0].save(b / "a.gif", save_all=True, append_images=frames[1:])
|
|
assert (b / "a.gif").stat().st_size > THUMB_LIGHT_BYTES
|
|
assert ensure_thumb(b, "a.gif") is None
|
|
|
|
|
|
def test_a_thumbnail_cut_to_the_old_rule_is_not_served(tmp_path):
|
|
"""The live booths hold 512-cap thumbnails that are NEWER than their
|
|
sources, so the mtime check alone would serve them forever. The size rule
|
|
is in the cache name, so a thumbnail cut to another rule is simply not
|
|
found. Defeating change: an unversioned cache name."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
legacy = b / THUMB_DIR / "p.png.webp"
|
|
legacy.parent.mkdir(parents=True)
|
|
PIL.new("RGB", (256, 512)).save(legacy, "WEBP")
|
|
os.utime(legacy, None)
|
|
out = ensure_thumb(b, "p.png")
|
|
assert out == thumb_path(b, "p.png") and out != legacy
|
|
assert PIL.open(out).size == (704, 1408)
|
|
|
|
|
|
# ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------
|
|
#
|
|
# The cache sits in a directory any fleet session can write into, so every entry
|
|
# on the way to it may be planted. The new size rules only governed cache MISSES;
|
|
# the hit path trusted a name and an mtime.
|
|
|
|
|
|
def test_a_planted_directory_at_the_cache_path_is_not_served(tmp_path):
|
|
"""4/4, seat-executed: a directory at the cache path, with a future mtime,
|
|
was returned AS the thumbnail. Defeating change: a cache hit that checks
|
|
only the mtime."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
out = thumb_path(b, "p.png")
|
|
out.mkdir(parents=True)
|
|
os.utime(out, (2e9, 2e9))
|
|
got = ensure_thumb(b, "p.png")
|
|
assert got is None or got.is_file()
|
|
|
|
|
|
def test_a_source_replaced_with_an_older_mtime_is_rebuilt(tmp_path):
|
|
"""kimi: `cp -p` or an archive extract keeps an OLDER mtime, and a cache
|
|
newer than its source was served forever. The cache now carries its
|
|
source's exact mtime, so any change is a miss. Defeating change: `>=`."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
first = ensure_thumb(b, "p.png")
|
|
assert PIL.open(first).size == (704, 1408)
|
|
_noise(b / "p.png", 1536, 768)
|
|
os.utime(b / "p.png", (1e9, 1e9)) # an older stamp than the cache
|
|
assert PIL.open(ensure_thumb(b, "p.png")).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
|
|
|
|
|
|
def test_a_symlinked_cache_dir_is_never_written_through(tmp_path):
|
|
"""seat P4: `.thumbs` planted as a link to another directory put the cache
|
|
outside the booth, beyond the sweep. Defeating change: `mkdir(parents=True)`,
|
|
which follows an existing link."""
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
elsewhere = tmp_path / "elsewhere"
|
|
elsewhere.mkdir()
|
|
(b / THUMB_DIR).symlink_to(elsewhere)
|
|
assert ensure_thumb(b, "p.png") is None
|
|
assert list(elsewhere.iterdir()) == []
|
|
|
|
|
|
def test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write(tmp_path):
|
|
"""groa, seat P5: the temp name was `<out>.<pid>.tmp`, predictable, so a
|
|
link planted there made the encoder truncate and overwrite its target
|
|
(600 B -> 316,400 B). Defeating change: any predictable temp name."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
victim = tmp_path / "victim.txt"
|
|
victim.write_text("untouched")
|
|
out = thumb_path(b, "p.png")
|
|
out.parent.mkdir(parents=True)
|
|
(out.parent / (out.name + f".{os.getpid()}.tmp")).symlink_to(victim)
|
|
ensure_thumb(b, "p.png")
|
|
assert victim.read_text() == "untouched"
|
|
|
|
|
|
def test_palette_transparency_survives_the_thumbnail(tmp_path):
|
|
"""3/4, seat-executed, and INTRODUCED by this change: the fits-but-heavy
|
|
branch newly re-encodes palette PNGs, and `getbands()` of mode P has no A
|
|
even with a tRNS chunk, so transparency became opaque. Defeating change:
|
|
choosing RGBA by `getbands()` alone."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
b.mkdir()
|
|
im = PIL.frombytes("P", (400, 400), os.urandom(400 * 400))
|
|
im.putpalette(os.urandom(768))
|
|
im.save(b / "p.png", "PNG", transparency=0)
|
|
assert (b / "p.png").stat().st_size > THUMB_LIGHT_BYTES
|
|
t = PIL.open(ensure_thumb(b, "p.png"))
|
|
assert t.mode == "RGBA" and t.getchannel("A").getextrema()[0] == 0
|
|
|
|
|
|
def test_a_camera_portrait_is_sized_and_saved_upright(tmp_path):
|
|
"""groa, seat-verified: EXIF orientation was ignored, so a portrait shot
|
|
stored sideways was sized as a landscape and tiled sideways. Defeating
|
|
change: sizing the raw pixels without `exif_transpose`."""
|
|
import os
|
|
b = tmp_path / "g"
|
|
b.mkdir()
|
|
exif = PIL.Exif()
|
|
exif[0x0112] = 6 # rotate 90 CW to display
|
|
PIL.frombytes("RGB", (1200, 800), os.urandom(1200 * 800 * 3)).save(
|
|
b / "cam.jpg", "JPEG", exif=exif, quality=95)
|
|
assert PIL.open(ensure_thumb(b, "cam.jpg")).size == (THUMB_WIDTH, 1152)
|
|
|
|
|
|
def test_an_image_past_the_pixel_budget_is_never_decoded(tmp_path, monkeypatch):
|
|
"""2/4: the header is free to read and `thumbnail()` then decodes whatever it
|
|
claims, on every request, since a failure is not cached. Over the budget,
|
|
the original is served instead. Defeating change: no budget check."""
|
|
import booth.thumbs as thumbs
|
|
b = tmp_path / "g"
|
|
_noise(b / "p.png", 704, 1408)
|
|
monkeypatch.setattr(thumbs, "THUMB_MAX_PIXELS", 704 * 1408 - 1)
|
|
assert ensure_thumb(b, "p.png") is None
|