From c903e93e8b575e871a4cc0e0c42ae8f4b05207fb Mon Sep 17 00:00:00 2001 From: Myshkin451 <79880574+myshkin451@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:45:21 +0000 Subject: [PATCH] fix: preserve file preview content type (#37211) Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/files/image_preview.py | 20 ++++- .../controllers/files/test_image_preview.py | 87 ++++++++++++++++++- 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py index 0d4b89096ae..e27cf79abe1 100644 --- a/api/controllers/files/image_preview.py +++ b/api/controllers/files/image_preview.py @@ -1,3 +1,4 @@ +import os from urllib.parse import quote from uuid import UUID @@ -29,6 +30,18 @@ class FilePreviewQuery(FileSignatureQuery): register_schema_models(files_ns, FileSignatureQuery, FilePreviewQuery) +def _is_svg_content(mime_type: str | None, filename: str | None, extension: str | None) -> bool: + normalized_mime_type = mime_type.split(";", 1)[0].strip().lower() if mime_type else "" + if normalized_mime_type == "image/svg+xml": + return True + + normalized_extension = extension.lstrip(".").lower() if extension else "" + if normalized_extension == "svg": + return True + + return bool(filename and os.path.splitext(filename)[1].lstrip(".").lower() == "svg") + + @files_ns.route("//image-preview") class ImagePreviewApi(Resource): """Deprecated endpoint for retrieving image previews.""" @@ -129,10 +142,13 @@ class FilePreviewApi(Resource): response.headers["Accept-Ranges"] = "bytes" if upload_file.size > 0: response.headers["Content-Length"] = str(upload_file.size) - if args.as_attachment: + is_svg = _is_svg_content(upload_file.mime_type, upload_file.name, upload_file.extension) + if args.as_attachment or is_svg: encoded_filename = quote(upload_file.name) response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" - response.headers["Content-Type"] = "application/octet-stream" + response.headers["Content-Type"] = "application/octet-stream" + if is_svg: + response.headers["X-Content-Type-Options"] = "nosniff" enforce_download_for_html( response, diff --git a/api/tests/unit_tests/controllers/files/test_image_preview.py b/api/tests/unit_tests/controllers/files/test_image_preview.py index 9ad7120c30f..f1b8db3b5b1 100644 --- a/api/tests/unit_tests/controllers/files/test_image_preview.py +++ b/api/tests/unit_tests/controllers/files/test_image_preview.py @@ -79,7 +79,7 @@ class TestImagePreviewApi: class TestFilePreviewApi: @patch.object(module, "enforce_download_for_html") @patch.object(module, "FileService") - def test_basic_stream(self, mock_file_service, mock_enforce): + def test_inline_preview_uses_upload_file_mimetype(self, mock_file_service, mock_enforce): module.request = fake_request( { "timestamp": "123", @@ -90,7 +90,12 @@ class TestFilePreviewApi: ) generator = iter([b"data"]) - upload_file = DummyUploadFile(size=100) + upload_file = DummyUploadFile( + mime_type="application/pdf", + size=100, + name="doc.pdf", + extension="pdf", + ) mock_file_service.return_value.get_file_generator_by_file_id.return_value = ( generator, @@ -102,11 +107,87 @@ class TestFilePreviewApi: response = get_fn("file-id") - assert response.mimetype == "application/octet-stream" + assert response.mimetype == "application/pdf" + assert response.headers["Content-Type"] == "application/pdf" assert response.headers["Content-Length"] == "100" assert "Accept-Ranges" not in response.headers mock_enforce.assert_called_once() + @pytest.mark.parametrize( + ("mime_type", "name", "extension"), + [ + ("Image/SVG+XML; charset=UTF-8", "image.png", "png"), + ("image/png", "image.SVG", "png"), + ("image/png", "image.png", ".SVG"), + ], + ids=("mime-type", "filename", "extension"), + ) + @patch.object(module, "FileService") + def test_svg_preview_forces_download(self, mock_file_service, mime_type, name, extension): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + generator = iter([b""]) + upload_file = DummyUploadFile( + mime_type=mime_type, + size=11, + name=name, + extension=extension, + ) + + mock_file_service.return_value.get_file_generator_by_file_id.return_value = ( + generator, + upload_file, + ) + + api = module.FilePreviewApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id") + + assert response.headers["Content-Disposition"].startswith("attachment") + assert response.headers["Content-Type"] == "application/octet-stream" + assert response.headers["X-Content-Type-Options"] == "nosniff" + + @patch.object(module, "FileService") + def test_html_preview_still_forces_download(self, mock_file_service): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + generator = iter([b""]) + upload_file = DummyUploadFile( + mime_type="text/html", + size=25, + name="unsafe.html", + extension="html", + ) + + mock_file_service.return_value.get_file_generator_by_file_id.return_value = ( + generator, + upload_file, + ) + + api = module.FilePreviewApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id") + + assert response.headers["Content-Disposition"].startswith("attachment") + assert response.headers["Content-Type"] == "application/octet-stream" + assert response.headers["X-Content-Type-Options"] == "nosniff" + @patch.object(module, "enforce_download_for_html") @patch.object(module, "FileService") def test_as_attachment(self, mock_file_service, mock_enforce):