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.
This commit is contained in:
maziggy
2026-04-24 16:17:06 +02:00
parent 08601b4772
commit 794cb6c6bd
3 changed files with 249 additions and 7 deletions
+1
View File
@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
### Fixed
- **Uploads to writable external folders silently landed in internal storage** ([#1112](https://github.com/maziggy/bambuddy/issues/1112)) — `LibraryFolder` has an `external_readonly` flag, so the model already distinguishes writable from read-only external mounts, but `POST /library/files` rejected only the read-only branch and then unconditionally wrote to `get_library_files_dir()` with a UUID-scoped filename. The resulting `LibraryFile` row linked back to the external folder via `folder_id`, so the file showed up in the Bambuddy UI and could be printed, but the bytes physically lived in `archive/library/files/` and never touched the mount — invisible from any other machine accessing the same NAS/SMB share. New `_resolve_upload_destination()` helper detects writable external targets and writes through to `<external_path>/<filename>` (keeping the original filename so the file is recognisable on the mount), with guards for missing/inaccessible path (400), non-writable mount (400), pre-existing filename on the mount (409 — no silent overwrite; the user is expected to rename and retry, matching how scan treats external files as externally-owned bytes), and a `resolve + relative_to` path-traversal guard on the joined destination. DB row now matches what scan produces: `is_external=True`, `file_path=<absolute external path>`, so the existing download / delete / dedupe paths work unchanged (`to_absolute_path` already fast-paths `is_absolute()` inputs, and external-file deletion already bypasses trash and only drops the DB row + internal thumbnail). `POST /library/files/extract-zip` is now rejected against *any* external folder (not just read-only) with a clear "extract the ZIP on the external mount and run Scan" message — the nested-subfolder creation path would need to `mkdir` on the mount and create matching `is_external=True` `LibraryFolder` rows, which is a separate design round, and the Scan flow already handles that shape. 7 new integration tests cover: bytes land on the mount; DB row has `is_external=True` + absolute `file_path`; filename collision → 409 with prior bytes preserved; vanished external path → 400; path-traversal filename never escapes the external dir; extract-zip into writable external rejected with the Scan hint; root uploads unchanged.
- **Queue item stuck at "printing" when print failed before reaching RUNNING** ([#1111](https://github.com/maziggy/bambuddy/issues/1111)) — Dispatching a file sliced for the wrong nozzle size (or any other pre-print error: AMS fault, wrong plate, nozzle not installed, etc.) left the queue item stuck at `status="printing"` forever, blocking every subsequent pending item for that printer (`check_queue` seeds `busy_printers` from any row in `'printing'` state and skips further dispatches for those printer IDs). Completion detection in `BambuMQTTClient._process_message` required the print to have reached `RUNNING` — either via `_previous_gcode_state == "RUNNING"` or the `_was_running` fallback — but a nozzle-mismatch failure transitions the printer `IDLE → PREPARE → FAILED` without ever entering `RUNNING`, so neither branch matched and `on_print_complete` never fired. The diagnostic log line at `bambu_mqtt.py:2690` ("State is FAILED but completion NOT triggered: prev=PREPARE, was_running=False") confirmed the path. Completion now also fires on `FAILED` from a pre-print state (`PREPARE` or `SLICING`) — restricted to those two so a stale `FAILED` on first connection (prev=None) still can't accidentally advance an unrelated queue item. Additionally, when a queue item transitions to `failed` the handler in `main.py` now populates `error_message` from the printer's current HMS error list, rendered via the existing `backend/app/services/hms_errors.py` lookup table (e.g. `[0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.`) — previously `error_message` was left `NULL`, so users saw "failed" with no hint at the cause. 5 new unit tests in `TestPrePrintFailureCompletion` cover PREPARE→FAILED and SLICING→FAILED firing, IDLE→FAILED and initial-FAILED *not* firing (boot-time safety), and HMS errors being passed through in the callback payload; 6 new tests in `test_hms_error_summary.py` cover the error-message formatter (known-code lookup, unknown-code fallback, multi-error join, malformed-entry tolerance, all-malformed → None, empty → None). Thanks to @MartinNYHC for the report.
- **Tailscale cert-renewal restart silently failed mid-way** (follow-up to [#1070](https://github.com/maziggy/bambuddy/issues/1070)) — The daily renewal path creates an `asyncio.Task` to restart VP services with the new cert. Inside that task, `stop_server()` / `stop_proxy()` call `_cancel_restart_task()`, which cancelled+awaited the currently-running task (itself). The self-await raised `RuntimeError`, got caught by the broad exception handler, but the cancel flag was still set — so the next `await` in `stop_server` raised `CancelledError` and aborted the restart partway through. The VP kept running the OLD expired cert until the process was manually restarted, silently defeating the feature. `_cancel_restart_task` now checks `asyncio.current_task()` and skips the cancel+await when the caller IS the restart task itself. Two new regression tests cover the self-cancel and outside-cancel paths.
- **Settings table filled with duplicate rows on legacy SQLite installs** — pre-UNIQUE-constraint databases stored the `settings.key` column without a unique index, so the seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT and every `systemctl restart bambuddy` added another row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful of restarts, `scalar_one_or_none()` in `is_advanced_auth_enabled` and similar sites blew up with `MultipleResultsFound`, 500'ing the login flow. `run_migrations` now dedupes (keeps MIN(id) per key) and creates the missing `ix_settings_key` unique index before the seed loop runs. Postgres installs were unaffected. 4 new regression tests cover legacy-with-dupes, legacy-already-clean (idempotent), and fresh-install (no-op) paths.
+77 -7
View File
@@ -130,6 +130,61 @@ def calculate_file_hash(file_path: Path) -> str:
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):
"""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"
# Verify folder exists if specified
target_folder = None
if folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
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
content = await file.read()
@@ -1366,11 +1421,13 @@ async def upload_file(
if generate_stl_thumbnails:
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(
folder_id=folder_id,
is_external=is_external_upload,
filename=filename,
file_path=to_relative_path(file_path),
file_path=_stored_file_path(file_path, is_external_upload),
file_type=file_type,
file_size=len(content),
file_hash=file_hash,
@@ -1430,6 +1487,19 @@ async def extract_zip_file(
raise HTTPException(status_code=404, detail="Target folder not found")
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")
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
try:
@@ -539,3 +539,174 @@ class TestExternalFolderProtections:
)
assert response.status_code == 403
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("/")