mirror of
https://github.com/zulip/zulip.git
synced 2025-11-03 21:43:21 +00:00
Fixes #16284. Most of the work for this was done when we implemented correct behavior for guest users, since they treat public streams like private streams anyway. The general method involves moving the messages to the new stream with special care of UserMessage. We delete UserMessages for subs who are losing access to the message. For private streams with protected history, we also create UserMessage elements for users who are not present in the old stream, since that's important for those users to access the moved messages.
261 lines
12 KiB
Python
261 lines
12 KiB
Python
import datetime
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
import orjson
|
|
from django.db import IntegrityError
|
|
from django.http import HttpRequest, HttpResponse
|
|
from django.utils.timezone import now as timezone_now
|
|
from django.utils.translation import ugettext as _
|
|
|
|
from zerver.decorator import REQ, has_request_variables
|
|
from zerver.lib.actions import (
|
|
do_delete_messages,
|
|
do_update_message,
|
|
get_user_info_for_message_updates,
|
|
render_incoming_message,
|
|
)
|
|
from zerver.lib.exceptions import JsonableError
|
|
from zerver.lib.html_diff import highlight_html_differences
|
|
from zerver.lib.markdown import MentionData
|
|
from zerver.lib.message import access_message, truncate_body
|
|
from zerver.lib.queue import queue_json_publish
|
|
from zerver.lib.response import json_error, json_success
|
|
from zerver.lib.streams import get_stream_by_id
|
|
from zerver.lib.timestamp import datetime_to_timestamp
|
|
from zerver.lib.topic import LEGACY_PREV_TOPIC, REQ_topic
|
|
from zerver.lib.validator import check_bool, check_string_in, to_non_negative_int
|
|
from zerver.models import Message, Realm, UserMessage, UserProfile
|
|
|
|
|
|
def fill_edit_history_entries(message_history: List[Dict[str, Any]], message: Message) -> None:
|
|
"""This fills out the message edit history entries from the database,
|
|
which are designed to have the minimum data possible, to instead
|
|
have the current topic + content as of that time, plus data on
|
|
whatever changed. This makes it much simpler to do future
|
|
processing.
|
|
|
|
Note that this mutates what is passed to it, which is sorta a bad pattern.
|
|
"""
|
|
prev_content = message.content
|
|
prev_rendered_content = message.rendered_content
|
|
prev_topic = message.topic_name()
|
|
|
|
# Make sure that the latest entry in the history corresponds to the
|
|
# message's last edit time
|
|
if len(message_history) > 0:
|
|
assert message.last_edit_time is not None
|
|
assert(datetime_to_timestamp(message.last_edit_time) ==
|
|
message_history[0]['timestamp'])
|
|
|
|
for entry in message_history:
|
|
entry['topic'] = prev_topic
|
|
if LEGACY_PREV_TOPIC in entry:
|
|
prev_topic = entry[LEGACY_PREV_TOPIC]
|
|
entry['prev_topic'] = prev_topic
|
|
del entry[LEGACY_PREV_TOPIC]
|
|
|
|
entry['content'] = prev_content
|
|
entry['rendered_content'] = prev_rendered_content
|
|
if 'prev_content' in entry:
|
|
del entry['prev_rendered_content_version']
|
|
prev_content = entry['prev_content']
|
|
prev_rendered_content = entry['prev_rendered_content']
|
|
assert prev_rendered_content is not None
|
|
entry['content_html_diff'] = highlight_html_differences(
|
|
prev_rendered_content,
|
|
entry['rendered_content'],
|
|
message.id)
|
|
|
|
message_history.append(dict(
|
|
topic = prev_topic,
|
|
content = prev_content,
|
|
rendered_content = prev_rendered_content,
|
|
timestamp = datetime_to_timestamp(message.date_sent),
|
|
user_id = message.sender_id,
|
|
))
|
|
|
|
@has_request_variables
|
|
def get_message_edit_history(request: HttpRequest, user_profile: UserProfile,
|
|
message_id: int=REQ(converter=to_non_negative_int,
|
|
path_only=True)) -> HttpResponse:
|
|
if not user_profile.realm.allow_edit_history:
|
|
return json_error(_("Message edit history is disabled in this organization"))
|
|
message, ignored_user_message = access_message(user_profile, message_id)
|
|
|
|
# Extract the message edit history from the message
|
|
if message.edit_history is not None:
|
|
message_edit_history = orjson.loads(message.edit_history)
|
|
else:
|
|
message_edit_history = []
|
|
|
|
# Fill in all the extra data that will make it usable
|
|
fill_edit_history_entries(message_edit_history, message)
|
|
return json_success({"message_history": list(reversed(message_edit_history))})
|
|
|
|
PROPAGATE_MODE_VALUES = ["change_later", "change_one", "change_all"]
|
|
@has_request_variables
|
|
def update_message_backend(request: HttpRequest, user_profile: UserMessage,
|
|
message_id: int=REQ(converter=to_non_negative_int, path_only=True),
|
|
stream_id: Optional[int]=REQ(converter=to_non_negative_int, default=None),
|
|
topic_name: Optional[str]=REQ_topic(),
|
|
propagate_mode: Optional[str]=REQ(
|
|
default="change_one",
|
|
str_validator=check_string_in(PROPAGATE_MODE_VALUES)),
|
|
send_notification_to_old_thread: bool=REQ(default=True, validator=check_bool),
|
|
send_notification_to_new_thread: bool=REQ(default=True, validator=check_bool),
|
|
content: Optional[str]=REQ(default=None)) -> HttpResponse:
|
|
if not user_profile.realm.allow_message_editing:
|
|
return json_error(_("Your organization has turned off message editing"))
|
|
|
|
if propagate_mode != "change_one" and topic_name is None and stream_id is None:
|
|
return json_error(_("Invalid propagate_mode without topic edit"))
|
|
|
|
message, ignored_user_message = access_message(user_profile, message_id)
|
|
is_no_topic_msg = (message.topic_name() == "(no topic)")
|
|
|
|
# You only have permission to edit a message if:
|
|
# you change this value also change those two parameters in message_edit.js.
|
|
# 1. You sent it, OR:
|
|
# 2. This is a topic-only edit for a (no topic) message, OR:
|
|
# 3. This is a topic-only edit and you are an admin, OR:
|
|
# 4. This is a topic-only edit and your realm allows users to edit topics.
|
|
if message.sender == user_profile:
|
|
pass
|
|
elif (content is None) and (is_no_topic_msg or
|
|
user_profile.is_realm_admin or
|
|
user_profile.realm.allow_community_topic_editing):
|
|
pass
|
|
else:
|
|
raise JsonableError(_("You don't have permission to edit this message"))
|
|
|
|
# If there is a change to the content, check that it hasn't been too long
|
|
# Allow an extra 20 seconds since we potentially allow editing 15 seconds
|
|
# past the limit, and in case there are network issues, etc. The 15 comes
|
|
# from (min_seconds_to_edit + seconds_left_buffer) in message_edit.js; if
|
|
# you change this value also change those two parameters in message_edit.js.
|
|
edit_limit_buffer = 20
|
|
if content is not None and user_profile.realm.message_content_edit_limit_seconds > 0:
|
|
deadline_seconds = user_profile.realm.message_content_edit_limit_seconds + edit_limit_buffer
|
|
if (timezone_now() - message.date_sent) > datetime.timedelta(seconds=deadline_seconds):
|
|
raise JsonableError(_("The time limit for editing this message has passed"))
|
|
|
|
# If there is a change to the topic, check that the user is allowed to
|
|
# edit it and that it has not been too long. If this is not the user who
|
|
# sent the message, they are not the admin, and the time limit for editing
|
|
# topics is passed, raise an error.
|
|
if content is None and message.sender != user_profile and not user_profile.is_realm_admin and \
|
|
not is_no_topic_msg:
|
|
deadline_seconds = Realm.DEFAULT_COMMUNITY_TOPIC_EDITING_LIMIT_SECONDS + edit_limit_buffer
|
|
if (timezone_now() - message.date_sent) > datetime.timedelta(seconds=deadline_seconds):
|
|
raise JsonableError(_("The time limit for editing this message has passed"))
|
|
|
|
if topic_name is None and content is None and stream_id is None:
|
|
return json_error(_("Nothing to change"))
|
|
if topic_name is not None:
|
|
topic_name = topic_name.strip()
|
|
if topic_name == "":
|
|
raise JsonableError(_("Topic can't be empty"))
|
|
rendered_content = None
|
|
links_for_embed: Set[str] = set()
|
|
prior_mention_user_ids: Set[int] = set()
|
|
mention_user_ids: Set[int] = set()
|
|
mention_data: Optional[MentionData] = None
|
|
if content is not None:
|
|
content = content.strip()
|
|
if content == "":
|
|
content = "(deleted)"
|
|
content = truncate_body(content)
|
|
|
|
mention_data = MentionData(
|
|
realm_id=user_profile.realm.id,
|
|
content=content,
|
|
)
|
|
user_info = get_user_info_for_message_updates(message.id)
|
|
prior_mention_user_ids = user_info['mention_user_ids']
|
|
|
|
# We render the message using the current user's realm; since
|
|
# the cross-realm bots never edit messages, this should be
|
|
# always correct.
|
|
# Note: If rendering fails, the called code will raise a JsonableError.
|
|
rendered_content = render_incoming_message(message,
|
|
content,
|
|
user_info['message_user_ids'],
|
|
user_profile.realm,
|
|
mention_data=mention_data)
|
|
links_for_embed |= message.links_for_preview
|
|
|
|
mention_user_ids = message.mentions_user_ids
|
|
|
|
new_stream = None
|
|
number_changed = 0
|
|
|
|
if stream_id is not None:
|
|
if not user_profile.is_realm_admin:
|
|
raise JsonableError(_("You don't have permission to move this message"))
|
|
if content is not None:
|
|
raise JsonableError(_("Cannot change message content while changing stream"))
|
|
|
|
new_stream = get_stream_by_id(stream_id)
|
|
|
|
number_changed = do_update_message(user_profile, message, new_stream,
|
|
topic_name, propagate_mode,
|
|
send_notification_to_old_thread,
|
|
send_notification_to_new_thread,
|
|
content, rendered_content,
|
|
prior_mention_user_ids,
|
|
mention_user_ids, mention_data)
|
|
|
|
# Include the number of messages changed in the logs
|
|
request._log_data['extra'] = f"[{number_changed}]"
|
|
if links_for_embed:
|
|
event_data = {
|
|
'message_id': message.id,
|
|
'message_content': message.content,
|
|
# The choice of `user_profile.realm_id` rather than
|
|
# `sender.realm_id` must match the decision made in the
|
|
# `render_incoming_message` call earlier in this function.
|
|
'message_realm_id': user_profile.realm_id,
|
|
'urls': list(links_for_embed)}
|
|
queue_json_publish('embed_links', event_data)
|
|
return json_success()
|
|
|
|
|
|
def validate_can_delete_message(user_profile: UserProfile, message: Message) -> None:
|
|
if user_profile.is_realm_admin:
|
|
# Admin can delete any message, any time.
|
|
return
|
|
if message.sender != user_profile:
|
|
# Users can only delete messages sent by them.
|
|
raise JsonableError(_("You don't have permission to delete this message"))
|
|
if not user_profile.realm.allow_message_deleting:
|
|
# User can not delete message, if message deleting is not allowed in realm.
|
|
raise JsonableError(_("You don't have permission to delete this message"))
|
|
|
|
deadline_seconds = user_profile.realm.message_content_delete_limit_seconds
|
|
if deadline_seconds == 0:
|
|
# 0 for no time limit to delete message
|
|
return
|
|
if (timezone_now() - message.date_sent) > datetime.timedelta(seconds=deadline_seconds):
|
|
# User can not delete message after deadline time of realm
|
|
raise JsonableError(_("The time limit for deleting this message has passed"))
|
|
return
|
|
|
|
@has_request_variables
|
|
def delete_message_backend(request: HttpRequest, user_profile: UserProfile,
|
|
message_id: int=REQ(converter=to_non_negative_int,
|
|
path_only=True)) -> HttpResponse:
|
|
message, ignored_user_message = access_message(user_profile, message_id)
|
|
validate_can_delete_message(user_profile, message)
|
|
try:
|
|
do_delete_messages(user_profile.realm, [message])
|
|
except (Message.DoesNotExist, IntegrityError):
|
|
raise JsonableError(_("Message already deleted"))
|
|
return json_success()
|
|
|
|
@has_request_variables
|
|
def json_fetch_raw_message(request: HttpRequest, user_profile: UserProfile,
|
|
message_id: int=REQ(converter=to_non_negative_int,
|
|
path_only=True)) -> HttpResponse:
|
|
(message, user_message) = access_message(user_profile, message_id)
|
|
return json_success({"raw_content": message.content})
|