thumbnail: Use a consistent set of supported image types.

This commit is contained in:
Alex Vandiver
2024-07-11 01:40:35 +00:00
committed by Tim Abbott
parent a091b9ef81
commit 4bc563128e
6 changed files with 94 additions and 18 deletions

View File

@@ -13,7 +13,18 @@ export type UploadFunction = (
const default_max_file_size = 5;
const supported_types = ["image/jpeg", "image/png", "image/gif", "image/tiff"];
// These formats do not need to be universally understood by clients; they are all
// converted, server-side, currently to PNGs. This list should be kept in sync with
// the THUMBNAIL_ACCEPT_IMAGE_TYPES in zerver/lib/thumbnail.py
const supported_types = [
"image/avif",
"image/gif",
"image/heic",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
];
function is_image_format(file: File): boolean {
const type = file.type;

View File

@@ -22,6 +22,27 @@ IMAGE_BOMB_TOTAL_PIXELS = 90000000
MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 # 128 kb
# These are the image content-types which the server supports parsing
# and thumbnailing; these do not need to supported on all browsers,
# since we will the serving thumbnailed versions of them. Note that
# this does not provide any *security*, since the content-type is
# provided by the browser, and may not match the bytes they uploaded.
#
# This should be kept synced with the client-side image-picker in
# web/upload_widget.ts.
THUMBNAIL_ACCEPT_IMAGE_TYPES = frozenset(
[
"image/avif",
"image/gif",
"image/heic",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
]
)
class BadImageError(JsonableError):
code = ErrorCode.BAD_IMAGE

View File

@@ -3,17 +3,19 @@ import os
from concurrent.futures import ProcessPoolExecutor, as_completed
import bmemcached
import magic
from django.conf import settings
from django.core.cache import cache
from django.db import connection
from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.mime_types import guess_type
from zerver.lib.upload import upload_avatar_image, upload_emoji_image
from zerver.lib.upload import upload_emoji_image, write_avatar_images
from zerver.lib.upload.s3 import S3UploadBackend, upload_image_to_s3
from zerver.models import Attachment, RealmEmoji, UserProfile
s3backend = S3UploadBackend()
mime_magic = magic.Magic(mime=True)
def transfer_uploads_to_s3(processes: int) -> None:
@@ -30,7 +32,18 @@ def _transfer_avatar_to_s3(user: UserProfile) -> None:
file_path = os.path.join(settings.LOCAL_AVATARS_DIR, avatar_path)
try:
with open(file_path + ".original", "rb") as f:
upload_avatar_image(f, user, backend=s3backend, future=False)
# We call write_avatar_images directly to walk around the
# content-type checking in upload_avatar_image. We don't
# know the original file format, and we don't need to know
# it because we never serve them directly.
write_avatar_images(
user_avatar_path(user, future=False),
user,
f.read(),
content_type="application/octet-stream",
backend=s3backend,
future=False,
)
logging.info("Uploaded avatar for %s in realm %s", user.id, user.realm.name)
except FileNotFoundError:
pass

View File

@@ -18,6 +18,7 @@ from zerver.lib.outgoing_http import OutgoingSession
from zerver.lib.thumbnail import (
MAX_EMOJI_GIF_FILE_SIZE_BYTES,
MEDIUM_AVATAR_SIZE,
THUMBNAIL_ACCEPT_IMAGE_TYPES,
BadImageError,
resize_avatar,
resize_emoji,
@@ -250,6 +251,8 @@ def upload_avatar_image(
) -> None:
if content_type is None:
content_type = guess_type(user_file.name)[0]
if content_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES:
raise BadImageError(_("Invalid image format"))
file_path = user_avatar_path(user_profile, future=future)
image_data = user_file.read()
@@ -311,12 +314,16 @@ def delete_avatar_image(user_profile: UserProfile, avatar_version: int) -> None:
def upload_icon_image(user_file: IO[bytes], user_profile: UserProfile, content_type: str) -> None:
if content_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES:
raise BadImageError(_("Invalid image format"))
upload_backend.upload_realm_icon_image(user_file, user_profile, content_type)
def upload_logo_image(
user_file: IO[bytes], user_profile: UserProfile, night: bool, content_type: str
) -> None:
if content_type not in THUMBNAIL_ACCEPT_IMAGE_TYPES:
raise BadImageError(_("Invalid image format"))
upload_backend.upload_realm_logo_image(user_file, user_profile, night, content_type)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1058,7 +1058,6 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
("img.tif", "tif_resized.png"),
("cmyk.jpg", None),
]
corrupt_files = ["text.txt", "corrupt.png", "corrupt.gif"]
def test_get_gravatar_avatar(self) -> None:
self.login("hamlet")
@@ -1341,15 +1340,24 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
"""
A PUT request to /json/users/me/avatar with an invalid file should fail.
"""
for fname in self.corrupt_files:
corrupt_files = [
("text.txt", False),
("unsupported.bmp", False),
("corrupt.png", True),
("corrupt.gif", True),
]
for fname, is_valid_image_format in corrupt_files:
with self.subTest(fname=fname):
self.login("hamlet")
with get_test_image_file(fname) as fp:
result = self.client_post("/json/users/me/avatar", {"file": fp})
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
if is_valid_image_format:
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
else:
self.assert_json_error(result, "Invalid image format")
user_profile = self.example_user("hamlet")
self.assertEqual(user_profile.avatar_version, 1)
@@ -1490,7 +1498,6 @@ class RealmIconTest(UploadSerializeMixin, ZulipTestCase):
("img.tif", "tif_resized.png"),
("cmyk.jpg", None),
]
corrupt_files = ["text.txt", "corrupt.png", "corrupt.gif"]
def test_no_admin_user_upload(self) -> None:
self.login("hamlet")
@@ -1558,15 +1565,24 @@ class RealmIconTest(UploadSerializeMixin, ZulipTestCase):
"""
A PUT request to /json/realm/icon with an invalid file should fail.
"""
for fname in self.corrupt_files:
corrupt_files = [
("text.txt", False),
("unsupported.bmp", False),
("corrupt.png", True),
("corrupt.gif", True),
]
for fname, is_valid_image_format in corrupt_files:
with self.subTest(fname=fname):
self.login("iago")
with get_test_image_file(fname) as fp:
result = self.client_post("/json/realm/icon", {"file": fp})
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
if is_valid_image_format:
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
else:
self.assert_json_error(result, "Invalid image format")
def test_delete_icon(self) -> None:
"""
@@ -1634,7 +1650,6 @@ class RealmLogoTest(UploadSerializeMixin, ZulipTestCase):
("img.tif", "tif_resized.png"),
("cmyk.jpg", None),
]
corrupt_files = ["text.txt", "corrupt.png", "corrupt.gif"]
def test_no_admin_user_upload(self) -> None:
self.login("hamlet")
@@ -1744,7 +1759,13 @@ class RealmLogoTest(UploadSerializeMixin, ZulipTestCase):
"""
A PUT request to /json/realm/logo with an invalid file should fail.
"""
for fname in self.corrupt_files:
corrupt_files = [
("text.txt", False),
("unsupported.bmp", False),
("corrupt.png", True),
("corrupt.gif", True),
]
for fname, is_valid_image_format in corrupt_files:
with self.subTest(fname=fname):
self.login("iago")
with get_test_image_file(fname) as fp:
@@ -1752,9 +1773,12 @@ class RealmLogoTest(UploadSerializeMixin, ZulipTestCase):
"/json/realm/logo", {"file": fp, "night": orjson.dumps(self.night).decode()}
)
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
if is_valid_image_format:
self.assert_json_error(
result, "Could not decode image; did you upload an image file?"
)
else:
self.assert_json_error(result, "Invalid image format")
def test_delete_logo(self) -> None:
"""