|
@@ -49,6 +49,7 @@ from backend.app.schemas.library import (
|
|
|
BatchThumbnailResult,
|
|
BatchThumbnailResult,
|
|
|
BulkDeleteRequest,
|
|
BulkDeleteRequest,
|
|
|
BulkDeleteResponse,
|
|
BulkDeleteResponse,
|
|
|
|
|
+ ClientThumbnailResponse,
|
|
|
ExternalFolderCreate,
|
|
ExternalFolderCreate,
|
|
|
FileDuplicate,
|
|
FileDuplicate,
|
|
|
FileListResponse,
|
|
FileListResponse,
|
|
@@ -75,6 +76,7 @@ from backend.app.services.design_settings import (
|
|
|
overrides_from_config,
|
|
overrides_from_config,
|
|
|
)
|
|
)
|
|
|
from backend.app.services.filament_requirements import annotate_rack_groups
|
|
from backend.app.services.filament_requirements import annotate_rack_groups
|
|
|
|
|
+from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
|
|
|
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
|
|
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
|
|
|
from backend.app.services.process_overrides import apply_process_overrides
|
|
from backend.app.services.process_overrides import apply_process_overrides
|
|
|
from backend.app.services.slice_output_check import (
|
|
from backend.app.services.slice_output_check import (
|
|
@@ -90,6 +92,7 @@ from backend.app.utils.filename import (
|
|
|
safe_path_component,
|
|
safe_path_component,
|
|
|
validate_print_filename,
|
|
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.printer_models import is_gcode_compatible
|
|
|
from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
|
|
from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
|
|
|
from backend.app.utils.threemf_tools import (
|
|
from backend.app.utils.threemf_tools import (
|
|
@@ -808,6 +811,29 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
|
|
|
# Supported image extensions for thumbnails
|
|
# Supported image extensions for thumbnails
|
|
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
|
|
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:
|
|
async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
|
|
|
"""Generate STL thumbnails for an external folder tree in the background.
|
|
"""Generate STL thumbnails for an external folder tree in the background.
|
|
@@ -1494,6 +1520,8 @@ async def delete_folder(
|
|
|
|
|
|
|
|
await delete_dependent_variants(db, doomed_file_ids)
|
|
await delete_dependent_variants(db, doomed_file_ids)
|
|
|
await release_queue_references(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)
|
|
# Delete folder (cascade will handle files and subfolders)
|
|
|
await db.delete(folder)
|
|
await db.delete(folder)
|
|
@@ -1588,6 +1616,12 @@ _SCANNABLE_EXTENSIONS = {
|
|
|
".webp",
|
|
".webp",
|
|
|
".svg",
|
|
".svg",
|
|
|
".md",
|
|
".md",
|
|
|
|
|
+ # Documents that ship alongside a job folder and now have in-app
|
|
|
|
|
+ # previews (#2976): drawings/datasheets and part lists.
|
|
|
|
|
+ ".pdf",
|
|
|
|
|
+ ".csv",
|
|
|
|
|
+ ".xlsx",
|
|
|
|
|
+ ".ods",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1975,6 +2009,14 @@ async def scan_external_folder(
|
|
|
if thumbnail_path_str:
|
|
if thumbnail_path_str:
|
|
|
thumbnail_path = to_relative_path(Path(thumbnail_path_str))
|
|
thumbnail_path = to_relative_path(Path(thumbnail_path_str))
|
|
|
|
|
|
|
|
|
|
+ # Render page one of a PDF so it has a thumbnail before anyone opens it
|
|
|
|
|
+ if file_type == "pdf" and thumbnail_path is None:
|
|
|
|
|
+ thumbnail_path_str = await asyncio.to_thread(
|
|
|
|
|
+ generate_pdf_thumbnail, filepath, get_library_thumbnails_dir()
|
|
|
|
|
+ )
|
|
|
|
|
+ if thumbnail_path_str:
|
|
|
|
|
+ thumbnail_path = to_relative_path(Path(thumbnail_path_str))
|
|
|
|
|
+
|
|
|
db_file = LibraryFile(
|
|
db_file = LibraryFile(
|
|
|
folder_id=target_folder_id,
|
|
folder_id=target_folder_id,
|
|
|
is_external=True,
|
|
is_external=True,
|
|
@@ -2009,6 +2051,10 @@ async def scan_external_folder(
|
|
|
abs_thumb.unlink()
|
|
abs_thumb.unlink()
|
|
|
except OSError:
|
|
except OSError:
|
|
|
pass
|
|
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)
|
|
await db.delete(db_file)
|
|
|
removed += 1
|
|
removed += 1
|
|
|
|
|
|
|
@@ -2229,6 +2275,9 @@ async def list_files(
|
|
|
tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
|
|
tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
|
|
|
variant_group_id=f.variant_group_id,
|
|
variant_group_id=f.variant_group_id,
|
|
|
variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
|
|
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 []),
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -2362,6 +2411,11 @@ async def upload_file(
|
|
|
# For image files, create a thumbnail from the image itself
|
|
# For image files, create a thumbnail from the image itself
|
|
|
thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
|
|
thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
|
|
|
+ elif ext.lower() == ".pdf":
|
|
|
|
|
+ # Page one, rendered server-side; the browser preview's own
|
|
|
|
|
+ # render (POST /preview-thumbnail) remains the fallback.
|
|
|
|
|
+ thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
|
|
|
|
|
+
|
|
|
elif ext == ".stl":
|
|
elif ext == ".stl":
|
|
|
# Generate STL thumbnail if enabled. Same MIN_USABLE_STL_BYTES
|
|
# Generate STL thumbnail if enabled. Same MIN_USABLE_STL_BYTES
|
|
|
# pre-skip as extract_zip_file — stubs / placeholders below this
|
|
# pre-skip as extract_zip_file — stubs / placeholders below this
|
|
@@ -2634,6 +2688,9 @@ async def extract_zip_file(
|
|
|
elif ext.lower() in IMAGE_EXTENSIONS:
|
|
elif ext.lower() in IMAGE_EXTENSIONS:
|
|
|
thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
|
|
thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
|
|
|
+ elif ext.lower() == ".pdf":
|
|
|
|
|
+ thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
|
|
|
|
|
+
|
|
|
elif ext == ".stl":
|
|
elif ext == ".stl":
|
|
|
# Generate STL thumbnail if enabled. Pre-skip files
|
|
# Generate STL thumbnail if enabled. Pre-skip files
|
|
|
# below MIN_USABLE_STL_BYTES — they can't contain
|
|
# below MIN_USABLE_STL_BYTES — they can't contain
|
|
@@ -2706,27 +2763,31 @@ async def batch_generate_stl_thumbnails(
|
|
|
db: AsyncSession = Depends(get_db),
|
|
db: AsyncSession = Depends(get_db),
|
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
|
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
|
|
):
|
|
):
|
|
|
- """Generate thumbnails for STL files in batch.
|
|
|
|
|
|
|
+ """Generate thumbnails for STL and PDF files in batch.
|
|
|
|
|
|
|
|
Note: Requires library:update_all permission since this is a batch operation
|
|
Note: Requires library:update_all permission since this is a batch operation
|
|
|
that may affect files owned by different users.
|
|
that may affect files owned by different users.
|
|
|
|
|
|
|
|
|
|
+ PDFs are included so the ones added before server-side PDF thumbnails
|
|
|
|
|
+ existed can be backfilled without opening each preview. The route keeps
|
|
|
|
|
+ its name for API compatibility.
|
|
|
|
|
+
|
|
|
Can generate thumbnails for:
|
|
Can generate thumbnails for:
|
|
|
- Specific file IDs (file_ids)
|
|
- Specific file IDs (file_ids)
|
|
|
- - All STL files in a folder (folder_id)
|
|
|
|
|
- - All STL files missing thumbnails (all_missing=True)
|
|
|
|
|
|
|
+ - All STL/PDF files in a folder (folder_id)
|
|
|
|
|
+ - All STL/PDF files missing thumbnails (all_missing=True)
|
|
|
"""
|
|
"""
|
|
|
thumbnails_dir = get_library_thumbnails_dir()
|
|
thumbnails_dir = get_library_thumbnails_dir()
|
|
|
results: list[BatchThumbnailResult] = []
|
|
results: list[BatchThumbnailResult] = []
|
|
|
|
|
|
|
|
# Build query based on request
|
|
# Build query based on request
|
|
|
- query = LibraryFile.active().where(LibraryFile.file_type == "stl")
|
|
|
|
|
|
|
+ query = LibraryFile.active().where(LibraryFile.file_type.in_(("stl", "pdf")))
|
|
|
|
|
|
|
|
if request.file_ids:
|
|
if request.file_ids:
|
|
|
# Specific files
|
|
# Specific files
|
|
|
query = query.where(LibraryFile.id.in_(request.file_ids))
|
|
query = query.where(LibraryFile.id.in_(request.file_ids))
|
|
|
elif request.folder_id is not None:
|
|
elif request.folder_id is not None:
|
|
|
- # All STL files in a specific folder
|
|
|
|
|
|
|
+ # All STL/PDF files in a specific folder
|
|
|
query = query.where(LibraryFile.folder_id == request.folder_id)
|
|
query = query.where(LibraryFile.folder_id == request.folder_id)
|
|
|
if not request.all_missing:
|
|
if not request.all_missing:
|
|
|
# If not specifically asking for missing thumbnails, get all
|
|
# If not specifically asking for missing thumbnails, get all
|
|
@@ -2734,7 +2795,7 @@ async def batch_generate_stl_thumbnails(
|
|
|
else:
|
|
else:
|
|
|
query = query.where(LibraryFile.thumbnail_path.is_(None))
|
|
query = query.where(LibraryFile.thumbnail_path.is_(None))
|
|
|
elif request.all_missing:
|
|
elif request.all_missing:
|
|
|
- # All STL files without thumbnails
|
|
|
|
|
|
|
+ # All STL/PDF files without thumbnails
|
|
|
query = query.where(LibraryFile.thumbnail_path.is_(None))
|
|
query = query.where(LibraryFile.thumbnail_path.is_(None))
|
|
|
else:
|
|
else:
|
|
|
# No criteria specified - return empty
|
|
# No criteria specified - return empty
|
|
@@ -2746,19 +2807,19 @@ async def batch_generate_stl_thumbnails(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
result = await db.execute(query)
|
|
result = await db.execute(query)
|
|
|
- stl_files = result.scalars().all()
|
|
|
|
|
|
|
+ target_files = result.scalars().all()
|
|
|
|
|
|
|
|
succeeded = 0
|
|
succeeded = 0
|
|
|
failed = 0
|
|
failed = 0
|
|
|
|
|
|
|
|
- for stl_file in stl_files:
|
|
|
|
|
- file_path = to_absolute_path(stl_file.file_path)
|
|
|
|
|
|
|
+ for target_file in target_files:
|
|
|
|
|
+ file_path = to_absolute_path(target_file.file_path)
|
|
|
|
|
|
|
|
if not file_path or not file_path.exists():
|
|
if not file_path or not file_path.exists():
|
|
|
results.append(
|
|
results.append(
|
|
|
BatchThumbnailResult(
|
|
BatchThumbnailResult(
|
|
|
- file_id=stl_file.id,
|
|
|
|
|
- filename=stl_file.filename,
|
|
|
|
|
|
|
+ file_id=target_file.id,
|
|
|
|
|
+ filename=target_file.filename,
|
|
|
success=False,
|
|
success=False,
|
|
|
error="File not found on disk",
|
|
error="File not found on disk",
|
|
|
)
|
|
)
|
|
@@ -2767,16 +2828,19 @@ async def batch_generate_stl_thumbnails(
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
+ if target_file.file_type == "pdf":
|
|
|
|
|
+ thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
|
|
|
|
|
+ else:
|
|
|
|
|
+ thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
|
|
|
|
|
|
|
|
if thumbnail_path:
|
|
if thumbnail_path:
|
|
|
# Update database with relative path
|
|
# Update database with relative path
|
|
|
- stl_file.thumbnail_path = to_relative_path(thumbnail_path)
|
|
|
|
|
|
|
+ target_file.thumbnail_path = to_relative_path(thumbnail_path)
|
|
|
await db.flush()
|
|
await db.flush()
|
|
|
results.append(
|
|
results.append(
|
|
|
BatchThumbnailResult(
|
|
BatchThumbnailResult(
|
|
|
- file_id=stl_file.id,
|
|
|
|
|
- filename=stl_file.filename,
|
|
|
|
|
|
|
+ file_id=target_file.id,
|
|
|
|
|
+ filename=target_file.filename,
|
|
|
success=True,
|
|
success=True,
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
@@ -2784,19 +2848,19 @@ async def batch_generate_stl_thumbnails(
|
|
|
else:
|
|
else:
|
|
|
results.append(
|
|
results.append(
|
|
|
BatchThumbnailResult(
|
|
BatchThumbnailResult(
|
|
|
- file_id=stl_file.id,
|
|
|
|
|
- filename=stl_file.filename,
|
|
|
|
|
|
|
+ file_id=target_file.id,
|
|
|
|
|
+ filename=target_file.filename,
|
|
|
success=False,
|
|
success=False,
|
|
|
error="Thumbnail generation failed",
|
|
error="Thumbnail generation failed",
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
failed += 1
|
|
failed += 1
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
- logger.error("Failed to generate thumbnail for %s: %s", stl_file.filename, e)
|
|
|
|
|
|
|
+ logger.error("Failed to generate thumbnail for %s: %s", target_file.filename, e)
|
|
|
results.append(
|
|
results.append(
|
|
|
BatchThumbnailResult(
|
|
BatchThumbnailResult(
|
|
|
- file_id=stl_file.id,
|
|
|
|
|
- filename=stl_file.filename,
|
|
|
|
|
|
|
+ file_id=target_file.id,
|
|
|
|
|
+ filename=target_file.filename,
|
|
|
success=False,
|
|
success=False,
|
|
|
error=str(e),
|
|
error=str(e),
|
|
|
)
|
|
)
|
|
@@ -2806,7 +2870,7 @@ async def batch_generate_stl_thumbnails(
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
|
|
|
|
|
return BatchThumbnailResponse(
|
|
return BatchThumbnailResponse(
|
|
|
- processed=len(stl_files),
|
|
|
|
|
|
|
+ processed=len(target_files),
|
|
|
succeeded=succeeded,
|
|
succeeded=succeeded,
|
|
|
failed=failed,
|
|
failed=failed,
|
|
|
results=results,
|
|
results=results,
|
|
@@ -5058,6 +5122,9 @@ async def get_file(
|
|
|
print_count=file.print_count,
|
|
print_count=file.print_count,
|
|
|
last_printed_at=file.last_printed_at,
|
|
last_printed_at=file.last_printed_at,
|
|
|
notes=file.notes,
|
|
notes=file.notes,
|
|
|
|
|
+ external_url=file.external_url,
|
|
|
|
|
+ photos=list(file.photos or []),
|
|
|
|
|
+ source_url=file.source_url,
|
|
|
duplicates=duplicates if duplicates else None,
|
|
duplicates=duplicates if duplicates else None,
|
|
|
duplicate_count=duplicate_count,
|
|
duplicate_count=duplicate_count,
|
|
|
created_by_id=file.created_by_id,
|
|
created_by_id=file.created_by_id,
|
|
@@ -5132,6 +5199,9 @@ async def update_file(
|
|
|
if data.notes is not None:
|
|
if data.notes is not None:
|
|
|
file.notes = data.notes if data.notes else 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.commit()
|
|
|
await db.refresh(file)
|
|
await db.refresh(file)
|
|
|
|
|
|
|
@@ -5186,6 +5256,7 @@ async def delete_file(
|
|
|
|
|
|
|
|
await delete_dependent_variants(db, [file.id])
|
|
await delete_dependent_variants(db, [file.id])
|
|
|
await release_queue_references(db, [file.id])
|
|
await release_queue_references(db, [file.id])
|
|
|
|
|
+ remove_library_photos_dir(file.id)
|
|
|
await db.delete(file)
|
|
await db.delete(file)
|
|
|
await db.commit()
|
|
await db.commit()
|
|
|
return {"status": "success", "message": "File deleted", "trashed": False}
|
|
return {"status": "success", "message": "File deleted", "trashed": False}
|
|
@@ -5328,6 +5399,218 @@ async def get_thumbnail(
|
|
|
return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
|
|
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")
|
|
@router.get("/files/{file_id}/gcode")
|
|
|
async def get_gcode(
|
|
async def get_gcode(
|
|
|
file_id: int,
|
|
file_id: int,
|
|
@@ -5558,6 +5841,7 @@ async def bulk_delete(
|
|
|
await delete_dependent_variants(db, hard_deleted_ids)
|
|
await delete_dependent_variants(db, hard_deleted_ids)
|
|
|
await release_queue_references(db, hard_deleted_ids)
|
|
await release_queue_references(db, hard_deleted_ids)
|
|
|
for file in hard_deleted:
|
|
for file in hard_deleted:
|
|
|
|
|
+ remove_library_photos_dir(file.id)
|
|
|
await db.delete(file)
|
|
await db.delete(file)
|
|
|
|
|
|
|
|
# Delete folders (cascade will handle contents). Folders have no ownership
|
|
# Delete folders (cascade will handle contents). Folders have no ownership
|
|
@@ -5580,6 +5864,10 @@ async def bulk_delete(
|
|
|
tree_file_ids = await _folder_tree_file_ids(db, folder_id)
|
|
tree_file_ids = await _folder_tree_file_ids(db, folder_id)
|
|
|
await delete_dependent_variants(db, tree_file_ids)
|
|
await delete_dependent_variants(db, tree_file_ids)
|
|
|
await release_queue_references(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)
|
|
await db.delete(folder)
|
|
|
deleted_folders += 1
|
|
deleted_folders += 1
|
|
|
|
|
|