mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
225 lines
9.2 KiB
Python
225 lines
9.2 KiB
Python
"""Who sees which library folder, and who may write into it (#3201).
|
|
|
|
A folder has an owner (``created_by_id``, the user who made it) and can be
|
|
marked ``shared`` by an admin. A user with ``library:read_all`` (or any
|
|
caller when auth is off) sees every folder. An API key is passed in as its
|
|
``ApiKeyActor`` and treated as its owner within its scopes. A user with only
|
|
``library:read_own`` sees:
|
|
|
|
- folders they own,
|
|
- shared folders,
|
|
- folders holding one of their own files,
|
|
- and the parents needed to reach any of those, for navigation only.
|
|
|
|
They may write into (upload, extract, move files, create subfolders in)
|
|
their own folders and shared ones, plus the root. Everything else is hidden:
|
|
a folder they can't see answers 404, exactly like another user's file.
|
|
|
|
A folder made without a user (auth off, a key without an owner) is created shared, so
|
|
switching auth on later doesn't hide it. Folders from before #3201 got an
|
|
owner or the shared flag from the upgrade backfill in ``core/database.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.library import LibraryFile, LibraryFolder
|
|
from backend.app.models.user import User
|
|
|
|
|
|
def sees_all_folders(user: User | None) -> bool:
|
|
"""True for ``library:read_all``, and for ``None`` (auth off)."""
|
|
return user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
|
|
|
|
|
|
def owns(folder: LibraryFolder, user: User | None) -> bool:
|
|
return user is not None and folder.created_by_id is not None and folder.created_by_id == user.id
|
|
|
|
|
|
def can_write_folder(folder: LibraryFolder, user: User | None) -> bool:
|
|
"""May ``user`` put files or subfolders into ``folder``."""
|
|
return sees_all_folders(user) or owns(folder, user) or bool(folder.shared)
|
|
|
|
|
|
def can_rename_folder(folder: LibraryFolder, user: User | None) -> bool:
|
|
if user is None or user.has_permission(Permission.LIBRARY_UPDATE_ALL.value):
|
|
return True
|
|
return user.has_permission(Permission.LIBRARY_UPDATE_OWN.value) and owns(folder, user)
|
|
|
|
|
|
@dataclass
|
|
class FolderIndex:
|
|
"""Every folder plus who owns the files in it, loaded in two queries.
|
|
|
|
``file_owners`` counts trashed files too: a folder delete cascades to
|
|
them, so they decide who may delete it just like live files do.
|
|
"""
|
|
|
|
folders: dict[int, LibraryFolder] = field(default_factory=dict)
|
|
children: dict[int | None, list[int]] = field(default_factory=dict)
|
|
file_owners: dict[int, set[int | None]] = field(default_factory=dict)
|
|
live_file_owners: dict[int, set[int | None]] = field(default_factory=dict)
|
|
|
|
def subtree(self, folder_id: int) -> list[int]:
|
|
"""``folder_id`` and every folder below it."""
|
|
out: list[int] = []
|
|
seen: set[int] = set()
|
|
stack = [folder_id]
|
|
while stack:
|
|
fid = stack.pop()
|
|
if fid in seen: # a parent_id loop must not hang the request
|
|
continue
|
|
seen.add(fid)
|
|
out.append(fid)
|
|
stack.extend(self.children.get(fid, []))
|
|
return out
|
|
|
|
|
|
async def load_folder_index(db: AsyncSession, *, with_files: bool = True) -> FolderIndex:
|
|
"""Every folder, and with ``with_files`` who owns the files in each.
|
|
|
|
Without the files, visibility and delete rules are only right for users
|
|
who see everything and may delete everything.
|
|
"""
|
|
index = FolderIndex()
|
|
for folder in (await db.execute(select(LibraryFolder))).scalars().all():
|
|
index.folders[folder.id] = folder
|
|
index.children.setdefault(folder.parent_id, []).append(folder.id)
|
|
if not with_files:
|
|
return index
|
|
rows = await db.execute(
|
|
select(LibraryFile.folder_id, LibraryFile.created_by_id, LibraryFile.deleted_at.is_(None))
|
|
.where(LibraryFile.folder_id.isnot(None))
|
|
.distinct()
|
|
)
|
|
for folder_id, owner_id, live in rows.all():
|
|
index.file_owners.setdefault(folder_id, set()).add(owner_id)
|
|
if live:
|
|
index.live_file_owners.setdefault(folder_id, set()).add(owner_id)
|
|
return index
|
|
|
|
|
|
def visible_folder_ids(index: FolderIndex, user: User | None) -> set[int] | None:
|
|
"""The folders ``user`` may see, or ``None`` for all of them."""
|
|
if sees_all_folders(user):
|
|
return None
|
|
assert user is not None
|
|
reachable = {
|
|
fid
|
|
for fid, folder in index.folders.items()
|
|
if owns(folder, user) or folder.shared or user.id in index.live_file_owners.get(fid, set())
|
|
}
|
|
visible = set(reachable)
|
|
for fid in reachable:
|
|
parent = index.folders[fid].parent_id
|
|
while parent is not None and parent not in visible and parent in index.folders:
|
|
visible.add(parent)
|
|
parent = index.folders[parent].parent_id
|
|
return visible
|
|
|
|
|
|
def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User | None) -> str | None:
|
|
"""Why ``user`` may NOT delete ``folder``, or None if they may.
|
|
|
|
``library:delete_all`` deletes anything. With ``library:delete_own`` a user
|
|
deletes a folder they own when everything under it, folders and files
|
|
(trashed ones too), is theirs as well. A folder without an owner, made
|
|
before #3201, keeps the old rule (#1781): only when it is truly empty.
|
|
"""
|
|
if user is None or user.has_permission(Permission.LIBRARY_DELETE_ALL.value):
|
|
return None
|
|
if not user.has_permission(Permission.LIBRARY_DELETE_OWN.value):
|
|
return "Deleting folders requires library:delete_own or library:delete_all"
|
|
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"
|
|
|
|
if folder.created_by_id is None:
|
|
if index.children.get(folder.id):
|
|
return "Only empty folders can be deleted without library:delete_all"
|
|
if index.file_owners.get(folder.id):
|
|
return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
|
|
return None
|
|
|
|
if not owns(folder, user):
|
|
return "Only the folder's owner can delete it without library:delete_all"
|
|
for fid in index.subtree(folder.id):
|
|
sub = index.folders[fid]
|
|
if fid != folder.id and not owns(sub, user):
|
|
return "The folder contains folders of other users; deleting it requires library:delete_all"
|
|
if sub.is_external or sub.project_id is not None or sub.archive_id is not None:
|
|
return "The folder contains external or linked folders; deleting it requires library:delete_all"
|
|
if index.file_owners.get(fid, set()) - {user.id}:
|
|
return (
|
|
"The folder contains files of other users (the folder may contain trashed files); "
|
|
"deleting it requires library:delete_all"
|
|
)
|
|
return None
|
|
|
|
|
|
async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
|
|
"""The folder, or 404 when it doesn't exist or ``user`` can't see it."""
|
|
folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
|
|
if folder is None:
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
if not sees_all_folders(user):
|
|
visible = visible_folder_ids(await load_folder_index(db), user)
|
|
if visible is not None and folder.id not in visible:
|
|
raise HTTPException(status_code=404, detail="Folder not found")
|
|
return folder
|
|
|
|
|
|
async def get_writable_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
|
|
"""The folder ``user`` puts something into: 404 when unseen, 403 when only seen."""
|
|
folder = await get_visible_folder(db, folder_id, user)
|
|
if not can_write_folder(folder, user):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="You can only add to your own folders and folders shared with everyone",
|
|
)
|
|
return folder
|
|
|
|
|
|
async def default_import_folder(db: AsyncSession, name: str, user: User | None) -> LibraryFolder:
|
|
"""The top-level folder an import lands in when none was chosen.
|
|
|
|
The first one is created shared, so every importer's models land side by
|
|
side as before #3201. If an admin made it private, a user who can't write
|
|
to it gets a folder of the same name of their own instead of being
|
|
refused.
|
|
"""
|
|
candidates = (
|
|
(
|
|
await db.execute(
|
|
select(LibraryFolder)
|
|
.where(
|
|
LibraryFolder.name == name,
|
|
LibraryFolder.parent_id.is_(None),
|
|
LibraryFolder.is_external.is_(False),
|
|
)
|
|
.order_by(LibraryFolder.id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
for folder in candidates:
|
|
if can_write_folder(folder, user):
|
|
return folder
|
|
folder = LibraryFolder(
|
|
name=name,
|
|
parent_id=None,
|
|
created_by_id=user.id if user else None,
|
|
shared=not candidates,
|
|
)
|
|
db.add(folder)
|
|
await db.flush()
|
|
return folder
|