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..09e26f376b 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"], @@ -1491,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") @@ -1556,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) @@ -1592,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) @@ -1616,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) @@ -1642,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 95c0f27a5c..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", ) @@ -1532,7 +1534,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