fix(playlists): split cover decode (400) from persist (500), unique temp, existence re-check (#842)

Two pre-existing cover-upload issues CodeRabbit flagged on #841 (verbatim move,
so correctly not fixed there):

1. One `except Exception as e` wrapped BOTH the PIL decode and the img.save/
   tmp.replace, returned 400 for both, and echoed `e` — so a disk/permission
   failure was mislabeled a client error and could leak a filesystem path. Now:
   decode/validation -> 400 "Invalid image" (generic); save/replace failure ->
   logged 500 "could not save cover" (no detail).
2. A shared `{pid}.png.tmp` let two concurrent uploads clobber each other's temp
   file. Now a unique `tempfile.mkstemp` in the cover dir, atomic replace to
   publish. Plus an existence re-check just before publishing so an upload that
   raced a playlist delete can't leave an orphan cover.

mkstemp itself is INSIDE the try (Codex catch): an unwritable dir / full disk
raises there and is the same persistence failure as save/replace, so it hits the
logged generic-500 path instead of escaping as an unhandled 500. Cleanup guards
`tmp is not None` for the mkstemp-failed case.

Did NOT add a full per-playlist critical section (CodeRabbit's "heavy lift"):
FeedBack is single-user (Principle I), so a cover upload racing a delete on the
same id can't happen — documented in the code rather than building a lock
framework for a precluded race.

tests/test_playlist_cover_errors.py pins all of it; negative-checked three ways:
the old single-except-400 shape fails the 500 + no-leak-400 tests, and moving
mkstemp back outside the try fails the temp-creation-500 test. Fix passes 5/5.
Full suite 2405 passed; boot smoke: valid cover 200, bad image -> generic
"Invalid image" 400, no .tmp litter.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-10 20:16:22 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a883f9213f
commit 6da01c55a4
2 changed files with 141 additions and 4 deletions
+34 -4
View File
@@ -6,6 +6,9 @@ Extracted verbatim from ``server.py`` (R3). Edits: ``@app`` -> ``@router``,
``reqfields``. See ``appstate.py``. ``reqfields``. See ``appstate.py``.
""" """
import logging
import os
import tempfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter from fastapi import APIRouter
@@ -14,6 +17,8 @@ from fastapi.responses import FileResponse, JSONResponse
import appstate import appstate
from reqfields import _clean_str from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter() router = APIRouter()
# Cache policy for the custom-cover file response: revalidate every time so a # Cache policy for the custom-cover file response: revalidate every time so a
@@ -204,15 +209,40 @@ async def api_set_playlist_cover(pid: int, data: dict):
return JSONResponse({"error": "Invalid base64"}, status_code=400) return JSONResponse({"error": "Invalid base64"}, status_code=400)
cover = _playlist_cover_path(pid) cover = _playlist_cover_path(pid)
cover.parent.mkdir(parents=True, exist_ok=True) cover.parent.mkdir(parents=True, exist_ok=True)
# Decode/validate the image — a bad payload is a CLIENT error (400), and the
# message stays generic so it can't echo internals.
try: try:
from PIL import Image from PIL import Image
img = Image.open(io.BytesIO(img_data)).convert("RGB") img = Image.open(io.BytesIO(img_data)).convert("RGB")
img.thumbnail((640, 640)) # covers stay small img.thumbnail((640, 640)) # covers stay small
tmp = cover.with_suffix(".png.tmp") except Exception:
img.save(str(tmp), "PNG") return JSONResponse({"error": "Invalid image"}, status_code=400)
# Persist. A save/replace failure is a SERVER error (500, logged, no
# filesystem detail leaked) — the pre-split handler mislabeled these as 400
# and echoed the exception. A unique temp name in the cover dir (not a shared
# `{pid}.png.tmp`) means two concurrent uploads can't clobber each other's
# temp file; the atomic replace publishes. Re-check the playlist still exists
# just before publishing so a delete that raced the decode above can't leave
# an orphan cover — cheap belt-and-braces; FeedBack is single-user
# (Principle I), so a full per-playlist lock would be for a race the
# deployment model precludes.
tmp = None
try:
# mkstemp is inside the try too: an unwritable dir / full disk raises
# here, and that's the same class of persistence failure as save/replace.
fd, tmp_name = tempfile.mkstemp(prefix=f".{pid}.", suffix=".png.tmp", dir=str(cover.parent))
tmp = Path(tmp_name)
with os.fdopen(fd, "wb") as f:
img.save(f, "PNG")
if appstate.meta_db.get_playlist(pid) is None:
tmp.unlink(missing_ok=True)
return JSONResponse({"error": "not found"}, status_code=404)
tmp.replace(cover) tmp.replace(cover)
except Exception as e: except Exception:
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400) if tmp is not None:
tmp.unlink(missing_ok=True)
log.exception("playlist cover save failed (pid=%s)", pid)
return JSONResponse({"error": "could not save cover"}, status_code=500)
return {"ok": True, "cover_url": _playlist_cover_url(pid)} return {"ok": True, "cover_url": _playlist_cover_url(pid)}
+107
View File
@@ -0,0 +1,107 @@
"""Playlist cover upload: client vs server errors, no temp litter, no leak.
The pre-split handler caught decode AND persistence failures in one `except`,
returned 400 for both, and echoed the exception (`Invalid image: {e}`) — so a
disk/permission failure was mislabeled as a client error and could leak a
filesystem path. These pin the split.
"""
import base64
import io
import pytest
from fastapi.testclient import TestClient
from PIL import Image
import appstate
from metadata_db import MetadataDB
from routers import playlists
@pytest.fixture()
def client(tmp_path):
prev = (appstate.meta_db, appstate.config_dir)
db = MetadataDB(tmp_path)
appstate.configure(meta_db=db, config_dir=tmp_path)
app_ = __import__("fastapi").FastAPI()
app_.include_router(playlists.router)
try:
yield TestClient(app_), tmp_path
finally:
db.conn.close()
appstate.configure(meta_db=prev[0], config_dir=prev[1])
def _png_b64():
buf = io.BytesIO()
Image.new("RGB", (8, 8), (10, 20, 30)).save(buf, "PNG")
return base64.b64encode(buf.getvalue()).decode()
def _make_playlist(client):
return client.post("/api/playlists", json={"name": "P"}).json()["id"]
def test_valid_cover_saves_and_leaves_no_temp(client):
c, tmp_path = client
pid = _make_playlist(c)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert r.status_code == 200
cover_dir = tmp_path / "playlist_covers"
assert (cover_dir / f"{pid}.png").exists()
# The atomic-publish temp file must not linger.
assert not list(cover_dir.glob("*.tmp"))
def test_undecodable_image_is_a_400_without_leaking(client):
c, _ = client
pid = _make_playlist(c)
# valid base64, not a valid image
r = c.post(f"/api/playlists/{pid}/cover", json={"image": base64.b64encode(b"not an image").decode()})
assert r.status_code == 400
body = r.json()["error"]
assert body == "Invalid image" # generic — no exception detail echoed
assert "playlist_covers" not in body # no filesystem path leak
def test_save_failure_is_a_500_not_a_400(client, monkeypatch):
"""A persistence failure (here: Image.save raising) must be a logged 500,
not a 400 — the whole point of the decode/persist split. Negative-checks
against the pre-fix behavior, which returned 400 for exactly this."""
c, tmp_path = client
pid = _make_playlist(c)
payload = _png_b64() # build BEFORE patching save
def boom(self, fp, *a, **k):
raise OSError("disk full")
monkeypatch.setattr(Image.Image, "save", boom)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
assert r.status_code == 500
assert "disk full" not in r.json()["error"] # no internal detail
assert not list((tmp_path / "playlist_covers").glob("*.tmp")) # temp cleaned up
def test_temp_creation_failure_is_a_500(client, monkeypatch):
"""mkstemp raising (unwritable dir / full disk) must hit the same logged 500
path as a save failure, not escape as an unhandled server error."""
import tempfile as _tempfile
c, _ = client
pid = _make_playlist(c)
payload = _png_b64()
def boom(*a, **k):
raise OSError("read-only file system")
monkeypatch.setattr(_tempfile, "mkstemp", boom)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
assert r.status_code == 500
assert "read-only" not in r.json()["error"]
def test_upload_to_missing_playlist_is_404(client):
c, _ = client
r = c.post("/api/playlists/9999/cover", json={"image": _png_b64()})
assert r.status_code == 404