From ed75eb98378b3400b416e9c08935bd20918fb856 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 1 Sep 2026 14:49:03 +0200 Subject: [PATCH 1/2] fix: serve files as_attachment only when too large Signed-off-by: tdruez --- scanpipe/settings.py | 4 ++++ scanpipe/tests/test_views.py | 14 ++++++++++++++ scanpipe/views.py | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scanpipe/settings.py b/scanpipe/settings.py index c76b5978a8..21585f336f 100644 --- a/scanpipe/settings.py +++ b/scanpipe/settings.py @@ -65,6 +65,10 @@ # Syntax in .env: SCANCODEIO_GLOBAL_WEBHOOK=target_url=https://webhook.url, # trigger_on_each_run=False,include_summary=True,include_results=False "GLOBAL_WEBHOOK": {}, + # Maximum file size, in bytes, served inline in the browser rather than + # forced as an attachment download. Above this size, the browser tab + # rendering the file (e.g. a large JSON) risks hanging. + "INLINE_DOWNLOAD_MAX_SIZE": 10_000_000, # Default limit for "most common" entries in QuerySets. "MOST_COMMON_LIMIT": 7, # Syntax in .env: SCANCODEIO_NETRC_LOCATION="~/.netrc" diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index e87f5ff07f..36d59028df 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -364,6 +364,13 @@ def test_scanpipe_views_project_details_download_input_view(self): response = self.client.get(url) self.assertTrue(response.getvalue().startswith(b"# SPDX-License-Identifier")) self.assertEqual("application/octet-stream", response.headers["Content-Type"]) + self.assertEqual( + 'inline; filename="notice.NOTICE"', + response.headers["Content-Disposition"], + ) + + with override_settings(SCANPIPE={"INLINE_DOWNLOAD_MAX_SIZE": 0}): + response = self.client.get(url) self.assertEqual( 'attachment; filename="notice.NOTICE"', response.headers["Content-Disposition"], @@ -381,6 +388,13 @@ def test_scanpipe_views_project_details_download_output_view(self): response = self.client.get(url) self.assertTrue(response.getvalue().startswith(b"# SPDX-License-Identifier")) self.assertEqual("application/octet-stream", response.headers["Content-Type"]) + self.assertEqual( + 'inline; filename="notice.NOTICE"', + response.headers["Content-Disposition"], + ) + + with override_settings(SCANPIPE={"INLINE_DOWNLOAD_MAX_SIZE": 0}): + response = self.client.get(url) self.assertEqual( 'attachment; filename="notice.NOTICE"', response.headers["Content-Disposition"], diff --git a/scanpipe/views.py b/scanpipe/views.py index 95c0f27a5c..4744c6afe3 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -1532,7 +1532,8 @@ def download_project_file(request, slug, filename, path_type): if not file_path.exists(): raise Http404(f"{file_path} not found") - return FileResponse(file_path.open("rb"), as_attachment=True) + is_too_large = file_path.stat().st_size > scanpipe_settings.INLINE_DOWNLOAD_MAX_SIZE + return FileResponse(file_path.open("rb"), as_attachment=is_too_large) @conditional_login_required From ae641a9d93b22bb0af65b7fff7b513a812e2fa96 Mon Sep 17 00:00:00 2001 From: tdruez Date: Tue, 1 Sep 2026 14:51:05 +0200 Subject: [PATCH 2/2] add new behavior in ExportJSONMixin Signed-off-by: tdruez --- scanpipe/tests/test_views.py | 12 ++++++++---- scanpipe/views.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index 36d59028df..09e26f376b 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -1505,6 +1505,10 @@ def test_project_packages_export_json(self): self.assertIsInstance(response, FileResponse) self.assertEqual(response.get("Content-Type"), "application/json") + self.assertTrue(response.get("Content-Disposition").startswith("inline")) + + with override_settings(SCANPIPE={"INLINE_DOWNLOAD_MAX_SIZE": 0}): + response = self.client.get(url + "?export_json=True") self.assertTrue(response.get("Content-Disposition").startswith("attachment")) file_content = b"".join(response.streaming_content).decode("utf-8") @@ -1570,7 +1574,7 @@ def test_project_dependencies_export_json(self): self.assertIsInstance(response, FileResponse) self.assertEqual(response.get("Content-Type"), "application/json") - self.assertTrue(response.get("Content-Disposition").startswith("attachment")) + self.assertTrue(response.get("Content-Disposition").startswith("inline")) file_content = b"".join(response.streaming_content).decode("utf-8") json_data = json.loads(file_content) @@ -1606,7 +1610,7 @@ def test_project_relations_export_json(self): self.assertIsInstance(response, FileResponse) self.assertEqual(response.get("Content-Type"), "application/json") - self.assertTrue(response.get("Content-Disposition").startswith("attachment")) + self.assertTrue(response.get("Content-Disposition").startswith("inline")) file_content = b"".join(response.streaming_content).decode("utf-8") json_data = json.loads(file_content) @@ -1630,7 +1634,7 @@ def test_project_messages_export_json(self): self.assertIsInstance(response, FileResponse) self.assertEqual(response.get("Content-Type"), "application/json") - self.assertTrue(response.get("Content-Disposition").startswith("attachment")) + self.assertTrue(response.get("Content-Disposition").startswith("inline")) file_content = b"".join(response.streaming_content).decode("utf-8") json_data = json.loads(file_content) @@ -1656,7 +1660,7 @@ def test_project_codebase_resources_export_json(self): self.assertIsInstance(response, FileResponse) self.assertEqual(response.get("Content-Type"), "application/json") - self.assertTrue(response.get("Content-Disposition").startswith("attachment")) + self.assertTrue(response.get("Content-Disposition").startswith("inline")) file_content = b"".join(response.streaming_content).decode("utf-8") json_data = json.loads(file_content) diff --git a/scanpipe/views.py b/scanpipe/views.py index 4744c6afe3..7981f2a7d7 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -540,11 +540,13 @@ def export_json_file_response(self): serializer = serializer_class(queryset, many=True) serialized_data = json.dumps(serializer.data, indent=2, cls=DjangoJSONEncoder) - output_file = io.BytesIO(serialized_data.encode("utf-8")) + encoded_data = serialized_data.encode("utf-8") + output_file = io.BytesIO(encoded_data) + is_too_large = len(encoded_data) > scanpipe_settings.INLINE_DOWNLOAD_MAX_SIZE return FileResponse( output_file, - as_attachment=True, + as_attachment=is_too_large, filename=self.get_export_json_filename(), content_type="application/json", )