markdown: Show thumbnails for uploaded images.

Fixes: #16210.
This commit is contained in:
Alex Vandiver 2024-06-21 19:02:36 +00:00 committed by Tim Abbott
parent 71406ac767
commit b42863be4b
11 changed files with 654 additions and 149 deletions

View File

@ -509,6 +509,8 @@ export function parse_media_data(media: HTMLElement): Payload {
type = "image"; type = "image";
if ($media.attr("data-src-fullsize")) { if ($media.attr("data-src-fullsize")) {
source = $media.attr("data-src-fullsize"); source = $media.attr("data-src-fullsize");
} else if ($media.attr("src")?.startsWith("/user_uploads/thumbnail/")) {
source = url;
} else { } else {
source = preview_src; source = preview_src;
} }

View File

@ -366,33 +366,30 @@ def update_user_message_flags(
def do_update_embedded_data( def do_update_embedded_data(
user_profile: UserProfile, user_profile: UserProfile,
message: Message, message: Message,
content: str | None, rendered_content: str | MessageRenderingResult,
rendering_result: MessageRenderingResult,
) -> None: ) -> None:
timestamp = timezone_now() ums = UserMessage.objects.filter(message=message.id)
update_fields = ["rendered_content"]
if isinstance(rendered_content, MessageRenderingResult):
update_user_message_flags(rendered_content, ums)
message.rendered_content = rendered_content.rendered_content
message.rendered_content_version = markdown_version
update_fields.append("rendered_content_version")
else:
message.rendered_content = rendered_content
message.save(update_fields=update_fields)
update_message_cache([message])
event: dict[str, Any] = { event: dict[str, Any] = {
"type": "update_message", "type": "update_message",
"user_id": None, "user_id": None,
"edit_timestamp": datetime_to_timestamp(timestamp), "edit_timestamp": datetime_to_timestamp(timezone_now()),
"message_id": message.id, "message_id": message.id,
"message_ids": [message.id],
"content": message.content,
"rendered_content": message.rendered_content,
"rendering_only": True, "rendering_only": True,
} }
changed_messages = [message]
rendered_content: str | None = None
ums = UserMessage.objects.filter(message=message.id)
if content is not None:
update_user_message_flags(rendering_result, ums)
rendered_content = rendering_result.rendered_content
message.rendered_content = rendered_content
message.rendered_content_version = markdown_version
event["content"] = content
event["rendered_content"] = rendered_content
message.save(update_fields=["content", "rendered_content"])
event["message_ids"] = update_message_cache(changed_messages)
def user_info(um: UserMessage) -> dict[str, Any]: def user_info(um: UserMessage) -> dict[str, Any]:
return { return {

View File

@ -13,7 +13,7 @@ from datetime import datetime, timezone
from functools import lru_cache from functools import lru_cache
from re import Match, Pattern from re import Match, Pattern
from typing import Any, Generic, Optional, TypeAlias, TypedDict, TypeVar, cast from typing import Any, Generic, Optional, TypeAlias, TypedDict, TypeVar, cast
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit, urlunsplit from urllib.parse import parse_qs, quote, urljoin, urlsplit, urlunsplit
from xml.etree.ElementTree import Element, SubElement from xml.etree.ElementTree import Element, SubElement
import ahocorasick import ahocorasick
@ -55,7 +55,7 @@ from zerver.lib.mention import (
from zerver.lib.outgoing_http import OutgoingSession from zerver.lib.outgoing_http import OutgoingSession
from zerver.lib.subdomains import is_static_or_current_realm_url from zerver.lib.subdomains import is_static_or_current_realm_url
from zerver.lib.tex import render_tex from zerver.lib.tex import render_tex
from zerver.lib.thumbnail import user_uploads_or_external from zerver.lib.thumbnail import get_user_upload_previews, rewrite_thumbnailed_images
from zerver.lib.timeout import unsafe_timeout from zerver.lib.timeout import unsafe_timeout
from zerver.lib.timezone import common_timezones from zerver.lib.timezone import common_timezones
from zerver.lib.types import LinkifierDict from zerver.lib.types import LinkifierDict
@ -125,6 +125,7 @@ class DbData:
sent_by_bot: bool sent_by_bot: bool
stream_names: dict[str, int] stream_names: dict[str, int]
translate_emoticons: bool translate_emoticons: bool
user_upload_previews: dict[str, tuple[str, bool] | None]
# Format version of the Markdown rendering; stored along with rendered # Format version of the Markdown rendering; stored along with rendered
@ -615,18 +616,21 @@ class InlineInterestingLinkProcessor(markdown.treeprocessors.Treeprocessor):
if data_id is not None: if data_id is not None:
a.set("data-id", data_id) a.set("data-id", data_id)
img = SubElement(a, "img") img = SubElement(a, "img")
if ( if image_url.startswith("/user_uploads/") and self.zmd.zulip_db_data:
settings.THUMBNAIL_IMAGES path_id = image_url[len("/user_uploads/") :]
and (not already_thumbnailed)
and user_uploads_or_external(image_url) # We should have pulled the preview data for this image
): # (even if that's "no preview yet") from the database
# We strip leading '/' from relative URLs here to ensure # before rendering; is_image should have enforced that.
# consistency in what gets passed to /thumbnail assert path_id in self.zmd.zulip_db_data.user_upload_previews
image_url = image_url.lstrip("/")
img.set("src", "/thumbnail?" + urlencode({"url": image_url, "size": "thumbnail"})) # Insert a placeholder image spinner. We post-process
img.set( # this content (see rewrite_thumbnailed_images in
"data-src-fullsize", "/thumbnail?" + urlencode({"url": image_url, "size": "full"}) # zerver.lib.thumbnail), looking specifically for this
) # tag, and may re-write it into the thumbnail URL if it
# already exists when the message is sent.
img.set("class", "image-loading-placeholder")
img.set("src", "/static/images/loading/loader-black.svg")
else: else:
img.set("src", image_url) img.set("src", image_url)
@ -724,6 +728,13 @@ class InlineInterestingLinkProcessor(markdown.treeprocessors.Treeprocessor):
if parsed_url.netloc == "pasteboard.co": if parsed_url.netloc == "pasteboard.co":
return False return False
# Check against the previews we generated -- if we didn't have
# a row for the ImageAttachment, then its header didn't parse
# as a valid image type which libvips handles.
if url.startswith("/user_uploads/") and self.zmd.zulip_db_data:
path_id = url[len("/user_uploads/") :]
return path_id in self.zmd.zulip_db_data.user_upload_previews
return any(parsed_url.path.lower().endswith(ext) for ext in IMAGE_EXTENSIONS) return any(parsed_url.path.lower().endswith(ext) for ext in IMAGE_EXTENSIONS)
def corrected_image_source(self, url: str) -> str | None: def corrected_image_source(self, url: str) -> str | None:
@ -2623,6 +2634,7 @@ def do_convert(
_md_engine.url_embed_data = url_embed_data _md_engine.url_embed_data = url_embed_data
# Pre-fetch data from the DB that is used in the Markdown thread # Pre-fetch data from the DB that is used in the Markdown thread
user_upload_previews = None
if message_realm is not None: if message_realm is not None:
# Here we fetch the data structures needed to render # Here we fetch the data structures needed to render
# mentions/stream mentions from the database, but only # mentions/stream mentions from the database, but only
@ -2645,6 +2657,7 @@ def do_convert(
else: else:
active_realm_emoji = {} active_realm_emoji = {}
user_upload_previews = get_user_upload_previews(message_realm.id, content)
_md_engine.zulip_db_data = DbData( _md_engine.zulip_db_data = DbData(
realm_alert_words_automaton=realm_alert_words_automaton, realm_alert_words_automaton=realm_alert_words_automaton,
mention_data=mention_data, mention_data=mention_data,
@ -2653,6 +2666,7 @@ def do_convert(
sent_by_bot=sent_by_bot, sent_by_bot=sent_by_bot,
stream_names=stream_name_info, stream_names=stream_name_info,
translate_emoticons=translate_emoticons, translate_emoticons=translate_emoticons,
user_upload_previews=user_upload_previews,
) )
try: try:
@ -2663,6 +2677,14 @@ def do_convert(
# infinite-loop). # infinite-loop).
rendering_result.rendered_content = unsafe_timeout(5, lambda: _md_engine.convert(content)) rendering_result.rendered_content = unsafe_timeout(5, lambda: _md_engine.convert(content))
# Post-process the result with the rendered image previews:
if user_upload_previews is not None:
content_with_thumbnails = rewrite_thumbnailed_images(
rendering_result.rendered_content, user_upload_previews
)
if content_with_thumbnails is not None:
rendering_result.rendered_content = content_with_thumbnails
# Throw an exception if the content is huge; this protects the # Throw an exception if the content is huge; this protects the
# rest of the codebase from any bugs where we end up rendering # rest of the codebase from any bugs where we end up rendering
# something huge. # something huge.

View File

@ -8,6 +8,7 @@ from typing import TypeVar
from urllib.parse import urljoin from urllib.parse import urljoin
import pyvips import pyvips
from bs4 import BeautifulSoup
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 django.utils.translation import gettext as _
from typing_extensions import override from typing_extensions import override
@ -136,12 +137,6 @@ class BadImageError(JsonableError):
code = ErrorCode.BAD_IMAGE 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: def generate_thumbnail_url(path: str, size: str = "0x0") -> str:
path = urljoin("/", path) path = urljoin("/", path)
@ -324,3 +319,99 @@ def split_thumbnail_path(file_path: str) -> tuple[str, BaseThumbnailFormat]:
assert thumbnail_format is not None assert thumbnail_format is not None
path_id = "/".join(path_parts[1:]) path_id = "/".join(path_parts[1:])
return path_id, thumbnail_format return path_id, thumbnail_format
def get_user_upload_previews(realm_id: int, content: str) -> dict[str, tuple[str, bool] | None]:
matches = re.findall(r"/user_uploads/(\d+/[/\w.-]+)", content)
upload_preview_data: dict[str, tuple[str, bool] | None] = {}
for image_attachment in ImageAttachment.objects.filter(realm_id=realm_id, path_id__in=matches):
if image_attachment.thumbnail_metadata == []:
# Image exists, and header of it parsed as a valid image,
# but has not been thumbnailed yet; we will render a
# spinner.
upload_preview_data[image_attachment.path_id] = None
else:
upload_preview_data[image_attachment.path_id] = get_default_thumbnail_url(
image_attachment
)
return upload_preview_data
def get_default_thumbnail_url(image_attachment: ImageAttachment) -> tuple[str, bool]:
# For "dumb" clients which cannot rewrite it into their
# preferred format and size, we choose the first one in
# THUMBNAIL_OUTPUT_FORMATS which matches the animated/not
# nature of the source image.
found_format: ThumbnailFormat | None = None
for thumbnail_format in THUMBNAIL_OUTPUT_FORMATS:
if thumbnail_format.animated == (image_attachment.frames > 1):
found_format = thumbnail_format
break
if found_format is None:
# No animated thumbnail formats exist somehow, and the
# image is animated? Just take the first thumbnail
# format.
found_format = THUMBNAIL_OUTPUT_FORMATS[0]
return (
"/user_uploads/" + get_image_thumbnail_path(image_attachment, found_format),
found_format.animated,
)
def rewrite_thumbnailed_images(
rendered_content: str,
images: dict[str, tuple[str, bool] | None],
to_delete: set[str] | None = None,
) -> str | None:
if not images and not to_delete:
return None
parsed_message = BeautifulSoup(rendered_content, "html.parser")
changed = False
for inline_image_div in parsed_message.find_all("div", class_="message_inline_image"):
image_link = inline_image_div.find("a")
if (
image_link is None
or image_link["href"] is None
or not image_link["href"].startswith("/user_uploads/")
):
# This is not an inline image generated by the markdown
# processor for a locally-uploaded image.
continue
image_tag = image_link.find("img", class_="image-loading-placeholder")
if image_tag is None:
# The placeholder was already replaced -- for instance,
# this is expected if multiple images are included in the
# same message. The second time this is run, for the
# second image, the first image will have no placeholder.
continue
path_id = image_link["href"][len("/user_uploads/") :]
if to_delete and path_id in to_delete:
# This was not a valid thumbnail target, for some reason.
# Trim out the whole "message_inline_image" element, since
# it's not going be renderable by clients either.
inline_image_div.decompose()
changed = True
continue
image_data = images.get(path_id)
if image_data is None:
# Has not been thumbnailed yet; leave it as a spinner.
# This happens routinely when a message contained multiple
# unthumbnailed images, and only one of those images just
# completed thumbnailing.
pass
else:
changed = True
del image_tag["class"]
image_tag["src"], is_animated = image_data
if is_animated:
image_tag["data-animated"] = "true"
if changed:
# The formatter="html5" means we do not produce self-closing tags
return parsed_message.encode(formatter="html5").decode().strip()
else:
return None

View File

@ -408,7 +408,7 @@
{ {
"name": "inline_image", "name": "inline_image",
"input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring", "input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring",
"expected_output": "<p>Google logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boring</p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img data-src-fullsize=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"></a></div>", "expected_output": "<p>Google logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boring</p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"></a></div>",
"backend_only_rendering": true, "backend_only_rendering": true,
"text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\n" "text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\n"
}, },
@ -422,34 +422,34 @@
{ {
"name": "two_inline_images", "name": "two_inline_images",
"input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring\nZulip logo: https://zulip.com/static/images/logo/zulip-icon-128x128.png", "input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring\nZulip logo: https://zulip.com/static/images/logo/zulip-icon-128x128.png",
"expected_output": "<p>Google logo today: <a href=\"https:\/\/www.google.com\/images\/srpr\/logo4w.png\">https:\/\/www.google.com\/images\/srpr\/logo4w.png<\/a><br>\nKinda boring<br>\nZulip logo: <a href=\"https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\">https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png<\/a><\/p>\n<div class=\"message_inline_image\"><a href=\"https:\/\/www.google.com\/images\/srpr\/logo4w.png\"><img data-src-fullsize=\"\/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"\/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"><\/a><\/div><div class=\"message_inline_image\"><a href=\"https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\"><img data-src-fullsize=\"\/thumbnail?url=https%3A%2F%2Fzulip.com%2Fstatic%2Fimages%2Flogo%2Fzulip-icon-128x128.png&amp;size=full\" src=\"\/thumbnail?url=https%3A%2F%2Fzulip.com%2Fstatic%2Fimages%2Flogo%2Fzulip-icon-128x128.png&amp;size=thumbnail\"><\/a><\/div>", "expected_output": "<p>Google logo today: <a href=\"https:\/\/www.google.com\/images\/srpr\/logo4w.png\">https:\/\/www.google.com\/images\/srpr\/logo4w.png<\/a><br>\nKinda boring<br>\nZulip logo: <a href=\"https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\">https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png<\/a><\/p>\n<div class=\"message_inline_image\"><a href=\"https:\/\/www.google.com\/images\/srpr\/logo4w.png\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"><\/a><\/div><div class=\"message_inline_image\"><a href=\"https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\"><img src=\"https://external-content.zulipcdn.net/external_content/213b3c6f660b53018c3cb27d48d34f0940a74954/68747470733a2f2f7a756c69702e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67\"><\/a><\/div>",
"backend_only_rendering": true, "backend_only_rendering": true,
"text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\nZulip logo: https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\n" "text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\nZulip logo: https:\/\/zulip.com\/static\/images\/logo\/zulip-icon-128x128.png\n"
}, },
{ {
"name": "deduplicate_inline_previews", "name": "deduplicate_inline_previews",
"input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boringGoogle logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring", "input": "Google logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boringGoogle logo today: https://www.google.com/images/srpr/logo4w.png\nKinda boring",
"expected_output": "<p>Google logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boringGoogle logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boring</p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img data-src-fullsize=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"></a></div>", "expected_output": "<p>Google logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boringGoogle logo today: <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><br>\nKinda boring</p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"></a></div>",
"backend_only_rendering": true, "backend_only_rendering": true,
"text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boringGoogle logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\n" "text_content": "Google logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boringGoogle logo today: https:\/\/www.google.com\/images\/srpr\/logo4w.png\nKinda boring\n"
}, },
{ {
"name": "bulleted_list_inlining", "name": "bulleted_list_inlining",
"input": "* Google?\n* Google. https://www.google.com/images/srpr/logo4w.png\n* Google!", "input": "* Google?\n* Google. https://www.google.com/images/srpr/logo4w.png\n* Google!",
"expected_output": "<ul>\n<li>Google?</li>\n<li>Google. <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img data-src-fullsize=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"></a></div></li>\n<li>Google!</li>\n</ul>", "expected_output": "<ul>\n<li>Google?</li>\n<li>Google. <a href=\"https://www.google.com/images/srpr/logo4w.png\">https://www.google.com/images/srpr/logo4w.png</a><div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"></a></div></li>\n<li>Google!</li>\n</ul>",
"backend_only_rendering": true, "backend_only_rendering": true,
"text_content": "\nGoogle?\nGoogle. https://www.google.com/images/srpr/logo4w.png\nGoogle!\n" "text_content": "\nGoogle?\nGoogle. https://www.google.com/images/srpr/logo4w.png\nGoogle!\n"
}, },
{ {
"name": "only_inline_image", "name": "only_inline_image",
"input": "https://www.google.com/images/srpr/logo4w.png", "input": "https://www.google.com/images/srpr/logo4w.png",
"expected_output": "<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img data-src-fullsize=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"></a></div>", "expected_output": "<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"></a></div>",
"backend_only_rendering": true "backend_only_rendering": true
}, },
{ {
"name": "only_named_inline_image", "name": "only_named_inline_image",
"input": "[Google link](https://www.google.com/images/srpr/logo4w.png)", "input": "[Google link](https://www.google.com/images/srpr/logo4w.png)",
"expected_output": "<p><a href=\"https://www.google.com/images/srpr/logo4w.png\">Google link</a></p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\" title=\"Google link\"><img data-src-fullsize=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full\" src=\"/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail\"></a></div>", "expected_output": "<p><a href=\"https://www.google.com/images/srpr/logo4w.png\">Google link</a></p>\n<div class=\"message_inline_image\"><a href=\"https://www.google.com/images/srpr/logo4w.png\" title=\"Google link\"><img src=\"https://external-content.zulipcdn.net/external_content/56c362a24201593891955ff526b3b412c0f9fcd2/68747470733a2f2f7777772e676f6f676c652e636f6d2f696d616765732f737270722f6c6f676f34772e706e67\"></a></div>",
"backend_only_rendering": true, "backend_only_rendering": true,
"text_content": "Google link\n" "text_content": "Google link\n"
}, },
@ -993,7 +993,7 @@
{ {
"name": "spoiler_with_inline_image", "name": "spoiler_with_inline_image",
"input": "```spoiler header\nContent http://example.com/image.png\n```", "input": "```spoiler header\nContent http://example.com/image.png\n```",
"expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n<div class=\"message_inline_image\"><a href=\"http://example.com/image.png\"><img data-src-fullsize=\"/thumbnail?url=http%3A%2F%2Fexample.com%2Fimage.png&amp;size=full\" src=\"/thumbnail?url=http%3A%2F%2Fexample.com%2Fimage.png&amp;size=thumbnail\"></a></div></div></div>", "expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n<div class=\"message_inline_image\"><a href=\"http://example.com/image.png\"><img src=\"https://external-content.zulipcdn.net/external_content/25fef3bfd3a93e0266153205ea66a83c53d310b4/687474703a2f2f6578616d706c652e636f6d2f696d6167652e706e67\"></a></div></div></div>",
"marked_expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n</div></div>", "marked_expected_output": "<div class=\"spoiler-block\"><div class=\"spoiler-header\">\n<p>header</p>\n</div><div class=\"spoiler-content\" aria-hidden=\"true\">\n<p>Content <a href=\"http://example.com/image.png\">http://example.com/image.png</a></p>\n</div></div>",
"text_content": "header (…)\n" "text_content": "header (…)\n"
}, },

View File

@ -211,12 +211,14 @@ from zerver.lib.test_helpers import (
create_dummy_file, create_dummy_file,
get_subscription, get_subscription,
get_test_image_file, get_test_image_file,
read_test_image_file,
reset_email_visibility_to_everyone_in_zulip_realm, reset_email_visibility_to_everyone_in_zulip_realm,
stdout_suppressed, stdout_suppressed,
) )
from zerver.lib.timestamp import convert_to_UTC, datetime_to_timestamp from zerver.lib.timestamp import convert_to_UTC, datetime_to_timestamp
from zerver.lib.topic import TOPIC_NAME from zerver.lib.topic import TOPIC_NAME
from zerver.lib.types import ProfileDataElementUpdateDict from zerver.lib.types import ProfileDataElementUpdateDict
from zerver.lib.upload import upload_message_attachment
from zerver.lib.user_groups import ( from zerver.lib.user_groups import (
AnonymousSettingGroupDict, AnonymousSettingGroupDict,
get_group_setting_value_for_api, get_group_setting_value_for_api,
@ -225,6 +227,7 @@ from zerver.lib.user_groups import (
from zerver.models import ( from zerver.models import (
Attachment, Attachment,
CustomProfileField, CustomProfileField,
ImageAttachment,
Message, Message,
MultiuseInvite, MultiuseInvite,
NamedUserGroup, NamedUserGroup,
@ -258,6 +261,7 @@ from zerver.tornado.event_queue import (
send_web_reload_client_events, send_web_reload_client_events,
) )
from zerver.views.realm_playgrounds import access_playground_by_id from zerver.views.realm_playgrounds import access_playground_by_id
from zerver.worker.thumbnail import ensure_thumbnails
class BaseAction(ZulipTestCase): class BaseAction(ZulipTestCase):
@ -935,7 +939,7 @@ class NormalActionsTest(BaseAction):
content = "embed_content" content = "embed_content"
rendering_result = render_message_markdown(message, content) rendering_result = render_message_markdown(message, content)
with self.verify_action(state_change_expected=False) as events: with self.verify_action(state_change_expected=False) as events:
do_update_embedded_data(self.user_profile, message, content, rendering_result) do_update_embedded_data(self.user_profile, message, rendering_result)
check_update_message( check_update_message(
"events[0]", "events[0]",
events[0], events[0],
@ -1028,6 +1032,27 @@ class NormalActionsTest(BaseAction):
is_embedded_update_only=False, is_embedded_update_only=False,
) )
def test_thumbnail_event(self) -> None:
iago = self.example_user("iago")
url = upload_message_attachment(
"img.png", "image/png", read_test_image_file("img.png"), self.example_user("iago")
)
path_id = url[len("/user_upload/") + 1 :]
self.send_stream_message(iago, "Verona", f"[img.png]({url})")
# Generating a thumbnail for an image sends a message update event
with self.verify_action(state_change_expected=False) as events:
ensure_thumbnails(ImageAttachment.objects.get(path_id=path_id))
check_update_message(
"events[0]",
events[0],
is_stream_message=False,
has_content=False,
has_topic=False,
has_new_stream_id=False,
is_embedded_update_only=True,
)
def test_update_message_flags(self) -> None: def test_update_message_flags(self) -> None:
# Test message flag update events # Test message flag update events
message = self.send_personal_message( message = self.send_personal_message(

View File

@ -713,51 +713,13 @@ class MarkdownEmbedsTest(ZulipTestCase):
f"""<p><a href="http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw">http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw</a></p>\n<div class="youtube-video message_inline_image"><a data-id="nOJgD4fcZhI" href="http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw"><img src="{get_camo_url("https://i.ytimg.com/vi/nOJgD4fcZhI/default.jpg")}"></a></div>""", f"""<p><a href="http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw">http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw</a></p>\n<div class="youtube-video message_inline_image"><a data-id="nOJgD4fcZhI" href="http://www.youtube.com/watch_videos?video_ids=nOJgD4fcZhI,i96UO8-GFvw"><img src="{get_camo_url("https://i.ytimg.com/vi/nOJgD4fcZhI/default.jpg")}"></a></div>""",
) )
@override_settings(THUMBNAIL_IMAGES=True)
def test_inline_image_thumbnail_url(self) -> None:
realm = get_realm("zephyr")
msg = "[foobar](/user_uploads/{realm_id}/50/w2G6ok9kr8AMCQCTNAUOFMln/IMG_0677.JPG)"
msg = msg.format(realm_id=realm.id)
thumbnail_img = '<img data-src-fullsize="/thumbnail?url=user_uploads%2F{realm_id}%2F50%2Fw2G6ok9kr8AMCQCTNAUOFMln%2FIMG_0677.JPG&amp;size=full" src="/thumbnail?url=user_uploads%2F{realm_id}%2F50%2Fw2G6ok9kr8AMCQCTNAUOFMln%2FIMG_0677.JPG&amp;size=thumbnail"><'
thumbnail_img = thumbnail_img.format(realm_id=realm.id)
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
msg = "https://www.google.com/images/srpr/logo4w.png"
thumbnail_img = '<img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail">'
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
msg = "www.google.com/images/srpr/logo4w.png"
thumbnail_img = '<img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail">'
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
msg = "https://www.google.com/images/srpr/logo4w.png"
thumbnail_img = f"""<div class="message_inline_image"><a href="https://www.google.com/images/srpr/logo4w.png"><img src="{get_camo_url("https://www.google.com/images/srpr/logo4w.png")}"></a></div>"""
with self.settings(THUMBNAIL_IMAGES=False):
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
# Any URL which is not an external link and doesn't start with
# /user_uploads/ is not thumbnailed
msg = "[foobar](/static/images/cute/turtle.png)"
thumbnail_img = '<div class="message_inline_image"><a href="/static/images/cute/turtle.png" title="foobar"><img src="/static/images/cute/turtle.png"></a></div>'
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
msg = "[foobar](/user_avatars/{realm_id}/emoji/images/50.png)"
msg = msg.format(realm_id=realm.id)
thumbnail_img = '<div class="message_inline_image"><a href="/user_avatars/{realm_id}/emoji/images/50.png" title="foobar"><img src="/user_avatars/{realm_id}/emoji/images/50.png"></a></div>'
thumbnail_img = thumbnail_img.format(realm_id=realm.id)
converted = markdown_convert_wrapper(msg)
self.assertIn(thumbnail_img, converted)
@override_settings(THUMBNAIL_IMAGES=True)
def test_inline_image_preview(self) -> None: def test_inline_image_preview(self) -> None:
with_preview = '<div class="message_inline_image"><a href="http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fcdn.wallpapersafari.com%2F13%2F6%2F16eVjx.jpeg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fcdn.wallpapersafari.com%2F13%2F6%2F16eVjx.jpeg&amp;size=thumbnail"></a></div>' url = "http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg"
without_preview = '<p><a href="http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg">http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg</a></p>' camo_url = get_camo_url(url)
content = "http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg" with_preview = (
f'<div class="message_inline_image"><a href="{url}"><img src="{camo_url}"></a></div>'
)
without_preview = f'<p><a href="{url}">{url}</a></p>'
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
@ -765,7 +727,7 @@ class MarkdownEmbedsTest(ZulipTestCase):
sending_client=get_client("test"), sending_client=get_client("test"),
realm=sender_user_profile.realm, realm=sender_user_profile.realm,
) )
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, url)
self.assertEqual(converted.rendered_content, with_preview) self.assertEqual(converted.rendered_content, with_preview)
realm = msg.get_realm() realm = msg.get_realm()
@ -778,17 +740,9 @@ class MarkdownEmbedsTest(ZulipTestCase):
sending_client=get_client("test"), sending_client=get_client("test"),
realm=sender_user_profile.realm, realm=sender_user_profile.realm,
) )
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, url)
self.assertEqual(converted.rendered_content, without_preview) self.assertEqual(converted.rendered_content, without_preview)
@override_settings(EXTERNAL_URI_SCHEME="https://")
def test_external_image_preview_use_camo(self) -> None:
content = "https://example.com/thing.jpeg"
thumbnail_img = f"""<div class="message_inline_image"><a href="{content}"><img src="{get_camo_url(content)}"></a></div>"""
converted = markdown_convert_wrapper(content)
self.assertIn(converted, thumbnail_img)
@override_settings(EXTERNAL_URI_SCHEME="https://") @override_settings(EXTERNAL_URI_SCHEME="https://")
def test_static_image_preview_skip_camo(self) -> None: def test_static_image_preview_skip_camo(self) -> None:
content = f"{ settings.STATIC_URL }/thing.jpeg" content = f"{ settings.STATIC_URL }/thing.jpeg"
@ -843,10 +797,13 @@ class MarkdownEmbedsTest(ZulipTestCase):
soup = BeautifulSoup(converted, "html.parser") soup = BeautifulSoup(converted, "html.parser")
self.assert_length(soup(class_="message_inline_image"), 0) self.assert_length(soup(class_="message_inline_image"), 0)
@override_settings(THUMBNAIL_IMAGES=True)
def test_inline_image_quoted_blocks(self) -> None: def test_inline_image_quoted_blocks(self) -> None:
content = "http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg" url = "http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg"
expected = '<div class="message_inline_image"><a href="http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fcdn.wallpapersafari.com%2F13%2F6%2F16eVjx.jpeg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fcdn.wallpapersafari.com%2F13%2F6%2F16eVjx.jpeg&amp;size=thumbnail"></a></div>' camo_url = get_camo_url(url)
content = f"{url}"
expected = (
f'<div class="message_inline_image"><a href="{url}"><img src="{camo_url}"></a></div>'
)
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
sender=sender_user_profile, sender=sender_user_profile,
@ -856,8 +813,8 @@ class MarkdownEmbedsTest(ZulipTestCase):
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
content = ">http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg\n\nAwesome!" content = f">{url}\n\nAwesome!"
expected = '<blockquote>\n<p><a href="http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg">http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg</a></p>\n</blockquote>\n<p>Awesome!</p>' expected = f'<blockquote>\n<p><a href="{url}">{url}</a></p>\n</blockquote>\n<p>Awesome!</p>'
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
sender=sender_user_profile, sender=sender_user_profile,
@ -867,8 +824,8 @@ class MarkdownEmbedsTest(ZulipTestCase):
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
content = ">* http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg\n\nAwesome!" content = f">* {url}\n\nAwesome!"
expected = '<blockquote>\n<ul>\n<li><a href="http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg">http://cdn.wallpapersafari.com/13/6/16eVjx.jpeg</a></li>\n</ul>\n</blockquote>\n<p>Awesome!</p>' expected = f'<blockquote>\n<ul>\n<li><a href="{url}">{url}</a></li>\n</ul>\n</blockquote>\n<p>Awesome!</p>'
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
sender=sender_user_profile, sender=sender_user_profile,
@ -878,12 +835,23 @@ class MarkdownEmbedsTest(ZulipTestCase):
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
@override_settings(THUMBNAIL_IMAGES=True)
def test_inline_image_preview_order(self) -> None: def test_inline_image_preview_order(self) -> None:
realm = get_realm("zulip") urls = [
content = "http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg\nhttp://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg\nhttp://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg" "http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg",
expected = '<p><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg">http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg</a><br>\n<a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg">http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg</a><br>\n<a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg">http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg</a></p>\n<div class="message_inline_image"><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_01.jpg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_01.jpg&amp;size=thumbnail"></a></div><div class="message_inline_image"><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_02.jpg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_02.jpg&amp;size=thumbnail"></a></div><div class="message_inline_image"><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_03.jpg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_03.jpg&amp;size=thumbnail"></a></div>' "http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg",
"http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg",
]
content = "\n".join(urls)
expected = (
"<p>"
f'<a href="{urls[0]}">{urls[0]}</a><br>\n'
f'<a href="{urls[1]}">{urls[1]}</a><br>\n'
f'<a href="{urls[2]}">{urls[2]}</a>'
"</p>\n"
f'<div class="message_inline_image"><a href="{urls[0]}"><img src="{get_camo_url(urls[0])}"></a></div>'
f'<div class="message_inline_image"><a href="{urls[1]}"><img src="{get_camo_url(urls[1])}"></a></div>'
f'<div class="message_inline_image"><a href="{urls[2]}"><img src="{get_camo_url(urls[2])}"></a></div>'
)
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
sender=sender_user_profile, sender=sender_user_profile,
@ -893,10 +861,16 @@ class MarkdownEmbedsTest(ZulipTestCase):
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
content = "http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg\n\n>http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg\n\n* http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg\n* https://www.google.com/images/srpr/logo4w.png" urls.append("https://www.google.com/images/srpr/logo4w.png")
expected = '<div class="message_inline_image"><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_01.jpg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_01.jpg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_01.jpg&amp;size=thumbnail"></a></div><blockquote>\n<p><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg">http://imaging.nikon.com/lineup/dslr/df/img/sample/img_02.jpg</a></p>\n</blockquote>\n<ul>\n<li><div class="message_inline_image"><a href="http://imaging.nikon.com/lineup/dslr/df/img/sample/img_03.jpg"><img data-src-fullsize="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_03.jpg&amp;size=full" src="/thumbnail?url=http%3A%2F%2Fimaging.nikon.com%2Flineup%2Fdslr%2Fdf%2Fimg%2Fsample%2Fimg_03.jpg&amp;size=thumbnail"></a></div></li>\n<li><div class="message_inline_image"><a href="https://www.google.com/images/srpr/logo4w.png"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fwww.google.com%2Fimages%2Fsrpr%2Flogo4w.png&amp;size=thumbnail"></a></div></li>\n</ul>' content = f"{urls[0]}\n\n" f">{urls[1]}\n\n" f"* {urls[2]}\n" f"* {urls[3]}"
expected = (
sender_user_profile = self.example_user("othello") f'<div class="message_inline_image"><a href="{urls[0]}"><img src="{get_camo_url(urls[0])}"></a></div>'
f'<blockquote>\n<p><a href="{urls[1]}">{urls[1]}</a></p>\n</blockquote>\n'
"<ul>\n"
f'<li><div class="message_inline_image"><a href="{urls[2]}"><img src="{get_camo_url(urls[2])}"></a></div></li>\n'
f'<li><div class="message_inline_image"><a href="{urls[3]}"><img src="{get_camo_url(urls[3])}"></a></div></li>\n'
"</ul>"
)
msg = Message( msg = Message(
sender=sender_user_profile, sender=sender_user_profile,
sending_client=get_client("test"), sending_client=get_client("test"),
@ -905,24 +879,14 @@ class MarkdownEmbedsTest(ZulipTestCase):
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
content = "Test 1\n[21136101110_1dde1c1a7e_o.jpg](/user_uploads/{realm_id}/6d/F1PX6u16JA2P-nK45PyxHIYZ/21136101110_1dde1c1a7e_o.jpg) \n\nNext image\n[IMG_20161116_023910.jpg](/user_uploads/{realm_id}/69/sh7L06e7uH7NaX6d5WFfVYQp/IMG_20161116_023910.jpg) \n\nAnother screenshot\n[Screenshot-from-2016-06-01-16-22-42.png](/user_uploads/{realm_id}/70/_aZmIEWaN1iUaxwkDjkO7bpj/Screenshot-from-2016-06-01-16-22-42.png)"
content = content.format(realm_id=realm.id)
expected = '<p>Test 1<br>\n<a href="/user_uploads/{realm_id}/6d/F1PX6u16JA2P-nK45PyxHIYZ/21136101110_1dde1c1a7e_o.jpg">21136101110_1dde1c1a7e_o.jpg</a> </p>\n<div class="message_inline_image"><a href="/user_uploads/{realm_id}/6d/F1PX6u16JA2P-nK45PyxHIYZ/21136101110_1dde1c1a7e_o.jpg" title="21136101110_1dde1c1a7e_o.jpg"><img data-src-fullsize="/thumbnail?url=user_uploads%2F{realm_id}%2F6d%2FF1PX6u16JA2P-nK45PyxHIYZ%2F21136101110_1dde1c1a7e_o.jpg&amp;size=full" src="/thumbnail?url=user_uploads%2F{realm_id}%2F6d%2FF1PX6u16JA2P-nK45PyxHIYZ%2F21136101110_1dde1c1a7e_o.jpg&amp;size=thumbnail"></a></div><p>Next image<br>\n<a href="/user_uploads/{realm_id}/69/sh7L06e7uH7NaX6d5WFfVYQp/IMG_20161116_023910.jpg">IMG_20161116_023910.jpg</a> </p>\n<div class="message_inline_image"><a href="/user_uploads/{realm_id}/69/sh7L06e7uH7NaX6d5WFfVYQp/IMG_20161116_023910.jpg" title="IMG_20161116_023910.jpg"><img data-src-fullsize="/thumbnail?url=user_uploads%2F{realm_id}%2F69%2Fsh7L06e7uH7NaX6d5WFfVYQp%2FIMG_20161116_023910.jpg&amp;size=full" src="/thumbnail?url=user_uploads%2F{realm_id}%2F69%2Fsh7L06e7uH7NaX6d5WFfVYQp%2FIMG_20161116_023910.jpg&amp;size=thumbnail"></a></div><p>Another screenshot<br>\n<a href="/user_uploads/{realm_id}/70/_aZmIEWaN1iUaxwkDjkO7bpj/Screenshot-from-2016-06-01-16-22-42.png">Screenshot-from-2016-06-01-16-22-42.png</a></p>\n<div class="message_inline_image"><a href="/user_uploads/{realm_id}/70/_aZmIEWaN1iUaxwkDjkO7bpj/Screenshot-from-2016-06-01-16-22-42.png" title="Screenshot-from-2016-06-01-16-22-42.png"><img data-src-fullsize="/thumbnail?url=user_uploads%2F{realm_id}%2F70%2F_aZmIEWaN1iUaxwkDjkO7bpj%2FScreenshot-from-2016-06-01-16-22-42.png&amp;size=full" src="/thumbnail?url=user_uploads%2F{realm_id}%2F70%2F_aZmIEWaN1iUaxwkDjkO7bpj%2FScreenshot-from-2016-06-01-16-22-42.png&amp;size=thumbnail"></a></div>'
expected = expected.format(realm_id=realm.id)
msg = Message(
sender=sender_user_profile,
sending_client=get_client("test"),
realm=sender_user_profile.realm,
)
converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected)
@override_settings(THUMBNAIL_IMAGES=True)
def test_corrected_image_source(self) -> None: def test_corrected_image_source(self) -> None:
# testing only Wikipedia because linx.li URLs can be expected to expire # testing only Wikipedia because linx.li URLs can be expected to expire
content = "https://en.wikipedia.org/wiki/File:Wright_of_Derby,_The_Orrery.jpg" content = "https://en.wikipedia.org/wiki/File:Wright_of_Derby,_The_Orrery.jpg"
expected = '<div class="message_inline_image"><a href="https://en.wikipedia.org/wiki/Special:FilePath/File:Wright_of_Derby,_The_Orrery.jpg"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FSpecial%3AFilePath%2FFile%3AWright_of_Derby%2C_The_Orrery.jpg&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FSpecial%3AFilePath%2FFile%3AWright_of_Derby%2C_The_Orrery.jpg&amp;size=thumbnail"></a></div>' expected_url = (
"https://en.wikipedia.org/wiki/Special:FilePath/File:Wright_of_Derby,_The_Orrery.jpg"
)
camo_url = get_camo_url(expected_url)
expected = f'<div class="message_inline_image"><a href="{expected_url}"><img src="{camo_url}"></a></div>'
sender_user_profile = self.example_user("othello") sender_user_profile = self.example_user("othello")
msg = Message( msg = Message(
@ -934,7 +898,8 @@ class MarkdownEmbedsTest(ZulipTestCase):
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
content = "https://en.wikipedia.org/static/images/icons/wikipedia.png" content = "https://en.wikipedia.org/static/images/icons/wikipedia.png"
expected = '<div class="message_inline_image"><a href="https://en.wikipedia.org/static/images/icons/wikipedia.png"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fen.wikipedia.org%2Fstatic%2Fimages%2Ficons%2Fwikipedia.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fen.wikipedia.org%2Fstatic%2Fimages%2Ficons%2Fwikipedia.png&amp;size=thumbnail"></a></div>' camo_url = get_camo_url(content)
expected = f'<div class="message_inline_image"><a href="https://en.wikipedia.org/static/images/icons/wikipedia.png"><img src="{camo_url}"></a></div>'
converted = render_message_markdown(msg, content) converted = render_message_markdown(msg, content)
self.assertEqual(converted.rendered_content, expected) self.assertEqual(converted.rendered_content, expected)
@ -1042,13 +1007,19 @@ class MarkdownEmbedsTest(ZulipTestCase):
@override_settings(THUMBNAIL_IMAGES=True) @override_settings(THUMBNAIL_IMAGES=True)
def test_inline_dropbox_negative(self) -> None: def test_inline_dropbox_negative(self) -> None:
# Make sure we're not overzealous in our conversion: # Make sure we're not overzealous in our conversion:
msg = "Look at the new dropbox logo: https://www.dropbox.com/static/images/home_logo.png" url = "https://www.dropbox.com/static/images/home_logo.png"
msg = f"Look at the new dropbox logo: {url}"
with mock.patch("zerver.lib.markdown.fetch_open_graph_image", return_value=None): with mock.patch("zerver.lib.markdown.fetch_open_graph_image", return_value=None):
converted = markdown_convert_wrapper(msg) converted = markdown_convert_wrapper(msg)
camo_url = get_camo_url(url)
self.assertEqual( self.assertEqual(
converted, converted,
'<p>Look at the new dropbox logo: <a href="https://www.dropbox.com/static/images/home_logo.png">https://www.dropbox.com/static/images/home_logo.png</a></p>\n<div class="message_inline_image"><a href="https://www.dropbox.com/static/images/home_logo.png"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fwww.dropbox.com%2Fstatic%2Fimages%2Fhome_logo.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fwww.dropbox.com%2Fstatic%2Fimages%2Fhome_logo.png&amp;size=thumbnail"></a></div>', (
f'<p>Look at the new dropbox logo: <a href="{url}">{url}</a></p>'
"\n"
f'<div class="message_inline_image"><a href="{url}"><img src="{camo_url}"></a></div>'
),
) )
def test_inline_dropbox_bad(self) -> None: def test_inline_dropbox_bad(self) -> None:
@ -1063,21 +1034,35 @@ class MarkdownEmbedsTest(ZulipTestCase):
@override_settings(THUMBNAIL_IMAGES=True) @override_settings(THUMBNAIL_IMAGES=True)
def test_inline_github_preview(self) -> None: def test_inline_github_preview(self) -> None:
# Test photo album previews # Test github URL translation
msg = "Test: https://github.com/zulip/zulip/blob/main/static/images/logo/zulip-icon-128x128.png" url = "https://github.com/zulip/zulip/blob/main/static/images/logo/zulip-icon-128x128.png"
camo_url = get_camo_url(
"https://raw.githubusercontent.com/zulip/zulip/main/static/images/logo/zulip-icon-128x128.png"
)
msg = f"Test: {url}"
converted = markdown_convert_wrapper(msg) converted = markdown_convert_wrapper(msg)
self.assertEqual( self.assertEqual(
converted, converted,
'<p>Test: <a href="https://github.com/zulip/zulip/blob/main/static/images/logo/zulip-icon-128x128.png">https://github.com/zulip/zulip/blob/main/static/images/logo/zulip-icon-128x128.png</a></p>\n<div class="message_inline_image"><a href="https://github.com/zulip/zulip/blob/main/static/images/logo/zulip-icon-128x128.png"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzulip%2Fzulip%2Fmain%2Fstatic%2Fimages%2Flogo%2Fzulip-icon-128x128.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzulip%2Fzulip%2Fmain%2Fstatic%2Fimages%2Flogo%2Fzulip-icon-128x128.png&amp;size=thumbnail"></a></div>', (
f'<p>Test: <a href="{url}">{url}</a></p>'
"\n"
f'<div class="message_inline_image"><a href="{url}"><img src="{camo_url}"></a></div>'
),
) )
msg = "Test: https://developer.github.com/assets/images/hero-circuit-bg.png" url = "https://developer.github.com/assets/images/hero-circuit-bg.png"
camo_url = get_camo_url(url)
msg = f"Test: {url}"
converted = markdown_convert_wrapper(msg) converted = markdown_convert_wrapper(msg)
self.assertEqual( self.assertEqual(
converted, converted,
'<p>Test: <a href="https://developer.github.com/assets/images/hero-circuit-bg.png">https://developer.github.com/assets/images/hero-circuit-bg.png</a></p>\n<div class="message_inline_image"><a href="https://developer.github.com/assets/images/hero-circuit-bg.png"><img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fdeveloper.github.com%2Fassets%2Fimages%2Fhero-circuit-bg.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fdeveloper.github.com%2Fassets%2Fimages%2Fhero-circuit-bg.png&amp;size=thumbnail"></a></div>', (
f'<p>Test: <a href="{url}">{url}</a></p>'
"\n"
f'<div class="message_inline_image"><a href="{url}"><img src="{camo_url}"></a></div>'
),
) )
def test_inline_youtube_preview(self) -> None: def test_inline_youtube_preview(self) -> None:
@ -3168,7 +3153,7 @@ class MarkdownStreamMentionTests(ZulipTestCase):
"</p>\n" "</p>\n"
'<div class="message_inline_image">' '<div class="message_inline_image">'
'<a href="https://example.com/testimage.png" title="My favorite image">' '<a href="https://example.com/testimage.png" title="My favorite image">'
'<img data-src-fullsize="/thumbnail?url=https%3A%2F%2Fexample.com%2Ftestimage.png&amp;size=full" src="/thumbnail?url=https%3A%2F%2Fexample.com%2Ftestimage.png&amp;size=thumbnail">' '<img src="https://external-content.zulipcdn.net/external_content/5cd6ddfa28639e2e95bb85a7c7910b31f5474e03/68747470733a2f2f6578616d706c652e636f6d2f74657374696d6167652e706e67">'
"</a>" "</a>"
"</div>", "</div>",
) )

View File

@ -0,0 +1,334 @@
import re
from unittest.mock import patch
import pyvips
from zerver.actions.message_delete import do_delete_messages
from zerver.lib.camo import get_camo_url
from zerver.lib.markdown import render_message_markdown
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import get_test_image_file, read_test_image_file
from zerver.lib.thumbnail import ThumbnailFormat
from zerver.lib.upload import upload_message_attachment
from zerver.models import ArchivedAttachment, ArchivedMessage, Attachment, ImageAttachment, Message
from zerver.models.clients import get_client
from zerver.worker.thumbnail import ensure_thumbnails
class MarkdownThumbnailTest(ZulipTestCase):
def upload_image(self, image_name: str) -> str:
self.login("othello")
with get_test_image_file(image_name) as image_file:
response = self.assert_json_success(
self.client_post("/json/user_uploads", {"file": image_file})
)
return re.sub(r"/user_uploads/", "", response["url"])
def upload_and_thumbnail_image(self, image_name: str) -> str:
with self.captureOnCommitCallbacks(execute=True):
# Running captureOnCommitCallbacks includes inserting into
# the Rabbitmq queue, which in testing means we
# immediately run the worker for it, producing the thumbnails.
return self.upload_image(image_name)
def assert_message_content_is(
self, message_id: int, rendered_content: str, user_name: str = "othello"
) -> None:
sender_user_profile = self.example_user(user_name)
result = self.assert_json_success(
self.api_get(sender_user_profile, f"/api/v1/messages/{message_id}")
)
self.assertEqual(result["message"]["content"], rendered_content)
def send_message_content(
self, content: str, do_thumbnail: bool = False, user_name: str = "othello"
) -> int:
sender_user_profile = self.example_user(user_name)
return self.send_stream_message(
sender=sender_user_profile,
stream_name="Verona",
content=content,
skip_capture_on_commit_callbacks=not do_thumbnail,
)
def test_uploads_preview_order(self) -> None:
image_names = ["img.jpg", "img.png", "img.gif"]
path_ids = [self.upload_and_thumbnail_image(image_name) for image_name in image_names]
content = (
f"Test 1\n[{image_names[0]}](/user_uploads/{path_ids[0]}) \n\n"
f"Next image\n[{image_names[1]}](/user_uploads/{path_ids[1]}) \n\n"
f"Another screenshot\n[{image_names[2]}](/user_uploads/{path_ids[2]})"
)
sender_user_profile = self.example_user("othello")
msg = Message(
sender=sender_user_profile,
sending_client=get_client("test"),
realm=sender_user_profile.realm,
)
converted = render_message_markdown(msg, content)
self.assertEqual(
converted.rendered_content,
(
"<p>Test 1<br>\n"
f'<a href="/user_uploads/{path_ids[0]}">{image_names[0]}</a> </p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_ids[0]}" title="{image_names[0]}">'
f'<img src="/user_uploads/thumbnail/{path_ids[0]}/840x560.webp"></a></div>'
"<p>Next image<br>\n"
f'<a href="/user_uploads/{path_ids[1]}">{image_names[1]}</a> </p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_ids[1]}" title="{image_names[1]}">'
f'<img src="/user_uploads/thumbnail/{path_ids[1]}/840x560.webp"></a></div>'
"<p>Another screenshot<br>\n"
f'<a href="/user_uploads/{path_ids[2]}">{image_names[2]}</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_ids[2]}" title="{image_names[2]}">'
f'<img src="/user_uploads/thumbnail/{path_ids[2]}/840x560.webp"></a></div>'
),
)
def test_thumbnail_code_block(self) -> None:
url = "http://example.com/image.png"
path_id = self.upload_and_thumbnail_image("img.png")
# We have a path_id of an image in the message content, so we
# will prefetch the thumbnail metadata -- but not insert it.
sender_user_profile = self.example_user("othello")
msg = Message(
sender=sender_user_profile,
sending_client=get_client("test"),
realm=sender_user_profile.realm,
)
converted = render_message_markdown(msg, f"{url}\n```\n/user_uploads/{path_id}\n```")
self.assertEqual(
converted.rendered_content,
(
f'<div class="message_inline_image"><a href="{url}"><img src="{get_camo_url(url)}"></a></div>'
f'<div class="codehilite"><pre><span></span><code>/user_uploads/{path_id}\n'
"</code></pre></div>"
),
)
def test_thumbnail_after_send(self) -> None:
with self.captureOnCommitCallbacks(execute=True):
path_id = self.upload_image("img.png")
content = f"[image](/user_uploads/{path_id})"
expected = (
f'<p><a href="/user_uploads/{path_id}">image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="image">'
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>'
)
message_id = self.send_message_content(content)
self.assert_message_content_is(message_id, expected)
# Exit the block and run thumbnailing
expected = (
f'<p><a href="/user_uploads/{path_id}">image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="image">'
f'<img src="/user_uploads/thumbnail/{path_id}/840x560.webp"></a></div>'
)
self.assert_message_content_is(message_id, expected)
def test_thumbnail_escaping(self) -> None:
self.login("othello")
with self.captureOnCommitCallbacks(execute=True):
url = upload_message_attachment(
"I am 95% ± 5% certain!",
"image/png",
read_test_image_file("img.png"),
self.example_user("othello"),
)
path_id = re.sub(r"/user_uploads/", "", url)
self.assertTrue(ImageAttachment.objects.filter(path_id=path_id).exists())
message_id = self.send_message_content(f"[I am 95% ± 5% certain!](/user_uploads/{path_id})")
expected = (
f'<p><a href="/user_uploads/{path_id}">I am 95% &plusmn; 5% certain!</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="I am 95% &plusmn; 5% certain!"><img src="/user_uploads/thumbnail/{path_id}/840x560.webp"></a></div>'
)
self.assert_message_content_is(message_id, expected)
def test_thumbnail_repeated(self) -> None:
# We currently have no way to generate a thumbnailing event
# for the worker except during upload, meaning that we will
# never repeat a ImageAttachment thumbnailing. However, the
# code supports it, so test it.
# Thumbnail with one set of sizes
with self.thumbnail_formats(
ThumbnailFormat("webp", 100, 75, animated=True),
ThumbnailFormat("webp", 100, 75, animated=False),
):
path_id = self.upload_and_thumbnail_image("animated_img.gif")
content = f"[animated_img.gif](/user_uploads/{path_id})"
expected = (
f'<p><a href="/user_uploads/{path_id}">animated_img.gif</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="animated_img.gif">'
f'<img data-animated="true" src="/user_uploads/thumbnail/{path_id}/100x75-anim.webp"></a></div>'
)
message_id = self.send_message_content(content, do_thumbnail=True)
self.assert_message_content_is(message_id, expected)
self.assert_length(ImageAttachment.objects.get(path_id=path_id).thumbnail_metadata, 2)
# Re-thumbnail with a non-overlapping set of sizes
with self.thumbnail_formats(ThumbnailFormat("jpg", 100, 75, animated=False)):
ensure_thumbnails(ImageAttachment.objects.get(path_id=path_id))
# We generate a new size but leave the old ones
self.assert_length(ImageAttachment.objects.get(path_id=path_id).thumbnail_metadata, 3)
# And the contents are not updated to the new size
self.assert_message_content_is(message_id, expected)
def test_thumbnail_sequential_edits(self) -> None:
first_path_id = self.upload_image("img.png")
second_path_id = self.upload_image("img.jpg")
message_id = self.send_message_content(
f"[first image](/user_uploads/{first_path_id})\n[second image](/user_uploads/{second_path_id})",
do_thumbnail=False,
)
self.assert_message_content_is(
message_id,
(
f'<p><a href="/user_uploads/{first_path_id}">first image</a><br>\n'
f'<a href="/user_uploads/{second_path_id}">second image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{first_path_id}" title="first image">'
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>'
f'<div class="message_inline_image"><a href="/user_uploads/{second_path_id}" title="second image">'
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>'
),
)
# Complete thumbnailing the second image first -- replacing only that spinner
ensure_thumbnails(ImageAttachment.objects.get(path_id=second_path_id))
self.assert_message_content_is(
message_id,
(
f'<p><a href="/user_uploads/{first_path_id}">first image</a><br>\n'
f'<a href="/user_uploads/{second_path_id}">second image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{first_path_id}" title="first image">'
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>'
f'<div class="message_inline_image"><a href="/user_uploads/{second_path_id}" title="second image">'
f'<img src="/user_uploads/thumbnail/{second_path_id}/840x560.webp"></a></div>'
),
)
# Finish the other thumbnail
ensure_thumbnails(ImageAttachment.objects.get(path_id=first_path_id))
self.assert_message_content_is(
message_id,
(
f'<p><a href="/user_uploads/{first_path_id}">first image</a><br>\n'
f'<a href="/user_uploads/{second_path_id}">second image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{first_path_id}" title="first image">'
f'<img src="/user_uploads/thumbnail/{first_path_id}/840x560.webp"></a></div>'
f'<div class="message_inline_image"><a href="/user_uploads/{second_path_id}" title="second image">'
f'<img src="/user_uploads/thumbnail/{second_path_id}/840x560.webp"></a></div>'
),
)
def test_thumbnail_of_deleted(self) -> None:
sender_user_profile = self.example_user("othello")
path_id = self.upload_image("img.png")
message_id = self.send_message_content(f"[image](/user_uploads/{path_id})")
# Delete the message
do_delete_messages(
sender_user_profile.realm, [Message.objects.get(id=message_id)], acting_user=None
)
# There is still an ImageAttachment row
self.assertFalse(Attachment.objects.filter(path_id=path_id).exists())
self.assertTrue(ArchivedAttachment.objects.filter(path_id=path_id).exists())
self.assertTrue(ImageAttachment.objects.filter(path_id=path_id).exists())
# Completing rendering after it is deleted should work, and
# update the rendered content in the archived message
ensure_thumbnails(ImageAttachment.objects.get(path_id=path_id))
expected = (
f'<p><a href="/user_uploads/{path_id}">image</a></p>\n'
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="image">'
f'<img src="/user_uploads/thumbnail/{path_id}/840x560.webp"></a></div>'
)
self.assertEqual(
ArchivedMessage.objects.get(id=message_id).rendered_content,
expected,
)
# See test_delete_unclaimed_attachments for tests of the
# archiving process itself, and how it interacts with
# thumbnails.
def test_thumbnail_bad_image(self) -> None:
"""Test what happens if the file looks fine, but resizing later fails"""
path_id = self.upload_image("img.png")
message_id = self.send_message_content(f"[image](/user_uploads/{path_id})")
self.assert_length(ImageAttachment.objects.get(path_id=path_id).thumbnail_metadata, 0)
# If the image is found to be bad, we remove all trace of the preview
with (
patch.object(
pyvips.Image, "thumbnail_buffer", side_effect=pyvips.Error("some bad error")
) as thumb_mock,
self.assertLogs("zerver.worker.thumbnail", "ERROR") as thumbnail_logs,
):
ensure_thumbnails(ImageAttachment.objects.get(path_id=path_id))
thumb_mock.assert_called_once()
self.assert_length(thumbnail_logs.output, 1)
self.assertTrue(
thumbnail_logs.output[0].startswith("ERROR:zerver.worker.thumbnail:some bad error")
)
self.assertFalse(ImageAttachment.objects.filter(path_id=path_id).exists())
self.assert_message_content_is(
message_id, f'<p><a href="/user_uploads/{path_id}">image</a></p>'
)
def test_thumbnail_multiple_messages(self) -> None:
sender_user_profile = self.example_user("othello")
path_id = self.upload_image("img.png")
channel_message_id = self.send_message_content(f"A public [image](/user_uploads/{path_id})")
private_message_id = self.send_personal_message(
from_user=sender_user_profile,
to_user=self.example_user("hamlet"),
content=f"This [image](/user_uploads/{path_id}) is private",
)
placeholder = (
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="image">'
'<img class="image-loading-placeholder" src="/static/images/loading/loader-black.svg"></a></div>'
)
self.assert_message_content_is(
channel_message_id,
f'<p>A public <a href="/user_uploads/{path_id}">image</a></p>\n{placeholder}',
)
self.assert_message_content_is(
private_message_id,
f'<p>This <a href="/user_uploads/{path_id}">image</a> is private</p>\n{placeholder}',
)
with (
patch.object(
pyvips.Image, "thumbnail_buffer", wraps=pyvips.Image.thumbnail_buffer
) as thumb_mock,
self.thumbnail_formats(
ThumbnailFormat("webp", 100, 75, animated=False),
ThumbnailFormat("webp", 200, 150, animated=False),
),
):
ensure_thumbnails(ImageAttachment.objects.get(path_id=path_id))
# Called once per format
self.assertEqual(thumb_mock.call_count, 2)
rendered_thumb = (
f'<div class="message_inline_image"><a href="/user_uploads/{path_id}" title="image">'
f'<img src="/user_uploads/thumbnail/{path_id}/100x75.webp"></a></div>'
)
self.assert_message_content_is(
channel_message_id,
f'<p>A public <a href="/user_uploads/{path_id}">image</a></p>\n{rendered_thumb}',
)
self.assert_message_content_is(
private_message_id,
f'<p>This <a href="/user_uploads/{path_id}">image</a> is private</p>\n{rendered_thumb}',
)

View File

@ -56,7 +56,7 @@ class FetchLinksEmbedData(QueueProcessingWorker):
realm, realm,
url_embed_data=url_embed_data, url_embed_data=url_embed_data,
) )
do_update_embedded_data(message.sender, message, message.content, rendering_result) do_update_embedded_data(message.sender, message, rendering_result)
@override @override
def timer_expired( def timer_expired(

View File

@ -8,10 +8,17 @@ import pyvips
from django.db import transaction from django.db import transaction
from typing_extensions import override from typing_extensions import override
from zerver.actions.message_edit import do_update_embedded_data
from zerver.lib.mime_types import guess_type from zerver.lib.mime_types import guess_type
from zerver.lib.thumbnail import StoredThumbnailFormat, get_image_thumbnail_path, missing_thumbnails from zerver.lib.thumbnail import (
StoredThumbnailFormat,
get_default_thumbnail_url,
get_image_thumbnail_path,
missing_thumbnails,
rewrite_thumbnailed_images,
)
from zerver.lib.upload import save_attachment_contents, upload_backend from zerver.lib.upload import save_attachment_contents, upload_backend
from zerver.models import ImageAttachment from zerver.models import ArchivedMessage, ImageAttachment, Message
from zerver.worker.base import QueueProcessingWorker, assign_queue from zerver.worker.base import QueueProcessingWorker, assign_queue
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -120,9 +127,52 @@ def ensure_thumbnails(image_attachment: ImageAttachment) -> int:
# We have never thumbnailed this -- it most likely had # We have never thumbnailed this -- it most likely had
# bad data. Remove the ImageAttachment row, since it is # bad data. Remove the ImageAttachment row, since it is
# not valid for thumbnailing. # not valid for thumbnailing.
update_message_rendered_content(
image_attachment.realm_id, image_attachment.path_id, None
)
image_attachment.delete() image_attachment.delete()
return 0 return 0
else: # nocoverage
# TODO: Clean up any dangling thumbnails we may have
# produced? Seems unlikely that we'd fail on one size,
# but not another, but anything's possible.
pass
image_attachment.save(update_fields=["thumbnail_metadata"]) image_attachment.save(update_fields=["thumbnail_metadata"])
update_message_rendered_content(
image_attachment.realm_id,
image_attachment.path_id,
get_default_thumbnail_url(image_attachment),
)
return written_images return written_images
def update_message_rendered_content(
realm_id: int, path_id: str, image_data: tuple[str, bool] | None
) -> None:
for message_class in [Message, ArchivedMessage]:
messages_with_image = (
message_class.objects.filter( # type: ignore[attr-defined] # TODO: ?
realm_id=realm_id, attachment__path_id=path_id
)
.select_for_update()
.order_by("id")
)
for message in messages_with_image:
rendered_content = rewrite_thumbnailed_images(
message.rendered_content,
{} if image_data is None else {path_id: image_data},
{path_id} if image_data is None else set(),
)
if rendered_content is None:
# There were no updates -- for instance, if we re-run
# ensure_thumbnails on an ImageAttachment we already
# ran it on once. Do not bother to no-op update
# clients.
continue
if isinstance(message, Message):
# Perform a silent update push to the clients
do_update_embedded_data(message.sender, message, rendered_content)
else:
message.rendered_content = rendered_content
message.save(update_fields=["rendered_content"])

View File

@ -203,7 +203,6 @@ REDIS_PORT = 6379
REMOTE_POSTGRES_HOST = "" REMOTE_POSTGRES_HOST = ""
REMOTE_POSTGRES_PORT = "" REMOTE_POSTGRES_PORT = ""
REMOTE_POSTGRES_SSLMODE = "" REMOTE_POSTGRES_SSLMODE = ""
THUMBNAIL_IMAGES = False
TORNADO_PORTS: list[int] = [] TORNADO_PORTS: list[int] = []
USING_TORNADO = True USING_TORNADO = True