upload: Allow uploads to set charset.

Previously, the `user_file.content_type` only contained the MIME type
of the uploaded file; no other parameters were included, meaning that
a file a client specified as `content-type: text/plain; charset=big5`
would be stored with an `Attachment.content_type` of `text/plain`.

Re-construct the full content-type header from `content_type_extra`,
which includes those parameters.

We do not include a test because Django does not support specifying
such parameters in the upload path.
This commit is contained in:
Alex Vandiver
2025-07-24 15:56:36 +00:00
committed by Tim Abbott
parent edb5943d8b
commit ae001dfa96
2 changed files with 30 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import re
import unicodedata import unicodedata
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from datetime import datetime from datetime import datetime
from email.message import EmailMessage
from typing import IO, Any from typing import IO, Any
from urllib.parse import unquote, urljoin from urllib.parse import unquote, urljoin
@@ -80,9 +81,9 @@ def get_file_info(user_file: UploadedFile) -> tuple[str, str]:
uploaded_file_name = user_file.name uploaded_file_name = user_file.name
assert uploaded_file_name is not None assert uploaded_file_name is not None
content_type = user_file.content_type
# It appears Django's UploadedFile.content_type defaults to an empty string, # It appears Django's UploadedFile.content_type defaults to an empty string,
# even though the value is documented as `str | None`. So we check for both. # even though the value is documented as `str | None`. So we check for both.
content_type = user_file.content_type
if content_type is None or content_type == "": if content_type is None or content_type == "":
guessed_type = guess_type(uploaded_file_name)[0] guessed_type = guess_type(uploaded_file_name)[0]
if guessed_type is not None: if guessed_type is not None:
@@ -92,6 +93,13 @@ def get_file_info(user_file: UploadedFile) -> tuple[str, str]:
# different content-type from the filename. # different content-type from the filename.
content_type = "application/octet-stream" content_type = "application/octet-stream"
fake_msg = EmailMessage()
extras = {}
if user_file.content_type_extra:
extras = {k: v.decode() if v else None for k, v in user_file.content_type_extra.items()} # type: ignore[attr-defined] # https://github.com/typeddjango/django-stubs/pull/2754
fake_msg.add_header("content-type", content_type, **extras)
content_type = fake_msg["content-type"]
uploaded_file_name = unquote(uploaded_file_name) uploaded_file_name = unquote(uploaded_file_name)
return uploaded_file_name, content_type return uploaded_file_name, content_type

View File

@@ -1,5 +1,6 @@
import os import os
import re import re
import tempfile
from datetime import timedelta from datetime import timedelta
from io import StringIO from io import StringIO
from unittest import mock from unittest import mock
@@ -215,6 +216,26 @@ class FileUploadTest(UploadSerializeMixin, ZulipTestCase):
self.assertEqual(result["Content-Type"], "image/png") self.assertEqual(result["Content-Type"], "image/png")
consume_response(result) consume_response(result)
def test_content_type_charset_specified(self) -> None:
with tempfile.NamedTemporaryFile() as uploaded_file:
uploaded_file.write("नाम में क्या रक्खा हे".encode())
uploaded_file.seek(0)
uploaded_file.content_type = "text/plain; test-key=test_value; charset=big5" # type: ignore[attr-defined]
result = self.api_post(
self.example_user("hamlet"), "/api/v1/user_uploads", {"file": uploaded_file}
)
self.login("hamlet")
response_dict = self.assert_json_success(result)
url = response_dict["url"]
result = self.client_get(url)
self.assertEqual(result.status_code, 200)
self.assertEqual(
result["Content-Type"], 'text/plain; test-key="test_value"; charset="big5"'
)
consume_response(result)
# This test will go through the code path for uploading files onto LOCAL storage # This test will go through the code path for uploading files onto LOCAL storage
# when Zulip is in DEVELOPMENT mode. # when Zulip is in DEVELOPMENT mode.
def test_file_upload_authed(self) -> None: def test_file_upload_authed(self) -> None: