zulip/zerver/lib/thumbnail.py

161 lines
5.8 KiB
Python
Raw Normal View History

import io
from typing import Optional, Tuple
from urllib.parse import urljoin
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.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:
return not url_has_allowed_host_and_scheme(url, allowed_hosts=None) or url.startswith(
"/user_uploads/"
)
def generate_thumbnail_url(path: str, size: str = "0x0") -> str:
path = urljoin("/", path)
if url_has_allowed_host_and_scheme(path, allowed_hosts=None):
return 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."))