From 301c77fbb983e9f99084d9c1e349c180a5ca5cdf Mon Sep 17 00:00:00 2001 From: rangoDJ <18576423+rangoDJ@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:01:59 -0400 Subject: [PATCH] Hide upload temp files and remove abandoned ones Uploads are written to .upload-.part first, and the file list showed those while an upload ran. One left by a container stopped mid-upload stayed for good. Listing now skips them and deletes any untouched for a day. Fixes #58 Co-Authored-By: Claude Opus 5.5 --- root/app/backend/file_manager.py | 12 ++++++++++ tests/test_file_manager.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/root/app/backend/file_manager.py b/root/app/backend/file_manager.py index 569efc8..2e14720 100644 --- a/root/app/backend/file_manager.py +++ b/root/app/backend/file_manager.py @@ -1,5 +1,7 @@ import os +import re import shutil +import time import uuid from pathlib import Path from typing import List, Dict, Any @@ -9,6 +11,12 @@ SHARED_DIR = Path(os.environ.get("SHARED_DIR", "/shared")).resolve() +# Uploads are written to a temp file first (see save_uploaded_file). A running upload +# keeps touching its file; one untouched this long was cut off (container stopped +# mid-upload, so the cleanup never ran) and is removed the next time its folder is listed. +UPLOAD_TEMP_NAME = re.compile(r"^\.upload-[0-9a-f]{32}\.part$") +STALE_UPLOAD_SECONDS = 24 * 60 * 60 + def ensure_shared_dir(): SHARED_DIR.mkdir(parents=True, exist_ok=True) @@ -52,6 +60,10 @@ def list_files(relative_path: str = "") -> List[Dict[str, Any]]: for entry in target.iterdir(): try: stat = entry.stat() + if UPLOAD_TEMP_NAME.match(entry.name): + if entry.is_file() and time.time() - stat.st_mtime > STALE_UPLOAD_SECONDS: + entry.unlink(missing_ok=True) + continue is_dir = entry.is_dir() rel_entry = entry.relative_to(SHARED_DIR).as_posix() items.append({ diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py index 131b408..bdc9650 100644 --- a/tests/test_file_manager.py +++ b/tests/test_file_manager.py @@ -234,3 +234,43 @@ def test_an_existing_file_is_only_replaced_with_overwrite(shared): assert exc.value.status_code == 409 upload("report.txt", b"two", overwrite=True) assert (shared / "report.txt").read_bytes() == b"two" + + +# ----------------- Upload temp files ----------------- + +def temp_name(): + import uuid + return f".upload-{uuid.uuid4().hex}.part" + + +def test_upload_temp_files_are_not_listed(shared): + (shared / temp_name()).write_bytes(b"partial") + (shared / "report.txt").write_text("x") + assert [f["name"] for f in fm.list_files("")] == ["report.txt"] + + +def test_a_stale_upload_temp_file_is_removed(shared): + stale = shared / temp_name() + stale.write_bytes(b"cut off") + old = stale.stat().st_mtime - fm.STALE_UPLOAD_SECONDS - 60 + os.utime(stale, (old, old)) + fm.list_files("") + assert not stale.exists() + + +def test_a_running_upload_temp_file_is_kept(shared): + running = shared / temp_name() + running.write_bytes(b"in progress") + fm.list_files("") + assert running.exists() + + +@pytest.mark.parametrize("name", [".upload-notes.part", "upload-" + "0" * 32 + ".part", ".hidden"]) +def test_files_that_only_look_similar_are_listed(shared, name): + (shared / name).write_text("x") + assert [f["name"] for f in fm.list_files("")] == [name] + + +def test_a_finished_upload_leaves_no_temp_file(shared): + upload("done.txt") + assert [p.name for p in shared.iterdir()] == ["done.txt"]