mirror of
https://github.com/zulip/zulip.git
synced 2025-11-06 15:03:34 +00:00
A new table is created to track which path_id attachments are images, and for those their metadata, and which thumbnails have been created. Using path_id as the effective primary key lets us ignore if the attachment is archived or not, saving some foreign key messes. A new worker is added to observe events when rows are added to this table, and to generate and store thumbnails for those images in differing sizes and formats.
472 lines
20 KiB
Python
472 lines
20 KiB
Python
import re
|
|
from dataclasses import asdict
|
|
from io import StringIO
|
|
from unittest.mock import patch
|
|
|
|
import orjson
|
|
import pyvips
|
|
from django.conf import settings
|
|
from django.test import override_settings
|
|
|
|
from zerver.lib.test_classes import ZulipTestCase
|
|
from zerver.lib.test_helpers import get_test_image_file, ratelimit_rule, read_test_image_file
|
|
from zerver.lib.thumbnail import (
|
|
BadImageError,
|
|
BaseThumbnailFormat,
|
|
StoredThumbnailFormat,
|
|
ThumbnailFormat,
|
|
missing_thumbnails,
|
|
resize_emoji,
|
|
)
|
|
from zerver.lib.upload import (
|
|
all_message_attachments,
|
|
get_image_thumbnail_path,
|
|
split_thumbnail_path,
|
|
)
|
|
from zerver.models import Attachment, ImageAttachment
|
|
from zerver.worker.thumbnail import ensure_thumbnails
|
|
|
|
|
|
class ThumbnailRedirectEndpointTest(ZulipTestCase):
|
|
"""Tests for the legacy /thumbnail endpoint."""
|
|
|
|
def test_thumbnail_upload_redirect(self) -> None:
|
|
self.login("hamlet")
|
|
fp = StringIO("zulip!")
|
|
fp.name = "zulip.jpeg"
|
|
|
|
result = self.client_post("/json/user_uploads", {"file": fp})
|
|
self.assert_json_success(result)
|
|
json = orjson.loads(result.content)
|
|
self.assertIn("uri", json)
|
|
self.assertIn("url", json)
|
|
url = json["url"]
|
|
self.assertEqual(json["uri"], url)
|
|
base = "/user_uploads/"
|
|
self.assertEqual(base, url[: len(base)])
|
|
|
|
result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
|
|
self.assertEqual(result.status_code, 302, result)
|
|
self.assertEqual(url, result["Location"])
|
|
|
|
self.login("iago")
|
|
result = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
|
|
self.assertEqual(result.status_code, 403, result)
|
|
self.assert_in_response("You are not authorized to view this file.", result)
|
|
|
|
def test_thumbnail_external_redirect(self) -> None:
|
|
url = "https://www.google.com/images/srpr/logo4w.png"
|
|
result = self.client_get("/thumbnail", {"url": url, "size": "full"})
|
|
self.assertEqual(result.status_code, 302, result)
|
|
base = "https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
|
|
self.assertEqual(base, result["Location"])
|
|
|
|
url = "http://www.google.com/images/srpr/logo4w.png"
|
|
result = self.client_get("/thumbnail", {"url": url, "size": "full"})
|
|
self.assertEqual(result.status_code, 302, result)
|
|
base = "https://external-content.zulipcdn.net/external_content/7b6552b60c635e41e8f6daeb36d88afc4eabde79/687474703a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
|
|
self.assertEqual(base, result["Location"])
|
|
|
|
url = "//www.google.com/images/srpr/logo4w.png"
|
|
result = self.client_get("/thumbnail", {"url": url, "size": "full"})
|
|
self.assertEqual(result.status_code, 302, result)
|
|
base = "https://external-content.zulipcdn.net/external_content/676530cf4b101d56f56cc4a37c6ef4d4fd9b0c03/2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67"
|
|
self.assertEqual(base, result["Location"])
|
|
|
|
@override_settings(RATE_LIMITING=True)
|
|
def test_thumbnail_redirect_for_spectator(self) -> None:
|
|
self.login("hamlet")
|
|
fp = StringIO("zulip!")
|
|
fp.name = "zulip.jpeg"
|
|
|
|
result = self.client_post("/json/user_uploads", {"file": fp})
|
|
self.assert_json_success(result)
|
|
json = orjson.loads(result.content)
|
|
url = json["url"]
|
|
self.assertEqual(json["uri"], url)
|
|
|
|
with ratelimit_rule(86400, 1000, domain="spectator_attachment_access_by_file"):
|
|
# Deny file access for non-web-public stream
|
|
self.subscribe(self.example_user("hamlet"), "Denmark")
|
|
host = self.example_user("hamlet").realm.host
|
|
body = f"First message ...[zulip.txt](http://{host}" + url + ")"
|
|
self.send_stream_message(self.example_user("hamlet"), "Denmark", body, "test")
|
|
|
|
self.logout()
|
|
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
# Allow file access for web-public stream
|
|
self.login("hamlet")
|
|
self.make_stream("web-public-stream", is_web_public=True)
|
|
self.subscribe(self.example_user("hamlet"), "web-public-stream")
|
|
body = f"First message ...[zulip.txt](http://{host}" + url + ")"
|
|
self.send_stream_message(self.example_user("hamlet"), "web-public-stream", body, "test")
|
|
|
|
self.logout()
|
|
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
# Deny file access since rate limited
|
|
with ratelimit_rule(86400, 0, domain="spectator_attachment_access_by_file"):
|
|
response = self.client_get("/thumbnail", {"url": url[1:], "size": "full"})
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
# Deny random file access
|
|
response = self.client_get(
|
|
"/thumbnail",
|
|
{
|
|
"url": "user_uploads/2/71/QYB7LA-ULMYEad-QfLMxmI2e/zulip-non-existent.txt",
|
|
"size": "full",
|
|
},
|
|
)
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
|
|
class ThumbnailEmojiTest(ZulipTestCase):
|
|
def animated_test(self, filename: str) -> None:
|
|
animated_unequal_img_data = read_test_image_file(filename)
|
|
original_image = pyvips.Image.new_from_buffer(animated_unequal_img_data, "n=-1")
|
|
resized_img_data, still_img_data = resize_emoji(
|
|
animated_unequal_img_data, filename, size=50
|
|
)
|
|
assert still_img_data is not None
|
|
emoji_image = pyvips.Image.new_from_buffer(resized_img_data, "n=-1")
|
|
self.assertEqual(emoji_image.get("vips-loader"), "gifload_buffer")
|
|
self.assertEqual(emoji_image.get_n_pages(), original_image.get_n_pages())
|
|
self.assertEqual(emoji_image.get("page-height"), 50)
|
|
self.assertEqual(emoji_image.height, 150)
|
|
self.assertEqual(emoji_image.width, 50)
|
|
|
|
still_image = pyvips.Image.new_from_buffer(still_img_data, "")
|
|
self.assertEqual(still_image.get("vips-loader"), "pngload_buffer")
|
|
self.assertEqual(still_image.get_n_pages(), 1)
|
|
self.assertEqual(still_image.height, 50)
|
|
self.assertEqual(still_image.width, 50)
|
|
|
|
def test_resize_animated_square(self) -> None:
|
|
"""An animated image which is square"""
|
|
self.animated_test("animated_large_img.gif")
|
|
|
|
def test_resize_animated_emoji(self) -> None:
|
|
"""An animated image which is not square"""
|
|
self.animated_test("animated_unequal_img.gif")
|
|
|
|
def test_resize_corrupt_emoji(self) -> None:
|
|
corrupted_img_data = read_test_image_file("corrupt.gif")
|
|
with self.assertRaises(BadImageError):
|
|
resize_emoji(corrupted_img_data, "corrupt.gif")
|
|
|
|
def test_resize_too_many_pixels(self) -> None:
|
|
"""An image file with too many pixels is not resized"""
|
|
with patch("zerver.lib.thumbnail.IMAGE_BOMB_TOTAL_PIXELS", 100):
|
|
animated_large_img_data = read_test_image_file("animated_large_img.gif")
|
|
with self.assertRaises(BadImageError):
|
|
resize_emoji(animated_large_img_data, "animated_large_img.gif", size=50)
|
|
|
|
bomb_img_data = read_test_image_file("bomb.png")
|
|
with self.assertRaises(BadImageError):
|
|
resize_emoji(bomb_img_data, "bomb.png", size=50)
|
|
|
|
def test_resize_still_gif(self) -> None:
|
|
"""A non-animated square emoji resize"""
|
|
still_large_img_data = read_test_image_file("still_large_img.gif")
|
|
resized_img_data, no_still_data = resize_emoji(
|
|
still_large_img_data, "still_large_img.gif", size=50
|
|
)
|
|
emoji_image = pyvips.Image.new_from_buffer(resized_img_data, "n=-1")
|
|
self.assertEqual(emoji_image.get("vips-loader"), "gifload_buffer")
|
|
self.assertEqual(emoji_image.height, 50)
|
|
self.assertEqual(emoji_image.width, 50)
|
|
self.assertEqual(emoji_image.get_n_pages(), 1)
|
|
assert no_still_data is None
|
|
|
|
def test_resize_still_jpg(self) -> None:
|
|
"""A non-animatatable format resize"""
|
|
still_large_img_data = read_test_image_file("img.jpg")
|
|
resized_img_data, no_still_data = resize_emoji(still_large_img_data, "img.jpg", size=50)
|
|
emoji_image = pyvips.Image.new_from_buffer(resized_img_data, "")
|
|
self.assertEqual(emoji_image.get("vips-loader"), "jpegload_buffer")
|
|
self.assertEqual(emoji_image.height, 50)
|
|
self.assertEqual(emoji_image.width, 50)
|
|
self.assertEqual(emoji_image.get_n_pages(), 1)
|
|
assert no_still_data is None
|
|
|
|
def test_non_image_format_wrong_content_type(self) -> None:
|
|
"""A file that is not an image"""
|
|
non_img_data = read_test_image_file("text.txt")
|
|
with self.assertRaises(BadImageError):
|
|
resize_emoji(non_img_data, "text.png", size=50)
|
|
|
|
|
|
class ThumbnailClassesTest(ZulipTestCase):
|
|
def test_class_equivalence(self) -> None:
|
|
self.assertNotEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
"150x100-anim.webp",
|
|
)
|
|
|
|
self.assertEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=10"),
|
|
)
|
|
|
|
self.assertEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
BaseThumbnailFormat("webp", 150, 100, animated=True),
|
|
)
|
|
|
|
self.assertNotEqual(
|
|
ThumbnailFormat("jpeg", 150, 100, animated=True, opts="Q=90"),
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
)
|
|
|
|
self.assertNotEqual(
|
|
ThumbnailFormat("webp", 300, 100, animated=True, opts="Q=90"),
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
)
|
|
|
|
self.assertNotEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=False, opts="Q=90"),
|
|
ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90"),
|
|
)
|
|
|
|
# We can compare stored thumbnails, with much more metadata,
|
|
# to the thumbnail formats that spec how they are generated
|
|
self.assertEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=False, opts="Q=90"),
|
|
StoredThumbnailFormat(
|
|
"webp",
|
|
150,
|
|
100,
|
|
animated=False,
|
|
content_type="image/webp",
|
|
width=120,
|
|
height=100,
|
|
byte_size=123,
|
|
),
|
|
)
|
|
|
|
# But differences in the base four properties mean they are not equal
|
|
self.assertNotEqual(
|
|
ThumbnailFormat("webp", 150, 100, animated=False, opts="Q=90"),
|
|
StoredThumbnailFormat(
|
|
"webp",
|
|
150,
|
|
100,
|
|
animated=True, # Note this change
|
|
content_type="image/webp",
|
|
width=120,
|
|
height=100,
|
|
byte_size=123,
|
|
),
|
|
)
|
|
|
|
def test_stringification(self) -> None:
|
|
# These formats need to be stable, since they are written into URLs in the messages.
|
|
self.assertEqual(
|
|
str(ThumbnailFormat("webp", 150, 100, animated=False)),
|
|
"150x100.webp",
|
|
)
|
|
|
|
self.assertEqual(
|
|
str(ThumbnailFormat("webp", 150, 100, animated=True)),
|
|
"150x100-anim.webp",
|
|
)
|
|
|
|
# And they should round-trip into BaseThumbnailFormat, losing the opts= which we do not serialize
|
|
thumb_format = ThumbnailFormat("webp", 150, 100, animated=True, opts="Q=90")
|
|
self.assertEqual(thumb_format.extension, "webp")
|
|
self.assertEqual(thumb_format.max_width, 150)
|
|
self.assertEqual(thumb_format.max_height, 100)
|
|
self.assertEqual(thumb_format.animated, True)
|
|
|
|
round_trip = BaseThumbnailFormat.from_string(str(thumb_format))
|
|
assert round_trip is not None
|
|
self.assertEqual(thumb_format, round_trip)
|
|
self.assertEqual(round_trip.extension, "webp")
|
|
self.assertEqual(round_trip.max_width, 150)
|
|
self.assertEqual(round_trip.max_height, 100)
|
|
self.assertEqual(round_trip.animated, True)
|
|
|
|
self.assertIsNone(BaseThumbnailFormat.from_string("bad.webp"))
|
|
|
|
|
|
class TestStoreThumbnail(ZulipTestCase):
|
|
@patch(
|
|
"zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS",
|
|
[ThumbnailFormat("webp", 100, 75, animated=True)],
|
|
)
|
|
def test_upload_image(self) -> None:
|
|
assert settings.LOCAL_FILES_DIR
|
|
self.login_user(self.example_user("hamlet"))
|
|
|
|
with self.captureOnCommitCallbacks(execute=True):
|
|
with get_test_image_file("animated_unequal_img.gif") as image_file:
|
|
response = self.assert_json_success(
|
|
self.client_post("/json/user_uploads", {"file": image_file})
|
|
)
|
|
path_id = re.sub(r"/user_uploads/", "", response["url"])
|
|
self.assertEqual(Attachment.objects.filter(path_id=path_id).count(), 1)
|
|
|
|
image_attachment = ImageAttachment.objects.get(path_id=path_id)
|
|
self.assertEqual(image_attachment.original_height_px, 56)
|
|
self.assertEqual(image_attachment.original_width_px, 128)
|
|
self.assertEqual(image_attachment.frames, 3)
|
|
self.assertEqual(image_attachment.thumbnail_metadata, [])
|
|
|
|
self.assertEqual(
|
|
[r[0] for r in all_message_attachments(include_thumbnails=True)],
|
|
[path_id],
|
|
)
|
|
|
|
# The worker triggers when we exit this block and call the pending callbacks
|
|
image_attachment = ImageAttachment.objects.get(path_id=path_id)
|
|
self.assert_length(image_attachment.thumbnail_metadata, 1)
|
|
generated_thumbnail = StoredThumbnailFormat(**image_attachment.thumbnail_metadata[0])
|
|
|
|
self.assertEqual(str(generated_thumbnail), "100x75-anim.webp")
|
|
self.assertEqual(generated_thumbnail.animated, True)
|
|
self.assertEqual(generated_thumbnail.width, 100)
|
|
self.assertEqual(generated_thumbnail.height, 44)
|
|
self.assertEqual(generated_thumbnail.content_type, "image/webp")
|
|
self.assertGreater(generated_thumbnail.byte_size, 200)
|
|
self.assertLess(generated_thumbnail.byte_size, 2 * 1024)
|
|
|
|
self.assertEqual(
|
|
get_image_thumbnail_path(image_attachment, generated_thumbnail),
|
|
f"thumbnail/{path_id}/100x75-anim.webp",
|
|
)
|
|
parsed_path = split_thumbnail_path(f"thumbnail/{path_id}/100x75-anim.webp")
|
|
self.assertEqual(parsed_path[0], path_id)
|
|
self.assertIsInstance(parsed_path[1], BaseThumbnailFormat)
|
|
self.assertEqual(str(parsed_path[1]), str(generated_thumbnail))
|
|
|
|
self.assertEqual(
|
|
sorted([r[0] for r in all_message_attachments(include_thumbnails=True)]),
|
|
sorted([path_id, f"thumbnail/{path_id}/100x75-anim.webp"]),
|
|
)
|
|
|
|
self.assertEqual(ensure_thumbnails(image_attachment), 0)
|
|
|
|
bigger_thumb_format = ThumbnailFormat("webp", 150, 100, opts="Q=90", animated=False)
|
|
with patch("zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [bigger_thumb_format]):
|
|
self.assertEqual(ensure_thumbnails(image_attachment), 1)
|
|
self.assert_length(image_attachment.thumbnail_metadata, 2)
|
|
|
|
bigger_thumbnail = StoredThumbnailFormat(**image_attachment.thumbnail_metadata[1])
|
|
|
|
self.assertEqual(str(bigger_thumbnail), "150x100.webp")
|
|
self.assertEqual(bigger_thumbnail.animated, False)
|
|
# We don't scale up, so these are the original dimensions
|
|
self.assertEqual(bigger_thumbnail.width, 128)
|
|
self.assertEqual(bigger_thumbnail.height, 56)
|
|
self.assertEqual(bigger_thumbnail.content_type, "image/webp")
|
|
self.assertGreater(bigger_thumbnail.byte_size, 200)
|
|
self.assertLess(bigger_thumbnail.byte_size, 2 * 1024)
|
|
|
|
self.assertEqual(
|
|
sorted([r[0] for r in all_message_attachments(include_thumbnails=True)]),
|
|
sorted(
|
|
[
|
|
path_id,
|
|
f"thumbnail/{path_id}/100x75-anim.webp",
|
|
f"thumbnail/{path_id}/150x100.webp",
|
|
]
|
|
),
|
|
)
|
|
|
|
@patch(
|
|
"zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS",
|
|
[ThumbnailFormat("webp", 100, 75, animated=False)],
|
|
)
|
|
def test_bad_upload(self) -> None:
|
|
assert settings.LOCAL_FILES_DIR
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
|
|
with self.captureOnCommitCallbacks(execute=True):
|
|
with get_test_image_file("truncated.gif") as image_file:
|
|
response = self.assert_json_success(
|
|
self.client_post("/json/user_uploads", {"file": image_file})
|
|
)
|
|
path_id = re.sub(r"/user_uploads/", "", response["url"])
|
|
self.assertEqual(Attachment.objects.filter(path_id=path_id).count(), 1)
|
|
|
|
# This doesn't generate an ImageAttachment row because it's corrupted
|
|
self.assertEqual(ImageAttachment.objects.filter(path_id=path_id).count(), 0)
|
|
|
|
# Fake making one, based on if just part of the file is readable
|
|
image_attachment = ImageAttachment.objects.create(
|
|
realm_id=hamlet.realm_id,
|
|
path_id=path_id,
|
|
original_height_px=128,
|
|
original_width_px=128,
|
|
frames=1,
|
|
thumbnail_metadata=[],
|
|
)
|
|
self.assert_length(missing_thumbnails(image_attachment), 1)
|
|
with self.assertLogs("zerver.worker.thumbnail", level="ERROR") as error_log:
|
|
self.assertEqual(ensure_thumbnails(image_attachment), 0)
|
|
libvips_version = (pyvips.version(0), pyvips.version(1))
|
|
# This error message changed
|
|
if libvips_version < (8, 13): # nocoverage # branch varies with version
|
|
expected_message = "gifload_buffer: Insufficient data to do anything"
|
|
else: # nocoverage # branch varies with version
|
|
expected_message = "gifload_buffer: no frames in GIF"
|
|
self.assertTrue(expected_message in error_log.output[0])
|
|
|
|
# It should have now been removed
|
|
self.assertEqual(ImageAttachment.objects.filter(path_id=path_id).count(), 0)
|
|
|
|
def test_missing_thumbnails(self) -> None:
|
|
image_attachment = ImageAttachment(
|
|
path_id="example",
|
|
original_width_px=150,
|
|
original_height_px=100,
|
|
frames=1,
|
|
thumbnail_metadata=[],
|
|
)
|
|
with patch("zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", []):
|
|
self.assertEqual(missing_thumbnails(image_attachment), [])
|
|
|
|
still_webp = ThumbnailFormat("webp", 100, 75, animated=False, opts="Q=90")
|
|
with patch("zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [still_webp]):
|
|
self.assertEqual(missing_thumbnails(image_attachment), [still_webp])
|
|
|
|
anim_webp = ThumbnailFormat("webp", 100, 75, animated=True, opts="Q=90")
|
|
with patch("zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [still_webp, anim_webp]):
|
|
# It's not animated, so the animated format doesn't appear at all
|
|
self.assertEqual(missing_thumbnails(image_attachment), [still_webp])
|
|
|
|
still_jpeg = ThumbnailFormat("jpeg", 100, 75, animated=False, opts="Q=90")
|
|
with patch(
|
|
"zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [still_webp, anim_webp, still_jpeg]
|
|
):
|
|
# But other still formats do
|
|
self.assertEqual(missing_thumbnails(image_attachment), [still_webp, still_jpeg])
|
|
|
|
# If we have a rendered 150x100.webp, then we're not missing it
|
|
rendered_still_webp = StoredThumbnailFormat(
|
|
"webp",
|
|
100,
|
|
75,
|
|
animated=False,
|
|
width=150,
|
|
height=50,
|
|
content_type="image/webp",
|
|
byte_size=1234,
|
|
)
|
|
image_attachment.thumbnail_metadata = [asdict(rendered_still_webp)]
|
|
with patch(
|
|
"zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [still_webp, anim_webp, still_jpeg]
|
|
):
|
|
self.assertEqual(missing_thumbnails(image_attachment), [still_jpeg])
|
|
|
|
# If we have the still, and it's animated, we do still need the animated
|
|
image_attachment.frames = 10
|
|
with patch(
|
|
"zerver.lib.thumbnail.THUMBNAIL_OUTPUT_FORMATS", [still_webp, anim_webp, still_jpeg]
|
|
):
|
|
self.assertEqual(missing_thumbnails(image_attachment), [anim_webp, still_jpeg])
|