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(