fix: make the thumbnail progress stream POST-only

GET /prompt_manager/images/generate-thumbnails/progress wrote thumbnails
to disk, so any prefetch, link preview or <img src> could start a full
output-directory scan. The route now accepts POST only (GET answers
405); quality still comes from the query string so the SSE client can
keep the same URL shape.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 2dbb21edc2
commit 1c37f0ab56
2 changed files with 28 additions and 14 deletions
+8 -4
View File
@@ -311,7 +311,9 @@ class ImageRoutesMixin:
async def generate_thumbnails_route(request):
return await self.generate_thumbnails(request)
@routes.get("/prompt_manager/images/generate-thumbnails/progress")
# POST only: the stream writes files, so it must not be reachable
# from a prefetch, link preview or <img src>.
@routes.post("/prompt_manager/images/generate-thumbnails/progress")
async def generate_thumbnails_progress_route(request):
return await self.generate_thumbnails_with_progress(request)
@@ -772,10 +774,12 @@ class ImageRoutesMixin:
return {**result, "action": "error", "error": _safe_error(e)}
async def generate_thumbnails_with_progress(self, request):
"""Generate thumbnails with Server-Sent Events progress updates.
"""Generate thumbnails with Server-Sent Events progress updates (POST).
Every blocking step (scan, PIL/ffmpeg) runs in the executor; the
event loop only writes SSE frames between files.
``quality`` is read from the query string so a POST-capable SSE
client can keep using the same URL shape. Every blocking step (scan,
PIL/ffmpeg) runs in the executor; the event loop only writes SSE
frames between files.
"""
thumbnail_size = self._thumbnail_size(request.query.get("quality", "medium"))
response = web.StreamResponse(
+20 -10
View File
@@ -377,14 +377,24 @@ class ThumbnailTestCase(ImageAPITestCase):
class TestThumbnailProgressStream(ThumbnailTestCase):
"""GET /prompt_manager/images/generate-thumbnails/progress (SSE)."""
"""POST /prompt_manager/images/generate-thumbnails/progress (SSE)."""
async def test_get_is_method_not_allowed(self):
# The stream writes thumbnails, so a plain GET (prefetch, link
# preview, <img src>) must not be able to trigger it.
make_png(self.output_dir / "a.png")
resp = await self.client.request("GET", self.PROGRESS_URL)
self.assertEqual(resp.status, 405)
self.assertFalse(self._thumb_path("a").exists())
async def test_three_pngs_yield_three_thumbnails_and_events(self):
for name in ("a", "b", "c"):
make_png(self.output_dir / f"{name}.png", size=(64, 64))
calls = self._spy_executor()
resp = await self.client.request("GET", f"{self.PROGRESS_URL}?quality=low")
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=low")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "text/event-stream")
@@ -407,7 +417,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
(self.output_dir / "bad.png").write_bytes(b"\x89PNG definitely not a png")
make_png(self.output_dir / "good2.png")
resp = await self.client.request("GET", self.PROGRESS_URL)
resp = await self.client.request("POST", self.PROGRESS_URL)
events = parse_sse(await resp.text())
file_errors = [d for e, d in events if e == "file_error"]
@@ -423,24 +433,24 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
async def test_response_has_no_wildcard_cors_header(self):
make_png(self.output_dir / "one.png")
resp = await self.client.request("GET", self.PROGRESS_URL)
resp = await self.client.request("POST", self.PROGRESS_URL)
await resp.text()
self.assertNotIn("Access-Control-Allow-Origin", resp.headers)
async def test_existing_newer_thumbnail_is_skipped(self):
make_png(self.output_dir / "one.png")
first = await self.client.request("GET", self.PROGRESS_URL)
first = await self.client.request("POST", self.PROGRESS_URL)
await first.text()
second = await self.client.request("GET", self.PROGRESS_URL)
second = await self.client.request("POST", self.PROGRESS_URL)
complete = dict(parse_sse(await second.text()))["complete"]
self.assertEqual(complete["skipped"], 1)
self.assertEqual(complete["count"], 0)
async def test_empty_output_dir_completes_with_zero(self):
resp = await self.client.request("GET", self.PROGRESS_URL)
resp = await self.client.request("POST", self.PROGRESS_URL)
events = parse_sse(await resp.text())
self.assertEqual(events[-1][0], "complete")
@@ -449,7 +459,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
async def test_missing_output_dir_sends_error_event(self):
self.api._find_comfyui_output_dir = lambda: None
resp = await self.client.request("GET", self.PROGRESS_URL)
resp = await self.client.request("POST", self.PROGRESS_URL)
events = parse_sse(await resp.text())
self.assertEqual(events[-1][0], "error")
@@ -457,7 +467,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
async def test_unknown_quality_falls_back_to_medium(self):
make_png(self.output_dir / "one.png", size=(400, 400))
resp = await self.client.request("GET", f"{self.PROGRESS_URL}?quality=huge")
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=huge")
await resp.text()
with Image.open(self._thumb_path("one")) as thumb:
@@ -1085,7 +1095,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
async def test_progress_stream_reports_internal_failure(self):
self.api._thumbnail_targets = _raise
resp = await self.client.request(
"GET", "/prompt_manager/images/generate-thumbnails/progress"
"POST", "/prompt_manager/images/generate-thumbnails/progress"
)
events = parse_sse(await resp.text())
self.assertEqual(events[-1][0], "error")