From 9610f3b774c4ec7b291acd622d400971502215e4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 3 Aug 2026 17:11:24 -0400 Subject: [PATCH] Camera uploads stop overwriting each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS names every camera capture "image.jpg"; the replace-to-reshoot semantics (same name = re-extract this photo) then silently destroyed the previous shot — which is how a shelf photo vanished today. Generic capture names (image/photo/img/capture stems) now get minted unique names (shelf-[-n]) server-side, colliding names within one batch uniquify too, and an identical re-send of the same shot dedupes to a no-op. Named photos (IMG_1234.jpeg) keep the deliberate reshoot replacement flow. The upload feedback shows the minted names, so the phone sees exactly what landed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- src/bggpipe/webreview.py | 36 +++++++++++++++++++++++++++++++----- tests/test_webreview.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 66ff70c..1a0dcd0 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -811,24 +811,50 @@ def create_app( def api_job() -> dict: return jobs.snapshot() + # camera captures arrive with generic names (iOS: every photo is + # "image.jpg") — treating those as the deliberate replace-to-reshoot + # flow silently overwrites the previous shot + GENERIC_STEMS = {"image", "photo", "img", "capture"} + + def _minted_name(suffix: str, batch: set[str]) -> str: + stamp = time.strftime("%Y%m%d-%H%M%S") + for n in range(1000): + candidate = f"shelf-{stamp}{f'-{n}' if n else ''}{suffix}" + if candidate not in batch and not (cfg.photos_dir / candidate).exists(): + return candidate + raise HTTPException(500, "couldn't find a free photo name") + @app.post("/api/photos") async def api_photos(files: list[UploadFile]) -> dict: saved = [] + batch: set[str] = set() cfg.photos_dir.mkdir(parents=True, exist_ok=True) for upload_file in files: name = Path(upload_file.filename or "").name # strips any path - if not name or Path(name).suffix.lower() not in PHOTO_SUFFIXES: + suffix = Path(name).suffix.lower() + if not name or suffix not in PHOTO_SUFFIXES: raise HTTPException(400, f"not a photo: {name or '(unnamed)'}") - target = cfg.photos_dir / name data = await upload_file.read() - # a re-uploaded photo means "re-extract this one": the raw cache - # goes FIRST, so a crash can never leave the new photo paired - # with the old photo's extraction + target = cfg.photos_dir / name + if Path(name).stem.casefold() in GENERIC_STEMS or name in batch: + if ( + name not in batch + and target.exists() + and target.read_bytes() == data + ): + saved.append(name) # the same shot, re-sent: no-op + continue + name = _minted_name(suffix, batch) + target = cfg.photos_dir / name + # a re-uploaded NAMED photo means "re-extract this one": the raw + # cache goes FIRST, so a crash can never leave the new photo + # paired with the old photo's extraction stale = cfg.extract_raw_dir / f"{name}.json" if stale.exists(): stale.unlink() atomic_write_bytes(target, data) saved.append(name) + batch.add(name) return {"saved": saved, "photos": len(photo_names())} @app.get("/api/state") diff --git a/tests/test_webreview.py b/tests/test_webreview.py index 70f3f7b..9d270c9 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -1059,3 +1059,35 @@ def test_home_screen_icon_is_served_and_public(tmp_path): assert phone.get("/static/apple-touch-icon.png").status_code == 200 page = phone.get("/?k=sekret", follow_redirects=True) assert 'rel="apple-touch-icon"' in page.text + + +def test_generic_camera_names_never_overwrite(tmp_path): + web, cfg = make_client(tmp_path) + + def send(*photos): + return web.post( + "/api/photos", + files=[("files", (name, data, "image/jpeg")) for name, data in photos], + ) + + # two camera captures in one batch, both "image.jpg" (the iOS shape) + res = send(("image.jpg", b"\xff\xd8first"), ("image.jpg", b"\xff\xd8second")) + assert res.status_code == 200 + saved = res.json()["saved"] + assert len(saved) == len(set(saved)) == 2 + assert all(n.startswith("shelf-") for n in saved) + on_disk = {p.name: p.read_bytes() for p in cfg.photos_dir.glob("shelf-*")} + assert set(on_disk.values()) == {b"\xff\xd8first", b"\xff\xd8second"} + + # a third capture later gets its own name too + res2 = send(("image.jpg", b"\xff\xd8third")) + (third,) = res2.json()["saved"] + assert third.startswith("shelf-") and third not in saved + + # a named photo keeps the deliberate replace-to-reshoot semantics + (cfg.extract_raw_dir).mkdir(parents=True, exist_ok=True) + (cfg.extract_raw_dir / "IMG_9999.jpeg.json").write_text("{}") + res3 = send(("IMG_9999.jpeg", b"\xff\xd8reshoot")) + assert res3.json()["saved"] == ["IMG_9999.jpeg"] + assert (cfg.photos_dir / "IMG_9999.jpeg").read_bytes() == b"\xff\xd8reshoot" + assert not (cfg.extract_raw_dir / "IMG_9999.jpeg.json").exists()