library_folder_access.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. """Who sees which library folder, and who may write into it (#3201).
  2. A folder has an owner (``created_by_id``, the user who made it) and can be
  3. marked ``shared`` by an admin. A user with ``library:read_all`` (or any
  4. caller when auth is off, or an API key) sees every folder. A user with only
  5. ``library:read_own`` sees:
  6. - folders they own,
  7. - shared folders,
  8. - folders holding one of their own files,
  9. - and the parents needed to reach any of those, for navigation only.
  10. They may write into (upload, extract, move files, create subfolders in)
  11. their own folders and shared ones, plus the root. Everything else is hidden:
  12. a folder they can't see answers 404, exactly like another user's file.
  13. A folder made without a user (auth off, an API key) is created shared, so
  14. switching auth on later doesn't hide it. Folders from before #3201 got an
  15. owner or the shared flag from the upgrade backfill in ``core/database.py``.
  16. """
  17. from __future__ import annotations
  18. from dataclasses import dataclass, field
  19. from fastapi import HTTPException
  20. from sqlalchemy import select
  21. from sqlalchemy.ext.asyncio import AsyncSession
  22. from backend.app.core.permissions import Permission
  23. from backend.app.models.library import LibraryFile, LibraryFolder
  24. from backend.app.models.user import User
  25. def sees_all_folders(user: User | None) -> bool:
  26. """True for ``library:read_all``, and for ``None`` (auth off or an API key)."""
  27. return user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
  28. def owns(folder: LibraryFolder, user: User | None) -> bool:
  29. return user is not None and folder.created_by_id is not None and folder.created_by_id == user.id
  30. def can_write_folder(folder: LibraryFolder, user: User | None) -> bool:
  31. """May ``user`` put files or subfolders into ``folder``."""
  32. return sees_all_folders(user) or owns(folder, user) or bool(folder.shared)
  33. def can_rename_folder(folder: LibraryFolder, user: User | None) -> bool:
  34. if user is None or user.has_permission(Permission.LIBRARY_UPDATE_ALL.value):
  35. return True
  36. return user.has_permission(Permission.LIBRARY_UPDATE_OWN.value) and owns(folder, user)
  37. @dataclass
  38. class FolderIndex:
  39. """Every folder plus who owns the files in it, loaded in two queries.
  40. ``file_owners`` counts trashed files too: a folder delete cascades to
  41. them, so they decide who may delete it just like live files do.
  42. """
  43. folders: dict[int, LibraryFolder] = field(default_factory=dict)
  44. children: dict[int | None, list[int]] = field(default_factory=dict)
  45. file_owners: dict[int, set[int | None]] = field(default_factory=dict)
  46. live_file_owners: dict[int, set[int | None]] = field(default_factory=dict)
  47. def subtree(self, folder_id: int) -> list[int]:
  48. """``folder_id`` and every folder below it."""
  49. out: list[int] = []
  50. seen: set[int] = set()
  51. stack = [folder_id]
  52. while stack:
  53. fid = stack.pop()
  54. if fid in seen: # a parent_id loop must not hang the request
  55. continue
  56. seen.add(fid)
  57. out.append(fid)
  58. stack.extend(self.children.get(fid, []))
  59. return out
  60. async def load_folder_index(db: AsyncSession, *, with_files: bool = True) -> FolderIndex:
  61. """Every folder, and with ``with_files`` who owns the files in each.
  62. Without the files, visibility and delete rules are only right for users
  63. who see everything and may delete everything.
  64. """
  65. index = FolderIndex()
  66. for folder in (await db.execute(select(LibraryFolder))).scalars().all():
  67. index.folders[folder.id] = folder
  68. index.children.setdefault(folder.parent_id, []).append(folder.id)
  69. if not with_files:
  70. return index
  71. rows = await db.execute(
  72. select(LibraryFile.folder_id, LibraryFile.created_by_id, LibraryFile.deleted_at.is_(None))
  73. .where(LibraryFile.folder_id.isnot(None))
  74. .distinct()
  75. )
  76. for folder_id, owner_id, live in rows.all():
  77. index.file_owners.setdefault(folder_id, set()).add(owner_id)
  78. if live:
  79. index.live_file_owners.setdefault(folder_id, set()).add(owner_id)
  80. return index
  81. def visible_folder_ids(index: FolderIndex, user: User | None) -> set[int] | None:
  82. """The folders ``user`` may see, or ``None`` for all of them."""
  83. if sees_all_folders(user):
  84. return None
  85. assert user is not None
  86. reachable = {
  87. fid
  88. for fid, folder in index.folders.items()
  89. if owns(folder, user) or folder.shared or user.id in index.live_file_owners.get(fid, set())
  90. }
  91. visible = set(reachable)
  92. for fid in reachable:
  93. parent = index.folders[fid].parent_id
  94. while parent is not None and parent not in visible and parent in index.folders:
  95. visible.add(parent)
  96. parent = index.folders[parent].parent_id
  97. return visible
  98. def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User | None) -> str | None:
  99. """Why ``user`` may NOT delete ``folder``, or None if they may.
  100. ``library:delete_all`` deletes anything. With ``library:delete_own`` a user
  101. deletes a folder they own when everything under it, folders and files
  102. (trashed ones too), is theirs as well. A folder without an owner, made
  103. before #3201, keeps the old rule (#1781): only when it is truly empty.
  104. """
  105. if user is None or user.has_permission(Permission.LIBRARY_DELETE_ALL.value):
  106. return None
  107. if not user.has_permission(Permission.LIBRARY_DELETE_OWN.value):
  108. return "Deleting folders requires library:delete_own or library:delete_all"
  109. if folder.is_external:
  110. return "External folders can only be deleted by users with library:delete_all"
  111. if folder.project_id is not None or folder.archive_id is not None:
  112. return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
  113. if folder.created_by_id is None:
  114. if index.children.get(folder.id):
  115. return "Only empty folders can be deleted without library:delete_all"
  116. if index.file_owners.get(folder.id):
  117. return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
  118. return None
  119. if not owns(folder, user):
  120. return "Only the folder's owner can delete it without library:delete_all"
  121. for fid in index.subtree(folder.id):
  122. sub = index.folders[fid]
  123. if fid != folder.id and not owns(sub, user):
  124. return "The folder contains folders of other users; deleting it requires library:delete_all"
  125. if sub.is_external or sub.project_id is not None or sub.archive_id is not None:
  126. return "The folder contains external or linked folders; deleting it requires library:delete_all"
  127. if index.file_owners.get(fid, set()) - {user.id}:
  128. return (
  129. "The folder contains files of other users (the folder may contain trashed files); "
  130. "deleting it requires library:delete_all"
  131. )
  132. return None
  133. async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
  134. """The folder, or 404 when it doesn't exist or ``user`` can't see it."""
  135. folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
  136. if folder is None:
  137. raise HTTPException(status_code=404, detail="Folder not found")
  138. if not sees_all_folders(user):
  139. visible = visible_folder_ids(await load_folder_index(db), user)
  140. if visible is not None and folder.id not in visible:
  141. raise HTTPException(status_code=404, detail="Folder not found")
  142. return folder
  143. async def get_writable_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
  144. """The folder ``user`` puts something into: 404 when unseen, 403 when only seen."""
  145. folder = await get_visible_folder(db, folder_id, user)
  146. if not can_write_folder(folder, user):
  147. raise HTTPException(
  148. status_code=403,
  149. detail="You can only add to your own folders and folders shared with everyone",
  150. )
  151. return folder
  152. async def default_import_folder(db: AsyncSession, name: str, user: User | None) -> LibraryFolder:
  153. """The top-level folder an import lands in when none was chosen.
  154. The first one is created shared, so every importer's models land side by
  155. side as before #3201. If an admin made it private, a user who can't write
  156. to it gets a folder of the same name of their own instead of being
  157. refused.
  158. """
  159. candidates = (
  160. (
  161. await db.execute(
  162. select(LibraryFolder)
  163. .where(
  164. LibraryFolder.name == name,
  165. LibraryFolder.parent_id.is_(None),
  166. LibraryFolder.is_external.is_(False),
  167. )
  168. .order_by(LibraryFolder.id)
  169. )
  170. )
  171. .scalars()
  172. .all()
  173. )
  174. for folder in candidates:
  175. if can_write_folder(folder, user):
  176. return folder
  177. folder = LibraryFolder(
  178. name=name,
  179. parent_id=None,
  180. created_by_id=user.id if user else None,
  181. shared=not candidates,
  182. )
  183. db.add(folder)
  184. await db.flush()
  185. return folder