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
from collections.abc import Callable, Iterator
from datetime import datetime
from email.message import EmailMessage
from typing import IO, Any
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
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,
# 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 == "":
guessed_type = guess_type(uploaded_file_name)[0]
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.
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)
return uploaded_file_name, content_type

View File

@@ -1,5 +1,6 @@
import os
import re
import tempfile
from datetime import timedelta
from io import StringIO
from unittest import mock
@@ -215,6 +216,26 @@ class FileUploadTest(UploadSerializeMixin, ZulipTestCase):
self.assertEqual(result["Content-Type"], "image/png")
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
# when Zulip is in DEVELOPMENT mode.
def test_file_upload_authed(self) -> None: