|
|
@@ -80,6 +80,17 @@ from backend.app.services.design_settings import (
|
|
|
overrides_from_config,
|
|
|
)
|
|
|
from backend.app.services.filament_requirements import annotate_rack_groups
|
|
|
+from backend.app.services.library_folder_access import (
|
|
|
+ FolderIndex,
|
|
|
+ can_rename_folder,
|
|
|
+ can_write_folder,
|
|
|
+ folder_delete_blocker,
|
|
|
+ get_visible_folder,
|
|
|
+ get_writable_folder,
|
|
|
+ load_folder_index,
|
|
|
+ sees_all_folders,
|
|
|
+ visible_folder_ids,
|
|
|
+)
|
|
|
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.print_confirmation import confirm_outcome_for_new_queue_item
|
|
|
@@ -146,6 +157,41 @@ def _ensure_library_file_visible(
|
|
|
return library_file
|
|
|
|
|
|
|
|
|
+def _own_files_filter(user: User | None) -> list:
|
|
|
+ """Extra WHERE terms that count only ``user``'s files, for a read_own user.
|
|
|
+
|
|
|
+ Folder file counts and activity times must not reveal files the user
|
|
|
+ can't open (#3201).
|
|
|
+ """
|
|
|
+ if sees_all_folders(user):
|
|
|
+ return []
|
|
|
+ return [LibraryFile.created_by_id == user.id]
|
|
|
+
|
|
|
+
|
|
|
+async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
|
|
|
+ """The folder index a response for ``user`` needs (#3201).
|
|
|
+
|
|
|
+ Who owns which file is needed only to decide what a read_own user sees
|
|
|
+ and what a delete_own user may delete. Everyone else skips that scan of
|
|
|
+ the files table, which the folder tree would otherwise pay on every load.
|
|
|
+ """
|
|
|
+ needs_files = user is not None and not (
|
|
|
+ sees_all_folders(user) and user.has_permission(Permission.LIBRARY_DELETE_ALL.value)
|
|
|
+ )
|
|
|
+ return await load_folder_index(db, with_files=needs_files)
|
|
|
+
|
|
|
+
|
|
|
+def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | None) -> dict:
|
|
|
+ """The ownership fields of a folder response, for the user asking (#3201)."""
|
|
|
+ return {
|
|
|
+ "created_by_id": folder.created_by_id,
|
|
|
+ "shared": bool(folder.shared),
|
|
|
+ "can_write": can_write_folder(folder, user),
|
|
|
+ "can_rename": can_rename_folder(folder, user),
|
|
|
+ "can_delete": folder_delete_blocker(index, folder, user) is None,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
def get_library_dir() -> Path:
|
|
|
"""Get the library storage directory."""
|
|
|
base_dir = Path(app_settings.archive_dir)
|
|
|
@@ -938,17 +984,25 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
|
|
|
async def list_folders(
|
|
|
response: Response,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: tuple[User | None, bool] = Depends(
|
|
|
+ auth_result: tuple[User | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
|
),
|
|
|
):
|
|
|
- """Get all folders as a tree structure."""
|
|
|
+ """Get all folders as a tree structure.
|
|
|
+
|
|
|
+ A library:read_own user gets only the folders they may see (#3201), and
|
|
|
+ the file counts and activity times of their own files only.
|
|
|
+ """
|
|
|
+ user, _ = auth_result
|
|
|
# Prevent browser caching of folder list
|
|
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
|
|
|
|
+ index = await _load_index(db, user)
|
|
|
+ visible = visible_folder_ids(index, user)
|
|
|
+
|
|
|
# Get all folders with project and archive joins
|
|
|
result = await db.execute(
|
|
|
select(LibraryFolder, Project.name, PrintArchive.print_name)
|
|
|
@@ -956,12 +1010,13 @@ async def list_folders(
|
|
|
.outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
|
|
|
.order_by(LibraryFolder.name)
|
|
|
)
|
|
|
- rows = result.all()
|
|
|
+ rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
+ own_files = _own_files_filter(user)
|
|
|
|
|
|
# Get file counts per folder
|
|
|
file_counts_result = await db.execute(
|
|
|
select(LibraryFile.folder_id, func.count(LibraryFile.id))
|
|
|
- .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
|
|
|
+ .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None), *own_files)
|
|
|
.group_by(LibraryFile.folder_id)
|
|
|
)
|
|
|
file_counts = dict(file_counts_result.all())
|
|
|
@@ -976,7 +1031,7 @@ async def list_folders(
|
|
|
LibraryFile.folder_id,
|
|
|
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
|
|
|
)
|
|
|
- .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
|
|
|
+ .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None), *own_files)
|
|
|
.group_by(LibraryFile.folder_id)
|
|
|
)
|
|
|
latest_file_activity = dict(latest_file_activity_result.all())
|
|
|
@@ -1007,6 +1062,7 @@ async def list_folders(
|
|
|
external_readonly=folder.external_readonly,
|
|
|
file_count=file_counts.get(folder.id, 0),
|
|
|
latest_activity_at=own_activity,
|
|
|
+ **_folder_access_fields(index, folder, user),
|
|
|
children=[],
|
|
|
)
|
|
|
folder_map[folder.id] = folder_item
|
|
|
@@ -1048,21 +1104,25 @@ async def list_folders(
|
|
|
async def get_folders_by_project(
|
|
|
project_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: tuple[User | None, bool] = Depends(
|
|
|
+ auth_result: tuple[User | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
|
),
|
|
|
):
|
|
|
- """Get all folders linked to a specific project."""
|
|
|
+ """Get all folders linked to a specific project, those the user may see (#3201)."""
|
|
|
+ user, _ = auth_result
|
|
|
+ index = await _load_index(db, user)
|
|
|
+ visible = visible_folder_ids(index, user)
|
|
|
+ own_files = _own_files_filter(user)
|
|
|
result = await db.execute(
|
|
|
select(LibraryFolder, Project.name)
|
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
|
.where(LibraryFolder.project_id == project_id)
|
|
|
.order_by(LibraryFolder.name)
|
|
|
)
|
|
|
- rows = result.all()
|
|
|
+ rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
|
|
|
folders = []
|
|
|
for folder, project_name in rows:
|
|
|
@@ -1075,6 +1135,7 @@ async def get_folders_by_project(
|
|
|
).where(
|
|
|
LibraryFile.folder_id == folder.id,
|
|
|
LibraryFile.deleted_at.is_(None),
|
|
|
+ *own_files,
|
|
|
)
|
|
|
)
|
|
|
file_count, latest_file = agg_result.one()
|
|
|
@@ -1097,6 +1158,7 @@ async def get_folders_by_project(
|
|
|
external_show_hidden=folder.external_show_hidden,
|
|
|
file_count=file_count,
|
|
|
latest_activity_at=latest_activity_at,
|
|
|
+ **_folder_access_fields(index, folder, user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
@@ -1109,21 +1171,25 @@ async def get_folders_by_project(
|
|
|
async def get_folders_by_archive(
|
|
|
archive_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: tuple[User | None, bool] = Depends(
|
|
|
+ auth_result: tuple[User | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
|
),
|
|
|
):
|
|
|
- """Get all folders linked to a specific archive."""
|
|
|
+ """Get all folders linked to a specific archive, those the user may see (#3201)."""
|
|
|
+ user, _ = auth_result
|
|
|
+ index = await _load_index(db, user)
|
|
|
+ visible = visible_folder_ids(index, user)
|
|
|
+ own_files = _own_files_filter(user)
|
|
|
result = await db.execute(
|
|
|
select(LibraryFolder, PrintArchive.print_name)
|
|
|
.outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id)
|
|
|
.where(LibraryFolder.archive_id == archive_id)
|
|
|
.order_by(LibraryFolder.name)
|
|
|
)
|
|
|
- rows = result.all()
|
|
|
+ rows = [row for row in result.all() if visible is None or row[0].id in visible]
|
|
|
|
|
|
folders = []
|
|
|
for folder, archive_name in rows:
|
|
|
@@ -1136,6 +1202,7 @@ async def get_folders_by_archive(
|
|
|
).where(
|
|
|
LibraryFile.folder_id == folder.id,
|
|
|
LibraryFile.deleted_at.is_(None),
|
|
|
+ *own_files,
|
|
|
)
|
|
|
)
|
|
|
file_count, latest_file = agg_result.one()
|
|
|
@@ -1158,6 +1225,7 @@ async def get_folders_by_archive(
|
|
|
external_show_hidden=folder.external_show_hidden,
|
|
|
file_count=file_count,
|
|
|
latest_activity_at=latest_activity_at,
|
|
|
+ **_folder_access_fields(index, folder, user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
@@ -1171,14 +1239,12 @@ async def get_folders_by_archive(
|
|
|
async def create_folder(
|
|
|
data: FolderCreate,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
|
|
+ current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
|
|
):
|
|
|
- """Create a new folder."""
|
|
|
- # Verify parent exists if specified
|
|
|
+ """Create a new folder, owned by the user who makes it (#3201)."""
|
|
|
+ # A read_own user may only create inside their own or a shared folder.
|
|
|
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")
|
|
|
+ await get_writable_folder(db, data.parent_id, current_user)
|
|
|
|
|
|
# Verify project exists if specified
|
|
|
project_name = None
|
|
|
@@ -1203,10 +1269,14 @@ async def create_folder(
|
|
|
parent_id=data.parent_id,
|
|
|
project_id=data.project_id,
|
|
|
archive_id=data.archive_id,
|
|
|
+ created_by_id=current_user.id if current_user else None,
|
|
|
+ # Made without a user (auth off, an API key): everyone's, as before #3201.
|
|
|
+ shared=current_user is None,
|
|
|
)
|
|
|
db.add(folder)
|
|
|
await db.commit()
|
|
|
await db.refresh(folder)
|
|
|
+ index = await _load_index(db, current_user)
|
|
|
|
|
|
return FolderResponse(
|
|
|
id=folder.id,
|
|
|
@@ -1224,6 +1294,7 @@ async def create_folder(
|
|
|
# New folder has no files yet — fall back to the folder's own
|
|
|
# updated_at so this matches the list-route semantics (#1770).
|
|
|
latest_activity_at=folder.updated_at,
|
|
|
+ **_folder_access_fields(index, folder, current_user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
@@ -1233,14 +1304,19 @@ async def create_folder(
|
|
|
async def get_folder(
|
|
|
folder_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: tuple[User | None, bool] = Depends(
|
|
|
+ auth_result: tuple[User | None, bool] = Depends(
|
|
|
require_ownership_permission(
|
|
|
Permission.LIBRARY_READ_ALL,
|
|
|
Permission.LIBRARY_READ_OWN,
|
|
|
)
|
|
|
),
|
|
|
):
|
|
|
- """Get a folder by ID."""
|
|
|
+ """Get a folder by ID; 404 for a folder the user may not see (#3201)."""
|
|
|
+ user, _ = auth_result
|
|
|
+ index = await _load_index(db, user)
|
|
|
+ visible = visible_folder_ids(index, user)
|
|
|
+ if visible is not None and folder_id not in visible:
|
|
|
+ raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
result = await db.execute(
|
|
|
select(LibraryFolder, Project.name, PrintArchive.print_name)
|
|
|
.outerjoin(Project, LibraryFolder.project_id == Project.id)
|
|
|
@@ -1262,6 +1338,7 @@ async def get_folder(
|
|
|
).where(
|
|
|
LibraryFile.folder_id == folder_id,
|
|
|
LibraryFile.deleted_at.is_(None),
|
|
|
+ *_own_files_filter(user),
|
|
|
)
|
|
|
)
|
|
|
file_count, latest_file = agg_result.one()
|
|
|
@@ -1282,6 +1359,7 @@ async def get_folder(
|
|
|
external_show_hidden=folder.external_show_hidden,
|
|
|
file_count=file_count,
|
|
|
latest_activity_at=latest_activity_at,
|
|
|
+ **_folder_access_fields(index, folder, user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
@@ -1310,9 +1388,7 @@ async def get_folder_readme(
|
|
|
"""
|
|
|
user, can_read_all = auth_result
|
|
|
|
|
|
- folder_row = await db.execute(select(LibraryFolder.id).where(LibraryFolder.id == folder_id))
|
|
|
- if folder_row.scalar_one_or_none() is None:
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ await get_visible_folder(db, folder_id, user)
|
|
|
|
|
|
query = LibraryFile.active().where(
|
|
|
LibraryFile.folder_id == folder_id,
|
|
|
@@ -1358,18 +1434,35 @@ async def update_folder(
|
|
|
folder_id: int,
|
|
|
data: FolderUpdate,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
|
|
+ auth_result: tuple[User | None, bool] = Depends(
|
|
|
+ require_ownership_permission(
|
|
|
+ Permission.LIBRARY_UPDATE_ALL,
|
|
|
+ Permission.LIBRARY_UPDATE_OWN,
|
|
|
+ )
|
|
|
+ ),
|
|
|
):
|
|
|
"""Update a folder.
|
|
|
|
|
|
- Note: Folders require library:update_all permission since they don't have
|
|
|
- ownership tracking.
|
|
|
+ library:update_all changes any folder. With library:update_own a user may
|
|
|
+ rename their own folders and move them into another folder they can
|
|
|
+ write to (#3201); linking and sharing stay with library:update_all.
|
|
|
"""
|
|
|
- result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
|
|
- folder = result.scalar_one_or_none()
|
|
|
+ user, can_modify_all = auth_result
|
|
|
+ # library:update_all reaches every folder, seen or not, as before #3201.
|
|
|
+ folder = await get_visible_folder(db, folder_id, None if can_modify_all else user)
|
|
|
|
|
|
- if not folder:
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ if not can_modify_all:
|
|
|
+ if not can_rename_folder(folder, user):
|
|
|
+ raise HTTPException(status_code=403, detail="Only the folder's owner can change it")
|
|
|
+ if data.project_id is not None or data.archive_id is not None:
|
|
|
+ raise HTTPException(status_code=403, detail="Linking folders requires library:update_all")
|
|
|
+ if data.shared is not None:
|
|
|
+ raise HTTPException(status_code=403, detail="Sharing folders requires library:update_all")
|
|
|
+ if data.parent_id:
|
|
|
+ await get_writable_folder(db, data.parent_id, user)
|
|
|
+
|
|
|
+ if data.shared is not None:
|
|
|
+ folder.shared = data.shared
|
|
|
|
|
|
if data.name is not None:
|
|
|
folder.name = data.name
|
|
|
@@ -1425,6 +1518,7 @@ async def update_folder(
|
|
|
).where(
|
|
|
LibraryFile.folder_id == folder_id,
|
|
|
LibraryFile.deleted_at.is_(None),
|
|
|
+ *_own_files_filter(user),
|
|
|
)
|
|
|
)
|
|
|
file_count, latest_file = agg_result.one()
|
|
|
@@ -1455,37 +1549,12 @@ async def update_folder(
|
|
|
external_show_hidden=folder.external_show_hidden,
|
|
|
file_count=file_count,
|
|
|
latest_activity_at=latest_activity_at,
|
|
|
+ **_folder_access_fields(await _load_index(db, user), folder, user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
|
|
|
|
|
|
-async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
|
|
|
- """Why a library:delete_own user may NOT delete this folder, or None if they may.
|
|
|
-
|
|
|
- Folders have no ownership tracking, so users without library:delete_all may
|
|
|
- only delete folders that are truly empty — an empty folder contains nobody's
|
|
|
- data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
|
|
|
- cascades on folder delete, so a folder holding another user's trashed file
|
|
|
- would silently break trash restore.
|
|
|
- """
|
|
|
- if folder.is_external:
|
|
|
- return "External folders can only be deleted by users with library:delete_all"
|
|
|
- if folder.project_id is not None or folder.archive_id is not None:
|
|
|
- return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
|
|
|
-
|
|
|
- child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
|
|
|
- if (child_result.scalar() or 0) > 0:
|
|
|
- return "Only empty folders can be deleted without library:delete_all"
|
|
|
-
|
|
|
- # Includes trashed files (no deleted_at filter) — see docstring.
|
|
|
- file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
|
|
|
- if (file_result.scalar() or 0) > 0:
|
|
|
- return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
|
|
|
-
|
|
|
- return None
|
|
|
-
|
|
|
-
|
|
|
@router.delete("/folders/{folder_id}")
|
|
|
async def delete_folder(
|
|
|
folder_id: int,
|
|
|
@@ -1499,19 +1568,17 @@ async def delete_folder(
|
|
|
):
|
|
|
"""Delete a folder and all its contents (cascade).
|
|
|
|
|
|
- Folders have no ownership tracking, so cascade deletion requires
|
|
|
- library:delete_all. Users with only library:delete_own may delete empty,
|
|
|
- non-external, non-linked folders (#1781).
|
|
|
+ library:delete_all deletes any folder. With library:delete_own a user
|
|
|
+ deletes their own folder when everything in it is theirs too (#3201), or
|
|
|
+ an ownerless folder that is truly empty (#1781). See
|
|
|
+ ``library_folder_access.folder_delete_blocker``.
|
|
|
"""
|
|
|
- _, can_modify_all = auth_result
|
|
|
- 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")
|
|
|
+ user, can_modify_all = auth_result
|
|
|
+ # library:delete_all reaches every folder, seen or not, as before #3201.
|
|
|
+ folder = await get_visible_folder(db, folder_id, None if can_modify_all else user)
|
|
|
|
|
|
if not can_modify_all:
|
|
|
- blocker = await _restricted_folder_delete_blocker(db, folder)
|
|
|
+ blocker = folder_delete_blocker(await load_folder_index(db), folder, user)
|
|
|
if blocker:
|
|
|
raise HTTPException(status_code=403, detail=blocker)
|
|
|
|
|
|
@@ -1736,7 +1803,7 @@ async def create_external_folder(
|
|
|
# /api/v1/library/folders). LIBRARY_UPLOAD was always the wrong scope —
|
|
|
# SETTINGS_UPDATE is the admin-class gate that already protects every
|
|
|
# other host-affecting setting (SMTP, LDAP, cloud, smart plugs).
|
|
|
- _: User | None = Depends(require_permission_if_auth_enabled(Permission.SETTINGS_UPDATE)),
|
|
|
+ current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.SETTINGS_UPDATE)),
|
|
|
):
|
|
|
"""Create an external folder that points to a host directory."""
|
|
|
resolved = _validate_external_path(data.external_path)
|
|
|
@@ -1761,6 +1828,9 @@ async def create_external_folder(
|
|
|
name=data.name,
|
|
|
parent_id=data.parent_id,
|
|
|
is_external=True,
|
|
|
+ created_by_id=current_user.id if current_user else None,
|
|
|
+ # A mount is everyone's, as before #3201; an admin can stop sharing it.
|
|
|
+ shared=True,
|
|
|
external_path=str(resolved),
|
|
|
external_readonly=data.readonly,
|
|
|
external_show_hidden=data.show_hidden,
|
|
|
@@ -1783,6 +1853,7 @@ async def create_external_folder(
|
|
|
# Newly-created external folder hasn't been scanned yet — fall back
|
|
|
# to the folder's own updated_at (#1770).
|
|
|
latest_activity_at=folder.updated_at,
|
|
|
+ **_folder_access_fields(await _load_index(db, current_user), folder, current_user),
|
|
|
created_at=folder.created_at,
|
|
|
updated_at=folder.updated_at,
|
|
|
)
|
|
|
@@ -1802,18 +1873,15 @@ def _mtime_to_datetime(mtime: float) -> datetime:
|
|
|
async def scan_external_folder(
|
|
|
folder_id: int,
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
- _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
|
|
|
+ current_user: 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")
|
|
|
+ # A mount the user can't see is 404, like a missing one (#3201).
|
|
|
+ folder = await get_visible_folder(db, folder_id, current_user)
|
|
|
if not folder.is_external or not folder.external_path:
|
|
|
raise HTTPException(status_code=400, detail="Not an external folder")
|
|
|
|
|
|
@@ -1916,6 +1984,9 @@ async def scan_external_folder(
|
|
|
), # SEC-PATH-OK: current_path built from Path(rel_dir).parts of an os.walk descent under ext_path
|
|
|
external_readonly=folder.external_readonly,
|
|
|
external_show_hidden=folder.external_show_hidden,
|
|
|
+ # A scanned subfolder belongs with the mount (#3201).
|
|
|
+ created_by_id=folder.created_by_id,
|
|
|
+ shared=folder.shared,
|
|
|
)
|
|
|
db.add(new_folder)
|
|
|
await db.flush()
|
|
|
@@ -2341,13 +2412,10 @@ async def upload_file(
|
|
|
# bytes are on disk to be read.
|
|
|
ext = os.path.splitext(filename)[1].lower()
|
|
|
|
|
|
- # Verify folder exists if specified
|
|
|
+ # Verify folder exists if specified, and that the user may add to it (#3201)
|
|
|
target_folder = None
|
|
|
if folder_id is not None:
|
|
|
- folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
|
|
- target_folder = folder_result.scalar_one_or_none()
|
|
|
- if not target_folder:
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ target_folder = await get_writable_folder(db, folder_id, current_user)
|
|
|
|
|
|
# Writable external folders write through to the mount so the file is
|
|
|
# visible outside Bambuddy (#1112); everything else lands under the
|
|
|
@@ -2516,12 +2584,9 @@ async def extract_zip_file(
|
|
|
if not file.filename or not file.filename.lower().endswith(".zip"):
|
|
|
raise HTTPException(status_code=400, detail="Only ZIP files are supported")
|
|
|
|
|
|
- # Verify target folder exists if specified
|
|
|
+ # Verify target folder exists if specified, and that the user may add to it (#3201)
|
|
|
if folder_id is not None:
|
|
|
- folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
|
|
- target_folder = folder_result.scalar_one_or_none()
|
|
|
- if not target_folder:
|
|
|
- raise HTTPException(status_code=404, detail="Target folder not found")
|
|
|
+ target_folder = await get_writable_folder(db, folder_id, current_user)
|
|
|
if target_folder.is_external and target_folder.external_readonly:
|
|
|
raise HTTPException(status_code=403, detail="Cannot extract ZIP to a read-only external folder")
|
|
|
if target_folder.is_external:
|
|
|
@@ -2567,13 +2632,24 @@ async def extract_zip_file(
|
|
|
LibraryFolder.parent_id == folder_id if folder_id else LibraryFolder.parent_id.is_(None),
|
|
|
)
|
|
|
)
|
|
|
- existing_folder = existing.scalar_one_or_none()
|
|
|
+ # Reuse a same-named folder only when the user may write to it; never
|
|
|
+ # extract into someone else's folder that happens to share the name (#3201).
|
|
|
+ existing_folder = next(
|
|
|
+ (f for f in existing.scalars().all() if can_write_folder(f, current_user)),
|
|
|
+ None,
|
|
|
+ )
|
|
|
if existing_folder:
|
|
|
zip_folder_id = existing_folder.id
|
|
|
logger.info("Reusing existing folder '%s' with id=%s", zip_folder_name, zip_folder_id)
|
|
|
else:
|
|
|
# Create folder
|
|
|
- new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)
|
|
|
+ new_folder = LibraryFolder(
|
|
|
+ name=zip_folder_name,
|
|
|
+ parent_id=folder_id,
|
|
|
+ created_by_id=current_user.id if current_user else None,
|
|
|
+ # Made without a user (auth off, an API key): everyone's, as before #3201.
|
|
|
+ shared=current_user is None,
|
|
|
+ )
|
|
|
db.add(new_folder)
|
|
|
await db.flush()
|
|
|
await db.commit() # Commit folder creation immediately
|
|
|
@@ -2623,13 +2699,22 @@ async def extract_zip_file(
|
|
|
else LibraryFolder.parent_id.is_(None),
|
|
|
)
|
|
|
)
|
|
|
- existing_folder = existing.scalar_one_or_none()
|
|
|
+ existing_folder = next(
|
|
|
+ (f for f in existing.scalars().all() if can_write_folder(f, current_user)),
|
|
|
+ None,
|
|
|
+ )
|
|
|
|
|
|
if existing_folder:
|
|
|
current_parent = existing_folder.id
|
|
|
else:
|
|
|
# Create folder
|
|
|
- new_folder = LibraryFolder(name=part, parent_id=current_parent)
|
|
|
+ new_folder = LibraryFolder(
|
|
|
+ name=part,
|
|
|
+ parent_id=current_parent,
|
|
|
+ created_by_id=current_user.id if current_user else None,
|
|
|
+ # Made without a user (auth off, an API key): everyone's, as before #3201.
|
|
|
+ shared=current_user is None,
|
|
|
+ )
|
|
|
db.add(new_folder)
|
|
|
await db.flush()
|
|
|
current_parent = new_folder.id
|
|
|
@@ -2959,11 +3044,7 @@ async def combine_files(
|
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
|
|
if request.folder_id is not None:
|
|
|
- folder = (
|
|
|
- await db.execute(select(LibraryFolder).where(LibraryFolder.id == request.folder_id))
|
|
|
- ).scalar_one_or_none()
|
|
|
- if folder is None:
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ await get_writable_folder(db, request.folder_id, current_user)
|
|
|
|
|
|
# Same per-row visibility the slice route applies: a READ_OWN caller must
|
|
|
# not be able to pull another user's model into their own file by raw id.
|
|
|
@@ -5319,10 +5400,8 @@ async def update_file(
|
|
|
if data.folder_id == 0:
|
|
|
file.folder_id = None
|
|
|
else:
|
|
|
- # Verify folder exists
|
|
|
- folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
|
|
|
- if not folder_result.scalar_one_or_none():
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ # library:update_all moves into any folder, as before (#3201).
|
|
|
+ await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
|
|
file.folder_id = data.folder_id
|
|
|
|
|
|
if data.project_id is not None:
|
|
|
@@ -5866,10 +5945,9 @@ async def move_files(
|
|
|
# Verify folder exists if specified
|
|
|
target_folder: LibraryFolder | None = None
|
|
|
if data.folder_id is not None:
|
|
|
- folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
|
|
|
- target_folder = folder_result.scalar_one_or_none()
|
|
|
- if not target_folder:
|
|
|
- raise HTTPException(status_code=404, detail="Folder not found")
|
|
|
+ # Only into a folder the user may write to (#3201); library:update_all
|
|
|
+ # moves into any folder, as before.
|
|
|
+ target_folder = await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
|
|
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")
|
|
|
|
|
|
@@ -6007,14 +6085,18 @@ async def bulk_delete(
|
|
|
remove_library_photos_dir(file.id)
|
|
|
await db.delete(file)
|
|
|
|
|
|
- # Delete folders (cascade will handle contents). Folders have no ownership
|
|
|
- # tracking, so users without *_all permission may only delete empty,
|
|
|
- # non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
|
|
|
+ # Delete folders (cascade will handle contents). Without *_all permission a
|
|
|
+ # user deletes only what DELETE /folders/{id} would let them (#1781, #3201);
|
|
|
+ # a folder they can't see is skipped like one that doesn't exist.
|
|
|
+ index = await load_folder_index(db) if data.folder_ids and not can_modify_all else None
|
|
|
+ visible = visible_folder_ids(index, user) if index is not None else None
|
|
|
for folder_id in data.folder_ids:
|
|
|
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
|
|
folder = result.scalar_one_or_none()
|
|
|
if folder:
|
|
|
- if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
|
|
|
+ if visible is not None and folder.id not in visible:
|
|
|
+ continue
|
|
|
+ if not can_modify_all and folder_delete_blocker(index, folder, user):
|
|
|
continue
|
|
|
# Count files that will be deleted
|
|
|
file_count_result = await db.execute(
|
|
|
@@ -6065,9 +6147,13 @@ async def get_library_stats(
|
|
|
total_files_result = await db.execute(select(func.count(LibraryFile.id)).where(*file_filters))
|
|
|
total_files = total_files_result.scalar() or 0
|
|
|
|
|
|
- # Total folders (folders are shared org structure, not per-user — count all)
|
|
|
- total_folders_result = await db.execute(select(func.count(LibraryFolder.id)))
|
|
|
- total_folders = total_folders_result.scalar() or 0
|
|
|
+ # Total folders: the ones the user can see (#3201)
|
|
|
+ visible = None if sees_all_folders(user) else visible_folder_ids(await load_folder_index(db), user)
|
|
|
+ if visible is None:
|
|
|
+ total_folders_result = await db.execute(select(func.count(LibraryFolder.id)))
|
|
|
+ total_folders = total_folders_result.scalar() or 0
|
|
|
+ else:
|
|
|
+ total_folders = len(visible)
|
|
|
|
|
|
# Total size
|
|
|
total_size_result = await db.execute(select(func.sum(LibraryFile.file_size)).where(*file_filters))
|