mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-31 03:53:50 +00:00 
			
		
		
		
	This commit implements the backend of migrating the `allow_edit_history` setting to `message_edit_history_visibility_policy`. This allows organizations, to have an intermediate setting to view only the "Moves" history of the messages. We still pass `realm_allow_edit_history` in `/register` response though for older clients with its value being set depending on the value of `realm_message_edit_history_visibility_policy`. We set `realm_allow_edit_history` to `False` if the `realm_message_edit_history_visibility_policy` is "None", and `True` for "Moves only" or "All" message edit history. Fixes part of #21398. Co-authored-by: Shlok Patel <shlokcpatel2001@gmail.com> Co-authored-by: Tim Abbott <tabbott@zulip.com>
		
			
				
	
	
		
			399 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			399 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| from collections.abc import Iterable
 | |
| from typing import Annotated
 | |
| 
 | |
| from django.conf import settings
 | |
| from django.contrib.auth.models import AnonymousUser
 | |
| from django.db import connection, transaction
 | |
| from django.http import HttpRequest, HttpResponse
 | |
| from django.utils.html import escape as escape_html
 | |
| from django.utils.translation import gettext as _
 | |
| from pydantic import Json, NonNegativeInt
 | |
| from sqlalchemy.sql import and_, column, join, literal, literal_column, select, table
 | |
| from sqlalchemy.types import Integer, Text
 | |
| 
 | |
| from zerver.context_processors import get_valid_realm_from_request
 | |
| from zerver.lib.exceptions import (
 | |
|     IncompatibleParametersError,
 | |
|     JsonableError,
 | |
|     MissingAuthenticationError,
 | |
| )
 | |
| from zerver.lib.message import get_first_visible_message_id, messages_for_ids
 | |
| from zerver.lib.narrow import (
 | |
|     NarrowParameter,
 | |
|     add_narrow_conditions,
 | |
|     clean_narrow_for_message_fetch,
 | |
|     fetch_messages,
 | |
|     is_spectator_compatible,
 | |
|     is_web_public_narrow,
 | |
|     parse_anchor_value,
 | |
|     update_narrow_terms_containing_empty_topic_fallback_name,
 | |
| )
 | |
| from zerver.lib.request import RequestNotes
 | |
| from zerver.lib.response import json_success
 | |
| from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
 | |
| from zerver.lib.topic import DB_TOPIC_NAME, MATCH_TOPIC
 | |
| from zerver.lib.topic_sqlalchemy import topic_column_sa
 | |
| from zerver.lib.typed_endpoint import ApiParamConfig, typed_endpoint
 | |
| from zerver.models import UserMessage, UserProfile
 | |
| 
 | |
| MAX_MESSAGES_PER_FETCH = 5000
 | |
| 
 | |
| 
 | |
| def highlight_string(text: str, locs: Iterable[tuple[int, int]]) -> str:
 | |
|     highlight_start = '<span class="highlight">'
 | |
|     highlight_stop = "</span>"
 | |
|     pos = 0
 | |
|     result = ""
 | |
|     in_tag = False
 | |
| 
 | |
|     for loc in locs:
 | |
|         (offset, length) = loc
 | |
| 
 | |
|         prefix_start = pos
 | |
|         prefix_end = offset
 | |
|         match_start = offset
 | |
|         match_end = offset + length
 | |
| 
 | |
|         prefix = text[prefix_start:prefix_end]
 | |
|         match = text[match_start:match_end]
 | |
| 
 | |
|         for character in prefix + match:
 | |
|             if character == "<":
 | |
|                 in_tag = True
 | |
|             elif character == ">":
 | |
|                 in_tag = False
 | |
|         if in_tag:
 | |
|             result += prefix
 | |
|             result += match
 | |
|         else:
 | |
|             result += prefix
 | |
|             result += highlight_start
 | |
|             result += match
 | |
|             result += highlight_stop
 | |
|         pos = match_end
 | |
| 
 | |
|     result += text[pos:]
 | |
|     return result
 | |
| 
 | |
| 
 | |
| def get_search_fields(
 | |
|     rendered_content: str,
 | |
|     topic_name: str,
 | |
|     content_matches: Iterable[tuple[int, int]],
 | |
|     topic_matches: Iterable[tuple[int, int]],
 | |
| ) -> dict[str, str]:
 | |
|     return {
 | |
|         "match_content": highlight_string(rendered_content, content_matches),
 | |
|         MATCH_TOPIC: highlight_string(escape_html(topic_name), topic_matches),
 | |
|     }
 | |
| 
 | |
| 
 | |
| def clean_narrow_for_web_public_api(
 | |
|     narrow: list[NarrowParameter] | None,
 | |
| ) -> list[NarrowParameter] | None:
 | |
|     if narrow is None:
 | |
|         return None
 | |
| 
 | |
|     # Remove {'operator': 'in', 'operand': 'home', 'negated': False} from narrow.
 | |
|     # This is to allow spectators to access all messages. The narrow should still pass
 | |
|     # is_web_public_narrow check after this change.
 | |
|     return [
 | |
|         term
 | |
|         for term in narrow
 | |
|         if not (term.operator == "in" and term.operand == "home" and not term.negated)
 | |
|     ]
 | |
| 
 | |
| 
 | |
| @typed_endpoint
 | |
| def get_messages_backend(
 | |
|     request: HttpRequest,
 | |
|     maybe_user_profile: UserProfile | AnonymousUser,
 | |
|     *,
 | |
|     anchor_val: Annotated[str | None, ApiParamConfig("anchor")] = None,
 | |
|     include_anchor: Json[bool] = True,
 | |
|     num_before: Json[NonNegativeInt] = 0,
 | |
|     num_after: Json[NonNegativeInt] = 0,
 | |
|     narrow: Json[list[NarrowParameter] | None] = None,
 | |
|     use_first_unread_anchor_val: Annotated[
 | |
|         Json[bool], ApiParamConfig("use_first_unread_anchor")
 | |
|     ] = False,
 | |
|     client_gravatar: Json[bool] = True,
 | |
|     apply_markdown: Json[bool] = True,
 | |
|     allow_empty_topic_name: Json[bool] = False,
 | |
|     client_requested_message_ids: Annotated[
 | |
|         Json[list[NonNegativeInt] | None], ApiParamConfig("message_ids")
 | |
|     ] = None,
 | |
| ) -> HttpResponse:
 | |
|     # User has to either provide message_ids or both num_before and num_after.
 | |
|     if (
 | |
|         num_before or num_after or anchor_val is not None or use_first_unread_anchor_val
 | |
|     ) and client_requested_message_ids is not None:
 | |
|         raise IncompatibleParametersError(
 | |
|             [
 | |
|                 "num_before",
 | |
|                 "num_after",
 | |
|                 "anchor",
 | |
|                 "message_ids",
 | |
|                 "include_anchor",
 | |
|                 "use_first_unread_anchor",
 | |
|             ]
 | |
|         )
 | |
|     elif client_requested_message_ids is not None:
 | |
|         include_anchor = False
 | |
| 
 | |
|     anchor = None
 | |
|     if client_requested_message_ids is None:
 | |
|         anchor = parse_anchor_value(anchor_val, use_first_unread_anchor_val)
 | |
| 
 | |
|     realm = get_valid_realm_from_request(request)
 | |
|     narrow = clean_narrow_for_message_fetch(narrow, realm, maybe_user_profile)
 | |
| 
 | |
|     num_of_messages_requested = num_before + num_after
 | |
|     if client_requested_message_ids is not None:
 | |
|         num_of_messages_requested = len(client_requested_message_ids)
 | |
| 
 | |
|     if num_of_messages_requested > MAX_MESSAGES_PER_FETCH:
 | |
|         raise JsonableError(
 | |
|             _("Too many messages requested (maximum {max_messages}).").format(
 | |
|                 max_messages=MAX_MESSAGES_PER_FETCH,
 | |
|             )
 | |
|         )
 | |
|     if num_before > 0 and num_after > 0 and not include_anchor:
 | |
|         raise JsonableError(_("The anchor can only be excluded at an end of the range"))
 | |
| 
 | |
|     if not maybe_user_profile.is_authenticated:
 | |
|         # If user is not authenticated, clients must include
 | |
|         # `streams:web-public` in their narrow query to indicate this
 | |
|         # is a web-public query.  This helps differentiate between
 | |
|         # cases of web-public queries (where we should return the
 | |
|         # web-public results only) and clients with buggy
 | |
|         # authentication code (where we should return an auth error).
 | |
|         #
 | |
|         # GetOldMessagesTest.test_unauthenticated_* tests ensure
 | |
|         # that we are not leaking any secure data (direct messages and
 | |
|         # non-web-public stream messages) via this path.
 | |
|         if not realm.allow_web_public_streams_access():
 | |
|             raise MissingAuthenticationError
 | |
|         narrow = clean_narrow_for_web_public_api(narrow)
 | |
|         if not is_web_public_narrow(narrow):
 | |
|             raise MissingAuthenticationError
 | |
|         assert narrow is not None
 | |
|         if not is_spectator_compatible(narrow):
 | |
|             raise MissingAuthenticationError
 | |
| 
 | |
|         # We use None to indicate unauthenticated requests as it's more
 | |
|         # readable than using AnonymousUser, and the lack of Django
 | |
|         # stubs means that mypy can't check AnonymousUser well.
 | |
|         user_profile: UserProfile | None = None
 | |
|         is_web_public_query = True
 | |
|     else:
 | |
|         assert isinstance(maybe_user_profile, UserProfile)
 | |
|         user_profile = maybe_user_profile
 | |
|         assert user_profile is not None
 | |
|         is_web_public_query = False
 | |
| 
 | |
|     assert realm is not None
 | |
| 
 | |
|     if is_web_public_query:
 | |
|         # client_gravatar here is just the user-requested value. "finalize_payload" function
 | |
|         # is responsible for sending avatar_url based on each individual sender's
 | |
|         # email_address_visibility setting.
 | |
|         client_gravatar = False
 | |
| 
 | |
|     if narrow is not None:
 | |
|         # Add some metadata to our logging data for narrows
 | |
|         verbose_operators = []
 | |
|         for term in narrow:
 | |
|             if term.operator == "is":
 | |
|                 verbose_operators.append("is:" + term.operand)
 | |
|             else:
 | |
|                 verbose_operators.append(term.operator)
 | |
|         log_data = RequestNotes.get_notes(request).log_data
 | |
|         assert log_data is not None
 | |
|         log_data["extra"] = "[{}]".format(",".join(verbose_operators))
 | |
| 
 | |
|     with transaction.atomic(durable=True):
 | |
|         # We're about to perform a search, and then get results from
 | |
|         # it; this is done across multiple queries.  To prevent race
 | |
|         # conditions, we want the messages returned to be consistent
 | |
|         # with the version of the messages that was searched, to
 | |
|         # prevent changes which happened between them from leaking to
 | |
|         # clients who should not be able to see the new values, and
 | |
|         # when messages are deleted in between.  We set up
 | |
|         # repeatable-read isolation for this transaction, so that we
 | |
|         # prevent both phantom reads and non-repeatable reads.
 | |
|         #
 | |
|         # In a read-only repeatable-read transaction, it is not
 | |
|         # possible to encounter deadlocks or need retries due to
 | |
|         # serialization errors.
 | |
|         #
 | |
|         # You can only set the isolation level before any queries in
 | |
|         # the transaction, meaning it must be the top-most
 | |
|         # transaction, which durable=True establishes.  Except in
 | |
|         # tests, where durable=True is a lie, because there is an
 | |
|         # outer transaction for each test.  We thus skip this command
 | |
|         # in tests, since it would fail.
 | |
|         if not settings.TEST_SUITE:  # nocoverage
 | |
|             cursor = connection.cursor()
 | |
|             cursor.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
 | |
| 
 | |
|         query_info = fetch_messages(
 | |
|             narrow=narrow,
 | |
|             user_profile=user_profile,
 | |
|             realm=realm,
 | |
|             is_web_public_query=is_web_public_query,
 | |
|             anchor=anchor,
 | |
|             include_anchor=include_anchor,
 | |
|             num_before=num_before,
 | |
|             num_after=num_after,
 | |
|             client_requested_message_ids=client_requested_message_ids,
 | |
|         )
 | |
| 
 | |
|         anchor = query_info.anchor
 | |
|         include_history = query_info.include_history
 | |
|         is_search = query_info.is_search
 | |
|         rows = query_info.rows
 | |
| 
 | |
|         # The following is a little messy, but ensures that the code paths
 | |
|         # are similar regardless of the value of include_history.  The
 | |
|         # 'user_messages' dictionary maps each message to the user's
 | |
|         # UserMessage object for that message, which we will attach to the
 | |
|         # rendered message dict before returning it.  We attempt to
 | |
|         # bulk-fetch rendered message dicts from remote cache using the
 | |
|         # 'messages' list.
 | |
|         result_message_ids: list[int] = []
 | |
|         user_message_flags: dict[int, list[str]] = {}
 | |
|         if is_web_public_query:
 | |
|             # For spectators, we treat all historical messages as read.
 | |
|             for row in rows:
 | |
|                 message_id = row[0]
 | |
|                 result_message_ids.append(message_id)
 | |
|                 user_message_flags[message_id] = ["read"]
 | |
|         elif include_history:
 | |
|             assert user_profile is not None
 | |
|             result_message_ids = [row[0] for row in rows]
 | |
| 
 | |
|             # TODO: This could be done with an outer join instead of two queries
 | |
|             um_rows = UserMessage.objects.filter(
 | |
|                 user_profile=user_profile, message_id__in=result_message_ids
 | |
|             )
 | |
|             user_message_flags = {um.message_id: um.flags_list() for um in um_rows}
 | |
| 
 | |
|             for message_id in result_message_ids:
 | |
|                 if message_id not in user_message_flags:
 | |
|                     user_message_flags[message_id] = ["read", "historical"]
 | |
|         else:
 | |
|             for row in rows:
 | |
|                 message_id = row[0]
 | |
|                 flags = row[1]
 | |
|                 user_message_flags[message_id] = UserMessage.flags_list_for_flags(flags)
 | |
|                 result_message_ids.append(message_id)
 | |
| 
 | |
|         search_fields: dict[int, dict[str, str]] = {}
 | |
|         if is_search:
 | |
|             for row in rows:
 | |
|                 message_id = row[0]
 | |
|                 (topic_name, rendered_content, content_matches, topic_matches) = row[-4:]
 | |
|                 search_fields[message_id] = get_search_fields(
 | |
|                     rendered_content, topic_name, content_matches, topic_matches
 | |
|                 )
 | |
| 
 | |
|         message_list = messages_for_ids(
 | |
|             message_ids=result_message_ids,
 | |
|             user_message_flags=user_message_flags,
 | |
|             search_fields=search_fields,
 | |
|             apply_markdown=apply_markdown,
 | |
|             client_gravatar=client_gravatar,
 | |
|             allow_empty_topic_name=allow_empty_topic_name,
 | |
|             message_edit_history_visibility_policy=realm.message_edit_history_visibility_policy,
 | |
|             user_profile=user_profile,
 | |
|             realm=realm,
 | |
|         )
 | |
| 
 | |
|     if client_requested_message_ids is not None:
 | |
|         ret = dict(
 | |
|             messages=message_list,
 | |
|             result="success",
 | |
|             msg="",
 | |
|             history_limited=query_info.history_limited,
 | |
|             found_anchor=False,
 | |
|             found_oldest=False,
 | |
|             found_newest=False,
 | |
|         )
 | |
|     else:
 | |
|         ret = dict(
 | |
|             messages=message_list,
 | |
|             result="success",
 | |
|             msg="",
 | |
|             found_anchor=query_info.found_anchor,
 | |
|             found_oldest=query_info.found_oldest,
 | |
|             found_newest=query_info.found_newest,
 | |
|             history_limited=query_info.history_limited,
 | |
|             anchor=anchor,
 | |
|         )
 | |
| 
 | |
|     return json_success(request, data=ret)
 | |
| 
 | |
| 
 | |
| @typed_endpoint
 | |
| def messages_in_narrow_backend(
 | |
|     request: HttpRequest,
 | |
|     user_profile: UserProfile,
 | |
|     *,
 | |
|     msg_ids: Json[list[int]],
 | |
|     narrow: Json[list[NarrowParameter]],
 | |
| ) -> HttpResponse:
 | |
|     first_visible_message_id = get_first_visible_message_id(user_profile.realm)
 | |
|     msg_ids = [message_id for message_id in msg_ids if message_id >= first_visible_message_id]
 | |
|     # This query is limited to messages the user has access to because they
 | |
|     # actually received them, as reflected in `zerver_usermessage`.
 | |
|     query = (
 | |
|         select(column("message_id", Integer))
 | |
|         .where(
 | |
|             and_(
 | |
|                 column("user_profile_id", Integer) == literal(user_profile.id),
 | |
|                 column("message_id", Integer).in_(msg_ids),
 | |
|             )
 | |
|         )
 | |
|         .select_from(
 | |
|             join(
 | |
|                 table("zerver_usermessage"),
 | |
|                 table("zerver_message"),
 | |
|                 literal_column("zerver_usermessage.message_id", Integer)
 | |
|                 == literal_column("zerver_message.id", Integer),
 | |
|             )
 | |
|         )
 | |
|     )
 | |
| 
 | |
|     inner_msg_id_col = column("message_id", Integer)
 | |
|     updated_narrow = update_narrow_terms_containing_empty_topic_fallback_name(narrow)
 | |
|     query, is_search = add_narrow_conditions(
 | |
|         user_profile=user_profile,
 | |
|         inner_msg_id_col=inner_msg_id_col,
 | |
|         query=query,
 | |
|         narrow=updated_narrow,
 | |
|         is_web_public_query=False,
 | |
|         realm=user_profile.realm,
 | |
|     )
 | |
| 
 | |
|     if not is_search:
 | |
|         # `add_narrow_conditions` adds the following columns only if narrow has search operands.
 | |
|         query = query.add_columns(topic_column_sa(), column("rendered_content", Text))
 | |
| 
 | |
|     search_fields = {}
 | |
|     with get_sqlalchemy_connection() as sa_conn:
 | |
|         for row in sa_conn.execute(query).mappings():
 | |
|             message_id = row["message_id"]
 | |
|             topic_name: str = row[DB_TOPIC_NAME]
 | |
|             rendered_content: str = row["rendered_content"]
 | |
|             content_matches = row.get("content_matches", [])
 | |
|             topic_matches = row.get("topic_matches", [])
 | |
|             search_fields[str(message_id)] = get_search_fields(
 | |
|                 rendered_content,
 | |
|                 topic_name,
 | |
|                 content_matches,
 | |
|                 topic_matches,
 | |
|             )
 | |
| 
 | |
|     return json_success(request, data={"messages": search_fields})
 |