From f6f480afc457b21f2f9d91e44ead577aebf7cf8e Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 28 Jul 2026 12:03:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20exclude=20shortcuts=20from?= =?UTF-8?q?=20search,=20export=20and=20indexing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcuts are tree entries, not content: they never match a search, never reach the search index, and leave no entry in an exported archive. The target itself is indexed and exported through its own root, so users excluded from a restricted folder cannot find its content through search or an ancestor export. --- src/backend/core/api/viewsets.py | 3 +++ src/backend/core/services/item_exports.py | 4 +++ src/backend/core/services/search_indexers.py | 2 ++ .../core/tests/items/test_api_items_export.py | 26 +++++++++++++++++++ .../tests/items/test_api_items_shortcuts.py | 21 +++++++++++++++ .../tests/test_services_search_indexers.py | 16 ++++++++++++ 6 files changed, 72 insertions(+) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 139d2ef9..5be5b665 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1433,6 +1433,9 @@ class ItemViewSet( queryset = self._filter_suspicious_items(queryset, user) queryset = self._exclude_pending_items(queryset) + # Shortcuts are tree entries, not searchable content + queryset = queryset.exclude(type=models.ItemTypeChoices.SHORTCUT) + queryset = queryset.annotate_is_favorite(user) if workspace: diff --git a/src/backend/core/services/item_exports.py b/src/backend/core/services/item_exports.py index ae1a19b4..f2f7eb71 100644 --- a/src/backend/core/services/item_exports.py +++ b/src/backend/core/services/item_exports.py @@ -43,6 +43,10 @@ def export_descendants(folder): relative_paths = {str(folder.path): ""} for descendant in descendants: + # Shortcuts are tree entries: their target lives in another subtree + if descendant.type == models.ItemTypeChoices.SHORTCUT: + continue + parent_key = str(descendant.path).rsplit(".", 1)[0] parent_relative = relative_paths.get(parent_key) if parent_relative is None: diff --git a/src/backend/core/services/search_indexers.py b/src/backend/core/services/search_indexers.py index 10aef550..25701d15 100644 --- a/src/backend/core/services/search_indexers.py +++ b/src/backend/core/services/search_indexers.py @@ -196,6 +196,8 @@ class BaseItemIndexer(ABC): queryset = queryset or models.Item.objects.filter( main_workspace=False, ) + # Shortcuts are tree entries, not indexable content + queryset = queryset.exclude(type=models.ItemTypeChoices.SHORTCUT) queryset = queryset.order_by("id") while True: diff --git a/src/backend/core/tests/items/test_api_items_export.py b/src/backend/core/tests/items/test_api_items_export.py index 970f3c76..e71801d1 100644 --- a/src/backend/core/tests/items/test_api_items_export.py +++ b/src/backend/core/tests/items/test_api_items_export.py @@ -260,6 +260,32 @@ def test_api_items_export_file_missing_from_storage(): assert archive.read("gone.txt") == b"" +def test_api_items_export_skips_shortcuts(): + """Shortcuts leave no entry in the exported archive.""" + user = factories.UserFactory() + folder = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, models.RoleChoices.OWNER)], + ) + factories.ItemFactory( + parent=folder, + type=models.ItemTypeChoices.FILE, + update_upload_state=models.ItemUploadStateChoices.READY, + upload_bytes=b"kept", + upload_bytes__filename="kept.txt", + ) + restricted = factories.ItemFactory(parent=folder, type=models.ItemTypeChoices.FOLDER) + restricted.restrict(factories.UserFactory()) + + client = APIClient() + client.force_login(user) + + response = client.get(f"/api/v1.0/items/{folder.pk}/export/") + + assert response.status_code == 200 + assert _zip_names(response) == ["kept.txt"] + + def test_api_items_export_empty_folder(): """Exporting an empty folder returns an empty zip archive.""" user = factories.UserFactory() diff --git a/src/backend/core/tests/items/test_api_items_shortcuts.py b/src/backend/core/tests/items/test_api_items_shortcuts.py index df98023c..13c5264d 100644 --- a/src/backend/core/tests/items/test_api_items_shortcuts.py +++ b/src/backend/core/tests/items/test_api_items_shortcuts.py @@ -197,6 +197,27 @@ def test_api_items_shortcuts_children_list_constant_queries(): assert len(many) == len(single) +def test_api_items_shortcuts_excluded_from_search(): + """Shortcuts never show up as search results.""" + user = factories.UserFactory() + parent = factories.ItemFactory( + type=models.ItemTypeChoices.FOLDER, + users=[(user, "owner")], + ) + folder = _create_restricted_folder(parent, factories.UserFactory()) + shortcut = folder.shortcut + shortcut.title = "shortcut" + shortcut.save() + + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/items/search/", {"title": "shortcut"}) + + assert response.status_code == 200 + assert response.json()["results"] == [] + + def test_api_items_shortcuts_tree_includes_shortcuts(): """The tree endpoint includes shortcut entries so excluded users see them.""" parent_owner = factories.UserFactory() diff --git a/src/backend/core/tests/test_services_search_indexers.py b/src/backend/core/tests/test_services_search_indexers.py index 3063e20c..ba36b85c 100644 --- a/src/backend/core/tests/test_services_search_indexers.py +++ b/src/backend/core/tests/test_services_search_indexers.py @@ -484,6 +484,22 @@ def test_services_search_indexers_index_errors(indexer_settings): indexer.index() +@pytest.mark.usefixtures("indexer_settings") +@patch.object(SearchIndexer, "push") +def test_services_search_indexers_index_skips_shortcuts(mock_push): + """Shortcuts are never sent to the search index.""" + user = factories.UserFactory() + parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER) + folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER) + folder = folder.restrict(user) + + count = SearchIndexer().index() + + assert count == 2 + indexed_ids = {doc["id"] for call in mock_push.call_args_list for doc in call.args[0]} + assert indexed_ids == {str(parent.id), str(folder.id)} + + @patch.object(SearchIndexer, "push") def test_services_search_indexers_batches_pass_only_batch_accesses(mock_push, indexer_settings): """