Browse Source

Give library folders an owner and let admins share them (#3201)

Users with library:read_own saw every folder anyone had made, and the
folder counts and activity times included other users' files. Folders
now have an owner. A read_own user sees their own folders, shared ones,
folders holding their files and the parents leading there, and adds
files only to their own and shared folders. Admins can share a folder
with everyone. Owners can rename their folders and delete them when
everything inside is theirs.

On upgrade, a folder whose files all belong to one user becomes that
user's; every other folder is shared, so it stays visible as before.
maziggy 2 days ago
parent
commit
6735e45563
37 changed files with 1460 additions and 209 deletions
  1. 7 0
      CHANGELOG.md
  2. 192 106
      backend/app/api/routes/library.py
  3. 5 17
      backend/app/api/routes/makerworld.py
  4. 8 21
      backend/app/api/routes/manyfold.py
  5. 8 2
      backend/app/api/routes/projects.py
  6. 119 0
      backend/app/core/database.py
  7. 6 0
      backend/app/models/library.py
  8. 17 0
      backend/app/schemas/library.py
  9. 223 0
      backend/app/services/library_folder_access.py
  10. 505 0
      backend/tests/integration/test_library_folder_ownership_3201.py
  11. 7 3
      backend/tests/integration/test_ownership_permissions.py
  12. 129 2
      frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx
  13. 15 0
      frontend/src/api/client.ts
  14. 2 1
      frontend/src/components/ManyfoldTab.tsx
  15. 8 1
      frontend/src/i18n/locales/de.ts
  16. 8 1
      frontend/src/i18n/locales/en.ts
  17. 8 1
      frontend/src/i18n/locales/es.ts
  18. 8 1
      frontend/src/i18n/locales/fr.ts
  19. 8 1
      frontend/src/i18n/locales/it.ts
  20. 8 1
      frontend/src/i18n/locales/ja.ts
  21. 8 1
      frontend/src/i18n/locales/ko.ts
  22. 8 1
      frontend/src/i18n/locales/nl.ts
  23. 8 1
      frontend/src/i18n/locales/pt-BR.ts
  24. 8 1
      frontend/src/i18n/locales/ru.ts
  25. 8 1
      frontend/src/i18n/locales/sv.ts
  26. 8 1
      frontend/src/i18n/locales/tr.ts
  27. 8 1
      frontend/src/i18n/locales/uk.ts
  28. 8 1
      frontend/src/i18n/locales/zh-CN.ts
  29. 8 1
      frontend/src/i18n/locales/zh-TW.ts
  30. 93 38
      frontend/src/pages/FileManagerPage.tsx
  31. 2 1
      frontend/src/pages/MakerworldPage.tsx
  32. 0 0
      static/assets/ImagePreviewModal-DCYvw1Mv.js
  33. 0 1
      static/assets/PdfPreviewModal-DLcR2RDo.js
  34. 0 0
      static/assets/SpreadsheetPreviewModal-CUuH2pYY.js
  35. 1 1
      static/assets/index-BeGRJy9y.js
  36. 0 0
      static/assets/pdf-BpSt-JnU.js
  37. 1 1
      static/index.html

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Users who see only their own files now see only their own folders, and admins can share a folder with everyone (#3201, requested by @Minebuddy)** — Library files had an owner but folders did not, so a user with **View own library files** saw every folder anyone had made: a school with 100 student accounts showed each student every other student's folders, most of them looking empty, and offered them all as places to upload. Folders now have an owner, the user who made them.
+  - **What a user sees:** their own folders, folders shared with everyone, folders holding one of their files, and the folders above those, so they can reach them. Users with **View all library files** still see everything.
+  - **Sharing:** an admin (**Update all library files**) can mark a folder **Share with everyone** in its menu. A shared folder shows a people icon, everyone sees it and can add files and subfolders to it. Sharing applies to that folder, not to the folders inside it.
+  - **Adding files:** uploads, ZIP extraction, moving files, new subfolders and MakerWorld or Manyfold imports go only into your own folders, shared ones and the top level. A ZIP whose name matches someone else's folder now gets its own folder instead of landing in theirs.
+  - **Renaming and deleting:** with **Update own library files** you can rename and move your own folders; linking and sharing stay with **Update all**. With **Delete own library files** you can delete your own folder when everything in it is yours. An empty folder without an owner can still be deleted as before.
+  - **Counts:** a folder's file count and last-activity time now count only the files you can see; they used to reveal other users' files (reported by @lonix).
+  - **Existing folders:** on upgrade, a folder whose files all belong to one user becomes that user's folder, and an empty folder inside it goes with it. Every other folder (empty, used by several people, linked to a project, external, or the MakerWorld and Manyfold import folders) is shared, so it stays visible as before. Folders made while auth is off, and new external folders, are shared too, so turning auth on later hides nothing.
 - **The electricity price can come from a Home Assistant sensor, and energy is costed at the price of the hour it was used (#1251, requested by @Roving-Ronin)** — With a tariff that changes during the day, such as Amber in Australia or Nord Pool and Tibber in Europe, there was no way to get the right energy cost. Bambuddy kept one price and applied it after the fact: a print was costed at the price when it finished, and in **Total consumption** mode every kWh on the Statistics page, past months included, was multiplied by today's price. So the all-time energy cost moved every time the price did. Pushing the price in from Home Assistant through the API kept that one price current, but the arithmetic stayed wrong.
   - **Price from Home Assistant:** under **Settings → General → Cost Tracking**, **Electricity price source** can now be set to **Home Assistant sensor** and pointed at a price sensor. Bambuddy reads it when you save, every hour, and at the start and end of each print. Prices per MWh and per Wh are converted to per kWh. If Home Assistant can't be reached, the last price read is used. The push through the API still works.
   - **Costed hour by hour:** each hourly energy reading now records the price, and the energy used until the next reading is costed at it. A print that runs through cheap and expensive hours is costed at each, and the Statistics energy cost does the same for any date range and for all time.

+ 192 - 106
backend/app/api/routes/library.py

@@ -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))

+ 5 - 17
backend/app/api/routes/makerworld.py

@@ -39,7 +39,7 @@ from backend.app.core.auth import (
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import LibraryFile
 from backend.app.models.user import User
 from backend.app.schemas.makerworld import (
     MakerWorldImportRequest,
@@ -49,6 +49,7 @@ from backend.app.schemas.makerworld import (
     MakerWorldResolveRequest,
     MakerWorldStatus,
 )
+from backend.app.services.library_folder_access import default_import_folder, get_writable_folder
 from backend.app.services.model_providers import makerworld_provider, registry
 from backend.app.services.model_providers.base import (
     ModelProvider,
@@ -336,10 +337,8 @@ async def import_instance(
     current_user = await _authorize_for_provider(provider, provider.import_permission, credentials, x_api_key)
 
     if body.folder_id is not None:
-        folder_q = await db.execute(select(LibraryFolder).where(LibraryFolder.id == body.folder_id))
-        target_folder = folder_q.scalar_one_or_none()
-        if target_folder is None:
-            raise HTTPException(status_code=404, detail="Folder not found")
+        # Only into a folder the user may write to (#3201).
+        target_folder = await get_writable_folder(db, body.folder_id, current_user)
         if target_folder.is_external and target_folder.external_readonly:
             raise HTTPException(
                 status_code=403,
@@ -359,18 +358,7 @@ async def import_instance(
         if default_folder_name is None:
             effective_folder_id = None
         else:
-            default_folder_q = await db.execute(
-                select(LibraryFolder).where(
-                    LibraryFolder.name == default_folder_name,
-                    LibraryFolder.parent_id.is_(None),
-                    LibraryFolder.is_external.is_(False),
-                )
-            )
-            default_folder = default_folder_q.scalar_one_or_none()
-            if default_folder is None:
-                default_folder = LibraryFolder(name=default_folder_name, parent_id=None)
-                db.add(default_folder)
-                await db.flush()
+            default_folder = await default_import_folder(db, default_folder_name, current_user)
             effective_folder_id = default_folder.id
 
     service = await _build_service(db, provider, current_user, api_key_cloud_owner)

+ 8 - 21
backend/app/api/routes/manyfold.py

@@ -16,7 +16,7 @@ import os
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import Response
-from sqlalchemy import delete, select
+from sqlalchemy import delete
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
@@ -24,7 +24,7 @@ from backend.app.api.routes.settings import set_setting
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import LibraryFile
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.manyfold import (
@@ -40,6 +40,7 @@ from backend.app.schemas.manyfold import (
     ManyfoldTestRequest,
     ManyfoldTestResponse,
 )
+from backend.app.services.library_folder_access import default_import_folder, get_writable_folder
 from backend.app.services.model_providers import manyfold_provider
 from backend.app.services.model_providers.base import ProviderResourceRef
 from backend.app.services.model_providers.manyfold.config import (
@@ -290,29 +291,15 @@ async def get_preview(
 # ---- import -------------------------------------------------------------
 
 
-async def _import_folder_id(db: AsyncSession, folder_id: int | None) -> int | None:
+async def _import_folder_id(db: AsyncSession, folder_id: int | None, user: User | None) -> int | None:
     """The chosen folder, or the top-level "Manyfold" folder (created on first use)."""
     if folder_id is not None:
-        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")
+        # Only into a folder the user may write to (#3201).
+        folder = await get_writable_folder(db, folder_id, user)
         if folder.is_external and folder.external_readonly:
             raise HTTPException(status_code=403, detail="Cannot import into a read-only external folder")
         return folder_id
-    name = manyfold_provider.default_folder_name
-    folder = (
-        await db.execute(
-            select(LibraryFolder).where(
-                LibraryFolder.name == name,
-                LibraryFolder.parent_id.is_(None),
-                LibraryFolder.is_external.is_(False),
-            )
-        )
-    ).scalar_one_or_none()
-    if folder is None:
-        folder = LibraryFolder(name=name, parent_id=None)
-        db.add(folder)
-        await db.flush()
+    folder = await default_import_folder(db, manyfold_provider.default_folder_name, user)
     return folder.id
 
 
@@ -356,7 +343,7 @@ async def import_file(
 
     filename = _library_filename(file["filename"], file_id)
     validate_print_file_upload(filename, data)
-    folder_id = await _import_folder_id(db, body.folder_id)
+    folder_id = await _import_folder_id(db, body.folder_id, current_user)
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,
         file_bytes=data,

+ 8 - 2
backend/app/api/routes/projects.py

@@ -2015,7 +2015,7 @@ async def export_project(
 async def import_project(
     data: ProjectImport,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project with optional BOM items and linked folders."""
     # Create the project
@@ -2067,12 +2067,15 @@ async def import_project(
             existing_folder.project_id = project.id
         else:
             # Create new folder linked to project
+            # A project's folder is shared, like every linked folder (#3201).
             new_folder = LibraryFolder(
                 name=folder_data.name,
                 project_id=project.id,
                 is_external=False,
                 external_readonly=False,
                 external_show_hidden=False,
+                created_by_id=current_user.id if current_user else None,
+                shared=True,
             )
             db.add(new_folder)
 
@@ -2113,7 +2116,7 @@ async def import_project(
 async def import_project_file(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project from a ZIP or JSON file."""
     if not file.filename:
@@ -2207,12 +2210,15 @@ async def import_project_file(
             folder = existing_folder
         else:
             # Create new folder
+            # A project's folder is shared, like every linked folder (#3201).
             folder = LibraryFolder(
                 name=folder_name,
                 project_id=project.id,
                 is_external=False,
                 external_readonly=False,
                 external_show_hidden=False,
+                created_by_id=current_user.id if current_user else None,
+                shared=True,
             )
             db.add(folder)
             await db.flush()

+ 119 - 0
backend/app/core/database.py

@@ -5295,6 +5295,13 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_plug_id INTEGER")
     await _backfill_snapshot_prices(conn)
 
+    # Migration: library folder ownership and sharing (#3201).
+    await _safe_execute(
+        conn, "ALTER TABLE library_folders ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
+    )
+    await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN shared BOOLEAN DEFAULT FALSE")
+    await _backfill_library_folder_owners(conn)
+
 
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.
@@ -5346,6 +5353,118 @@ async def _backfill_snapshot_prices(conn) -> None:
         )
 
 
+async def _backfill_library_folder_owners(conn) -> None:
+    """Give the folders made before #3201 an owner, or share them.
+
+    Folders had no owner, and every library:read_own user saw all of them. A
+    folder whose files, all the way down and trashed ones included, belong to
+    one user becomes that user's own folder, hidden from the others. An empty
+    folder inside such a folder goes with it, so it doesn't leave a shared
+    hole in someone's private tree. Every other folder (empty, several
+    uploaders, files without an owner, external, or linked to a project or
+    archive) is shared, so it stays visible to everyone as before. So are the
+    top-level folders MakerWorld and Manyfold imports land in, which are
+    every importer's destination, not one user's folder.
+
+    Gated to run once: after it, a folder without an owner and not shared is
+    one an admin chose to make that way.
+    """
+    from sqlalchemy import text
+
+    flag = "_backfill_3201_folder_owners_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        folders = (
+            await conn.execute(
+                text("SELECT id, parent_id, is_external, project_id, archive_id, name FROM library_folders")
+            )
+        ).all()
+        children: dict[int | None, list[int]] = {}
+        for fid, parent_id, *_ in folders:
+            children.setdefault(parent_id, []).append(fid)
+        own_owners: dict[int, set] = {}
+        for folder_id, owner_id in (
+            await conn.execute(
+                text("SELECT DISTINCT folder_id, created_by_id FROM library_files WHERE folder_id IS NOT NULL")
+            )
+        ).all():
+            own_owners.setdefault(folder_id, set()).add(owner_id)
+
+        # Owners of every file in each subtree, deepest folders first.
+        subtree_owners: dict[int, set] = {}
+
+        def owners_of(root: int) -> set:
+            # ``seen`` guards against a parent_id loop: startup must never hang.
+            order: list[int] = []
+            seen: set[int] = set()
+            stack = [root]
+            while stack:
+                fid = stack.pop()
+                if fid in seen:
+                    continue
+                seen.add(fid)
+                order.append(fid)
+                stack.extend(children.get(fid, []))
+            for fid in reversed(order):
+                if fid not in subtree_owners:
+                    acc = set(own_owners.get(fid, set()))
+                    for child in children.get(fid, []):
+                        acc |= subtree_owners.get(child, set())
+                    subtree_owners[fid] = acc
+            return subtree_owners[root]
+
+        info = {
+            fid: (parent_id, bool(is_ext), project_id, archive_id, name)
+            for fid, parent_id, is_ext, project_id, archive_id, name in folders
+        }
+        # services/model_providers/*/provider.py ``default_folder_name``.
+        import_folders = {"MakerWorld", "Manyfold"}
+        result: dict[int, int | None] = {}
+
+        # Top down, so an empty folder can follow its parent's owner.
+        stack = list(children.get(None, []))
+        # Folders whose parent is missing (shouldn't happen with the FK) are roots too.
+        stack.extend(
+            fid for fid, (parent_id, *_rest) in info.items() if parent_id is not None and parent_id not in info
+        )
+        while stack:
+            fid = stack.pop()
+            if fid in result:
+                continue
+            parent_id, is_ext, project_id, archive_id, name = info[fid]
+            owners = owners_of(fid)
+            owner = None
+            is_import_folder = parent_id is None and name in import_folders
+            if not is_ext and project_id is None and archive_id is None and not is_import_folder:
+                if len(owners) == 1 and None not in owners:
+                    owner = next(iter(owners))
+                elif not owners and parent_id is not None:
+                    owner = result.get(parent_id)
+            result[fid] = owner
+            stack.extend(children.get(fid, []))
+        for fid in info:
+            result.setdefault(fid, None)
+
+        owned = [{"id": fid, "o": owner} for fid, owner in result.items() if owner is not None]
+        if owned:
+            await conn.execute(
+                text("UPDATE library_folders SET created_by_id = :o, shared = FALSE WHERE id = :id"), owned
+            )
+        shared = [{"id": fid} for fid, owner in result.items() if owner is None]
+        if shared:
+            await conn.execute(text("UPDATE library_folders SET shared = TRUE WHERE id = :id"), shared)
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
 

+ 6 - 0
backend/app/models/library.py

@@ -23,6 +23,12 @@ class LibraryFolder(Base):
     external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
     external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
 
+    # Ownership (#3201): who made the folder, and whether an admin shared it
+    # with everyone. library:read_own users see only their own folders, shared
+    # ones and the folders holding their files (services/library_folder_access).
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+    shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
     # Link to project or archive
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     # use_alter breaks a dependency cycle in the schema, and is not about this

+ 17 - 0
backend/app/schemas/library.py

@@ -33,6 +33,9 @@ class FolderUpdate(BaseModel):
     parent_id: int | None = None
     project_id: int | None = None  # 0 to unlink
     archive_id: int | None = None  # 0 to unlink
+    # Visible to (and writable by) every library:read_own user (#3201).
+    # Only library:update_all may change it.
+    shared: bool | None = None
 
 
 class FolderResponse(BaseModel):
@@ -56,6 +59,13 @@ class FolderResponse(BaseModel):
     # tracks rename/move events. Recursion across subfolders is intentionally
     # left out to keep the route a single GROUP BY rather than a recursive CTE.
     latest_activity_at: datetime | None = None
+    # Ownership (#3201). ``can_*`` are for the user asking: what the File
+    # Manager may offer on this folder. The routes enforce the same rules.
+    created_by_id: int | None = None
+    shared: bool = False
+    can_write: bool = True
+    can_rename: bool = True
+    can_delete: bool = True
     created_at: datetime
     updated_at: datetime
 
@@ -92,6 +102,13 @@ class FolderTreeItem(BaseModel):
     file_count: int = 0
     # See FolderResponse.latest_activity_at — #1770 folder sort source.
     latest_activity_at: datetime | None = None
+    # Ownership (#3201). ``can_*`` are for the user asking: what the File
+    # Manager may offer on this folder. The routes enforce the same rules.
+    created_by_id: int | None = None
+    shared: bool = False
+    can_write: bool = True
+    can_rename: bool = True
+    can_delete: bool = True
     children: list["FolderTreeItem"] = []
 
     class Config:

+ 223 - 0
backend/app/services/library_folder_access.py

@@ -0,0 +1,223 @@
+"""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, or an API key) sees every folder. 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, an API key) 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 or an API key)."""
+    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

+ 505 - 0
backend/tests/integration/test_library_folder_ownership_3201.py

@@ -0,0 +1,505 @@
+"""Library folder ownership and sharing (#3201).
+
+A library:read_own user sees their own folders, shared ones, folders holding
+their files and the parents leading there; they add only to their own and
+shared folders. Counts and activity times only reflect their own files.
+"""
+
+import io
+import zipfile
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import text
+
+from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
+
+
+def _h(token: str) -> dict:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _ids(tree: list[dict]) -> set[int]:
+    out: set[int] = set()
+    for node in tree:
+        out.add(node["id"])
+        out |= _ids(node["children"])
+    return out
+
+
+def _find(tree: list[dict], folder_id: int) -> dict | None:
+    for node in tree:
+        if node["id"] == folder_id:
+            return node
+        found = _find(node["children"], folder_id)
+        if found:
+            return found
+    return None
+
+
+class TestFolderOwnership(TestOwnershipPermissionsSetup):
+    @pytest.fixture
+    async def folder(self, db_session):
+        async def _create(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            folder = LibraryFolder(**{"name": "F", **kwargs})
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create
+
+    @pytest.fixture
+    async def file(self, db_session):
+        counter = [0]
+
+        async def _create(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            counter[0] += 1
+            row = LibraryFile(
+                **{
+                    "filename": f"f{counter[0]}.3mf",
+                    "file_path": f"library/f{counter[0]}.3mf",
+                    "file_type": "3mf",
+                    "file_size": 1,
+                    **kwargs,
+                }
+            )
+            db_session.add(row)
+            await db_session.commit()
+            await db_session.refresh(row)
+            return row
+
+        return _create
+
+    # ---- visibility ---------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tree_shows_only_what_the_user_may_see(self, async_client: AsyncClient, auth_setup, folder, file):
+        me = auth_setup["operator_user"]["id"]
+        other = auth_setup["operator2_user"]["id"]
+        mine = await folder(name="Mine", created_by_id=me)
+        theirs = await folder(name="Theirs", created_by_id=other)
+        shared = await folder(name="Class", created_by_id=other, shared=True)
+        # Their private folder holding one of my files: visible, and its
+        # parent too, for navigation; a sibling of it is not.
+        outer = await folder(name="Outer", created_by_id=other)
+        inner = await folder(name="Inner", created_by_id=other, parent_id=outer.id)
+        sibling = await folder(name="Sibling", created_by_id=other, parent_id=outer.id)
+        await file(folder_id=inner.id, created_by_id=me)
+        unowned_private = await folder(name="Unshared")
+
+        tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
+        assert _ids(tree) == {mine.id, shared.id, outer.id, inner.id}
+        assert theirs.id not in _ids(tree) and sibling.id not in _ids(tree)
+        assert unowned_private.id not in _ids(tree)
+
+        node = _find(tree, mine.id)
+        assert node["can_write"] and node["can_rename"] and node["can_delete"] and not node["shared"]
+        assert _find(tree, shared.id)["can_write"] and not _find(tree, shared.id)["can_rename"]
+        # Reachable, not writable.
+        assert not _find(tree, outer.id)["can_write"] and not _find(tree, inner.id)["can_write"]
+
+        admin_tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
+        assert {theirs.id, sibling.id, unowned_private.id} <= _ids(admin_tree)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_counts_and_activity_reflect_own_files_only(
+        self, async_client: AsyncClient, auth_setup, folder, file
+    ):
+        """lonix's point on #3201: counts and times leaked other users' files."""
+        from datetime import datetime
+
+        me = auth_setup["operator_user"]["id"]
+        other = auth_setup["operator2_user"]["id"]
+        shared = await folder(name="Class", shared=True, created_by_id=other)
+        await file(folder_id=shared.id, created_by_id=me)
+        recent = datetime(2030, 1, 1)
+        await file(folder_id=shared.id, created_by_id=other, updated_at=recent)
+        await file(folder_id=shared.id, created_by_id=other)
+
+        tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
+        node = _find(tree, shared.id)
+        assert node["file_count"] == 1
+        assert not node["latest_activity_at"].startswith("2030")
+
+        single = await async_client.get(
+            f"/api/v1/library/folders/{shared.id}", headers=_h(auth_setup["operator_token"])
+        )
+        assert single.json()["file_count"] == 1
+
+        admin = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["admin_token"]))).json()
+        assert _find(admin, shared.id)["file_count"] == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hidden_folder_answers_404(self, async_client: AsyncClient, auth_setup, folder):
+        theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
+        token = auth_setup["operator_token"]
+        assert (await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
+        readme = await async_client.get(f"/api/v1/library/folders/{theirs.id}/readme", headers=_h(token))
+        assert readme.status_code == 404 and readme.json()["detail"] == "Folder not found"
+        assert (
+            await async_client.get(f"/api/v1/library/folders/{theirs.id}", headers=_h(auth_setup["operator2_token"]))
+        ).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stats_count_visible_folders(self, async_client: AsyncClient, auth_setup, folder):
+        await folder(name="Mine", created_by_id=auth_setup["operator_user"]["id"])
+        await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
+        await folder(name="Class", shared=True)
+        stats = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["operator_token"]))
+        assert stats.json()["total_folders"] == 2
+        admin = await async_client.get("/api/v1/library/stats", headers=_h(auth_setup["admin_token"]))
+        assert admin.json()["total_folders"] == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scan_of_a_hidden_mount_answers_404(self, async_client: AsyncClient, auth_setup, folder):
+        hidden = await folder(
+            name="Mount", is_external=True, external_path="/nonexistent", created_by_id=auth_setup["admin_user"]["id"]
+        )
+        response = await async_client.post(
+            f"/api/v1/library/folders/{hidden.id}/scan", headers=_h(auth_setup["operator_token"])
+        )
+        assert response.status_code == 404
+
+    # ---- creating and adding ------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_new_folder_belongs_to_its_creator(self, async_client: AsyncClient, auth_setup):
+        created = await async_client.post(
+            "/api/v1/library/folders", json={"name": "Assignment 1"}, headers=_h(auth_setup["operator_token"])
+        )
+        assert created.status_code == 200
+        body = created.json()
+        assert body["created_by_id"] == auth_setup["operator_user"]["id"]
+        assert body["shared"] is False and body["can_write"] is True
+
+        other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
+        assert body["id"] not in _ids(other)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_made_without_a_user_is_shared(self, async_client: AsyncClient):
+        """Auth off: nobody to own it, so it stays everyone's, as before."""
+        body = (await async_client.post("/api/v1/library/folders", json={"name": "Open"})).json()
+        assert body["created_by_id"] is None and body["shared"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_subfolder_only_where_the_user_may_write(self, async_client: AsyncClient, auth_setup, folder, file):
+        me = auth_setup["operator_user"]["id"]
+        other = auth_setup["operator2_user"]["id"]
+        theirs = await folder(name="Theirs", created_by_id=other)
+        shared = await folder(name="Class", created_by_id=other, shared=True)
+        passthrough = await folder(name="Outer", created_by_id=other)
+        await file(folder_id=passthrough.id, created_by_id=me)
+        token = auth_setup["operator_token"]
+
+        async def create(parent_id):
+            return await async_client.post(
+                "/api/v1/library/folders", json={"name": "Sub", "parent_id": parent_id}, headers=_h(token)
+            )
+
+        assert (await create(theirs.id)).status_code == 404
+        assert (await create(passthrough.id)).status_code == 403
+        assert (await create(shared.id)).status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_files_only_into_writable_folders(self, async_client: AsyncClient, auth_setup, folder, file):
+        me = auth_setup["operator_user"]["id"]
+        other = auth_setup["operator2_user"]["id"]
+        theirs = await folder(name="Theirs", created_by_id=other)
+        shared = await folder(name="Class", shared=True)
+        mine = await file(created_by_id=me)
+        token = auth_setup["operator_token"]
+
+        moved = await async_client.post(
+            "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
+        )
+        assert moved.status_code == 404
+        update = await async_client.put(
+            f"/api/v1/library/files/{mine.id}", json={"folder_id": theirs.id}, headers=_h(token)
+        )
+        assert update.status_code == 404
+        moved = await async_client.post(
+            "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": shared.id}, headers=_h(token)
+        )
+        assert moved.status_code == 200 and moved.json()["moved"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zip_never_reuses_another_users_folder(self, async_client: AsyncClient, auth_setup, folder):
+        """Extracting "Alice.zip" must not land in Alice's own "Alice" folder."""
+        alice_folder = await folder(name="Alice", created_by_id=auth_setup["operator2_user"]["id"])
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w") as zf:
+            zf.writestr("Alice/model.txt", "x")
+        response = await async_client.post(
+            "/api/v1/library/files/extract-zip",
+            files={"file": ("Alice.zip", buf.getvalue(), "application/zip")},
+            params={"preserve_structure": "true", "create_folder_from_zip": "true"},
+            headers=_h(auth_setup["operator_token"]),
+        )
+        assert response.status_code == 200
+        landed = response.json()["files"][0]["folder_id"]
+        assert landed != alice_folder.id
+        tree = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator_token"]))).json()
+        assert alice_folder.id not in _ids(tree) and landed in _ids(tree)
+
+    # ---- renaming, sharing, deleting ---------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_owner_renames_admin_shares(self, async_client: AsyncClient, auth_setup, folder):
+        me = auth_setup["operator_user"]["id"]
+        mine = await folder(name="Mine", created_by_id=me)
+        class_folder = await folder(name="Class", shared=True, created_by_id=auth_setup["admin_user"]["id"])
+        token = auth_setup["operator_token"]
+
+        renamed = await async_client.put(
+            f"/api/v1/library/folders/{mine.id}", json={"name": "Mine2"}, headers=_h(token)
+        )
+        assert renamed.status_code == 200 and renamed.json()["name"] == "Mine2"
+        assert (
+            await async_client.put(f"/api/v1/library/folders/{class_folder.id}", json={"name": "X"}, headers=_h(token))
+        ).status_code == 403
+        assert (
+            await async_client.put(f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(token))
+        ).status_code == 403
+
+        # The admin shares my folder: operator2 now sees it and may add to it.
+        shared = await async_client.put(
+            f"/api/v1/library/folders/{mine.id}", json={"shared": True}, headers=_h(auth_setup["admin_token"])
+        )
+        assert shared.status_code == 200 and shared.json()["shared"] is True
+        other = (await async_client.get("/api/v1/library/folders", headers=_h(auth_setup["operator2_token"]))).json()
+        assert _find(other, mine.id)["can_write"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_owner_moves_folder_only_into_writable_parent(self, async_client: AsyncClient, auth_setup, folder):
+        me = auth_setup["operator_user"]["id"]
+        mine = await folder(name="Mine", created_by_id=me)
+        theirs = await folder(name="Theirs", created_by_id=auth_setup["operator2_user"]["id"])
+        token = auth_setup["operator_token"]
+        assert (
+            await async_client.put(
+                f"/api/v1/library/folders/{mine.id}", json={"parent_id": theirs.id}, headers=_h(token)
+            )
+        ).status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_owner_deletes_folder_only_when_everything_is_theirs(
+        self, async_client: AsyncClient, auth_setup, folder, file
+    ):
+        me = auth_setup["operator_user"]["id"]
+        other = auth_setup["operator2_user"]["id"]
+        token = auth_setup["operator_token"]
+
+        own_full = await folder(name="Full", created_by_id=me)
+        await folder(name="Sub", created_by_id=me, parent_id=own_full.id)
+        await file(folder_id=own_full.id, created_by_id=me)
+        assert (
+            await async_client.delete(f"/api/v1/library/folders/{own_full.id}", headers=_h(token))
+        ).status_code == 200
+
+        mixed = await folder(name="Mixed", created_by_id=me, shared=True)
+        await file(folder_id=mixed.id, created_by_id=other)
+        assert (await async_client.delete(f"/api/v1/library/folders/{mixed.id}", headers=_h(token))).status_code == 403
+
+        their_shared = await folder(name="Class", created_by_id=other, shared=True)
+        assert (
+            await async_client.delete(f"/api/v1/library/folders/{their_shared.id}", headers=_h(token))
+        ).status_code == 403
+
+        theirs = await folder(name="Theirs", created_by_id=other)
+        assert (await async_client.delete(f"/api/v1/library/folders/{theirs.id}", headers=_h(token))).status_code == 404
+
+        bulk = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            json={"file_ids": [], "folder_ids": [theirs.id, their_shared.id]},
+            headers=_h(token),
+        )
+        assert bulk.json()["deleted_folders"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_all_permissions_reach_folders_they_cannot_see(
+        self, async_client: AsyncClient, auth_setup, folder, file
+    ):
+        """A group with update_all/delete_all but only read_own kept full reach before #3201."""
+        admin = _h(auth_setup["admin_token"])
+        groups = (await async_client.get("/api/v1/groups/", headers=admin)).json()
+        operators = next(g for g in groups if g["name"] == "Operators")
+        perms = [p for p in operators["permissions"] if p not in ("library:update_own", "library:delete_own")]
+        perms += ["library:update_all", "library:delete_all"]
+        group = await async_client.post(
+            "/api/v1/groups/", json={"name": "Editors", "permissions": perms}, headers=admin
+        )
+        assert group.status_code in (200, 201), group.text
+        user = await async_client.post(
+            "/api/v1/users/",
+            json={"username": "editor1", "password": "Editorpass1!", "group_ids": [group.json()["id"]]},
+            headers=admin,
+        )
+        assert user.status_code in (200, 201), user.text
+        login = await async_client.post("/api/v1/auth/login", json={"username": "editor1", "password": "Editorpass1!"})
+        token = login.json()["access_token"]
+        other = auth_setup["operator2_user"]["id"]
+        theirs = await folder(name="Theirs", created_by_id=other)
+        doomed = await folder(name="Doomed", created_by_id=other)
+        mine = await file(created_by_id=user.json()["id"])
+
+        # Still hidden from view: read_own decides what is listed.
+        tree = (await async_client.get("/api/v1/library/folders", headers=_h(token))).json()
+        assert theirs.id not in _ids(tree)
+        # But the *_all permissions act on it as before.
+        renamed = await async_client.put(f"/api/v1/library/folders/{theirs.id}", json={"name": "R"}, headers=_h(token))
+        assert renamed.status_code == 200
+        moved = await async_client.post(
+            "/api/v1/library/files/move", json={"file_ids": [mine.id], "folder_id": theirs.id}, headers=_h(token)
+        )
+        assert moved.status_code == 200 and moved.json()["moved"] == 1
+        assert (await async_client.delete(f"/api/v1/library/folders/{doomed.id}", headers=_h(token))).status_code == 200
+
+    # ---- imports ------------------------------------------------------------
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_import_folder(self, db_session, auth_setup):
+        from sqlalchemy import select
+        from sqlalchemy.orm import selectinload
+
+        from backend.app.models.user import User
+        from backend.app.services.library_folder_access import default_import_folder
+
+        users = {
+            u.id: u for u in (await db_session.execute(select(User).options(selectinload(User.groups)))).scalars().all()
+        }
+        op1 = users[auth_setup["operator_user"]["id"]]
+        op2 = users[auth_setup["operator2_user"]["id"]]
+
+        first = await default_import_folder(db_session, "MakerWorld", op1)
+        assert first.shared is True
+        assert (await default_import_folder(db_session, "MakerWorld", op2)).id == first.id
+
+        # An admin made it private: op2 gets one of their own, not a refusal.
+        first.shared = False
+        await db_session.flush()
+        own = await default_import_folder(db_session, "MakerWorld", op2)
+        assert own.id != first.id and own.created_by_id == op2.id and own.shared is False
+
+
+class TestFolderOwnerBackfill:
+    @pytest.mark.asyncio
+    async def test_infers_owners_once(self, test_engine, db_session):
+        from backend.app.core.database import _backfill_library_folder_owners
+        from backend.app.models.library import LibraryFile, LibraryFolder
+        from backend.app.models.project import Project
+        from backend.app.models.user import User
+
+        alice = User(username="alice", password_hash="x", role="user")
+        bob = User(username="bob", password_hash="x", role="user")
+        project = Project(name="P")
+        db_session.add_all([alice, bob, project])
+        await db_session.flush()
+
+        def folder(name, parent=None, **kw):
+            f = LibraryFolder(name=name, parent_id=parent.id if parent else None, **kw)
+            db_session.add(f)
+            return f
+
+        students = folder("Students")
+        await db_session.flush()
+        a = folder("Alice", students)
+        b = folder("Bob", students)
+        empty = folder("Testing")
+        maker = folder("MakerWorld")
+        linked = folder("Linked")
+        await db_session.flush()
+        a_old = folder("Old", a)  # empty, inside Alice's folder
+        linked.project_id = project.id
+        await db_session.flush()
+
+        from datetime import datetime, timezone
+
+        db_session.add_all(
+            [
+                LibraryFile(
+                    folder_id=a.id, created_by_id=alice.id, filename="1", file_path="1", file_type="3mf", file_size=1
+                ),
+                LibraryFile(
+                    folder_id=a.id,
+                    created_by_id=alice.id,
+                    filename="2",
+                    file_path="2",
+                    file_type="3mf",
+                    file_size=1,
+                    deleted_at=datetime.now(timezone.utc),
+                ),
+                LibraryFile(
+                    folder_id=b.id, created_by_id=bob.id, filename="3", file_path="3", file_type="3mf", file_size=1
+                ),
+                LibraryFile(
+                    folder_id=maker.id,
+                    created_by_id=alice.id,
+                    filename="4",
+                    file_path="4",
+                    file_type="3mf",
+                    file_size=1,
+                ),
+                LibraryFile(
+                    folder_id=linked.id, created_by_id=bob.id, filename="5", file_path="5", file_type="3mf", file_size=1
+                ),
+            ]
+        )
+        await db_session.commit()
+        ids = {
+            n: f.id
+            for n, f in {
+                "students": students,
+                "a": a,
+                "b": b,
+                "empty": empty,
+                "maker": maker,
+                "linked": linked,
+                "a_old": a_old,
+            }.items()
+        }
+        alice_id, bob_id = alice.id, bob.id
+
+        async with test_engine.begin() as conn:
+            await _backfill_library_folder_owners(conn)
+
+        async with test_engine.begin() as conn:
+            rows = {
+                r[0]: (r[1], bool(r[2]))
+                for r in (await conn.execute(text("SELECT id, created_by_id, shared FROM library_folders"))).all()
+            }
+        assert rows[ids["a"]] == (alice_id, False)
+        assert rows[ids["a_old"]] == (alice_id, False)
+        assert rows[ids["b"]] == (bob_id, False)
+        assert rows[ids["students"]] == (None, True)  # two owners below it
+        assert rows[ids["empty"]] == (None, True)
+        assert rows[ids["maker"]] == (None, True)  # import destination
+        assert rows[ids["linked"]] == (None, True)
+
+        # Gated: a second boot leaves an admin's later choice alone.
+        async with test_engine.begin() as conn:
+            await conn.execute(text("UPDATE library_folders SET shared = FALSE WHERE id = :i"), {"i": ids["empty"]})
+            await _backfill_library_folder_owners(conn)
+            again = (
+                await conn.execute(text("SELECT shared FROM library_folders WHERE id = :i"), {"i": ids["empty"]})
+            ).scalar_one()
+        assert not again

+ 7 - 3
backend/tests/integration/test_ownership_permissions.py

@@ -856,8 +856,11 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
             from backend.app.models.library import LibraryFolder
 
             _counter[0] += 1
+            # Ownerless and shared: a folder from before #3201 after the
+            # upgrade backfill, or one made with auth off.
             defaults = {
                 "name": f"TestFolder_{_counter[0]}",
+                "shared": True,
             }
             defaults.update(kwargs)
 
@@ -1053,9 +1056,10 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert stranger.status_code == 404
 
     # ========================================================================
-    # Folder deletion (#1781): folders have no ownership tracking, so users
-    # with only library:delete_own may delete empty, non-external, non-linked
-    # folders. Everything else still requires library:delete_all.
+    # Folder deletion (#1781): a folder without an owner may be deleted with
+    # only library:delete_own when it is empty, non-external and non-linked.
+    # Everything else still requires library:delete_all. Owned folders (#3201)
+    # are covered in test_library_folder_ownership_3201.py.
     # ========================================================================
 
     @pytest.mark.asyncio

+ 129 - 2
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -116,7 +116,7 @@ describe('FileManager folder deletion gating (#1781)', () => {
     expect(deleteButton).not.toBeDisabled();
   });
 
-  it('disables delete on a non-empty folder for a delete_own user, with empty-only tooltip', async () => {
+  it('disables delete on a non-empty folder for a delete_own user, with own-folders tooltip', async () => {
     mockAuthUser(['library:read_own', 'library:delete_own']);
     render(<FileManagerPage />);
     await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
@@ -125,7 +125,10 @@ describe('FileManager folder deletion gating (#1781)', () => {
     const row = await openFolderMenu(user, 'HasFiles');
     const deleteButton = within(row).getByRole('button', { name: 'Delete' });
     expect(deleteButton).toBeDisabled();
-    expect(deleteButton).toHaveAttribute('title', 'You can only delete empty folders');
+    expect(deleteButton).toHaveAttribute(
+      'title',
+      'You can only delete your own folders when everything in them is yours, or empty folders without an owner',
+    );
   });
 
   it('disables delete on a linked folder for a delete_own user, with no-permission tooltip', async () => {
@@ -163,3 +166,127 @@ describe('FileManager folder deletion gating (#1781)', () => {
     expect(deleteButton).not.toBeDisabled();
   });
 });
+
+// #3201: the backend says per folder what the current user may do with it.
+function ownedFolder(overrides: Record<string, unknown>) {
+  return {
+    parent_id: null,
+    file_count: 0,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    external_path: null,
+    external_readonly: false,
+    latest_activity_at: null,
+    created_by_id: 7,
+    shared: false,
+    can_write: true,
+    can_rename: true,
+    can_delete: true,
+    children: [],
+    ...overrides,
+  };
+}
+
+describe('FileManager folder ownership (#3201)', () => {
+  const folders = [
+    ownedFolder({ id: 10, name: 'MyModels', file_count: 4 }),
+    ownedFolder({ id: 11, name: 'ClassProject', created_by_id: 1, shared: true, can_rename: false, can_delete: false }),
+    ownedFolder({ id: 12, name: 'PassThrough', created_by_id: 1, can_write: false, can_rename: false, can_delete: false }),
+  ];
+  let sharedBody: unknown = null;
+
+  beforeEach(() => {
+    localStorage.clear();
+    sharedBody = null;
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json(folders)),
+      http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 4,
+          total_folders: 3,
+          total_size_bytes: 1024,
+          disk_free_bytes: 10737418240,
+          disk_total_bytes: 107374182400,
+        }),
+      ),
+      http.put('/api/v1/library/folders/:id', async ({ request, params }) => {
+        sharedBody = await request.json();
+        return HttpResponse.json(ownedFolder({ id: Number(params.id), name: 'x', created_at: '', updated_at: '' }));
+      }),
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('lets an owner rename and delete their own non-empty folder', async () => {
+    mockAuthUser(['library:read_own', 'library:update_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('MyModels')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'MyModels');
+    expect(within(row).getByRole('button', { name: 'Rename' })).not.toBeDisabled();
+    expect(within(row).getByRole('button', { name: 'Delete' })).not.toBeDisabled();
+    // Sharing stays with library:update_all.
+    expect(within(row).queryByRole('button', { name: 'Share with everyone' })).not.toBeInTheDocument();
+  });
+
+  it("disables rename on someone else's shared folder and marks it shared", async () => {
+    mockAuthUser(['library:read_own', 'library:update_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('ClassProject')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'ClassProject');
+    expect(within(row).getByTitle('Shared with everyone')).toBeInTheDocument();
+    expect(within(row).getByRole('button', { name: 'Rename' })).toBeDisabled();
+  });
+
+  it('lets an admin share a folder', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('MyModels')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'MyModels');
+    await user.click(within(row).getByRole('button', { name: 'Share with everyone' }));
+    await waitFor(() => expect(sharedBody).toEqual({ shared: true }));
+  });
+
+  it('offers to stop sharing a shared folder', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('ClassProject')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'ClassProject');
+    await user.click(within(row).getByRole('button', { name: 'Stop sharing' }));
+    await waitFor(() => expect(sharedBody).toEqual({ shared: false }));
+  });
+
+  it('disables upload in a folder the user can only pass through', async () => {
+    mockAuthUser(['library:read_own', 'library:upload']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('PassThrough')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const upload = screen.getByRole('button', { name: 'Upload' });
+    expect(upload).not.toBeDisabled();
+    await user.click(screen.getByText('PassThrough'));
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled());
+    expect(screen.getByRole('button', { name: 'Upload' })).toHaveAttribute(
+      'title',
+      'You can only add to your own folders and folders shared with everyone',
+    );
+    expect(screen.getByRole('button', { name: 'New Folder' })).toBeDisabled();
+
+    await user.click(screen.getByText('ClassProject'));
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Upload' })).not.toBeDisabled());
+  });
+});

+ 15 - 0
frontend/src/api/client.ts

@@ -8530,6 +8530,13 @@ export interface LibraryFolderTree {
   // max(folder.updated_at, max(immediate-child file.updated_at)). Used by
   // the File Manager folder tree's "sort by recent activity" mode (#1770).
   latest_activity_at: string | null;
+  // Ownership (#3201). can_* are for the current user: what the File
+  // Manager may offer on this folder. The backend enforces the same rules.
+  created_by_id: number | null;
+  shared: boolean;
+  can_write: boolean;
+  can_rename: boolean;
+  can_delete: boolean;
   children: LibraryFolderTree[];
 }
 
@@ -8547,6 +8554,13 @@ export interface LibraryFolder {
   external_show_hidden: boolean;
   file_count: number;
   latest_activity_at: string | null;
+  // Ownership (#3201). can_* are for the current user: what the File
+  // Manager may offer on this folder. The backend enforces the same rules.
+  created_by_id: number | null;
+  shared: boolean;
+  can_write: boolean;
+  can_rename: boolean;
+  can_delete: boolean;
   created_at: string;
   updated_at: string;
 }
@@ -8571,6 +8585,7 @@ export interface LibraryFolderUpdate {
   parent_id?: number | null;
   project_id?: number | null;  // 0 to unlink
   archive_id?: number | null;  // 0 to unlink
+  shared?: boolean;  // library:update_all only (#3201)
 }
 
 export interface LibraryFileDuplicate {

+ 2 - 1
frontend/src/components/ManyfoldTab.tsx

@@ -312,7 +312,8 @@ function ImportFolderSelect({
       >
         <option value="">{t('manyfold.folderAuto')}</option>
         {options.map(({ folder, depth }) => (
-          <option key={folder.id} value={folder.id}>
+          // Listed for the tree's shape; only own and shared folders take imports (#3201).
+          <option key={folder.id} value={folder.id} disabled={folder.can_write === false}>
             {`${'— '.repeat(depth)}${folder.name}`}
           </option>
         ))}

+ 8 - 1
frontend/src/i18n/locales/de.ts

@@ -4272,7 +4272,12 @@ export default {
     noPermissionRenameFolder: 'Sie haben keine Berechtigung, Ordner umzubenennen',
     noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',
     noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',
-    onlyEmptyFoldersDeletable: 'Sie können nur leere Ordner löschen',
+    onlyOwnFoldersDeletable: 'Du kannst nur eigene Ordner löschen, in denen alles dir gehört, oder leere Ordner ohne Besitzer',
+    shareFolder: 'Mit allen teilen',
+    unshareFolder: 'Nicht mehr teilen',
+    shareFolderHint: 'Geteilte Ordner sind für alle Benutzer sichtbar, die darin Dateien und Unterordner anlegen können',
+    sharedFolder: 'Mit allen geteilt',
+    folderNotWritable: 'Du kannst nur in eigene Ordner und mit allen geteilte Ordner hinzufügen',
     noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',
     noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',
@@ -4360,6 +4365,8 @@ export default {
       failedToAddToQueue: 'Fehler beim Hinzufügen: {{error}}',
       fileRenamed: 'Datei umbenannt',
       folderRenamed: 'Ordner umbenannt',
+      folderShared: 'Ordner mit allen geteilt',
+      folderUnshared: 'Ordner wird nicht mehr geteilt',
       thumbnailsGenerated: '{{count}} Vorschaubild(er) generiert',
       thumbnailsGeneratedPartial: '{{succeeded}} Vorschaubild(er) generiert, {{failed}} fehlgeschlagen',
       noStlMissingThumbnails: 'Keine STL- oder PDF-Dateien ohne Vorschaubild',

+ 8 - 1
frontend/src/i18n/locales/en.ts

@@ -4304,7 +4304,12 @@ export default {
     noPermissionRenameFolder: 'You do not have permission to rename folders',
     noPermissionLinkFolder: 'You do not have permission to link folders',
     noPermissionDeleteFolder: 'You do not have permission to delete folders',
-    onlyEmptyFoldersDeletable: 'You can only delete empty folders',
+    onlyOwnFoldersDeletable: 'You can only delete your own folders when everything in them is yours, or empty folders without an owner',
+    shareFolder: 'Share with everyone',
+    unshareFolder: 'Stop sharing',
+    shareFolderHint: 'Shared folders are visible to every user, who can add files and subfolders to them',
+    sharedFolder: 'Shared with everyone',
+    folderNotWritable: 'You can only add to your own folders and folders shared with everyone',
     noPermissionPrint: 'You do not have permission to print',
     noPermissionAddToQueue: 'You do not have permission to add to queue',
     noPermissionSlice: 'You do not have permission to slice files',
@@ -4392,6 +4397,8 @@ export default {
       failedToAddToQueue: 'Failed to add files: {{error}}',
       fileRenamed: 'File renamed',
       folderRenamed: 'Folder renamed',
+      folderShared: 'Folder shared with everyone',
+      folderUnshared: 'Folder no longer shared',
       thumbnailsGenerated: 'Generated {{count}} thumbnail(s)',
       thumbnailsGeneratedPartial: 'Generated {{succeeded}} thumbnail(s), {{failed}} failed',
       noStlMissingThumbnails: 'No STL or PDF files missing thumbnails',

+ 8 - 1
frontend/src/i18n/locales/es.ts

@@ -4274,7 +4274,12 @@ export default {
     noPermissionRenameFolder: 'No tiene permiso para renombrar carpetas',
     noPermissionLinkFolder: 'No tiene permiso para vincular carpetas',
     noPermissionDeleteFolder: 'No tiene permiso para eliminar carpetas',
-    onlyEmptyFoldersDeletable: 'Solo puede eliminar carpetas vacías',
+    onlyOwnFoldersDeletable: 'Solo puedes eliminar tus propias carpetas cuando todo su contenido es tuyo, o carpetas vacías sin propietario',
+    shareFolder: 'Compartir con todos',
+    unshareFolder: 'Dejar de compartir',
+    shareFolderHint: 'Las carpetas compartidas son visibles para todos los usuarios, que pueden añadir archivos y subcarpetas',
+    sharedFolder: 'Compartida con todos',
+    folderNotWritable: 'Solo puedes añadir a tus propias carpetas y a las carpetas compartidas con todos',
     noPermissionPrint: 'No tiene permiso para imprimir',
     noPermissionAddToQueue: 'No tiene permiso para añadir a la cola',
     noPermissionSlice: 'No tiene permiso para laminar archivos',
@@ -4362,6 +4367,8 @@ export default {
       failedToAddToQueue: 'Error al añadir los archivos: {{error}}',
       fileRenamed: 'Archivo renombrado',
       folderRenamed: 'Carpeta renombrada',
+      folderShared: 'Carpeta compartida con todos',
+      folderUnshared: 'La carpeta ya no está compartida',
       thumbnailsGenerated: 'Se generaron {{count}} miniatura(s)',
       thumbnailsGeneratedPartial: 'Se generaron {{succeeded}} miniatura(s), {{failed}} fallidas',
       noStlMissingThumbnails: 'No hay archivos STL ni PDF sin miniaturas',

+ 8 - 1
frontend/src/i18n/locales/fr.ts

@@ -4260,7 +4260,12 @@ export default {
     noPermissionRenameFolder: 'Pas d\'autorisation renommage',
     noPermissionLinkFolder: 'Pas d\'autorisation lien',
     noPermissionDeleteFolder: 'Pas d\'autorisation suppression dossier',
-    onlyEmptyFoldersDeletable: 'Vous ne pouvez supprimer que les dossiers vides',
+    onlyOwnFoldersDeletable: 'Vous ne pouvez supprimer que vos propres dossiers dont tout le contenu vous appartient, ou des dossiers vides sans propriétaire',
+    shareFolder: 'Partager avec tout le monde',
+    unshareFolder: 'Ne plus partager',
+    shareFolderHint: 'Les dossiers partagés sont visibles par tous les utilisateurs, qui peuvent y ajouter des fichiers et des sous-dossiers',
+    sharedFolder: 'Partagé avec tout le monde',
+    folderNotWritable: 'Vous ne pouvez ajouter qu\'à vos propres dossiers et aux dossiers partagés avec tout le monde',
     noPermissionPrint: 'Pas d\'autorisation impression',
     noPermissionSlice: 'Vous n\'avez pas la permission de découper des fichiers',
     noPermissionAddToQueue: 'Pas d\'autorisation file',
@@ -4348,6 +4353,8 @@ export default {
       failedToAddToQueue: 'Échec ajout file : {{error}}',
       fileRenamed: 'Fichier renommé',
       folderRenamed: 'Dossier renommé',
+      folderShared: 'Dossier partagé avec tout le monde',
+      folderUnshared: 'Le dossier n\'est plus partagé',
       thumbnailsGenerated: '{{count}} vignette(s) générée(s)',
       thumbnailsGeneratedPartial: '{{succeeded}} succès, {{failed}} échecs',
       noStlMissingThumbnails: 'Aucun STL ni PDF sans vignette',

+ 8 - 1
frontend/src/i18n/locales/it.ts

@@ -4259,7 +4259,12 @@ export default {
     noPermissionRenameFolder: 'Non hai il permesso di rinominare cartelle',
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
-    onlyEmptyFoldersDeletable: 'Puoi eliminare solo cartelle vuote',
+    onlyOwnFoldersDeletable: 'Puoi eliminare solo le tue cartelle quando tutto il contenuto è tuo, oppure cartelle vuote senza proprietario',
+    shareFolder: 'Condividi con tutti',
+    unshareFolder: 'Interrompi condivisione',
+    shareFolderHint: 'Le cartelle condivise sono visibili a tutti gli utenti, che possono aggiungervi file e sottocartelle',
+    sharedFolder: 'Condivisa con tutti',
+    folderNotWritable: 'Puoi aggiungere solo alle tue cartelle e a quelle condivise con tutti',
     noPermissionPrint: 'Non hai il permesso di stampare',
     noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
@@ -4347,6 +4352,8 @@ export default {
       failedToAddToQueue: 'Aggiunta file fallita: {{error}}',
       fileRenamed: 'File rinominato',
       folderRenamed: 'Cartella rinominata',
+      folderShared: 'Cartella condivisa con tutti',
+      folderUnshared: 'La cartella non è più condivisa',
       thumbnailsGenerated: 'Generate {{count}} miniature',
       thumbnailsGeneratedPartial: 'Generate {{succeeded}} miniature, {{failed}} fallite',
       noStlMissingThumbnails: 'Nessun file STL o PDF senza miniature',

+ 8 - 1
frontend/src/i18n/locales/ja.ts

@@ -4272,7 +4272,12 @@ export default {
     noPermissionRenameFolder: 'フォルダー名を変更する権限がありません',
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
-    onlyEmptyFoldersDeletable: '空のフォルダーのみ削除できます',
+    onlyOwnFoldersDeletable: '削除できるのは、中身がすべて自分のものである自分のフォルダ、または所有者のいない空のフォルダのみです',
+    shareFolder: '全員と共有',
+    unshareFolder: '共有を停止',
+    shareFolderHint: '共有フォルダはすべてのユーザーに表示され、ファイルやサブフォルダを追加できます',
+    sharedFolder: '全員と共有中',
+    folderNotWritable: '追加できるのは自分のフォルダと全員と共有されたフォルダのみです',
     noPermissionPrint: '印刷する権限がありません',
     noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
@@ -4360,6 +4365,8 @@ export default {
       failedToAddToQueue: 'ファイルの追加に失敗: {{error}}',
       fileRenamed: 'ファイル名を変更しました',
       folderRenamed: 'フォルダ名を変更しました',
+      folderShared: 'フォルダを全員と共有しました',
+      folderUnshared: 'フォルダの共有を停止しました',
       thumbnailsGenerated: '{{count}}件のサムネイルを生成しました',
       thumbnailsGeneratedPartial: '{{succeeded}}件生成、{{failed}}件失敗',
       noStlMissingThumbnails: 'サムネイルのないSTL・PDFファイルはありません',

+ 8 - 1
frontend/src/i18n/locales/ko.ts

@@ -4082,7 +4082,12 @@ export default {
     noPermissionRenameFolder: '폴더 이름 변경 권한이 없습니다',
     noPermissionLinkFolder: '폴더 연결 권한이 없습니다',
     noPermissionDeleteFolder: '폴더 삭제 권한이 없습니다',
-    onlyEmptyFoldersDeletable: '빈 폴더만 삭제할 수 있습니다',
+    onlyOwnFoldersDeletable: '내용이 모두 본인 것인 본인 폴더, 또는 소유자가 없는 빈 폴더만 삭제할 수 있습니다',
+    shareFolder: '모두와 공유',
+    unshareFolder: '공유 중지',
+    shareFolderHint: '공유 폴더는 모든 사용자에게 표시되며, 파일과 하위 폴더를 추가할 수 있습니다',
+    sharedFolder: '모두와 공유됨',
+    folderNotWritable: '본인 폴더와 모두와 공유된 폴더에만 추가할 수 있습니다',
     noPermissionPrint: '인쇄 권한이 없습니다',
     noPermissionAddToQueue: '대기열 추가 권한이 없습니다',
     noPermissionSlice: '파일 슬라이싱 권한이 없습니다',
@@ -4169,6 +4174,8 @@ export default {
       failedToAddToQueue: '파일 추가 실패: {{error}}',
       fileRenamed: '파일 이름 변경됨',
       folderRenamed: '폴더 이름 변경됨',
+      folderShared: '폴더를 모두와 공유했습니다',
+      folderUnshared: '폴더 공유를 중지했습니다',
       thumbnailsGenerated: '{{count}}개 썸네일 생성됨',
       thumbnailsGeneratedPartial: '{{succeeded}}개 썸네일 생성됨, {{failed}}개 실패',
       noStlMissingThumbnails: '썸네일이 없는 STL 또는 PDF 파일 없음',

+ 8 - 1
frontend/src/i18n/locales/nl.ts

@@ -4302,7 +4302,12 @@ export default {
     noPermissionRenameFolder: 'Je hebt geen toestemming om mappen te hernoemen',
     noPermissionLinkFolder: 'Je hebt geen toestemming om mappen te koppelen',
     noPermissionDeleteFolder: 'Je hebt geen toestemming om mappen te verwijderen',
-    onlyEmptyFoldersDeletable: 'Je kunt alleen lege mappen verwijderen',
+    onlyOwnFoldersDeletable: 'Je kunt alleen je eigen mappen verwijderen als alles erin van jou is, of lege mappen zonder eigenaar',
+    shareFolder: 'Delen met iedereen',
+    unshareFolder: 'Niet meer delen',
+    shareFolderHint: 'Gedeelde mappen zijn zichtbaar voor alle gebruikers, die er bestanden en submappen aan kunnen toevoegen',
+    sharedFolder: 'Gedeeld met iedereen',
+    folderNotWritable: 'Je kunt alleen toevoegen aan je eigen mappen en mappen die met iedereen gedeeld zijn',
     noPermissionPrint: 'Je hebt geen toestemming om af te drukken',
     noPermissionAddToQueue: 'Je hebt geen toestemming om aan de wachtrij toe te voegen',
     noPermissionSlice: 'Je hebt geen toestemming om bestanden te slicen',
@@ -4390,6 +4395,8 @@ export default {
       failedToAddToQueue: 'Bestanden toevoegen mislukt: {{error}}',
       fileRenamed: 'Bestand hernoemd',
       folderRenamed: 'Map hernoemd',
+      folderShared: 'Map gedeeld met iedereen',
+      folderUnshared: 'Map wordt niet meer gedeeld',
       thumbnailsGenerated: '{{count}} miniatuur/miniaturen gegenereerd',
       thumbnailsGeneratedPartial: '{{succeeded}} miniatuur/miniaturen gegenereerd, {{failed}} mislukt',
       noStlMissingThumbnails: 'Geen STL- of PDF-bestanden zonder miniaturen',

+ 8 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4259,7 +4259,12 @@ export default {
     noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
     noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
     noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
-    onlyEmptyFoldersDeletable: 'Você só pode excluir pastas vazias',
+    onlyOwnFoldersDeletable: 'Você só pode excluir suas próprias pastas quando todo o conteúdo é seu, ou pastas vazias sem dono',
+    shareFolder: 'Compartilhar com todos',
+    unshareFolder: 'Parar de compartilhar',
+    shareFolderHint: 'Pastas compartilhadas ficam visíveis para todos os usuários, que podem adicionar arquivos e subpastas',
+    sharedFolder: 'Compartilhada com todos',
+    folderNotWritable: 'Você só pode adicionar às suas próprias pastas e às pastas compartilhadas com todos',
     noPermissionPrint: 'Você não tem permissão para imprimir',
     noPermissionSlice: 'Você não tem permissão para fatiar arquivos',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
@@ -4347,6 +4352,8 @@ export default {
       failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
       fileRenamed: 'Arquivo renomeado',
       folderRenamed: 'Pasta renomeada',
+      folderShared: 'Pasta compartilhada com todos',
+      folderUnshared: 'A pasta não está mais compartilhada',
       thumbnailsGenerated: 'Geradas {{count}} miniatura(s)',
       thumbnailsGeneratedPartial: 'Geradas {{succeeded}} miniatura(s), {{failed}} falharam',
       noStlMissingThumbnails: 'Nenhum arquivo STL ou PDF sem miniatura',

+ 8 - 1
frontend/src/i18n/locales/ru.ts

@@ -4074,7 +4074,12 @@ export default {
     noPermissionRenameFolder: "У вас нет прав на переименование папок",
     noPermissionLinkFolder: "У вас нет прав на связывание папок",
     noPermissionDeleteFolder: "У вас нет прав на удаление папок",
-    onlyEmptyFoldersDeletable: "Вы можете удалять только пустые папки",
+    onlyOwnFoldersDeletable: 'Удалять можно только свои папки, всё содержимое которых принадлежит вам, или пустые папки без владельца',
+    shareFolder: 'Открыть доступ всем',
+    unshareFolder: 'Закрыть общий доступ',
+    shareFolderHint: 'Общие папки видны всем пользователям, которые могут добавлять в них файлы и подпапки',
+    sharedFolder: 'Доступна всем',
+    folderNotWritable: 'Добавлять можно только в свои папки и в папки с общим доступом',
     noPermissionPrint: "У вас нет прав на запуск печати",
     noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
     noPermissionSlice: "У вас нет прав на нарезку файлов",
@@ -4161,6 +4166,8 @@ export default {
       failedToAddToQueue: "Не удалось добавить файлы: {{error}}",
       fileRenamed: "Файл переименован",
       folderRenamed: "Папка переименована",
+      folderShared: 'Папка открыта для всех',
+      folderUnshared: 'Общий доступ к папке закрыт',
       thumbnailsGenerated: "Создано миниатюр: {{count}}",
       thumbnailsGeneratedPartial: "Создано миниатюр: {{succeeded}}, ошибок: {{failed}}",
       noStlMissingThumbnails: "Нет STL- или PDF-файлов без миниатюр",

+ 8 - 1
frontend/src/i18n/locales/sv.ts

@@ -4301,7 +4301,12 @@ errors: {
     noPermissionRenameFolder: 'Du har inte behörighet att döpa om mappar',
     noPermissionLinkFolder: 'Du har inte behörighet att länka mappar',
     noPermissionDeleteFolder: 'Du har inte behörighet att ta bort mappar',
-    onlyEmptyFoldersDeletable: 'Du kan endast ta bort tomma mappar',
+    onlyOwnFoldersDeletable: 'Du kan bara ta bort dina egna mappar när allt i dem är ditt, eller tomma mappar utan ägare',
+    shareFolder: 'Dela med alla',
+    unshareFolder: 'Sluta dela',
+    shareFolderHint: 'Delade mappar syns för alla användare, som kan lägga till filer och undermappar i dem',
+    sharedFolder: 'Delad med alla',
+    folderNotWritable: 'Du kan bara lägga till i dina egna mappar och mappar som delas med alla',
     noPermissionPrint: 'Du har inte behörighet att skriva ut',
     noPermissionAddToQueue: 'Du har inte behörighet att lägga till i kö',
     noPermissionSlice: 'Du har inte behörighet att slica filer',
@@ -4389,6 +4394,8 @@ errors: {
       failedToAddToQueue: 'Kunde inte lägga till filer: {{error}}',
       fileRenamed: 'Fil omdöpt',
       folderRenamed: 'Mapp omdöpt',
+      folderShared: 'Mappen delas med alla',
+      folderUnshared: 'Mappen delas inte längre',
       thumbnailsGenerated: 'Genererade {{count}} minibild(er)',
       thumbnailsGeneratedPartial: 'Genererade {{succeeded}} minibild(er), {{failed}} misslyckades',
       noStlMissingThumbnails: 'Inga STL- eller PDF-filer saknar minibilder',

+ 8 - 1
frontend/src/i18n/locales/tr.ts

@@ -4266,7 +4266,12 @@ export default {
     noPermissionRenameFolder: 'Klasörleri yeniden adlandırma izniniz yok',
     noPermissionLinkFolder: 'Klasörleri bağlama izniniz yok',
     noPermissionDeleteFolder: 'Klasörleri silme izniniz yok',
-    onlyEmptyFoldersDeletable: 'Yalnızca boş klasörleri silebilirsiniz',
+    onlyOwnFoldersDeletable: 'Yalnızca içindeki her şey size ait olan kendi klasörlerinizi veya sahibi olmayan boş klasörleri silebilirsiniz',
+    shareFolder: 'Herkesle paylaş',
+    unshareFolder: 'Paylaşımı durdur',
+    shareFolderHint: 'Paylaşılan klasörler tüm kullanıcılara görünür ve kullanıcılar bunlara dosya ve alt klasör ekleyebilir',
+    sharedFolder: 'Herkesle paylaşıldı',
+    folderNotWritable: 'Yalnızca kendi klasörlerinize ve herkesle paylaşılan klasörlere ekleme yapabilirsiniz',
     noPermissionPrint: 'Yazdırma izniniz yok',
     noPermissionAddToQueue: 'Kuyruğa ekleme izniniz yok',
     noPermissionSlice: 'Dosyaları dilimleme izniniz yok',
@@ -4354,6 +4359,8 @@ export default {
       failedToAddToQueue: 'Dosyalar eklenemedi: {{error}}',
       fileRenamed: 'Dosya yeniden adlandırıldı',
       folderRenamed: 'Klasör yeniden adlandırıldı',
+      folderShared: 'Klasör herkesle paylaşıldı',
+      folderUnshared: 'Klasör artık paylaşılmıyor',
       thumbnailsGenerated: '{{count}} küçük resim oluşturuldu',
       thumbnailsGeneratedPartial: '{{succeeded}} küçük resim oluşturuldu, {{failed}} başarısız',
       noStlMissingThumbnails: 'Küçük resmi eksik STL veya PDF dosyası yok',

+ 8 - 1
frontend/src/i18n/locales/uk.ts

@@ -4299,7 +4299,12 @@ export default {
     noPermissionRenameFolder: "Ви не маєте дозволу на перейменування папок",
     noPermissionLinkFolder: "Ви не маєте дозволу на пов’язування папок",
     noPermissionDeleteFolder: "Ви не маєте дозволу на видалення папок",
-    onlyEmptyFoldersDeletable: "Можна видаляти лише порожні папки",
+    onlyOwnFoldersDeletable: 'Видаляти можна лише власні папки, увесь вміст яких належить вам, або порожні папки без власника',
+    shareFolder: 'Поділитися з усіма',
+    unshareFolder: 'Припинити спільний доступ',
+    shareFolderHint: 'Спільні папки бачать усі користувачі, які можуть додавати до них файли та підпапки',
+    sharedFolder: 'Спільна для всіх',
+    folderNotWritable: 'Додавати можна лише до власних папок і папок, спільних для всіх',
     noPermissionPrint: "Ви не маєте дозволу на друк",
     noPermissionAddToQueue: "Ви не маєте дозволу на додавання в чергу",
     noPermissionSlice: "Ви не маєте дозволу нарізати файли",
@@ -4387,6 +4392,8 @@ export default {
       failedToAddToQueue: "Не вдалося додати файли: {{error}}",
       fileRenamed: "Файл перейменовано",
       folderRenamed: "Папку перейменовано",
+      folderShared: 'Папку відкрито для всіх',
+      folderUnshared: 'Спільний доступ до папки припинено',
       thumbnailsGenerated: "Створено мініатюри {{count}}",
       thumbnailsGeneratedPartial: "Створено мініатюри {{succeeded}}, {{failed}} не вдалося",
       noStlMissingThumbnails: "Немає файлів STL або PDF без мініатюр",

+ 8 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4259,7 +4259,12 @@ export default {
     noPermissionRenameFolder: '您没有重命名文件夹的权限',
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
-    onlyEmptyFoldersDeletable: '您只能删除空文件夹',
+    onlyOwnFoldersDeletable: '只能删除内容全部属于你的自有文件夹,或没有所有者的空文件夹',
+    shareFolder: '与所有人共享',
+    unshareFolder: '停止共享',
+    shareFolderHint: '共享文件夹对所有用户可见,他们可以在其中添加文件和子文件夹',
+    sharedFolder: '已与所有人共享',
+    folderNotWritable: '只能添加到你自己的文件夹和与所有人共享的文件夹',
     noPermissionPrint: '您没有打印的权限',
     noPermissionSlice: '您没有切片文件的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',
@@ -4347,6 +4352,8 @@ export default {
       failedToAddToQueue: '添加文件失败:{{error}}',
       fileRenamed: '文件已重命名',
       folderRenamed: '文件夹已重命名',
+      folderShared: '文件夹已与所有人共享',
+      folderUnshared: '文件夹已停止共享',
       thumbnailsGenerated: '已生成 {{count}} 个缩略图',
       thumbnailsGeneratedPartial: '已生成 {{succeeded}} 个缩略图,{{failed}} 个失败',
       noStlMissingThumbnails: '没有缺少缩略图的 STL 或 PDF 文件',

+ 8 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4259,7 +4259,12 @@ export default {
     noPermissionRenameFolder: '您沒有重新命名資料夾的權限',
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
-    onlyEmptyFoldersDeletable: '您只能刪除空資料夾',
+    onlyOwnFoldersDeletable: '只能刪除內容全部屬於你的自有資料夾,或沒有擁有者的空資料夾',
+    shareFolder: '與所有人共用',
+    unshareFolder: '停止共用',
+    shareFolderHint: '共用資料夾對所有使用者可見,他們可以在其中新增檔案和子資料夾',
+    sharedFolder: '已與所有人共用',
+    folderNotWritable: '只能新增到你自己的資料夾和與所有人共用的資料夾',
     noPermissionPrint: '您沒有列印的權限',
     noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
@@ -4347,6 +4352,8 @@ export default {
       failedToAddToQueue: '新增檔案失敗:{{error}}',
       fileRenamed: '檔案已重新命名',
       folderRenamed: '資料夾已重新命名',
+      folderShared: '資料夾已與所有人共用',
+      folderUnshared: '資料夾已停止共用',
       thumbnailsGenerated: '已產生 {{count}} 個縮圖',
       thumbnailsGeneratedPartial: '已產生 {{succeeded}} 個縮圖,{{failed}} 個失敗',
       noStlMissingThumbnails: '沒有缺少縮圖的 STL 或 PDF 檔案',

+ 93 - 38
frontend/src/pages/FileManagerPage.tsx

@@ -49,6 +49,7 @@ import {
   FileSpreadsheet,
   Info,
   Globe,
+  Users,
   StickyNote,
   Camera,
   Eye,
@@ -537,10 +538,13 @@ interface MoveFilesModalProps {
 function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {
   const [targetFolder, setTargetFolder] = useState<number | null>(null);
 
-  const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => {
-    const result: { id: number | null; name: string; depth: number }[] = [];
+  // can_write: a library:read_own user moves files only into their own and
+  // shared folders (#3201); the rest stay listed so the tree keeps its shape.
+  type MoveTarget = { id: number | null; name: string; depth: number; canWrite: boolean };
+  const flattenFolders = (items: LibraryFolderTree[], depth = 0): MoveTarget[] => {
+    const result: MoveTarget[] = [];
     for (const item of items) {
-      result.push({ id: item.id, name: item.name, depth });
+      result.push({ id: item.id, name: item.name, depth, canWrite: item.can_write !== false });
       if (item.children.length > 0) {
         result.push(...flattenFolders(item.children, depth + 1));
       }
@@ -548,7 +552,7 @@ function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMo
     return result;
   };
 
-  const flatFolders = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0 }, ...flattenFolders(folders)];
+  const flatFolders: MoveTarget[] = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0, canWrite: true }, ...flattenFolders(folders)];
 
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
@@ -562,11 +566,12 @@ function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMo
               <button
                 key={folder.id ?? 'root'}
                 onClick={() => setTargetFolder(folder.id)}
-                disabled={folder.id === currentFolderId}
+                disabled={folder.id === currentFolderId || !folder.canWrite}
+                title={!folder.canWrite ? t('fileManager.folderNotWritable') : undefined}
                 className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
                   targetFolder === folder.id
                     ? 'bg-bambu-green/20 text-bambu-green'
-                    : folder.id === currentFolderId
+                    : folder.id === currentFolderId || !folder.canWrite
                     ? 'opacity-50 cursor-not-allowed text-bambu-gray'
                     : 'hover:bg-bambu-dark text-white'
                 }`}
@@ -770,6 +775,7 @@ interface FolderTreeItemProps {
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
+  onToggleShared?: (folder: LibraryFolderTree) => void;
   depth?: number;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
@@ -788,6 +794,8 @@ interface FolderActionsMenuProps {
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
+  // Share with every library:read_own user, or stop (#3201). library:update_all only.
+  onToggleShared?: (folder: LibraryFolderTree) => void;
   hasPermission: (permission: Permission) => boolean;
   // Hide the kebab until its `group` row is hovered or focused — only for
   // pointers that can hover (#2865). The menu is a DOM descendant, so the
@@ -798,7 +806,7 @@ interface FolderActionsMenuProps {
   t: TFunction;
 }
 
-function FolderActionsMenu({ folder, onDelete, onLink, onRename, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) {
+function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) {
   const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
   const rootRef = useRef<HTMLDivElement>(null);
   const buttonRef = useRef<HTMLButtonElement>(null);
@@ -812,18 +820,20 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, hasPermission,
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isExternal = folder.is_external;
-  // #1781: users with only library:delete_own may delete empty, unlinked,
-  // non-external folders. The backend enforces the same rule and additionally
-  // counts trashed files (invisible here), so a 403 can still come back.
+  // The backend says what this user may do with the folder (#3201): delete
+  // their own folder when everything in it is theirs, or an ownerless empty
+  // one (#1781). The fallbacks cover a response without those fields.
   const canDeleteFolder =
-    hasPermission('library:delete_all') ||
-    (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked);
+    folder.can_delete ??
+    (hasPermission('library:delete_all') ||
+      (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked));
   const deleteDisabledTooltip = canDeleteFolder
     ? undefined
     : hasPermission('library:delete_own') && !isExternal && !isLinked
-      ? t('fileManager.onlyEmptyFoldersDeletable')
+      ? t('fileManager.onlyOwnFoldersDeletable')
       : t('fileManager.noPermissionDeleteFolder');
-  const canRename = hasPermission('library:update_all');
+  const canRename = folder.can_rename ?? hasPermission('library:update_all');
+  const canLink = hasPermission('library:update_all');
 
   const items: ContextMenuItem[] = [
     {
@@ -837,9 +847,19 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, hasPermission,
       label: isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo'),
       icon: <Link2 className="w-3.5 h-3.5" />,
       onClick: () => onLink(folder),
-      disabled: !canRename,
-      title: !canRename ? t('fileManager.noPermissionLinkFolder') : undefined,
+      disabled: !canLink,
+      title: !canLink ? t('fileManager.noPermissionLinkFolder') : undefined,
     },
+    ...(onToggleShared && canLink
+      ? [
+          {
+            label: folder.shared ? t('fileManager.unshareFolder') : t('fileManager.shareFolder'),
+            icon: <Users className="w-3.5 h-3.5" />,
+            onClick: () => onToggleShared(folder),
+            title: t('fileManager.shareFolderHint'),
+          },
+        ]
+      : []),
     {
       label: t('common.delete'),
       icon: <Trash2 className="w-3.5 h-3.5" />,
@@ -887,7 +907,7 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, hasPermission,
   );
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, onToggleShared, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(defaultExpanded);
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
@@ -951,6 +971,11 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
             )}
           </button>
         )}
+        {folder.shared && (
+          <span title={t('fileManager.sharedFolder')}>
+            <Users className="w-3 h-3 text-bambu-gray flex-shrink-0" />
+          </span>
+        )}
         {/* Read-only indicator for external folders */}
         {isExternal && folder.external_readonly && (
           <span title={t('fileManager.readOnly')}>
@@ -975,6 +1000,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
           onDelete={onDelete}
           onLink={onLink}
           onRename={onRename}
+          onToggleShared={onToggleShared}
           hasPermission={hasPermission}
           revealOnHover={!wrapNames}
           t={t}
@@ -991,6 +1017,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onDelete={onDelete}
               onLink={onLink}
               onRename={onRename}
+              onToggleShared={onToggleShared}
               depth={depth + 1}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
@@ -2089,6 +2116,19 @@ export function FileManagerPage() {
     },
   });
 
+  const shareFolderMutation = useMutation({
+    mutationFn: ({ id, shared }: { id: number; shared: boolean }) => api.updateLibraryFolder(id, { shared }),
+    onSuccess: (_, variables) => {
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['project-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
+      showToast(variables.shared ? t('fileManager.toast.folderShared') : t('fileManager.toast.folderUnshared'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+  const toggleFolderShared = (folder: LibraryFolderTree) =>
+    shareFolderMutation.mutate({ id: folder.id, shared: !folder.shared });
+
   const batchThumbnailMutation = useMutation({
     mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
     onSuccess: (result) => {
@@ -2194,7 +2234,29 @@ export function FileManagerPage() {
   // and also disabled while the upload modal itself is open so drags into
   // the modal's own drop zone don't bubble up and flash the page overlay
   // behind it.
-  const canUpload = hasPermission('library:upload');
+  // Find the selected folder in the tree to check external status
+  const selectedFolder = useMemo(() => {
+    if (!selectedFolderId || !folders) return null;
+    const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
+      for (const item of items) {
+        if (item.id === selectedFolderId) return item;
+        const found = findFolder(item.children);
+        if (found) return found;
+      }
+      return null;
+    };
+    return findFolder(folders);
+  }, [selectedFolderId, folders]);
+
+  // A library:read_own user may add only to their own folders and shared
+  // ones (#3201); a folder they merely pass through is read-only for them.
+  const canWriteHere = selectedFolder?.can_write !== false;
+  const canUpload = hasPermission('library:upload') && canWriteHere;
+  const addDisabledTitle = !hasPermission('library:upload')
+    ? undefined
+    : !canWriteHere
+      ? t('fileManager.folderNotWritable')
+      : undefined;
   const { isDraggingOver, dragHandlers } = usePageFileDrop({
     disabled: !canUpload || showUploadModal,
     onFiles: (files) => {
@@ -2274,20 +2336,6 @@ export function FileManagerPage() {
 
   const isLoading = foldersLoading || filesLoading;
 
-  // Find the selected folder in the tree to check external status
-  const selectedFolder = useMemo(() => {
-    if (!selectedFolderId || !folders) return null;
-    const findFolder = (items: LibraryFolderTree[]): LibraryFolderTree | null => {
-      for (const item of items) {
-        if (item.id === selectedFolderId) return item;
-        const found = findFolder(item.children);
-        if (found) return found;
-      }
-      return null;
-    };
-    return findFolder(folders);
-  }, [selectedFolderId, folders]);
-
   // The chain of folders from a top-level folder down to the selected one.
   // Selection is the single source of truth — clicking a folder anywhere just
   // moves selectedFolderId, and the path (and with it the path bar and the set
@@ -2736,8 +2784,8 @@ export function FileManagerPage() {
           <Button
             variant="secondary"
             onClick={() => setShowNewFolderModal(true)}
-            disabled={!hasPermission('library:upload')}
-            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : undefined}
+            disabled={!canUpload}
+            title={!hasPermission('library:upload') ? t('fileManager.noPermissionCreateFolder') : addDisabledTitle}
           >
             <FolderPlus className="w-4 h-4 mr-2" />
             {t('fileManager.newFolder')}
@@ -2777,8 +2825,8 @@ export function FileManagerPage() {
           )}
           <Button
             onClick={() => setShowUploadModal(true)}
-            disabled={!hasPermission('library:upload')}
-            title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
+            disabled={!canUpload}
+            title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : addDisabledTitle}
           >
             <Upload className="w-4 h-4 mr-2" />
             {t('common.upload')}
@@ -3012,6 +3060,7 @@ export function FileManagerPage() {
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
+                onToggleShared={toggleFolderShared}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
                 showModified={showModified}
@@ -3456,6 +3505,11 @@ export function FileManagerPage() {
                             {(folder.project_id || folder.archive_id) && (
                               <Link2 className="w-3.5 h-3.5 flex-shrink-0 text-blue-700 dark:text-blue-400" />
                             )}
+                            {folder.shared && (
+                              <span title={t('fileManager.sharedFolder')}>
+                                <Users className="w-3.5 h-3.5 flex-shrink-0 text-bambu-gray" />
+                              </span>
+                            )}
                             {folder.is_external && folder.external_readonly && (
                               <Lock className="w-3.5 h-3.5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
                             )}
@@ -3473,6 +3527,7 @@ export function FileManagerPage() {
                             onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                             onLink={setLinkFolder}
                             onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
+                            onToggleShared={toggleFolderShared}
                             hasPermission={hasPermission}
                             revealOnHover={!isSelectedFolder}
                             tabIndex={isSelectedFolder ? 0 : -1}
@@ -3572,8 +3627,8 @@ export function FileManagerPage() {
               </p>
               <Button
                 onClick={() => setShowUploadModal(true)}
-                disabled={!hasPermission('library:upload')}
-                title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
+                disabled={!canUpload}
+                title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : addDisabledTitle}
               >
                 <Plus className="w-4 h-4 mr-2" />
                 {t('fileManager.uploadFiles')}

+ 2 - 1
frontend/src/pages/MakerworldPage.tsx

@@ -576,7 +576,8 @@ export function MakerworldPage({ embedded = false }: { embedded?: boolean } = {}
                     .filter((f) => !(f.is_external && f.external_readonly))
                     .flatMap((f) => flattenFolderTree(f))
                     .map(({ folder, depth }) => (
-                      <option key={folder.id} value={folder.id}>
+                      // Listed for the tree's shape; only own and shared folders take imports (#3201).
+                      <option key={folder.id} value={folder.id} disabled={folder.can_write === false}>
                         {`${'— '.repeat(depth)}${folder.name}`}
                       </option>
                     ))}

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-DCYvw1Mv.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-DLcR2RDo.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CUuH2pYY.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-BeGRJy9y.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-BpSt-JnU.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cl_K2DVh.js"></script>
+    <script type="module" crossorigin src="/assets/index-BeGRJy9y.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>

Some files were not shown because too many files changed in this diff