File Manager: external link, notes and photos on library files (#3128)

This commit is contained in:
Thomansky
2026-09-26 08:56:48 +02:00
committed by GitHub
parent 222f28c7f8
commit 12dddada0a
49 changed files with 4817 additions and 57 deletions
+264
View File
@@ -49,6 +49,7 @@ from backend.app.schemas.library import (
BatchThumbnailResult,
BulkDeleteRequest,
BulkDeleteResponse,
ClientThumbnailResponse,
ExternalFolderCreate,
FileDuplicate,
FileListResponse,
@@ -90,6 +91,7 @@ from backend.app.utils.filename import (
safe_path_component,
validate_print_filename,
)
from backend.app.utils.library_paths import library_photos_dir, remove_library_photos_dir
from backend.app.utils.printer_models import is_gcode_compatible
from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
from backend.app.utils.threemf_tools import (
@@ -808,6 +810,29 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
# Supported image extensions for thumbnails
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
# File types whose thumbnails are rendered client-side and uploaded back
# (#2976). The server has no renderer for these formats — STEP would need
# OpenCascade, PDF a rasteriser — so the browser posts its first preview
# render to POST /files/{id}/preview-thumbnail instead. Kept to exactly
# these types so the endpoint can never overwrite a server-generated
# STL/3MF/G-code/image thumbnail.
CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
# Photos of the printed result (#3077): same allowlist and naming as the
# archive photo routes. 10 MB is ample for a phone camera JPEG.
PHOTO_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
PHOTO_MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
MAX_PHOTO_BYTES = 10 * 1024 * 1024
# Upper bound for an uploaded client-rendered thumbnail. The FE sends a
# 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
"""Generate STL thumbnails for an external folder tree in the background.
@@ -1494,6 +1519,8 @@ async def delete_folder(
await delete_dependent_variants(db, doomed_file_ids)
await release_queue_references(db, doomed_file_ids)
for doomed_id in doomed_file_ids:
remove_library_photos_dir(doomed_id)
# Delete folder (cascade will handle files and subfolders)
await db.delete(folder)
@@ -1588,6 +1615,12 @@ _SCANNABLE_EXTENSIONS = {
".webp",
".svg",
".md",
# Documents that ship alongside a job folder and now have in-app
# previews (#2976): drawings/datasheets and part lists.
".pdf",
".csv",
".xlsx",
".ods",
}
@@ -2009,6 +2042,10 @@ async def scan_external_folder(
abs_thumb.unlink()
except OSError:
pass
# The row is gone for good — external files skip the trash — so
# its photos go with it rather than being orphaned under an id
# nothing points at any more (#3077).
remove_library_photos_dir(db_file.id)
await db.delete(db_file)
removed += 1
@@ -2229,6 +2266,9 @@ async def list_files(
tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
variant_group_id=f.variant_group_id,
variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
external_url=f.external_url,
has_notes=bool(f.notes),
photo_count=len(f.photos or []),
)
)
@@ -5058,6 +5098,9 @@ async def get_file(
print_count=file.print_count,
last_printed_at=file.last_printed_at,
notes=file.notes,
external_url=file.external_url,
photos=list(file.photos or []),
source_url=file.source_url,
duplicates=duplicates if duplicates else None,
duplicate_count=duplicate_count,
created_by_id=file.created_by_id,
@@ -5132,6 +5175,9 @@ async def update_file(
if data.notes is not None:
file.notes = data.notes if data.notes else None
if data.external_url is not None:
file.external_url = data.external_url.strip() or None
await db.commit()
await db.refresh(file)
@@ -5186,6 +5232,7 @@ async def delete_file(
await delete_dependent_variants(db, [file.id])
await release_queue_references(db, [file.id])
remove_library_photos_dir(file.id)
await db.delete(file)
await db.commit()
return {"status": "success", "message": "File deleted", "trashed": False}
@@ -5328,6 +5375,218 @@ async def get_thumbnail(
return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
@router.post("/files/{file_id}/preview-thumbnail", response_model=ClientThumbnailResponse)
async def upload_preview_thumbnail(
file_id: int,
thumbnail: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
):
"""Store a client-rendered preview thumbnail for a file (#2976).
STEP, PDF and spreadsheet previews are rendered in the browser; the FE
posts its first render here so the grid gets a thumbnail without the
server needing OpenCascade or a PDF rasteriser. Only file types in
``CLIENT_THUMBNAIL_TYPES`` are accepted, and only while the file has no
thumbnail yet — a stored thumbnail is never replaced by this route.
"""
user, can_modify_all = auth_result
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
file = result.scalar_one_or_none()
if not file:
raise HTTPException(status_code=404, detail="File not found")
# Ownership check (same shape as update_file)
if not can_modify_all:
if file.created_by_id != user.id:
raise HTTPException(status_code=403, detail="You can only update your own files")
if file.file_type not in CLIENT_THUMBNAIL_TYPES:
raise HTTPException(status_code=400, detail="File type does not accept client-rendered thumbnails")
if file.thumbnail_path:
return ClientThumbnailResponse(updated=False)
content = await thumbnail.read(MAX_CLIENT_THUMBNAIL_BYTES + 1)
if len(content) > MAX_CLIENT_THUMBNAIL_BYTES:
raise HTTPException(status_code=413, detail="Thumbnail too large")
# Decode and re-encode through PIL: validates the bytes are a real PNG
# and strips anything that isn't pixel data before it lands on disk.
import io
from PIL import Image, UnidentifiedImageError
try:
with Image.open(io.BytesIO(content)) as img:
img.load()
if img.format != "PNG":
raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
# The grid renders at ~256px; cap outliers instead of storing them.
if img.width > 512 or img.height > 512:
img.thumbnail((512, 512), Image.Resampling.LANCZOS)
thumbnails_dir = get_library_thumbnails_dir()
thumb_filename = f"{uuid.uuid4().hex}.png"
thumb_path = thumbnails_dir / thumb_filename # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
img.save(thumb_path, "PNG", optimize=True)
except HTTPException:
raise
except (UnidentifiedImageError, OSError, ValueError) as e:
raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
file.thumbnail_path = to_relative_path(thumb_path)
await db.commit()
return ClientThumbnailResponse(updated=True)
# ============ Photo Endpoints (#3077) ============
@router.post("/files/{file_id}/photos")
async def upload_file_photo(
file_id: int,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
):
"""Attach a photo of the printed result to a library file.
Photos are Bambuddy-side metadata, so external files take them too. Same
shape as the archive photo upload: extension allowlist, uuid-named on
disk, and the ``photos`` list re-assigned so SQLAlchemy sees the change.
"""
user, can_modify_all = auth_result
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
library_file = result.scalar_one_or_none()
if not library_file:
raise HTTPException(status_code=404, detail="File not found")
# Ownership check (same shape as update_file)
if not can_modify_all:
if library_file.created_by_id != user.id:
raise HTTPException(status_code=403, detail="You can only update your own files")
if not file.filename or not file.filename.lower().endswith(PHOTO_EXTENSIONS):
raise HTTPException(status_code=400, detail="File must be an image (.jpg, .jpeg, .png, .webp)")
content = await file.read(MAX_PHOTO_BYTES + 1)
if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail="Photo too large (max 10 MB)")
photos_dir = library_photos_dir(library_file.id)
photos_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix.lower()
photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
photo_path = photos_dir / photo_filename # SEC-PATH-OK: photo_filename = uuid.uuid4().hex[:8] + ext
photo_path.write_bytes(content)
photos = list(library_file.photos or [])
photos.append(photo_filename)
library_file.photos = photos
await db.commit()
await db.refresh(library_file)
return {"status": "uploaded", "filename": photo_filename, "photos": library_file.photos}
@router.get("/files/{file_id}/photos/{filename}")
async def get_file_photo(
file_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_media_token_ownership(
Permission.LIBRARY_READ_ALL,
Permission.LIBRARY_READ_OWN,
)
),
):
"""Serve one photo. Media-token auth like the thumbnail route (#3025)."""
user, can_read_all = auth_result
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
library_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
# Membership check first: names are uuid-generated on upload, so anything
# not in the stored list is not a photo, whatever is on disk.
if not library_file.photos or filename not in library_file.photos:
raise HTTPException(status_code=404, detail="Photo not found")
try:
photo_path = safe_join_under(library_photos_dir(library_file.id), filename, http=False)
except PathTraversalError:
raise HTTPException(status_code=404, detail="Photo not found") from None
if not photo_path.is_file():
raise HTTPException(status_code=404, detail="Photo not found")
media_type = PHOTO_MEDIA_TYPES.get(Path(filename).suffix.lower(), "image/jpeg")
return FastAPIFileResponse(str(photo_path), media_type=media_type)
@router.delete("/files/{file_id}/photos/{filename}")
async def delete_file_photo(
file_id: int,
filename: str,
db: AsyncSession = Depends(get_db),
auth_result: tuple[User | None, bool] = Depends(
require_ownership_permission(
Permission.LIBRARY_UPDATE_ALL,
Permission.LIBRARY_UPDATE_OWN,
)
),
):
"""Remove a photo from a library file."""
user, can_modify_all = auth_result
result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
library_file = result.scalar_one_or_none()
if not library_file:
raise HTTPException(status_code=404, detail="File not found")
if not can_modify_all:
if library_file.created_by_id != user.id:
raise HTTPException(status_code=403, detail="You can only update your own files")
if not library_file.photos or filename not in library_file.photos:
raise HTTPException(status_code=404, detail="Photo not found")
try:
photo_path = safe_join_under(library_photos_dir(library_file.id), filename, http=False)
except PathTraversalError:
raise HTTPException(status_code=404, detail="Photo not found") from None
if photo_path.is_file():
try:
photo_path.unlink()
except OSError as e:
logger.warning("Failed to delete photo from disk: %s", e)
photos = [p for p in library_file.photos if p != filename]
library_file.photos = photos if photos else None
await db.commit()
return {"status": "deleted", "photos": library_file.photos or []}
@router.get("/files/{file_id}/gcode")
async def get_gcode(
file_id: int,
@@ -5558,6 +5817,7 @@ async def bulk_delete(
await delete_dependent_variants(db, hard_deleted_ids)
await release_queue_references(db, hard_deleted_ids)
for file in hard_deleted:
remove_library_photos_dir(file.id)
await db.delete(file)
# Delete folders (cascade will handle contents). Folders have no ownership
@@ -5580,6 +5840,10 @@ async def bulk_delete(
tree_file_ids = await _folder_tree_file_ids(db, folder_id)
await delete_dependent_variants(db, tree_file_ids)
await release_queue_references(db, tree_file_ids)
# The cascade hard-deletes every row in the subtree, so their
# photos go with them — same as DELETE /folders/{id} (#3077).
for doomed_id in tree_file_ids:
remove_library_photos_dir(doomed_id)
await db.delete(folder)
deleted_folders += 1
+11
View File
@@ -50,6 +50,7 @@ from backend.app.services.email_service import (
send_email,
)
from backend.app.services.finance_defaults import ensure_user_finance_defaults
from backend.app.utils.library_paths import remove_library_photos_dir
router = APIRouter(prefix="/users", tags=["users"])
@@ -444,7 +445,14 @@ async def delete_user(
detail="Cannot delete your own account",
)
# Photo directories of the library rows about to go, resolved while the
# rows still exist to say which ids they belong to. Removed after the
# commit, so a failed delete leaves the pictures alone (#3077).
doomed_library_file_ids: list[int] = []
if delete_items:
doomed_library_file_ids = list(
(await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
)
# Delete all items created by this user
await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
@@ -494,6 +502,9 @@ async def delete_user(
await db.delete(user)
await db.commit()
for file_id in doomed_library_file_ids:
remove_library_photos_dir(file_id)
@router.post("/me/change-password", response_model=dict)
async def change_own_password(
+6
View File
@@ -4955,6 +4955,12 @@ async def run_migrations(conn):
conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
)
# Migration: user link + photos on library files (#3077), the same trio
# print_archives carries (external_url / photos). Photos are stored under
# <archive_dir>/library/photos/<file_id>/ — the column only holds the names.
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN external_url VARCHAR(500)")
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN photos JSON")
# Migration: storage location sensor alerts (#2824), own column rather than
# reusing on_ha_sensor_alert. That column can be scoped to one printer
# (printer_id), and a location alert has no printer to scope by — sharing
+30 -1
View File
@@ -4,6 +4,7 @@ import logging
import math
import os
import posixpath
import re
import secrets
import time
from contextlib import asynccontextmanager
@@ -9609,6 +9610,12 @@ def _frame_ancestors(default_value: str) -> str:
return f"frame-ancestors {default_value};"
# The Vite-emitted STEP preview worker chunk (#2976): src/workers/
# stepPreview.worker.ts becomes /assets/stepPreview.worker-<hash>.js. Matched
# exactly so the eval-relaxed CSP below can never apply to any other asset.
_STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
@app.middleware("http")
async def security_headers_middleware(request, call_next):
"""Add standard HTTP security headers to every response."""
@@ -9655,6 +9662,23 @@ async def security_headers_middleware(request, call_next):
"object-src 'none'; "
"base-uri 'self'; " + _frame_ancestors("'none'")
)
elif _STEP_WORKER_ASSET_RE.match(request.url.path):
# The STEP preview worker (#2976) runs OpenCascade compiled to WASM;
# its emscripten/embind glue generates invoker functions with `new
# Function(...)`, which needs 'unsafe-eval'. Per CSP3 a dedicated
# worker is governed by the policy delivered with the WORKER SCRIPT's
# own response — not the document's — so relaxing it here confines
# eval to that DOM-less worker context. The document policy below
# stays nonce-strict, and this response header has no effect when the
# file is merely fetched (a fetch's CSP is enforced against the
# requesting document, not the resource's own headers).
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; "
"connect-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; " + _frame_ancestors("'none'")
)
else:
# The streaming overlay is embedded same-origin by the URL builder's
# preview in Settings (#1422), so this branch allows 'self'.
@@ -9668,9 +9692,14 @@ async def security_headers_middleware(request, call_next):
# TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that
# allowlist in.
embeddable_same_origin = request.url.path.startswith("/overlay/")
# 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
# not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
# the STEP preview, which triangulates in the browser via OpenCascade
# compiled to WASM (#2976). Browsers that predate the keyword ignore
# it and simply keep blocking wasm, so this never widens JS execution.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{csp_nonce}'; "
f"script-src 'self' 'wasm-unsafe-eval' 'nonce-{csp_nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
+6
View File
@@ -147,6 +147,12 @@ class LibraryFile(Base):
# User notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# User-provided link (Printables, Thingiverse, ...) and photos of the printed
# result (#3077) — the same trio archives carry. ``photos`` is a list of
# stored filenames under ``library_paths.library_photos_dir(id)``.
external_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
photos: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Provenance — when the file was imported from an external source (e.g.
# MakerWorld), ``source_type`` identifies the source and ``source_url`` is
# the canonical public URL. Used for "already imported" detection and
+36 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# ============ Folder Schemas ============
@@ -122,6 +122,20 @@ class FileUpdate(BaseModel):
folder_id: int | None = None
project_id: int | None = None
notes: str | None = None
# Empty string clears the link, like ``notes`` (#3077).
external_url: str | None = Field(None, max_length=500)
@field_validator("external_url")
@classmethod
def validate_external_url(cls, v: str | None) -> str | None:
# The link is rendered as an href for every reader of the library, so
# only web URLs are accepted (no javascript:/data: schemes).
if v is None:
return None
v = v.strip()
if v and not v.lower().startswith(("http://", "https://")):
raise ValueError("external_url must start with http:// or https://")
return v
class FileDuplicate(BaseModel):
@@ -157,6 +171,11 @@ class FileResponse(BaseModel):
last_printed_at: datetime | None
notes: str | None
# User link + photos of the printed result (#3077); ``source_url`` is the
# read-only import provenance (MakerWorld) shown next to it.
external_url: str | None = None
photos: list[str] = []
source_url: str | None = None
# Duplicate detection
duplicates: list[FileDuplicate] | None = None
@@ -227,6 +246,12 @@ class FileListResponse(BaseModel):
variant_group_id: int | None = None
variant_count: int = 0
# Metadata indicators (#3077). The list never ships the notes text itself —
# ``has_notes`` is enough for the card badge; the details modal loads the rest.
external_url: str | None = None
has_notes: bool = False
photo_count: int = 0
class Config:
from_attributes = True
@@ -413,6 +438,16 @@ class BatchThumbnailResponse(BaseModel):
results: list[BatchThumbnailResult]
class ClientThumbnailResponse(BaseModel):
"""Schema for the client-rendered preview thumbnail upload response (#2976).
``updated`` is false when the file already had a thumbnail — the upload is
skipped so a stored thumbnail is never silently replaced.
"""
updated: bool
# ============ Variant Group Schemas (#671 / #2570) ============
+3 -1
View File
@@ -29,6 +29,7 @@ from backend.app.core.database import async_session
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
from backend.app.models.settings import Settings
from backend.app.utils.library_paths import remove_library_photos_dir
from backend.app.utils.local_time import utcnow_naive
logger = logging.getLogger(__name__)
@@ -364,7 +365,7 @@ class LibraryTrashService:
@staticmethod
def _unlink_on_disk(row: LibraryFile) -> None:
"""Best-effort cleanup of the file + thumbnail on disk."""
"""Best-effort cleanup of the file, thumbnail and photos (#3077) on disk."""
for rel in (row.file_path, row.thumbnail_path):
abs_path = _to_absolute_path(rel)
if abs_path is None:
@@ -374,6 +375,7 @@ class LibraryTrashService:
abs_path.unlink()
except OSError as e:
logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
remove_library_photos_dir(row.id)
# ---- User-facing trash ops ----------------------------------------
+71
View File
@@ -60,9 +60,11 @@ from backend.app.services.printer_manager import (
)
from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.utils.ams_humidity import ams_humidity_percent
from backend.app.utils.archive_paths import archive_photos_dir
from backend.app.utils.color_utils import perceptual_color_distance
from backend.app.utils.filament_types import canonical_filament_type
from backend.app.utils.filename import derive_remote_filename
from backend.app.utils.library_paths import move_library_photos
from backend.app.utils.local_time import utcnow_naive
from backend.app.utils.printer_models import (
is_dual_nozzle_model,
@@ -6351,6 +6353,10 @@ class PrintScheduler:
file_path = None
filename = None
cleanup_disk_paths: list[Path] = []
# Set when a dispatch consumes its library file, so the photos can be
# carried over after the commit that removes the row (#3077).
consumed_library_file_id: int | None = None
consumed_photos: list[str] = []
if item.archive_id:
# Print from archive
@@ -6452,6 +6458,9 @@ class PrintScheduler:
archive_id=archive.id,
dispatched_item_id=item.id,
)
# Read while the row is still here; the photos move
# below, once the delete has actually committed.
consumed_photos = list(library_file.photos or [])
await db.delete(library_file)
file_path = settings.base_dir / archive.file_path
filename = archive.filename
@@ -6493,6 +6502,68 @@ class PrintScheduler:
await self._power_off_if_needed(db, item)
return
# The photos follow the file into the archive that replaces it, for
# the same reason the siblings do (#3077). After the commit above,
# never before it: that commit can fail ("database is locked",
# #1853) and roll the library row back, and photos already moved
# would leave it naming a directory that no longer exists. The
# file and thumbnail unlinks are deferred for the same reason.
if consumed_library_file_id is not None and consumed_photos:
# Held as a plain int, read here while the session is still
# healthy, because the handler below may not touch an ORM
# instance at all. The commit it exists for fails inside the
# FLUSH, not at COMMIT: SQLite takes the write lock at the
# first DML statement, so a busy writer surfaces as "database
# is locked" on the UPDATE (#1853). SQLAlchemy rolls that back
# internally through safe_reraise before re-raising, which
# expires every loaded instance and leaves the session in
# pending-rollback state -- so `archive.id` inside the except
# would itself raise PendingRollbackError and the rollback
# below would never be reached.
archive_id = archive.id
try:
carried_photos = move_library_photos(
consumed_library_file_id,
consumed_photos,
archive_photos_dir(archive),
)
if carried_photos:
archive.photos = list(archive.photos or []) + carried_photos
await db.commit()
except Exception as e:
# The archive and the delete are already committed; the
# print goes ahead either way. Worst case the pictures sit
# unnamed in the archive's own directory.
#
# Ints only until the rollback has run, per the note above,
# which is why this logs queue_item_id and not item.id --
# the sibling handler forty lines up does the same.
logger.warning(
"Queue item %s: failed to carry library photos into archive %s: %s",
queue_item_id,
archive_id,
e,
)
await db.rollback()
# rollback() expires every loaded instance, and in async
# SQLAlchemy the next plain attribute read is lazy IO
# outside the greenlet -- MissingGreenlet, which would turn
# this cosmetic failure into a dispatch crash in exactly the
# "database is locked" case the block exists for (#1853).
# The nozzle guard, the upload and the start all keep
# reading item, archive and printer, so all three go back
# into the session before falling through.
item = await db.get(PrintQueueItem, queue_item_id)
archive = await db.get(PrintArchive, archive_id)
printer = await db.get(Printer, item.printer_id) if item else None
if not item or not archive or not printer:
logger.error(
"Queue item %s: item, archive %s or printer gone after the photo rollback",
queue_item_id,
archive_id,
)
return
else:
# Neither archive nor library file specified
item.status = "failed"
+88
View File
@@ -0,0 +1,88 @@
"""Where a library file's user photos live on disk (#3077).
Photos are Bambuddy-side metadata, so they sit inside the library data dir
regardless of whether the file itself is managed or external:
``<archive_dir>/library/photos/<file_id>/``. The routes, the trash sweeper,
the external-folder scan and the dispatch cleanup all derive the directory
from here — see ``archive_paths`` for why one path derived in several places
is a bug waiting to happen.
"""
from __future__ import annotations
import logging
import shutil
import uuid
from collections.abc import Sequence
from pathlib import Path
from backend.app.core.config import settings
from backend.app.utils.safe_path import PathTraversalError, safe_join_under
logger = logging.getLogger(__name__)
def library_photos_dir(file_id: int) -> Path:
"""The photo directory for library file *file_id* (not created)."""
library_dir = Path(settings.archive_dir) / "library"
return library_dir / "photos" / str(file_id) # SEC-PATH-OK: file_id is an int primary key
def remove_library_photos_dir(file_id: int) -> None:
"""Best-effort removal of a file's photo directory and everything in it."""
photos_dir = library_photos_dir(file_id)
if not photos_dir.is_dir():
return
try:
shutil.rmtree(photos_dir)
except OSError as e:
logger.warning("Failed to remove library photos dir %s: %s", photos_dir, e)
def move_library_photos(file_id: int, photos: Sequence[str], destination: Path) -> list[str]:
"""Move a library file's photos into *destination*, emptying its directory.
Used where a library row is consumed by the archive that replaces it
(``cleanup_library_after_dispatch``): the photos follow the file instead
of being orphaned under an id nothing points at any more. Returns the
names the photos ended up under, in order — a name already taken in
*destination* gets a fresh one, because both sides draw photo names from
the same 8-hex-digit alphabet.
Best-effort: a photo that cannot be moved is left out of the returned
list, so it is never named by an archive that does not have it. The
caller is mid-dispatch and has nowhere to report to. The source
directory is only removed once everything in the list did move, so a
failure orphans the pictures rather than destroying them.
"""
source_dir = library_photos_dir(file_id)
if not source_dir.is_dir():
return []
moved: list[str] = []
failed = False
for filename in photos:
try:
source = safe_join_under(source_dir, filename, http=False)
except PathTraversalError:
failed = True
continue
if not source.is_file():
continue
target_name = filename
try:
destination.mkdir(parents=True, exist_ok=True)
target = safe_join_under(destination, target_name, http=False)
if target.exists():
target_name = f"{uuid.uuid4().hex[:8]}{source.suffix.lower()}"
target = destination / target_name # SEC-PATH-OK: uuid.uuid4().hex[:8] + suffix
shutil.move(str(source), str(target))
except (OSError, PathTraversalError) as e:
logger.warning("Failed to move library photo %s to %s: %s", source, destination, e)
failed = True
continue
moved.append(target_name)
if failed:
logger.warning("Kept library photos dir %s: not every photo reached %s", source_dir, destination)
else:
remove_library_photos_dir(file_id)
return moved
@@ -0,0 +1,436 @@
"""Integration tests for library file notes, external link and photos (#3077).
Pins the contracts of the details modal's backend: the PUT round-trip for
``external_url`` (empty string clears), the list-view indicators
(``has_notes`` / ``photo_count``), and the photo routes — membership check
before any disk access, extension allowlist, size cap, and the photo
directory going away with the file.
"""
import io
import pytest
from httpx import AsyncClient
from PIL import Image
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
from backend.app.models.user import User
from backend.app.utils.library_paths import library_photos_dir
def _jpeg_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (32, 32), "red").save(buf, "JPEG")
return buf.getvalue()
@pytest.fixture
def isolated_storage(monkeypatch, tmp_path):
"""Point library storage at a throwaway directory."""
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@pytest.fixture
async def file_factory(db_session):
"""Factory for LibraryFile rows with sensible defaults."""
_counter = [0]
async def _create_file(**kwargs):
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"part{counter}.3mf",
"file_path": f"library/files/part{counter}.3mf",
"file_type": "3mf",
"file_size": 100,
}
defaults.update(kwargs)
library_file = LibraryFile(**defaults)
db_session.add(library_file)
await db_session.commit()
await db_session.refresh(library_file)
return library_file
return _create_file
async def _upload(async_client: AsyncClient, file_id: int, name: str = "result.jpg", content: bytes | None = None):
return await async_client.post(
f"/api/v1/library/files/{file_id}/photos",
files={"file": (name, content if content is not None else _jpeg_bytes(), "image/jpeg")},
)
class TestExternalUrlAndNotes:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_url_round_trip(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await async_client.put(
f"/api/v1/library/files/{library_file.id}",
json={"external_url": "https://www.printables.com/model/1234", "notes": "Print at 0.2mm"},
)
assert response.status_code == 200
body = response.json()
assert body["external_url"] == "https://www.printables.com/model/1234"
assert body["notes"] == "Print at 0.2mm"
assert body["photos"] == []
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["external_url"] == "https://www.printables.com/model/1234"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_string_clears_external_url(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": ""})
assert response.status_code == 200
assert response.json()["external_url"] is None
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"url",
["javascript:alert(1)", "data:text/html,hi", "ftp://example.com/x", "www.printables.com/model/1"],
)
async def test_non_http_external_url_is_rejected(
self, async_client: AsyncClient, file_factory, isolated_storage, url: str
):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": url})
assert response.status_code == 422
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["external_url"] == "https://example.com/x"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_omitted_external_url_is_left_alone(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(external_url="https://example.com/x")
response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"notes": "hi"})
assert response.status_code == 200
assert response.json()["external_url"] == "https://example.com/x"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_detail_exposes_source_url_read_only(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(source_type="makerworld", source_url="https://makerworld.com/models/1")
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["source_url"] == "https://makerworld.com/models/1"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_carries_indicators_but_not_notes(
self, async_client: AsyncClient, file_factory, isolated_storage
):
with_meta = await file_factory(
notes="secret notes", external_url="https://example.com/a", photos=["a.jpg", "b.png"]
)
bare = await file_factory()
response = await async_client.get("/api/v1/library/files")
assert response.status_code == 200
by_id = {item["id"]: item for item in response.json()}
assert by_id[with_meta.id]["has_notes"] is True
assert by_id[with_meta.id]["photo_count"] == 2
assert by_id[with_meta.id]["external_url"] == "https://example.com/a"
assert "notes" not in by_id[with_meta.id]
assert "photos" not in by_id[with_meta.id]
assert by_id[bare.id]["has_notes"] is False
assert by_id[bare.id]["photo_count"] == 0
assert by_id[bare.id]["external_url"] is None
class TestPhotos:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_serve_delete_round_trip(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory()
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
body = upload.json()
filename = body["filename"]
assert body["status"] == "uploaded"
assert body["photos"] == [filename]
assert filename.endswith(".jpg")
assert (library_photos_dir(library_file.id) / filename).is_file()
await db_session.refresh(library_file)
assert library_file.photos == [filename]
served = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert served.status_code == 200
assert served.headers["content-type"] == "image/jpeg"
assert served.content == _jpeg_bytes()
detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
assert detail.json()["photos"] == [filename]
deleted = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert deleted.status_code == 200
assert deleted.json() == {"status": "deleted", "photos": []}
assert not (library_photos_dir(library_file.id) / filename).exists()
gone = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
assert gone.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_file_takes_photos_too(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
upload = await _upload(async_client, library_file.id, name="shot.png")
assert upload.status_code == 200
assert (library_photos_dir(library_file.id) / upload.json()["filename"]).is_file()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unlisted_filename_is_404_even_when_on_disk(
self, async_client: AsyncClient, file_factory, isolated_storage
):
library_file = await file_factory()
photos_dir = library_photos_dir(library_file.id)
photos_dir.mkdir(parents=True)
(photos_dir / "stray.jpg").write_bytes(_jpeg_bytes())
response = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
assert response.status_code == 404
response = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
assert response.status_code == 404
assert (photos_dir / "stray.jpg").exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_traversal_filename_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
# Even a traversal-looking name that IS in the stored list never leaves
# the photo directory — the membership check is not the only guard.
library_file = await file_factory(photos=["../../secret.jpg"])
(isolated_storage / "archive" / "secret.jpg").parent.mkdir(parents=True, exist_ok=True)
(isolated_storage / "archive" / "secret.jpg").write_bytes(_jpeg_bytes())
response = await async_client.get(
f"/api/v1/library/files/{library_file.id}/photos/..%2F..%2Fsecret.jpg",
)
assert response.status_code in (400, 404)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_wrong_extension_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await _upload(async_client, library_file.id, name="notes.txt", content=b"hello")
assert response.status_code == 400
assert not library_photos_dir(library_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_oversized_upload_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory()
response = await _upload(async_client, library_file.id, content=b"\xff" * (10 * 1024 * 1024 + 1))
assert response.status_code == 413
assert not library_photos_dir(library_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_to_missing_file_is_404(self, async_client: AsyncClient, isolated_storage):
response = await _upload(async_client, 999999)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_trash_purge_removes_photo_dir(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory()
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
photos_dir = library_photos_dir(library_file.id)
assert photos_dir.is_dir()
trashed = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
assert trashed.status_code == 200
# Soft-delete keeps the photos, like the file bytes and thumbnail.
assert photos_dir.is_dir()
purged = await async_client.delete(f"/api/v1/library/trash/{library_file.id}")
assert purged.status_code == 200
assert not photos_dir.exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_file_delete_removes_photo_dir(
self, async_client: AsyncClient, file_factory, isolated_storage
):
library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
upload = await _upload(async_client, library_file.id)
assert upload.status_code == 200
photos_dir = library_photos_dir(library_file.id)
response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
assert response.status_code == 200
assert response.json()["trashed"] is False
assert not photos_dir.exists()
class TestPhotoDirectoryCleanup:
"""Every path that hard-deletes a library row takes its photos with it.
The upload/delete round-trip, the trash purge and the external single-file
delete are covered above; these are the remaining ones — folder delete,
bulk delete of files and of whole folders, the external-folder scan that
drops rows for files that vanished from the share, and the admin user
delete that takes the user's items with them.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_folder_delete_removes_photo_dir(self, async_client: AsyncClient, file_factory, isolated_storage):
folder = await async_client.post("/api/v1/library/folders", json={"name": "Brackets"})
assert folder.status_code == 200
folder_id = folder.json()["id"]
library_file = await file_factory(folder_id=folder_id)
assert (await _upload(async_client, library_file.id)).status_code == 200
photos_dir = library_photos_dir(library_file.id)
assert photos_dir.is_dir()
response = await async_client.delete(f"/api/v1/library/folders/{folder_id}")
assert response.status_code == 200
assert not photos_dir.exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_removes_photo_dir_of_hard_deleted_file(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# External files bypass the trash, so bulk delete hard-deletes them;
# a managed file is only soft-deleted and keeps its photos until the
# sweeper runs.
external = await file_factory(is_external=True, file_path="/mnt/nas/ext.stl", file_type="stl")
managed = await file_factory()
for library_file in (external, managed):
assert (await _upload(async_client, library_file.id)).status_code == 200
response = await async_client.post(
"/api/v1/library/bulk-delete",
json={"file_ids": [external.id, managed.id], "folder_ids": []},
)
assert response.status_code == 200
assert response.json()["deleted_files"] == 2
assert not library_photos_dir(external.id).exists()
assert library_photos_dir(managed.id).is_dir()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_delete_removes_photo_dirs_under_a_deleted_folder(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# The folder branch of bulk-delete lets the cascade hard-delete every
# row in the subtree, so it owes the same photo cleanup the file
# branch above it does — including files nested a level down.
parent = await async_client.post("/api/v1/library/folders", json={"name": "Jigs"})
assert parent.status_code == 200
parent_id = parent.json()["id"]
child = await async_client.post("/api/v1/library/folders", json={"name": "V2", "parent_id": parent_id})
assert child.status_code == 200
top_file = await file_factory(folder_id=parent_id)
nested_file = await file_factory(folder_id=child.json()["id"])
for library_file in (top_file, nested_file):
assert (await _upload(async_client, library_file.id)).status_code == 200
assert library_photos_dir(library_file.id).is_dir()
response = await async_client.post(
"/api/v1/library/bulk-delete",
json={"file_ids": [], "folder_ids": [parent_id]},
)
assert response.status_code == 200
assert response.json()["deleted_folders"] == 1
assert (await async_client.get(f"/api/v1/library/files/{top_file.id}")).status_code == 404
assert not library_photos_dir(top_file.id).exists()
assert not library_photos_dir(nested_file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_user_with_their_items_removes_photo_dirs(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
# DELETE /users/{id}?delete_items=true bulk-deletes the rows, which is
# a hard delete like any other and owes the photos with it.
owner = User(username="photo-owner", password_hash="x", role="user")
db_session.add(owner)
await db_session.commit()
await db_session.refresh(owner)
owned = await file_factory(created_by_id=owner.id)
someone_elses = await file_factory()
for library_file in (owned, someone_elses):
assert (await _upload(async_client, library_file.id)).status_code == 200
assert library_photos_dir(library_file.id).is_dir()
response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
assert response.status_code == 204
assert (await async_client.get(f"/api/v1/library/files/{owned.id}")).status_code == 404
assert not library_photos_dir(owned.id).exists()
assert library_photos_dir(someone_elses.id).is_dir()
@pytest.fixture
def external_share(self, monkeypatch, tmp_path):
"""Bambuddy's data dir and an opted-in external share, as siblings.
The share cannot live under ``base_dir`` — ``_validate_external_path``
refuses to mount a Bambuddy-managed directory, and the module's
``isolated_storage`` points ``base_dir`` at ``tmp_path`` itself.
"""
data_dir = tmp_path / "data"
data_dir.mkdir()
monkeypatch.setattr(app_settings, "base_dir", data_dir)
monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
share = tmp_path / "share"
share.mkdir()
monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
return share
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_scan_removes_photo_dir_of_vanished_file(self, async_client: AsyncClient, external_share):
share = external_share
(share / "bracket.stl").write_bytes(b"fakestl")
folder = await async_client.post(
"/api/v1/library/folders/external",
json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
)
assert folder.status_code == 200
folder_id = folder.json()["id"]
scan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
assert scan.status_code == 200
assert scan.json()["added"] == 1
listing = await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")
file_id = listing.json()[0]["id"]
assert (await _upload(async_client, file_id)).status_code == 200
photos_dir = library_photos_dir(file_id)
assert photos_dir.is_dir()
(share / "bracket.stl").unlink()
rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
assert rescan.status_code == 200
assert rescan.json()["removed"] == 1
assert not photos_dir.exists()
@@ -0,0 +1,171 @@
"""Integration tests for the client-rendered preview thumbnail upload (#2976).
STEP/PDF/spreadsheet previews render in the browser and post their first
render to POST /library/files/{id}/preview-thumbnail. These tests pin the
endpoint's contract: PNG-only, capped size, only for the client-preview file
types, and never replacing an existing thumbnail.
"""
import io
import pytest
from httpx import AsyncClient
from PIL import Image
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, "PNG")
return buf.getvalue()
@pytest.fixture
def isolated_storage(monkeypatch, tmp_path):
"""Point thumbnail storage at a throwaway directory."""
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@pytest.fixture
async def file_factory(db_session):
"""Factory for LibraryFile rows of arbitrary file_type."""
_counter = [0]
async def _create_file(**kwargs):
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"part{counter}.step",
"file_path": f"library/files/part{counter}.step",
"file_type": "step",
"file_size": 100,
}
defaults.update(kwargs)
library_file = LibraryFile(**defaults)
db_session.add(library_file)
await db_session.commit()
await db_session.refresh(library_file)
return library_file
return _create_file
class TestPreviewThumbnailUpload:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_sets_thumbnail_path(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="step")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 200
assert response.json() == {"updated": True}
await db_session.refresh(library_file)
assert library_file.thumbnail_path
stored = isolated_storage / library_file.thumbnail_path
assert stored.exists()
with Image.open(stored) as img:
assert img.format == "PNG"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_downscales_oversized_image(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(size=(1024, 1024)), "image/png")},
)
assert response.status_code == 200
await db_session.refresh(library_file)
with Image.open(isolated_storage / library_file.thumbnail_path) as img:
assert max(img.size) <= 512
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_skips_when_thumbnail_exists(
self, async_client: AsyncClient, db_session, file_factory, isolated_storage
):
library_file = await file_factory(file_type="csv", thumbnail_path="archive/library/thumbnails/existing.png")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 200
assert response.json() == {"updated": False}
await db_session.refresh(library_file)
assert library_file.thumbnail_path == "archive/library/thumbnails/existing.png"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejected_for_server_rendered_types(
self, async_client: AsyncClient, file_factory, isolated_storage
):
# STL thumbnails are generated server-side; the client route must not
# be able to overwrite them.
library_file = await file_factory(file_type="stl", filename="part.stl")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_non_png(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="step")
buf = io.BytesIO()
Image.new("RGB", (64, 64), "blue").save(buf, "JPEG")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", buf.getvalue(), "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_garbage_bytes(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="xlsx")
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", b"not an image at all", "image/png")},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_rejects_oversized_payload(self, async_client: AsyncClient, file_factory, isolated_storage):
library_file = await file_factory(file_type="ods")
oversized = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
response = await async_client.post(
f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
files={"thumbnail": ("preview.png", oversized, "image/png")},
)
assert response.status_code == 413
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_missing_file_returns_404(self, async_client: AsyncClient, isolated_storage):
response = await async_client.post(
"/api/v1/library/files/999999/preview-thumbnail",
files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
)
assert response.status_code == 404
@@ -944,6 +944,114 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
assert response.status_code == 403
# ========================================================================
# Photo routes (#3077). Upload and delete are gated on LIBRARY_UPDATE_*,
# so a non-owner is refused with 403 exactly like ``update_file``. The
# read path goes through ``_ensure_library_file_visible`` and answers 404
# instead, so an id that exists tells an outsider nothing.
# ========================================================================
@pytest.fixture
def photo_storage(self, monkeypatch, tmp_path):
"""Keep uploaded photos out of the real data directory."""
from backend.app.core.config import settings as app_settings
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
return tmp_path
@staticmethod
def _photo_upload():
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (8, 8), "blue").save(buf, "JPEG")
return {"file": ("result.jpg", buf.getvalue(), "image/jpeg")}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_can_upload_photo_to_own_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
response = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
files=self._photo_upload(),
)
assert response.status_code == 200
assert len(response.json()["photos"]) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_upload_photo_to_others_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
from backend.app.utils.library_paths import library_photos_dir
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
response = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
files=self._photo_upload(),
)
assert response.status_code == 403
assert not library_photos_dir(file.id).exists()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_cannot_delete_photo_from_others_library_file(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
from backend.app.utils.library_paths import library_photos_dir
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
upload = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
files=self._photo_upload(),
)
filename = upload.json()["filename"]
response = await async_client.delete(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 403
assert (library_photos_dir(file.id) / filename).is_file()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_operator_reading_others_library_file_photo_gets_404(
self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
):
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
upload = await async_client.post(
f"/api/v1/library/files/{file.id}/photos",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
files=self._photo_upload(),
)
filename = upload.json()["filename"]
owner = await async_client.get(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
)
assert owner.status_code == 200
stranger = await async_client.get(
f"/api/v1/library/files/{file.id}/photos/{filename}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert stranger.status_code == 404
# ========================================================================
# Folder deletion (#1781): folders have no ownership tracking, so users
# with only library:delete_own may delete empty, non-external, non-linked
@@ -289,6 +289,61 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
"""script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
The STEP preview triangulates in the browser via OpenCascade compiled to
WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
WebAssembly.instantiate() and the preview dies with a CompileError.
'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
stay blocked, which is what the second assertion pins.
"""
resp = await async_client.get("/api/v1/auth/status")
csp = resp.headers.get("Content-Security-Policy", "")
script_src = next(
(d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
"",
)
assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
# Substring check must not be fooled by 'wasm-unsafe-eval' containing
# "unsafe-eval" — compare whole tokens.
tokens = script_src.split()
assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_client: AsyncClient):
"""Only the STEP worker script's own response may carry 'unsafe-eval' (#2976).
The occt-import-js embind glue generates invokers with `new Function`,
so the dedicated worker needs an eval-permitting policy. Per CSP3 a
worker is governed by the policy delivered with its own script response,
which confines eval to that DOM-less context. Any other asset — and the
SPA document itself — must stay nonce-strict. Both requests 404 in the
test checkout; the security middleware stamps headers regardless.
"""
def script_src_tokens(resp) -> list[str]:
csp = resp.headers.get("Content-Security-Policy", "")
directive = next(
(d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
"",
)
return directive.split()
worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
other = await async_client.get("/assets/index-Ck9aB12c.js")
assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
# ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
@@ -0,0 +1,67 @@
"""``move_library_photos`` edge cases (#3077).
The happy path is pinned by the scheduler's cleanup tests; this covers what
they cannot reach — a name already taken in the destination, and a move that
fails halfway.
"""
import pytest
from backend.app.core.config import settings
from backend.app.utils.library_paths import library_photos_dir, move_library_photos
@pytest.fixture
def photo_dirs(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
destination = tmp_path / "archives" / "1" / "photos"
source = library_photos_dir(7)
source.mkdir(parents=True)
return source, destination
def test_moves_every_photo_and_drops_the_directory(photo_dirs):
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"one")
(source / "e5f6a7b8.png").write_bytes(b"two")
moved = move_library_photos(7, ["a1b2c3d4.jpg", "e5f6a7b8.png"], destination)
assert moved == ["a1b2c3d4.jpg", "e5f6a7b8.png"]
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"one"
assert not source.exists()
def test_renames_around_a_name_the_destination_already_holds(photo_dirs):
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"library")
destination.mkdir(parents=True)
(destination / "a1b2c3d4.jpg").write_bytes(b"archive")
moved = move_library_photos(7, ["a1b2c3d4.jpg"], destination)
assert moved != ["a1b2c3d4.jpg"]
assert moved[0].endswith(".jpg")
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"archive"
assert (destination / moved[0]).read_bytes() == b"library"
def test_a_traversal_name_is_skipped_and_keeps_the_directory(photo_dirs):
# A stored name is uuid-generated, so this only happens to a row someone
# has written to by hand — but the photos are then left alone rather than
# swept away by a cleanup that could not move them.
source, destination = photo_dirs
(source / "a1b2c3d4.jpg").write_bytes(b"one")
moved = move_library_photos(7, ["../escape.jpg", "a1b2c3d4.jpg"], destination)
assert moved == ["a1b2c3d4.jpg"]
assert source.is_dir()
def test_a_file_without_photos_is_a_no_op(photo_dirs):
source, destination = photo_dirs
source.rmdir()
assert move_library_photos(7, [], destination) == []
assert not destination.exists()
@@ -607,6 +607,7 @@ NOT_A_FETCH_TARGET = {
("SystemConfigRequest", "backend_url"),
("ExternalLinkCreate", "url"), # sidebar link, rendered in the UI, never requested
("ExternalLinkUpdate", "url"),
("FileUpdate", "external_url"), # library file link (#3077), rendered in the UI, never fetched
("MaintenanceTypeCreate", "wiki_url"), # documentation link surfaced in the UI/notifications
("MaintenanceTypeUpdate", "wiki_url"),
("ArchiveUpdate", "external_url"), # stored source link for the model, never fetched
@@ -1,10 +1,12 @@
import logging
from contextlib import ExitStack
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy import event, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
@@ -15,6 +17,8 @@ from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
from backend.app.utils.archive_paths import archive_photos_dir
from backend.app.utils.library_paths import library_photos_dir
from backend.tests._fixtures.background_tasks import discarding_spawn_patch
@@ -27,12 +31,13 @@ async def queue_factory(tmp_path):
session_maker = async_sessionmaker(engine, expire_on_commit=False)
case_counter = 0
async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=()):
async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=(), photos=()):
nonlocal case_counter
case_counter += 1
base_dir = tmp_path / f"case-{case_counter}"
base_dir.mkdir()
archive_dir = base_dir / "archive"
source_path = base_dir / "library" / f"source-{case_counter}.3mf"
source_path.parent.mkdir()
source_path.write_bytes(b"library source")
@@ -70,10 +75,21 @@ async def queue_factory(tmp_path):
thumbnail_path=thumbnail_db_path,
file_metadata=None,
is_external=is_external,
photos=list(photos) or None,
)
db.add_all([printer, library_file])
await db.flush()
# Photos of the printed result (#3077). Written through the real
# helper so the test cannot drift from the layout the code uses.
photos_dir = None
if photos:
with patch.object(scheduler_module.settings, "archive_dir", archive_dir):
photos_dir = library_photos_dir(library_file.id)
photos_dir.mkdir(parents=True, exist_ok=True)
for name in photos:
(photos_dir / name).write_bytes(f"photo {name}".encode())
item = PrintQueueItem(
printer_id=printer.id,
library_file_id=library_file.id,
@@ -152,7 +168,10 @@ async def queue_factory(tmp_path):
return SimpleNamespace(
session_maker=session_maker,
base_dir=base_dir,
archive_dir=archive_dir,
source_path=source_path,
photos_dir=photos_dir,
photo_names=list(photos),
thumbnail_path=thumbnail_actual_path,
printer_id=printer.id,
library_file_id=library_file.id,
@@ -170,7 +189,14 @@ async def queue_factory(tmp_path):
await engine.dispose()
async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
async def _dispatch_library_item(
ctx,
*,
archive_failure=False,
unlink_side_effect=None,
cleanup_commit_failure=False,
photo_commit_failure=False,
):
scheduler = PrintScheduler()
async def archive_print(
@@ -213,6 +239,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
patches = [
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
patch.object(scheduler_module.settings, "archive_dir", ctx.archive_dir),
patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
@@ -239,10 +266,99 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
stack.enter_context(patcher)
async with ctx.session_maker() as db:
if cleanup_commit_failure:
_arm_commit_failure_on_library_delete(db)
if photo_commit_failure:
_arm_commit_failure_on_photo_move(db, stack)
item = await db.get(PrintQueueItem, ctx.queue_item_id)
await scheduler._start_print(db, item)
def _fail_inside_the_next_flush(db, statement):
"""Make the next flush on this session fail, once, the way SQLite does.
Raising *instead of* calling `db.commit()` does not reproduce a failed
commit and is not the dangerous case: the session stays ACTIVE, nothing is
expired, and every loaded instance still reads out of `__dict__`. What a
busy writer actually gives you is a statement error raised inside the
flush — SQLite takes the write lock at the first DML statement, not at
COMMIT, so "database is locked" surfaces there (#1853). SQLAlchemy rolls
that back internally through `safe_reraise` before re-raising, which
expires every loaded instance and leaves the session in pending-rollback
state: the next ORM attribute read raises PendingRollbackError, *before*
the handler's own rollback can run. That is the state a handler on this
path has to survive, so it is the state these tests have to produce.
"""
sync_session = db.sync_session
fired = False
def after_flush(session, flush_context):
nonlocal fired
if fired:
return
fired = True
raise OperationalError(statement, {}, Exception("database is locked"))
event.listen(sync_session, "after_flush", after_flush)
def _arm_commit_failure_on_library_delete(db):
"""Make the one commit that removes the library row fail, once.
Stands in for the "database is locked" cascades the commit's own comment
cites (#1853). Armed by the delete rather than by a call count so it
cannot drift onto a different commit.
"""
original_delete = db.delete
original_commit = db.commit
armed = False
async def delete(obj):
nonlocal armed
if isinstance(obj, LibraryFile):
armed = True
return await original_delete(obj)
async def commit():
nonlocal armed
if armed:
armed = False
_fail_inside_the_next_flush(db, "DELETE FROM library_files WHERE library_files.id = ?")
return await original_commit()
db.delete = delete
db.commit = commit
def _arm_commit_failure_on_photo_move(db, stack):
"""Make the commit that records the carried photos fail, once.
The second commit of this path (#3077): the archive and the delete are
already committed, the pictures are already on disk under the archive,
and only `archive.photos` is pending. Armed by the move itself so it
cannot drift onto the delete's commit.
"""
original_commit = db.commit
original_move = scheduler_module.move_library_photos
armed = False
def move_library_photos(file_id, photos, destination):
nonlocal armed
carried = original_move(file_id, photos, destination)
armed = bool(carried)
return carried
async def commit():
nonlocal armed
if armed:
armed = False
_fail_inside_the_next_flush(db, "UPDATE print_archives SET photos=? WHERE print_archives.id = ?")
return await original_commit()
stack.enter_context(patch.object(scheduler_module, "move_library_photos", move_library_photos))
db.commit = commit
async def _queue_snapshot(ctx):
async with ctx.session_maker() as db:
item = await db.get(PrintQueueItem, ctx.queue_item_id)
@@ -279,6 +395,115 @@ async def test_external_library_file_skips_cleanup(queue_factory):
assert ctx.source_path.exists()
@pytest.mark.asyncio
async def test_cleanup_moves_the_photos_into_the_archive(queue_factory):
"""Photos follow the consumed file into the archive that replaces it (#3077).
The row is hard-deleted here, so leaving the photo directory alone
orphaned it under an id nothing points at any more — and the pictures
of a print that still has a record disappeared from the UI.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg", "e5f6a7b8.png"])
await _dispatch_library_item(ctx)
_, library_file, archive = await _queue_snapshot(ctx)
assert library_file is None
assert not ctx.photos_dir.exists()
assert archive.photos == ctx.photo_names
with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
destination = archive_photos_dir(archive)
for name in ctx.photo_names:
assert (destination / name).read_bytes() == f"photo {name}".encode()
@pytest.mark.asyncio
async def test_external_library_file_keeps_its_photos(queue_factory):
ctx = await queue_factory(cleanup=True, is_external=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx)
_, library_file, archive = await _queue_snapshot(ctx)
assert library_file is not None
assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
assert archive.photos is None
@pytest.mark.asyncio
async def test_archive_creation_failure_keeps_the_photos(queue_factory):
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx, archive_failure=True)
_, library_file, archive = await _queue_snapshot(ctx)
assert archive is None
assert library_file is not None
assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
@pytest.mark.asyncio
async def test_cleanup_commit_failure_keeps_the_photos_with_the_library_file(queue_factory):
"""The photos move after the delete commits, not before it (#3077).
The commit that removes the library row can fail; the except branch rolls
it back and the file is in the library again. Photos moved ahead of that
commit would be gone from under it — the row would name a directory that
no longer exists, and the pictures would sit under an archive that was
rolled back too.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
await _dispatch_library_item(ctx, cleanup_commit_failure=True)
item, library_file, archive = await _queue_snapshot(ctx)
assert item.status == "failed"
assert archive is None
assert library_file is not None
assert library_file.photos == ["a1b2c3d4.jpg"]
assert (ctx.photos_dir / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
assert not (ctx.base_dir / "archives" / "photos").exists()
@pytest.mark.asyncio
async def test_photo_commit_failure_still_dispatches_the_print(queue_factory, caplog):
"""A failed photos commit must not take the dispatch down with it (#3077).
The archive and the delete are committed by then, so the print goes ahead
and the pictures sit unnamed under the archive.
The commit fails inside the flush, which is where a locked SQLite fails —
see `_fail_inside_the_next_flush`. That expires every loaded instance
twice over: once by SQLAlchemy's internal rollback, before the handler
runs at all, and again at the handler's own `rollback()`. So the handler
may not read an ORM attribute on either side of that rollback. Before it,
a read raises PendingRollbackError; after it, MissingGreenlet — the nozzle
guard's `archive.nozzle_diameter` and the upload's `printer.name` are the
ones that used to die.
"""
ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
await _dispatch_library_item(ctx, photo_commit_failure=True)
item, library_file, archive = await _queue_snapshot(ctx)
# The handler absorbed it rather than the failure being skipped: it names
# the queue item and the archive from ints it held before the commit.
assert any(
f"Queue item {ctx.queue_item_id}: failed to carry library photos into archive {item.archive_id}"
in record.message
for record in caplog.records
)
assert item.status == "printing"
assert item.archive_id == archive.id
assert library_file is None
assert not archive.photos
ctx.upload.assert_awaited()
ctx.start_print.assert_called()
with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
destination = archive_photos_dir(archive)
assert (destination / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
@pytest.mark.asyncio
async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
ctx = await queue_factory(cleanup=True, thumbnail_path="relative")