Browse Source

fix(#1112): write uploads to external folders through to the mount

  POST /library/files only rejected the read-only external branch and
  then unconditionally wrote to get_library_files_dir() with a UUID
  filename. The resulting LibraryFile row pointed at the external folder
  via folder_id, so the file showed up in Bambuddy's UI, but the bytes
  physically lived in archive/library/files/ and never touched the mount
  -- invisible from any other machine accessing the NAS/SMB share.

  Writable external uploads now write through to <external_path>/<filename>
  with the original filename preserved, and the DB row matches what scan
  produces (is_external=True, file_path=<absolute mount path>). Collisions
  return 409 instead of silently overwriting; inaccessible or non-writable
  mount returns 400; path-traversal filenames are rejected via resolve +
  relative_to.

  Extract-zip is now rejected against any external folder (not just
  read-only) with a clear "extract on the mount and run Scan" message --
  the nested-subfolder creation path would need mkdir on the mount plus
  matching is_external LibraryFolder rows, which is a separate design.
  Scan already handles that shape.
maziggy 1 month ago
parent
commit
794cb6c6bd

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 77 - 7
backend/app/api/routes/library.py

@@ -130,6 +130,61 @@ def calculate_file_hash(file_path: Path) -> str:
     return sha256_hash.hexdigest()
     return sha256_hash.hexdigest()
 
 
 
 
+def _resolve_upload_destination(target_folder: LibraryFolder | None, filename: str) -> tuple[Path, bool]:
+    """Resolve the on-disk destination for an uploaded file.
+
+    Non-external target: returns ``(<library_files_dir>/<uuid><ext>, False)``.
+    Writable external target: writes to ``<external_path>/<filename>``
+    (preserves the real filename so the file is recognisable on the mount);
+    returns ``(dest, True)``. Raises ``HTTPException`` for read-only external
+    folders (403), missing/inaccessible/non-writable external paths (400), and
+    filename collisions on the external mount (409). See #1112 — previously
+    uploads to writable external folders were silently misrouted to the
+    internal library dir.
+    """
+    if target_folder is not None and target_folder.is_external:
+        if target_folder.external_readonly:
+            raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
+        if not target_folder.external_path:
+            raise HTTPException(status_code=400, detail="External folder has no configured path")
+        ext_dir = Path(target_folder.external_path)
+        if not ext_dir.exists() or not ext_dir.is_dir():
+            raise HTTPException(
+                status_code=400,
+                detail=f"External path is not accessible: {target_folder.external_path}",
+            )
+        if not os.access(ext_dir, os.W_OK):
+            raise HTTPException(
+                status_code=400,
+                detail=f"External path is not writable: {target_folder.external_path}",
+            )
+        # Guard against path-traversal via a pathological filename — join then
+        # verify the resolved destination is still inside the external dir.
+        dest = (ext_dir / filename).resolve()
+        try:
+            dest.relative_to(ext_dir.resolve())
+        except ValueError:
+            raise HTTPException(status_code=400, detail="Invalid filename")
+        if dest.exists():
+            raise HTTPException(
+                status_code=409,
+                detail=f"A file named {filename!r} already exists in the external folder",
+            )
+        return dest, True
+    ext = os.path.splitext(filename)[1].lower()
+    return get_library_files_dir() / f"{uuid.uuid4().hex}{ext}", False
+
+
+def _stored_file_path(abs_path: Path, is_external: bool) -> str:
+    """Produce the value to persist in ``LibraryFile.file_path``.
+
+    External files store the absolute mount path directly (same as scan does),
+    so ``to_absolute_path`` round-trips through its ``is_absolute()`` fast
+    path. Managed files store a path relative to ``base_dir`` for portability.
+    """
+    return str(abs_path) if is_external else to_relative_path(abs_path)
+
+
 def _clean_3mf_metadata(obj):
 def _clean_3mf_metadata(obj):
     """Strip bytes and thumbnail-carrier keys so the payload is JSON-storable.
     """Strip bytes and thumbnail-carrier keys so the payload is JSON-storable.
 
 
@@ -1278,17 +1333,17 @@ async def upload_file(
         file_type = ext[1:] if ext else "unknown"
         file_type = ext[1:] if ext else "unknown"
 
 
         # Verify folder exists if specified
         # Verify folder exists if specified
+        target_folder = None
         if folder_id is not None:
         if folder_id is not None:
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
             folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
             target_folder = folder_result.scalar_one_or_none()
             target_folder = folder_result.scalar_one_or_none()
             if not target_folder:
             if not target_folder:
                 raise HTTPException(status_code=404, detail="Folder not found")
                 raise HTTPException(status_code=404, detail="Folder not found")
-            if target_folder.is_external and target_folder.external_readonly:
-                raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
 
 
-        # Generate unique filename for storage
-        unique_filename = f"{uuid.uuid4().hex}{ext}"
-        file_path = get_library_files_dir() / unique_filename
+        # Writable external folders write through to the mount so the file is
+        # visible outside Bambuddy (#1112); everything else lands under the
+        # internal library dir with a UUID-scoped filename.
+        file_path, is_external_upload = _resolve_upload_destination(target_folder, filename)
 
 
         # Save file
         # Save file
         content = await file.read()
         content = await file.read()
@@ -1366,11 +1421,13 @@ async def upload_file(
             if generate_stl_thumbnails:
             if generate_stl_thumbnails:
                 thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
                 thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
 
-        # Create database entry (store relative paths for portability)
+        # Create database entry (managed files store relative paths for portability;
+        # external files store the absolute mount path — same shape as scan produces)
         library_file = LibraryFile(
         library_file = LibraryFile(
             folder_id=folder_id,
             folder_id=folder_id,
+            is_external=is_external_upload,
             filename=filename,
             filename=filename,
-            file_path=to_relative_path(file_path),
+            file_path=_stored_file_path(file_path, is_external_upload),
             file_type=file_type,
             file_type=file_type,
             file_size=len(content),
             file_size=len(content),
             file_hash=file_hash,
             file_hash=file_hash,
@@ -1430,6 +1487,19 @@ async def extract_zip_file(
             raise HTTPException(status_code=404, detail="Target folder not found")
             raise HTTPException(status_code=404, detail="Target folder not found")
         if target_folder.is_external and target_folder.external_readonly:
         if target_folder.is_external and target_folder.external_readonly:
             raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
             raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
+        if target_folder.is_external:
+            # Writable external folders aren't supported by extract-zip because the
+            # nested-subfolder creation path would need to mkdir on the mount and
+            # create matching is_external=True LibraryFolder rows — a separate
+            # design. Direct the user at Scan, which already handles that shape
+            # (#1112).
+            raise HTTPException(
+                status_code=400,
+                detail=(
+                    "Cannot extract ZIP directly into an external folder. "
+                    "Extract the ZIP on the external mount and run 'Scan External Folder' instead."
+                ),
+            )
 
 
     # Save ZIP to temp file
     # Save ZIP to temp file
     try:
     try:

+ 171 - 0
backend/tests/integration/test_external_folders_api.py

@@ -539,3 +539,174 @@ class TestExternalFolderProtections:
         )
         )
         assert response.status_code == 403
         assert response.status_code == 403
         assert "read-only" in response.json()["detail"].lower()
         assert "read-only" in response.json()["detail"].lower()
+
+
+class TestExternalFolderWritableUpload:
+    """Tests for upload write-through to writable external folders (#1112).
+
+    Before the fix, uploads to writable external folders silently landed in the
+    internal library dir while the DB row pointed at the external folder —
+    files were invisible when the mount was viewed from another machine.
+    """
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        ext_dir = tmp_path / "writable_share"
+        ext_dir.mkdir()
+        return ext_dir
+
+    @pytest.fixture
+    async def writable_folder(self, async_client, db_session, external_dir):
+        data = {
+            "name": "Writable NAS",
+            "external_path": str(external_dir),
+            "readonly": False,
+        }
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_lands_on_external_mount(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """Bytes are written to ``<external_path>/<filename>``, not the internal library dir."""
+        import io
+
+        content = b"hello-external-world"
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("upload.stl", io.BytesIO(content), "application/octet-stream")},
+        )
+        assert response.status_code == 200, response.text
+
+        on_disk = external_dir / "upload.stl"
+        assert on_disk.exists(), "file must be written to the external mount"
+        assert on_disk.read_bytes() == content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_persists_correct_db_shape(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """DB row must have ``is_external=True`` and ``file_path`` = absolute external path,
+        so scan-dedupe and deletion behaviour match scanned files."""
+        import io
+
+        from backend.app.models.library import LibraryFile
+
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("model.3mf", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        assert response.status_code == 200
+        file_id = response.json()["id"]
+
+        row = await db_session.get(LibraryFile, file_id)
+        await db_session.refresh(row)
+        assert row.is_external is True
+        assert row.file_path == str((external_dir / "model.3mf").resolve())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_filename_collision_returns_409(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """Re-uploading a filename that already exists on the mount must 409,
+        not silently overwrite — matches scan's treatment of external files as
+        externally-owned bytes."""
+        import io
+
+        (external_dir / "already.stl").write_bytes(b"prior")
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("already.stl", io.BytesIO(b"new"), "application/octet-stream")},
+        )
+        assert response.status_code == 409
+        assert (external_dir / "already.stl").read_bytes() == b"prior"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_to_missing_external_path_returns_400(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """If the external mount has gone away between folder-create and
+        upload, fail loud rather than silently misroute to internal storage."""
+        import io
+        import shutil
+
+        shutil.rmtree(external_dir)
+
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("x.stl", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        assert response.status_code == 400
+        assert "not accessible" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_path_traversal_filename(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """A malicious filename like ``../escape.stl`` must not write outside
+        the external folder. Defence-in-depth — FastAPI already strips these
+        on parse, but the resolve-and-relative_to guard is the final gate."""
+        import io
+
+        response = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("../escape.stl", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        # Either a 400 from our traversal guard or a 200 with basename-stripped
+        # filename inside the external dir — both prove nothing escaped.
+        if response.status_code == 200:
+            assert not (external_dir.parent / "escape.stl").exists()
+            assert (external_dir / "escape.stl").exists() or (external_dir / "..escape.stl").exists()
+        else:
+            assert response.status_code in (400, 422)
+            assert not (external_dir.parent / "escape.stl").exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zip_to_writable_external_folder_rejected(
+        self, async_client: AsyncClient, db_session, writable_folder
+    ):
+        """Extract-zip into writable external folders isn't supported (nested
+        subfolder creation on the mount is a separate design). Users are
+        pointed at the Scan flow instead."""
+        import io
+        import zipfile
+
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("a/b/c.stl", b"x")
+        buf.seek(0)
+
+        response = await async_client.post(
+            f"/api/v1/library/files/extract-zip?folder_id={writable_folder['id']}",
+            files={"file": ("test.zip", buf, "application/zip")},
+        )
+        assert response.status_code == 400
+        assert "scan" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_external_upload_unchanged(self, async_client: AsyncClient, db_session):
+        """Uploads with no folder_id (root) keep the existing internal-storage behaviour."""
+        import io
+
+        from backend.app.models.library import LibraryFile
+
+        response = await async_client.post(
+            "/api/v1/library/files",
+            files={"file": ("root.stl", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        assert response.status_code == 200
+        file_id = response.json()["id"]
+        row = await db_session.get(LibraryFile, file_id)
+        await db_session.refresh(row)
+        assert row.is_external is False
+        # Internal storage: file_path is UUID-scoped, stored as a relative path.
+        assert not row.file_path.startswith("/")

Some files were not shown because too many files changed in this diff