✨(backend) exclude shortcuts from search, export and indexing

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.
This commit is contained in:
Nicolas Clerc
2026-07-28 16:23:29 +02:00
parent 85c13cf0a1
commit f6f480afc4
6 changed files with 72 additions and 0 deletions
+3
View File
@@ -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:
@@ -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:
@@ -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:
@@ -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()
@@ -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()
@@ -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):
"""