diff --git a/pyproject.toml b/pyproject.toml index 3cac1007e..361b30740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "httpx-oauth~=0.17", "ijson>=3.5.1", "imap-tools~=1.14.0", - "jinja2~=3.1.5", + "jinja2~=3.1.6", "langdetect~=1.0.9", "llama-index-core>=0.14.23", "llama-index-embeddings-huggingface>=0.6.1", diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py index 05911febc..05488ba47 100644 --- a/src/documents/tests/test_api_objects.py +++ b/src/documents/tests/test_api_objects.py @@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase): - API is called THEN: - Last correspondence date is returned only if requested for list, and for detail + - The date is scoped to documents the requesting user may view """ Document.objects.create( @@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase): response.data["last_correspondence"], ) + # A newer document owned by another user must not leak through the + # aggregate for a non-superuser who cannot view it + other = User.objects.create_user(username="other") + Document.objects.create( + mime_type="application/pdf", + correspondent=self.c1, + created=datetime.date(2023, 6, 1), + checksum="hidden", + owner=other, + ) + + user = User.objects.create_user(username="regular") + user.user_permissions.add( + Permission.objects.get(codename="view_correspondent"), + ) + self.client.force_authenticate(user=user) + + response = self.client.get("/api/correspondents/?last_correspondence=true") + self.assertEqual(response.status_code, status.HTTP_200_OK) + result = next(r for r in response.data["results"] if r["id"] == self.c1.id) + self.assertIn("2022-01-02", result["last_correspondence"]) + + response = self.client.get(f"/api/correspondents/{self.c1.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("2022-01-02", response.data["last_correspondence"]) + def test_paginated_objects_include_all_only_for_legacy_version(self) -> None: response_v10 = self.client.get("/api/correspondents/") self.assertEqual(response_v10.status_code, status.HTTP_200_OK) diff --git a/src/documents/tests/test_share_link_bundles.py b/src/documents/tests/test_share_link_bundles.py index 9583e7e47..f58fd6eda 100644 --- a/src/documents/tests/test_share_link_bundles.py +++ b/src/documents/tests/test_share_link_bundles.py @@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_302_FOUND) self.assertIn("sharelink_notfound=1", response["Location"]) + def test_share_link_missing_file_redirects(self) -> None: + """ + GIVEN: + - A share link whose document file is missing from disk + WHEN: + - The public share link is requested anonymously + THEN: + - The user is redirected to login instead of a 500 error + """ + doc = DocumentFactory.create(filename="missing-original.pdf") + share_link = ShareLink.objects.create( + slug="missingfilelink", + document=doc, + file_version=ShareLink.FileVersion.ORIGINAL, + ) + + self.client.logout() + response = self.client.get(f"/share/{share_link.slug}/") + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertIn("sharelink_notfound=1", response["Location"]) + + def test_download_ready_bundle_missing_file_returns_503(self) -> None: + """ + GIVEN: + - A READY bundle whose zip file is missing from disk + WHEN: + - The public share link is requested anonymously + THEN: + - A 503 is returned instead of a 500 error + """ + bundle = ShareLinkBundle.objects.create( + slug="missingbundlefile", + file_version=ShareLink.FileVersion.ARCHIVE, + status=ShareLinkBundle.Status.READY, + file_path="bundles/gone.zip", + ) + bundle.documents.set([self.document]) + + self.client.logout() + response = self.client.get(f"/share/{bundle.slug}/") + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase): def setUp(self) -> None: diff --git a/src/documents/views.py b/src/documents/views.py index 4bca07f20..4e33d9f9d 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -577,13 +577,19 @@ class CorrespondentViewSet( def list(self, request, *args, **kwargs): if request.query_params.get("last_correspondence", None): self.queryset = self.queryset.annotate( - last_correspondence=Max("documents__created"), + last_correspondence=Max( + "documents__created", + filter=self.get_document_count_filter(), + ), ) return super().list(request, *args, **kwargs) def retrieve(self, request, *args, **kwargs): self.queryset = self.queryset.annotate( - last_correspondence=Max("documents__created"), + last_correspondence=Max( + "documents__created", + filter=self.get_document_count_filter(), + ), ) return super().retrieve(request, *args, **kwargs) @@ -4573,6 +4579,10 @@ class ShareLinkViewSet( class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]): model = ShareLinkBundle + # Bundles are immutable once created; rebuild via the dedicated action + # rather than PUT/PATCH. + http_method_names = ["get", "post", "delete", "head", "options"] + queryset = ShareLinkBundle.objects.all() serializer_class = ShareLinkBundleSerializer @@ -4707,12 +4717,15 @@ class SharedLinkView(View): and share_link.expiration < timezone.now() ): return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") - return serve_file( - doc=share_link.document, - use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE - and share_link.document.has_archive_version, - disposition="inline", - ) + try: + return serve_file( + doc=share_link.document, + use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE + and share_link.document.has_archive_version, + disposition="inline", + ) + except FileNotFoundError: + return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1") bundle = ShareLinkBundle.objects.filter(slug=slug).first() if bundle is None: @@ -4734,7 +4747,11 @@ class SharedLinkView(View): file_path = bundle.absolute_file_path - if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None: + if ( + bundle.status == ShareLinkBundle.Status.FAILED + or file_path is None + or not file_path.exists() + ): return HttpResponse( _( "The share link bundle is unavailable.", diff --git a/src/paperless/urls.py b/src/paperless/urls.py index a6e0ba6c7..0c02d172b 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -295,7 +295,7 @@ urlpatterns = [ ], ), ), - re_path(r"share/(?P\w+)/?$", SharedLinkView.as_view()), + re_path(r"^share/(?P\w+)/?$", SharedLinkView.as_view()), re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"), re_path(r"admin/", admin.site.urls), re_path( diff --git a/uv.lock b/uv.lock index c28f08587..0e056740e 100644 --- a/uv.lock +++ b/uv.lock @@ -3053,7 +3053,7 @@ requires-dist = [ { name = "httpx-oauth", specifier = "~=0.17" }, { name = "ijson", specifier = ">=3.5.1" }, { name = "imap-tools", specifier = "~=1.14.0" }, - { name = "jinja2", specifier = "~=3.1.5" }, + { name = "jinja2", specifier = "~=3.1.6" }, { name = "langdetect", specifier = "~=1.0.9" }, { name = "llama-index-core", specifier = ">=0.14.23" }, { name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" },