v0.2.2.2 (#830)
This commit is contained in:
MartinNYHC
2026-03-27 09:27:47 +01:00
committed by GitHub
parent 8270030687
commit 67f91d94de
112 changed files with 18595 additions and 9993 deletions
+332 -21
View File
@@ -39,6 +39,7 @@ from backend.app.schemas.library import (
BatchThumbnailResult,
BulkDeleteRequest,
BulkDeleteResponse,
ExternalFolderCreate,
FileDuplicate,
FileListResponse,
FileMoveRequest,
@@ -278,6 +279,9 @@ async def list_folders(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
file_count=file_counts.get(folder.id, 0),
children=[],
)
@@ -326,6 +330,10 @@ async def get_folders_by_project(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=None,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -367,6 +375,10 @@ async def get_folders_by_archive(
archive_id=folder.archive_id,
project_name=None,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -426,6 +438,10 @@ async def create_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=0,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -464,6 +480,10 @@ async def get_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -556,6 +576,10 @@ async def update_folder(
archive_id=folder.archive_id,
project_name=project_name,
archive_name=archive_name,
is_external=folder.is_external,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=file_count,
created_at=folder.created_at,
updated_at=folder.updated_at,
@@ -579,6 +603,9 @@ async def delete_folder(
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
# External folders: only remove DB records, never delete files from external path
is_ext = folder.is_external
# Get all files in this folder and subfolders to delete from disk
async def get_all_file_ids(fid: int) -> list[int]:
"""Recursively get all file IDs in a folder tree."""
@@ -586,20 +613,21 @@ async def delete_folder(
# Get files in this folder
files_result = await db.execute(
select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path).where(
select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path, LibraryFile.is_external).where(
LibraryFile.folder_id == fid
)
)
for file_id, file_path, thumb_path in files_result.all():
file_ids.append(file_id)
# Delete actual files
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if thumb_path and os.path.exists(thumb_path):
os.remove(thumb_path)
except OSError as e:
logger.warning("Failed to delete file: %s", e)
for fid_val, file_path, thumb_path, file_is_ext in files_result.all():
file_ids.append(fid_val)
# Only delete non-external files from disk
if not is_ext and not file_is_ext:
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if thumb_path and os.path.exists(thumb_path):
os.remove(thumb_path)
except OSError as e:
logger.warning("Failed to delete file: %s", e)
# Get child folders and recurse
children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid))
@@ -616,6 +644,272 @@ async def delete_folder(
return {"status": "success", "message": "Folder deleted"}
# ============ External Folder Endpoints ============
# Blocked system directories that cannot be mounted
_BLOCKED_PREFIXES = (
"/proc",
"/sys",
"/dev",
"/run",
"/boot",
"/sbin",
"/bin",
"/usr/sbin",
"/usr/bin",
"/lib",
"/etc",
)
# Supported file extensions for external folder scanning
_SCANNABLE_EXTENSIONS = {
".3mf",
".gcode",
".gcode.3mf",
".stl",
".obj",
".step",
".stp",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
}
def _validate_external_path(path_str: str) -> Path:
"""Validate an external path is safe to mount."""
path = Path(path_str).resolve()
if not path.is_absolute():
raise HTTPException(status_code=400, detail="Path must be absolute")
for prefix in _BLOCKED_PREFIXES:
if str(path).startswith(prefix):
raise HTTPException(status_code=400, detail=f"Cannot mount system directory: {prefix}")
if not path.exists():
raise HTTPException(status_code=400, detail=f"Path does not exist: {path}")
if not path.is_dir():
raise HTTPException(status_code=400, detail=f"Path is not a directory: {path}")
# Check readability
if not os.access(path, os.R_OK):
raise HTTPException(status_code=400, detail=f"Path is not readable: {path}")
return path
@router.post("/folders/external", response_model=FolderResponse)
async def create_external_folder(
data: ExternalFolderCreate,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
):
"""Create an external folder that points to a host directory."""
resolved = _validate_external_path(data.external_path)
# Check no other external folder already points to this path
existing = await db.execute(
select(LibraryFolder).where(
LibraryFolder.is_external.is_(True),
LibraryFolder.external_path == str(resolved),
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="An external folder already exists for this path")
# Verify parent exists if specified
if data.parent_id is not None:
parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id))
if not parent_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Parent folder not found")
folder = LibraryFolder(
name=data.name,
parent_id=data.parent_id,
is_external=True,
external_path=str(resolved),
external_readonly=data.readonly,
external_show_hidden=data.show_hidden,
)
db.add(folder)
await db.commit()
await db.refresh(folder)
return FolderResponse(
id=folder.id,
name=folder.name,
parent_id=folder.parent_id,
project_id=None,
archive_id=None,
is_external=True,
external_path=folder.external_path,
external_readonly=folder.external_readonly,
external_show_hidden=folder.external_show_hidden,
file_count=0,
created_at=folder.created_at,
updated_at=folder.updated_at,
)
@router.post("/folders/{folder_id}/scan")
async def scan_external_folder(
folder_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
):
"""Scan an external folder and sync files to the database.
Discovers new files, removes DB entries for deleted files.
Does not copy files — stores the external path directly.
"""
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
folder = result.scalar_one_or_none()
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")
if not folder.is_external or not folder.external_path:
raise HTTPException(status_code=400, detail="Not an external folder")
ext_path = Path(folder.external_path)
if not ext_path.exists() or not ext_path.is_dir():
raise HTTPException(status_code=400, detail=f"External path is not accessible: {folder.external_path}")
# Get existing DB files for this folder
existing_result = await db.execute(
select(LibraryFile).where(LibraryFile.folder_id == folder_id, LibraryFile.is_external.is_(True))
)
existing_files = {f.file_path: f for f in existing_result.scalars().all()}
# Scan the directory
added = 0
removed = 0
found_paths = set()
for dirpath, _dirnames, filenames in os.walk(ext_path):
for filename in filenames:
# Skip hidden files unless configured
if not folder.external_show_hidden and filename.startswith("."):
continue
filepath = Path(dirpath) / filename
ext = filepath.suffix.lower()
# Check for compound extensions like .gcode.3mf
if ext not in _SCANNABLE_EXTENSIONS:
# Check compound
compound = "".join(filepath.suffixes[-2:]).lower() if len(filepath.suffixes) >= 2 else ""
if compound not in _SCANNABLE_EXTENSIONS:
continue
# Resolve symlinks and ensure still under external_path
try:
real_path = filepath.resolve()
real_path.relative_to(ext_path.resolve())
except (ValueError, OSError):
continue # Symlink escapes the external dir
file_path_str = str(filepath)
found_paths.add(file_path_str)
if file_path_str in existing_files:
continue # Already tracked
# Get file info
try:
stat = filepath.stat()
except OSError:
continue
file_type = ext[1:] if ext else "unknown"
# For compound extensions, use the meaningful part
if file_type in ("3mf",) and len(filepath.suffixes) >= 2:
inner = filepath.suffixes[-2].lower()
if inner == ".gcode":
file_type = "gcode.3mf"
# Extract thumbnail for 3mf files
thumbnail_path = None
file_metadata = None
if file_type == "3mf":
try:
parser = ThreeMFParser(str(filepath))
meta = parser.parse()
if meta:
file_metadata = meta
thumb_data = parser.extract_thumbnail()
if thumb_data:
thumb_dir = get_library_thumbnails_dir()
thumb_filename = f"{uuid.uuid4().hex}.png"
thumb_full = thumb_dir / thumb_filename
thumb_full.write_bytes(thumb_data)
thumbnail_path = to_relative_path(thumb_full)
except Exception as e:
logger.debug("Failed to extract metadata from external 3mf %s: %s", filepath, e)
# Generate thumbnail for STL files
if file_type == "stl" and thumbnail_path is None:
try:
thumb_dir = get_library_thumbnails_dir()
thumb_result = generate_stl_thumbnail(str(filepath), str(thumb_dir))
if thumb_result:
thumbnail_path = to_relative_path(Path(thumb_result))
except Exception as e:
logger.debug("Failed to generate STL thumbnail for external %s: %s", filepath, e)
# Extract gcode thumbnail
if file_type == "gcode" and thumbnail_path is None:
thumb_data = extract_gcode_thumbnail(filepath)
if thumb_data:
thumb_dir = get_library_thumbnails_dir()
thumb_filename = f"{uuid.uuid4().hex}.png"
thumb_full = thumb_dir / thumb_filename
thumb_full.write_bytes(thumb_data)
thumbnail_path = to_relative_path(thumb_full)
# Create thumbnail for image files
if ext.lower() in IMAGE_EXTENSIONS and thumbnail_path is None:
thumbnail_path_str = create_image_thumbnail(filepath, get_library_thumbnails_dir())
if thumbnail_path_str:
thumbnail_path = to_relative_path(Path(thumbnail_path_str))
db_file = LibraryFile(
folder_id=folder_id,
is_external=True,
filename=filename,
file_path=file_path_str,
file_type=file_type,
file_size=stat.st_size,
file_hash=None, # Skip hashing external files for performance
thumbnail_path=thumbnail_path,
file_metadata=file_metadata,
)
db.add(db_file)
added += 1
# Remove DB entries for files that no longer exist on disk
for path_str, db_file in existing_files.items():
if path_str not in found_paths:
# Clean up thumbnail if we generated one
if db_file.thumbnail_path:
try:
abs_thumb = to_absolute_path(db_file.thumbnail_path)
if abs_thumb and abs_thumb.exists():
abs_thumb.unlink()
except OSError:
pass
await db.delete(db_file)
removed += 1
await db.commit()
return {"status": "success", "added": added, "removed": removed}
# ============ File Endpoints ============
@@ -678,6 +972,7 @@ async def list_files(
FileListResponse(
id=f.id,
folder_id=f.folder_id,
is_external=f.is_external,
filename=f.filename,
file_type=f.file_type,
file_size=f.file_size,
@@ -719,8 +1014,11 @@ async def upload_file(
# Verify folder exists if specified
if folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot upload to a read-only external folder")
# Generate unique filename for storage
unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -859,8 +1157,11 @@ async def extract_zip_file(
# Verify target folder exists if specified
if folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Target folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
# Save ZIP to temp file
try:
@@ -1994,12 +2295,14 @@ async def delete_file(
if file.created_by_id != user.id:
raise HTTPException(status_code=403, detail="You can only delete your own files")
# Delete actual files
# External files: only remove DB entry and thumbnail, never delete the actual file
try:
abs_file_path = to_absolute_path(file.file_path)
if not file.is_external:
abs_file_path = to_absolute_path(file.file_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
# Always clean up thumbnails we generated
abs_thumb_path = to_absolute_path(file.thumbnail_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
if abs_thumb_path and abs_thumb_path.exists():
abs_thumb_path.unlink()
except OSError as e:
@@ -2180,8 +2483,11 @@ async def move_files(
# Verify folder exists if specified
if data.folder_id is not None:
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
if not folder_result.scalar_one_or_none():
target_folder = folder_result.scalar_one_or_none()
if not target_folder:
raise HTTPException(status_code=404, detail="Folder not found")
if target_folder.is_external and target_folder.external_readonly:
raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
# Update files
moved = 0
@@ -2194,6 +2500,10 @@ async def move_files(
if not can_modify_all and file.created_by_id != user.id:
skipped += 1
continue
# Cannot move external files out of their folder
if file.is_external:
skipped += 1
continue
file.folder_id = data.folder_id
moved += 1
@@ -2231,10 +2541,11 @@ async def bulk_delete(
continue
try:
abs_file_path = to_absolute_path(file.file_path)
if not file.is_external:
abs_file_path = to_absolute_path(file.file_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
abs_thumb_path = to_absolute_path(file.thumbnail_path)
if abs_file_path and abs_file_path.exists():
abs_file_path.unlink()
if abs_thumb_path and abs_thumb_path.exists():
abs_thumb_path.unlink()
except OSError as e: