library_folder_access.py 9.2 KB

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