thumbnailing: Move resizing functions into zerver.lib.thumbnail.

This commit is contained in:
Alex Vandiver 2024-06-12 14:56:41 +00:00 committed by Alex Vandiver
parent 2c5dff7f59
commit 0153d6dbcd
14 changed files with 165 additions and 173 deletions

View File

@ -21,7 +21,7 @@ from PIL import Image
from zerver.lib.integrations import INTEGRATIONS from zerver.lib.integrations import INTEGRATIONS
from zerver.lib.storage import static_path from zerver.lib.storage import static_path
from zerver.lib.upload.base import DEFAULT_AVATAR_SIZE, resize_avatar from zerver.lib.thumbnail import DEFAULT_AVATAR_SIZE, resize_avatar
def create_square_image(png: bytes) -> bytes: def create_square_image(png: bytes) -> bytes:

View File

@ -47,7 +47,8 @@ from zerver.data_import.slack_message_conversion import (
from zerver.lib.emoji import codepoint_to_name from zerver.lib.emoji import codepoint_to_name
from zerver.lib.export import MESSAGE_BATCH_CHUNK_SIZE from zerver.lib.export import MESSAGE_BATCH_CHUNK_SIZE
from zerver.lib.storage import static_path from zerver.lib.storage import static_path
from zerver.lib.upload.base import resize_logo, sanitize_name from zerver.lib.thumbnail import resize_logo
from zerver.lib.upload.base import sanitize_name
from zerver.models import ( from zerver.models import (
CustomProfileField, CustomProfileField,
CustomProfileFieldValue, CustomProfileFieldValue,

View File

@ -9,8 +9,8 @@ from zerver.lib.avatar_hash import (
user_avatar_content_hash, user_avatar_content_hash,
user_avatar_path_from_ids, user_avatar_path_from_ids,
) )
from zerver.lib.thumbnail import MEDIUM_AVATAR_SIZE
from zerver.lib.upload import get_avatar_url from zerver.lib.upload import get_avatar_url
from zerver.lib.upload.base import MEDIUM_AVATAR_SIZE
from zerver.lib.url_encoding import append_url_query_string from zerver.lib.url_encoding import append_url_query_string
from zerver.models import UserProfile from zerver.models import UserProfile

View File

@ -32,9 +32,10 @@ from zerver.lib.push_notifications import sends_notifications_directly
from zerver.lib.remote_server import maybe_enqueue_audit_log_upload from zerver.lib.remote_server import maybe_enqueue_audit_log_upload
from zerver.lib.server_initialization import create_internal_realm, server_initialized from zerver.lib.server_initialization import create_internal_realm, server_initialized
from zerver.lib.streams import render_stream_description from zerver.lib.streams import render_stream_description
from zerver.lib.thumbnail import BadImageError
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.upload import upload_backend from zerver.lib.upload import upload_backend
from zerver.lib.upload.base import BadImageError, sanitize_name from zerver.lib.upload.base import sanitize_name
from zerver.lib.upload.s3 import get_bucket from zerver.lib.upload.s3 import get_bucket
from zerver.lib.user_counts import realm_user_count_by_role from zerver.lib.user_counts import realm_user_count_by_role
from zerver.lib.user_groups import create_system_user_groups_for_realm from zerver.lib.user_groups import create_system_user_groups_for_realm

View File

@ -1,8 +1,28 @@
import io
from typing import Optional, Tuple
from urllib.parse import urljoin from urllib.parse import urljoin
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext as _
from PIL import GifImagePlugin, Image, ImageOps, PngImagePlugin
from PIL.Image import DecompressionBombError
from zerver.lib.camo import get_camo_url from zerver.lib.camo import get_camo_url
from zerver.lib.exceptions import ErrorCode, JsonableError
DEFAULT_AVATAR_SIZE = 100
MEDIUM_AVATAR_SIZE = 500
DEFAULT_EMOJI_SIZE = 64
# These sizes were selected based on looking at the maximum common
# sizes in a library of animated custom emoji, balanced against the
# network cost of very large emoji images.
MAX_EMOJI_GIF_SIZE = 128
MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 * 1024 # 128 kb
class BadImageError(JsonableError):
code = ErrorCode.BAD_IMAGE
def user_uploads_or_external(url: str) -> bool: def user_uploads_or_external(url: str) -> bool:
@ -17,3 +37,124 @@ def generate_thumbnail_url(path: str, size: str = "0x0") -> str:
if url_has_allowed_host_and_scheme(path, allowed_hosts=None): if url_has_allowed_host_and_scheme(path, allowed_hosts=None):
return path return path
return get_camo_url(path) return get_camo_url(path)
def resize_avatar(image_data: bytes, size: int = DEFAULT_AVATAR_SIZE) -> bytes:
try:
im = Image.open(io.BytesIO(image_data))
im = ImageOps.exif_transpose(im)
im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS)
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))
out = io.BytesIO()
if im.mode == "CMYK":
im = im.convert("RGB")
im.save(out, format="png")
return out.getvalue()
def resize_logo(image_data: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(image_data))
im = ImageOps.exif_transpose(im)
im.thumbnail((8 * DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), Image.Resampling.LANCZOS)
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))
out = io.BytesIO()
if im.mode == "CMYK":
im = im.convert("RGB")
im.save(out, format="png")
return out.getvalue()
def resize_animated(im: Image.Image, size: int = DEFAULT_EMOJI_SIZE) -> bytes:
assert im.n_frames > 1
frames = []
duration_info = []
disposals = []
# If 'loop' info is not set then loop for infinite number of times.
loop = im.info.get("loop", 0)
for frame_num in range(im.n_frames):
im.seek(frame_num)
new_frame = im.copy()
new_frame.paste(im, (0, 0), im.convert("RGBA"))
new_frame = ImageOps.pad(new_frame, (size, size), Image.Resampling.LANCZOS)
frames.append(new_frame)
if im.info.get("duration") is None: # nocoverage
raise BadImageError(_("Corrupt animated image."))
duration_info.append(im.info["duration"])
if isinstance(im, GifImagePlugin.GifImageFile):
disposals.append(
im.disposal_method # type: ignore[attr-defined] # private member missing from stubs
)
elif isinstance(im, PngImagePlugin.PngImageFile):
disposals.append(im.info.get("disposal", PngImagePlugin.Disposal.OP_NONE))
else: # nocoverage
raise BadImageError(_("Unknown animated image format."))
out = io.BytesIO()
frames[0].save(
out,
save_all=True,
optimize=False,
format=im.format,
append_images=frames[1:],
duration=duration_info,
disposal=disposals,
loop=loop,
)
return out.getvalue()
def resize_emoji(
image_data: bytes, size: int = DEFAULT_EMOJI_SIZE
) -> Tuple[bytes, bool, Optional[bytes]]:
# This function returns three values:
# 1) Emoji image data.
# 2) If emoji is gif i.e. animated.
# 3) If is animated then return still image data i.e. first frame of gif.
try:
im = Image.open(io.BytesIO(image_data))
image_format = im.format
if getattr(im, "n_frames", 1) > 1:
# There are a number of bugs in Pillow which cause results
# in resized images being broken. To work around this we
# only resize under certain conditions to minimize the
# chance of creating ugly images.
should_resize = (
im.size[0] != im.size[1] # not square
or im.size[0] > MAX_EMOJI_GIF_SIZE # dimensions too large
or len(image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES # filesize too large
)
# Generate a still image from the first frame. Since
# we're converting the format to PNG anyway, we resize unconditionally.
still_image = im.copy()
still_image.seek(0)
still_image = ImageOps.exif_transpose(still_image)
still_image = ImageOps.fit(still_image, (size, size), Image.Resampling.LANCZOS)
out = io.BytesIO()
still_image.save(out, format="PNG")
still_image_data = out.getvalue()
if should_resize:
image_data = resize_animated(im, size)
return image_data, True, still_image_data
else:
# Note that this is essentially duplicated in the
# still_image code path, above.
im = ImageOps.exif_transpose(im)
im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS)
out = io.BytesIO()
im.save(out, format=image_format)
return out.getvalue(), False, None
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))

View File

@ -1,29 +1,12 @@
import io
import os import os
import re import re
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime
from typing import IO, Any, BinaryIO, Callable, Iterator, List, Optional, Tuple from typing import IO, Any, BinaryIO, Callable, Iterator, List, Optional, Tuple
from django.utils.translation import gettext as _
from PIL import GifImagePlugin, Image, ImageOps, PngImagePlugin
from PIL.Image import DecompressionBombError
from zerver.lib.exceptions import ErrorCode, JsonableError
from zerver.models import Attachment, Realm, UserProfile from zerver.models import Attachment, Realm, UserProfile
from zerver.models.users import is_cross_realm_bot_email from zerver.models.users import is_cross_realm_bot_email
DEFAULT_AVATAR_SIZE = 100
MEDIUM_AVATAR_SIZE = 500
DEFAULT_EMOJI_SIZE = 64
# These sizes were selected based on looking at the maximum common
# sizes in a library of animated custom emoji, balanced against the
# network cost of very large emoji images.
MAX_EMOJI_GIF_SIZE = 128
MAX_EMOJI_GIF_FILE_SIZE_BYTES = 128 * 1024 * 1024 # 128 kb
INLINE_MIME_TYPES = [ INLINE_MIME_TYPES = [
"application/pdf", "application/pdf",
"audio/aac", "audio/aac",
@ -66,131 +49,6 @@ def sanitize_name(value: str) -> str:
return value return value
class BadImageError(JsonableError):
code = ErrorCode.BAD_IMAGE
def resize_avatar(image_data: bytes, size: int = DEFAULT_AVATAR_SIZE) -> bytes:
try:
im = Image.open(io.BytesIO(image_data))
im = ImageOps.exif_transpose(im)
im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS)
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))
out = io.BytesIO()
if im.mode == "CMYK":
im = im.convert("RGB")
im.save(out, format="png")
return out.getvalue()
def resize_logo(image_data: bytes) -> bytes:
try:
im = Image.open(io.BytesIO(image_data))
im = ImageOps.exif_transpose(im)
im.thumbnail((8 * DEFAULT_AVATAR_SIZE, DEFAULT_AVATAR_SIZE), Image.Resampling.LANCZOS)
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))
out = io.BytesIO()
if im.mode == "CMYK":
im = im.convert("RGB")
im.save(out, format="png")
return out.getvalue()
def resize_animated(im: Image.Image, size: int = DEFAULT_EMOJI_SIZE) -> bytes:
assert im.n_frames > 1
frames = []
duration_info = []
disposals = []
# If 'loop' info is not set then loop for infinite number of times.
loop = im.info.get("loop", 0)
for frame_num in range(im.n_frames):
im.seek(frame_num)
new_frame = im.copy()
new_frame.paste(im, (0, 0), im.convert("RGBA"))
new_frame = ImageOps.pad(new_frame, (size, size), Image.Resampling.LANCZOS)
frames.append(new_frame)
if im.info.get("duration") is None: # nocoverage
raise BadImageError(_("Corrupt animated image."))
duration_info.append(im.info["duration"])
if isinstance(im, GifImagePlugin.GifImageFile):
disposals.append(
im.disposal_method # type: ignore[attr-defined] # private member missing from stubs
)
elif isinstance(im, PngImagePlugin.PngImageFile):
disposals.append(im.info.get("disposal", PngImagePlugin.Disposal.OP_NONE))
else: # nocoverage
raise BadImageError(_("Unknown animated image format."))
out = io.BytesIO()
frames[0].save(
out,
save_all=True,
optimize=False,
format=im.format,
append_images=frames[1:],
duration=duration_info,
disposal=disposals,
loop=loop,
)
return out.getvalue()
def resize_emoji(
image_data: bytes, size: int = DEFAULT_EMOJI_SIZE
) -> Tuple[bytes, bool, Optional[bytes]]:
# This function returns three values:
# 1) Emoji image data.
# 2) If emoji is gif i.e. animated.
# 3) If is animated then return still image data i.e. first frame of gif.
try:
im = Image.open(io.BytesIO(image_data))
image_format = im.format
if getattr(im, "n_frames", 1) > 1:
# There are a number of bugs in Pillow which cause results
# in resized images being broken. To work around this we
# only resize under certain conditions to minimize the
# chance of creating ugly images.
should_resize = (
im.size[0] != im.size[1] # not square
or im.size[0] > MAX_EMOJI_GIF_SIZE # dimensions too large
or len(image_data) > MAX_EMOJI_GIF_FILE_SIZE_BYTES # filesize too large
)
# Generate a still image from the first frame. Since
# we're converting the format to PNG anyway, we resize unconditionally.
still_image = im.copy()
still_image.seek(0)
still_image = ImageOps.exif_transpose(still_image)
still_image = ImageOps.fit(still_image, (size, size), Image.Resampling.LANCZOS)
out = io.BytesIO()
still_image.save(out, format="PNG")
still_image_data = out.getvalue()
if should_resize:
image_data = resize_animated(im, size)
return image_data, True, still_image_data
else:
# Note that this is essentially duplicated in the
# still_image code path, above.
im = ImageOps.exif_transpose(im)
im = ImageOps.fit(im, (size, size), Image.Resampling.LANCZOS)
out = io.BytesIO()
im.save(out, format=image_format)
return out.getvalue(), False, None
except OSError:
raise BadImageError(_("Could not decode image; did you upload an image file?"))
except DecompressionBombError:
raise BadImageError(_("Image size exceeds limit."))
class ZulipUploadBackend: class ZulipUploadBackend:
# Message attachment uploads # Message attachment uploads
def get_public_upload_root_url(self) -> str: def get_public_upload_root_url(self) -> str:

View File

@ -10,16 +10,9 @@ from django.conf import settings
from typing_extensions import override from typing_extensions import override
from zerver.lib.avatar_hash import user_avatar_path from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.thumbnail import MEDIUM_AVATAR_SIZE, resize_avatar, resize_emoji, resize_logo
from zerver.lib.timestamp import timestamp_to_datetime from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.upload.base import ( from zerver.lib.upload.base import ZulipUploadBackend, create_attachment, sanitize_name
MEDIUM_AVATAR_SIZE,
ZulipUploadBackend,
create_attachment,
resize_avatar,
resize_emoji,
resize_logo,
sanitize_name,
)
from zerver.lib.utils import assert_is_not_none from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm, RealmEmoji, UserProfile from zerver.models import Realm, RealmEmoji, UserProfile

View File

@ -14,14 +14,11 @@ from typing_extensions import override
from zerver.lib.avatar_hash import user_avatar_path from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.mime_types import guess_type from zerver.lib.mime_types import guess_type
from zerver.lib.thumbnail import MEDIUM_AVATAR_SIZE, resize_avatar, resize_emoji, resize_logo
from zerver.lib.upload.base import ( from zerver.lib.upload.base import (
INLINE_MIME_TYPES, INLINE_MIME_TYPES,
MEDIUM_AVATAR_SIZE,
ZulipUploadBackend, ZulipUploadBackend,
create_attachment, create_attachment,
resize_avatar,
resize_emoji,
resize_logo,
sanitize_name, sanitize_name,
) )
from zerver.models import Realm, RealmEmoji, UserProfile from zerver.models import Realm, RealmEmoji, UserProfile

View File

@ -92,8 +92,8 @@ from zerver.lib.test_helpers import (
read_test_image_file, read_test_image_file,
use_s3_backend, use_s3_backend,
) )
from zerver.lib.thumbnail import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
from zerver.lib.types import Validator from zerver.lib.types import Validator
from zerver.lib.upload.base import DEFAULT_AVATAR_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
from zerver.lib.user_groups import is_user_in_group from zerver.lib.user_groups import is_user_in_group
from zerver.lib.users import get_all_api_keys, get_api_key, get_users_for_api from zerver.lib.users import get_all_api_keys, get_api_key, get_users_for_api
from zerver.lib.utils import assert_is_not_none from zerver.lib.utils import assert_is_not_none

View File

@ -8,7 +8,7 @@ from zerver.actions.users import do_change_user_role
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import get_test_image_file from zerver.lib.test_helpers import get_test_image_file
from zerver.lib.upload.base import BadImageError from zerver.lib.thumbnail import BadImageError
from zerver.models import Realm, RealmEmoji, UserProfile from zerver.models import Realm, RealmEmoji, UserProfile
from zerver.models.realms import CommonPolicyEnum, get_realm from zerver.models.realms import CommonPolicyEnum, get_realm

View File

@ -13,6 +13,7 @@ from zerver.lib.test_helpers import (
get_test_image_file, get_test_image_file,
read_test_image_file, read_test_image_file,
) )
from zerver.lib.thumbnail import resize_emoji
from zerver.lib.transfer import ( from zerver.lib.transfer import (
transfer_avatars_to_s3, transfer_avatars_to_s3,
transfer_emoji_to_s3, transfer_emoji_to_s3,
@ -20,7 +21,6 @@ from zerver.lib.transfer import (
transfer_uploads_to_s3, transfer_uploads_to_s3,
) )
from zerver.lib.upload import upload_message_attachment from zerver.lib.upload import upload_message_attachment
from zerver.lib.upload.base import resize_emoji
from zerver.models import Attachment, RealmEmoji from zerver.models import Attachment, RealmEmoji

View File

@ -37,8 +37,9 @@ from zerver.lib.test_helpers import (
ratelimit_rule, ratelimit_rule,
read_test_image_file, read_test_image_file,
) )
from zerver.lib.thumbnail import BadImageError, resize_emoji
from zerver.lib.upload import upload_message_attachment from zerver.lib.upload import upload_message_attachment
from zerver.lib.upload.base import BadImageError, ZulipUploadBackend, resize_emoji, sanitize_name from zerver.lib.upload.base import ZulipUploadBackend, sanitize_name
from zerver.lib.upload.local import LocalUploadBackend from zerver.lib.upload.local import LocalUploadBackend
from zerver.lib.upload.s3 import S3UploadBackend from zerver.lib.upload.s3 import S3UploadBackend
from zerver.lib.users import get_api_key from zerver.lib.users import get_api_key
@ -1422,15 +1423,15 @@ class EmojiTest(UploadSerializeMixin, ZulipTestCase):
animated_large_img_data = read_test_image_file(f"animated_large_img.{img_format}") animated_large_img_data = read_test_image_file(f"animated_large_img.{img_format}")
# Test an image larger than max is resized # Test an image larger than max is resized
with patch("zerver.lib.upload.base.MAX_EMOJI_GIF_SIZE", 128): with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_SIZE", 128):
test_resize() test_resize()
# Test an image file larger than max is resized # Test an image file larger than max is resized
with patch("zerver.lib.upload.base.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 3 * 1024 * 1024): with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_FILE_SIZE_BYTES", 3 * 1024 * 1024):
test_resize() test_resize()
# Test an image smaller than max and smaller than file size max is not resized # Test an image smaller than max and smaller than file size max is not resized
with patch("zerver.lib.upload.base.MAX_EMOJI_GIF_SIZE", 512): with patch("zerver.lib.thumbnail.MAX_EMOJI_GIF_SIZE", 512):
test_resize(size=256) test_resize(size=256)
# Test a non-animated GIF image which does need to be resized # Test a non-animated GIF image which does need to be resized

View File

@ -10,6 +10,7 @@ import zerver.lib.upload
from zerver.lib.avatar_hash import user_avatar_path from zerver.lib.avatar_hash import user_avatar_path
from zerver.lib.test_classes import UploadSerializeMixin, ZulipTestCase from zerver.lib.test_classes import UploadSerializeMixin, ZulipTestCase
from zerver.lib.test_helpers import get_test_image_file, read_test_image_file from zerver.lib.test_helpers import get_test_image_file, read_test_image_file
from zerver.lib.thumbnail import DEFAULT_EMOJI_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
from zerver.lib.upload import ( from zerver.lib.upload import (
all_message_attachments, all_message_attachments,
delete_export_tarball, delete_export_tarball,
@ -20,7 +21,6 @@ from zerver.lib.upload import (
upload_export_tarball, upload_export_tarball,
upload_message_attachment, upload_message_attachment,
) )
from zerver.lib.upload.base import DEFAULT_EMOJI_SIZE, MEDIUM_AVATAR_SIZE, resize_avatar
from zerver.lib.upload.local import write_local_file from zerver.lib.upload.local import write_local_file
from zerver.models import Attachment, RealmEmoji from zerver.models import Attachment, RealmEmoji
from zerver.models.realms import get_realm from zerver.models.realms import get_realm

View File

@ -20,6 +20,12 @@ from zerver.lib.test_helpers import (
read_test_image_file, read_test_image_file,
use_s3_backend, use_s3_backend,
) )
from zerver.lib.thumbnail import (
DEFAULT_AVATAR_SIZE,
DEFAULT_EMOJI_SIZE,
MEDIUM_AVATAR_SIZE,
resize_avatar,
)
from zerver.lib.upload import ( from zerver.lib.upload import (
all_message_attachments, all_message_attachments,
delete_export_tarball, delete_export_tarball,
@ -29,12 +35,6 @@ from zerver.lib.upload import (
upload_export_tarball, upload_export_tarball,
upload_message_attachment, upload_message_attachment,
) )
from zerver.lib.upload.base import (
DEFAULT_AVATAR_SIZE,
DEFAULT_EMOJI_SIZE,
MEDIUM_AVATAR_SIZE,
resize_avatar,
)
from zerver.lib.upload.s3 import S3UploadBackend from zerver.lib.upload.s3 import S3UploadBackend
from zerver.models import Attachment, RealmEmoji, UserProfile from zerver.models import Attachment, RealmEmoji, UserProfile
from zerver.models.realms import get_realm from zerver.models.realms import get_realm