Files
upstream-zulip/zerver/lib/narrow.py
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

1683 lines
63 KiB
Python

import re
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Generic, Literal, TypeAlias, TypedDict, TypeVar
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.db import connection
from django.db.models import Exists, F, Func, OuterRef, Q, QuerySet, TextField
from django.db.models.expressions import RawSQL
from django.utils.translation import gettext as _
from psycopg2.sql import SQL, Composable, Identifier
from pydantic import BaseModel, model_validator
from typing_extensions import override
from zerver.lib.addressee import get_user_profiles, get_user_profiles_by_ids
from zerver.lib.exceptions import ErrorCode, JsonableError, MissingAuthenticationError
from zerver.lib.message import (
access_message,
access_web_public_message,
get_first_visible_message_id,
)
from zerver.lib.narrow_predicate import channel_operators, channels_operators
from zerver.lib.recipient_users import recipient_for_user_profiles
from zerver.lib.streams import (
access_stream_common,
can_access_stream_history_by_id,
can_access_stream_history_by_name,
get_archived_streams_queryset,
get_public_streams_queryset,
get_stream_by_narrow_operand_access_unchecked,
get_web_public_streams_queryset,
)
from zerver.lib.topic import (
DB_TOPIC_NAME,
get_followed_topic_condition_q,
get_resolved_topic_condition_q,
maybe_rename_general_chat_to_empty_topic,
topic_match_q,
)
from zerver.lib.types import Validator
from zerver.lib.user_groups import get_recursive_membership_groups
from zerver.lib.user_topics import exclude_stream_and_topic_mutes
from zerver.lib.validator import (
check_bool,
check_iso_datetime,
check_required_string,
check_string,
check_string_or_int,
check_string_or_int_list,
)
from zerver.models import (
DirectMessageGroup,
Message,
Reaction,
Realm,
Recipient,
Stream,
Subscription,
UserMessage,
UserProfile,
)
from zerver.models.recipients import get_direct_message_group_user_ids
from zerver.models.users import (
get_user_by_id_in_realm_including_cross_realm,
get_user_including_cross_realm,
)
class NarrowParameter(BaseModel):
operator: str
operand: Any
negated: bool = False
@model_validator(mode="before")
@classmethod
def convert_term(cls, elem: dict[str, Any] | list[str]) -> dict[str, Any]:
# We have to support a legacy tuple format.
if isinstance(elem, list):
if len(elem) != 2 or any(not isinstance(x, str) for x in elem):
raise ValueError("element is not a string pair")
return dict(operator=elem[0], operand=elem[1])
elif isinstance(elem, dict):
if "operand" not in elem or elem["operand"] is None:
raise ValueError("operand is missing")
if "operator" not in elem or elem["operator"] is None:
raise ValueError("operator is missing")
return elem
else:
raise ValueError("dict or list required")
@model_validator(mode="after")
def validate_terms(self) -> "NarrowParameter":
# Make sure to sync this list to frontend also when adding a new operator that
# supports integer IDs. Relevant code is located in web/src/message_fetch.ts
# in handle_operators_supporting_id_based_api function where you will need to
# update operators_supporting_id, or operators_supporting_ids array.
operators_supporting_id = [
*channel_operators,
"id",
"sender",
"group-pm-with",
"dm-including",
"mentions",
"with",
]
operators_supporting_ids = ["pm-with", "dm"]
operators_non_empty_operand = {"search"}
operator = self.operator
if operator in operators_supporting_id:
operand_validator: Validator[object] = check_string_or_int
elif operator in operators_supporting_ids:
operand_validator = check_string_or_int_list
elif operator in operators_non_empty_operand:
operand_validator = check_required_string
else:
operand_validator = check_string
try:
self.operand = operand_validator("operand", self.operand)
self.operator = check_string("operator", self.operator)
if self.negated is not None:
self.negated = check_bool("negated", self.negated)
except ValidationError as error:
raise JsonableError(error.message)
# whitelist the fields we care about for now
return self
def is_spectator_compatible(narrow: Iterable[NarrowParameter]) -> bool:
# This implementation should agree with is_spectator_compatible in hash_parser.ts.
supported_operators = [
*channel_operators,
*channels_operators,
"topic",
"sender",
"has",
"search",
"near",
"id",
"with",
]
for element in narrow:
operator = element.operator
operand = element.operand
if operator == "is" and operand == "resolved":
continue
if operator not in supported_operators:
return False
return True
def is_web_public_narrow(narrow: Iterable[NarrowParameter] | None) -> bool:
if narrow is None:
return False
return any(
# Web-public queries are only allowed for limited types of narrows.
# term == {'operator': 'channels', 'operand': 'web-public', 'negated': False}
# or term == {'operator': 'streams', 'operand': 'web-public', 'negated': False}
term.operator in channels_operators
and term.operand == "web-public"
and term.negated is False
for term in narrow
)
LARGER_THAN_MAX_MESSAGE_ID = 10000000000000000
class AnchorInfo(TypedDict):
type: Literal["message_id", "first_unread", "date"]
value: int | datetime | None
DEFAULT_ANCHOR_INFO: AnchorInfo = AnchorInfo(type="first_unread", value=None)
class BadNarrowOperatorError(JsonableError):
code = ErrorCode.BAD_NARROW
data_fields = ["desc"]
def __init__(self, desc: str) -> None:
self.desc: str = desc
@staticmethod
@override
def msg_format() -> str:
return _("Invalid narrow operator: {desc}")
class InvalidOperatorCombinationError(JsonableError):
code = ErrorCode.BAD_NARROW
data_fields = ["desc"]
def __init__(self, desc: str) -> None:
self.desc: str = desc
@staticmethod
@override
def msg_format() -> str:
return _("Invalid narrow operator combination: {desc}")
ConditionTransform: TypeAlias = Callable[[Q], Q]
def not_(cond: Q) -> Q:
return ~cond
# These delimiters will not appear in rendered messages or HTML-escaped topics.
TS_START = "<ts-match>"
TS_STOP = "</ts-match>"
def ts_locs_array(config: str, text: Composable, operand: str) -> RawSQL:
options = f"HighlightAll = TRUE, StartSel = {TS_START}, StopSel = {TS_STOP}"
composed = SQL(
"array(SELECT ARRAY["
"sum(length(p) - %s) OVER (ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) + %s, "
"strpos(p, %s) - 1] "
"FROM unnest(string_to_array("
"ts_headline(%s, {text}, plainto_tsquery(%s, %s), %s), "
"%s)) AS p OFFSET 1)"
).format(text=text)
params = [len(TS_STOP), len(TS_STOP), TS_STOP, config, config, operand, options, TS_START]
return RawSQL(composed.as_string(connection.connection), params) # noqa: S611
class NarrowBuilder:
"""
Build up a Django ORM query to find messages matching a narrow.
"""
# This class has an important security invariant:
#
# None of these methods ever *add* messages to a query's result.
#
# That is, the `add_term` method, and its helpers the `by_*` methods,
# are passed a `QuerySet[Message]` object; they may call some
# methods on it, and then they return a resulting `QuerySet[Message]`
# object. Things these methods may do to the queries they handle
# include
# * add conditions to filter out rows (i.e., messages), with `query.filter`
# * add columns for more information on the same message, with `query.annotate`
#
# Things they may not do include
# * anything that would pull in additional rows, or information on
# other messages.
def __init__(
self,
user_profile: UserProfile | None,
realm: Realm,
*,
is_web_public_query: bool = False,
) -> None:
self.user_profile = user_profile
self.realm = realm
self.is_web_public_query = is_web_public_query
self.by_method_map = {
"has": self.by_has,
"in": self.by_in,
"is": self.by_is,
"channel": self.by_channel,
# "stream" is a legacy alias for "channel"
"stream": self.by_channel,
"channels": self.by_channels,
# "streams" is a legacy alias for "channels"
"streams": self.by_channels,
"topic": self.by_topic,
"sender": self.by_sender,
"near": self.by_near,
"id": self.by_id,
"search": self.by_search,
"dm": self.by_dm,
# "pm-with:" is a legacy alias for "dm:"
"pm-with": self.by_dm,
"dm-including": self.by_dm_including,
"mentions": self.by_mention,
# "group-pm-with:" was deprecated by the addition of "dm-including:"
"group-pm-with": self.by_group_pm_with,
# TODO/compatibility: Prior to commit a9b3a9c, the server implementation
# for documented search operators with dashes, also implicitly supported
# clients sending those same operators with underscores. We can remove
# support for the below operators when support for the associated dashed
# operator is removed.
"pm_with": self.by_dm,
"group_pm_with": self.by_group_pm_with,
}
self.is_channel_narrow = False
self.is_dm_narrow = False
def check_not_both_channel_and_dm_narrow(
self,
maybe_negate: ConditionTransform,
is_dm_narrow: bool = False,
is_channel_narrow: bool = False,
) -> None:
if maybe_negate is not_:
return
if is_dm_narrow:
self.is_dm_narrow = True
if is_channel_narrow:
self.is_channel_narrow = True
if self.is_channel_narrow and self.is_dm_narrow:
raise BadNarrowOperatorError(
"No message can be both a channel message and direct message"
)
def add_term(self, query: QuerySet[Message], term: NarrowParameter) -> QuerySet[Message]:
"""
Extend the given query to one narrowed by the given term, and return the result.
This method satisfies an important security property: the returned
query never includes a message that the given query didn't. In
particular, if the given query will only find messages that a given
user can legitimately see, then so will the returned query.
"""
# To maintain the security property, we hold all the `by_*`
# methods to the same criterion. See the class's block comment
# for details.
operator = term.operator
operand = term.operand
negated = term.negated
if operator in self.by_method_map:
method = self.by_method_map[operator]
else:
raise BadNarrowOperatorError("unknown operator " + operator)
if negated:
maybe_negate: ConditionTransform = not_
else:
maybe_negate = lambda cond: cond
return method(query, operand, maybe_negate)
def by_has(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
if operand not in ["attachment", "image", "link", "reaction"]:
raise BadNarrowOperatorError("unknown 'has' operand " + operand)
if operand == "reaction":
exists_cond = Q(Exists(Reaction.objects.filter(message_id=OuterRef("id"))))
return query.filter(maybe_negate(exists_cond))
col_name = "has_" + operand
cond = Q(**{col_name: True})
return query.filter(maybe_negate(cond))
def by_in(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
# This operator does not support is_web_public_query.
assert not self.is_web_public_query
assert self.user_profile is not None
if operand == "home":
conditions = exclude_muting_conditions(
self.user_profile, [NarrowParameter(operator="in", operand="home")]
)
return query.filter(maybe_negate(conditions))
elif operand == "all":
return query
raise BadNarrowOperatorError("unknown 'in' operand " + operand)
def by_is(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
# Only `resolved` operand of this class supports is_web_public_query.
if operand == "resolved":
cond = get_resolved_topic_condition_q()
return query.filter(maybe_negate(cond))
assert not self.is_web_public_query
assert self.user_profile is not None
if operand in ["dm", "private"]:
# "is:private" is a legacy alias for "is:dm"
if maybe_negate is not_:
self.check_not_both_channel_and_dm_narrow(
maybe_negate=lambda cond: cond, is_channel_narrow=True
)
else:
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_dm_narrow=True)
return query.filter(
maybe_negate(Q(user_flags__andnz=UserMessage.flags.is_private.mask))
)
elif operand == "starred":
return query.filter(maybe_negate(Q(user_flags__andnz=UserMessage.flags.starred.mask)))
elif operand == "unread":
return query.filter(maybe_negate(Q(user_flags__andz=UserMessage.flags.read.mask)))
elif operand == "mentioned":
mention_flags_mask = (
UserMessage.flags.mentioned.mask
| UserMessage.flags.stream_wildcard_mentioned.mask
| UserMessage.flags.topic_wildcard_mentioned.mask
| UserMessage.flags.group_mentioned.mask
)
return query.filter(maybe_negate(Q(user_flags__andnz=mention_flags_mask)))
elif operand == "alerted":
return query.filter(
maybe_negate(Q(user_flags__andnz=UserMessage.flags.has_alert_word.mask))
)
elif operand == "followed":
cond = get_followed_topic_condition_q(self.user_profile.id)
return query.filter(maybe_negate(cond))
elif operand == "muted":
# TODO: If we also have a channel operator, this could be
# a lot more efficient if limited to only those muting
# rules that appear in such channels.
conditions = exclude_muting_conditions(
self.user_profile, [NarrowParameter(operator="is", operand="muted")]
)
return query.filter(maybe_negate(~conditions))
raise BadNarrowOperatorError("unknown 'is' operand " + operand)
_alphanum = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
def by_channel(
self, query: QuerySet[Message], operand: str | int, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_channel_narrow=True)
try:
# Because you can see your own message history for
# private channels you are no longer subscribed to, we
# need get_stream_by_narrow_operand_access_unchecked here.
channel = get_stream_by_narrow_operand_access_unchecked(operand, self.realm)
if self.is_web_public_query and not channel.is_web_public:
raise BadNarrowOperatorError("unknown web-public channel " + str(operand))
except Stream.DoesNotExist:
raise BadNarrowOperatorError("unknown channel " + str(operand))
recipient_id = channel.recipient_id
assert recipient_id is not None
cond = Q(recipient_id=recipient_id)
return query.filter(maybe_negate(cond))
def by_channels(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_channel_narrow=True)
if operand == "public":
# Get all both subscribed and non-subscribed public channels
# but exclude any private subscribed channels.
recipient_queryset = get_public_streams_queryset(self.realm)
elif operand == "web-public":
recipient_queryset = get_web_public_streams_queryset(self.realm)
elif operand == "archived":
recipient_queryset = get_archived_streams_queryset(self.realm)
else:
raise BadNarrowOperatorError("unknown channels operand " + operand)
recipient_ids = recipient_queryset.values_list("recipient_id", flat=True).order_by("id")
cond = Q(recipient_id__in=list(recipient_ids))
return query.filter(maybe_negate(cond))
def by_topic(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_channel_narrow=True)
cond = topic_match_q(operand)
return query.filter(maybe_negate(cond))
def by_sender(
self, query: QuerySet[Message], operand: str | int, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
try:
if isinstance(operand, str):
sender = get_user_including_cross_realm(operand, self.realm)
else:
sender = get_user_by_id_in_realm_including_cross_realm(operand, self.realm)
except UserProfile.DoesNotExist:
raise BadNarrowOperatorError("unknown user " + str(operand))
cond = Q(sender_id=sender.id)
return query.filter(maybe_negate(cond))
def by_near(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
return query
def by_id(
self, query: QuerySet[Message], operand: int | str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
if not str(operand).isdigit() or int(operand) > Message.MAX_POSSIBLE_MESSAGE_ID:
raise BadNarrowOperatorError("Invalid message ID")
cond = Q(id=int(operand))
return query.filter(maybe_negate(cond))
def by_dm(
self,
query: QuerySet[Message],
operand: str | Iterable[int],
maybe_negate: ConditionTransform,
) -> QuerySet[Message]:
# This operator does not support is_web_public_query.
assert not self.is_web_public_query
assert self.user_profile is not None
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_dm_narrow=True)
try:
if isinstance(operand, str):
email_list = operand.split(",")
user_profiles = get_user_profiles(
emails=email_list,
realm=self.realm,
)
else:
"""
This is where we handle passing a list of user IDs for the narrow, which is the
preferred/cleaner API.
"""
user_profiles = get_user_profiles_by_ids(
user_ids=operand,
realm=self.realm,
)
if user_profiles == []:
cond = Q(pk__in=[]) # Always false.
return query.filter(maybe_negate(cond))
recipient = recipient_for_user_profiles(
user_profiles=user_profiles,
forwarded_mirror_message=False,
forwarder_user_profile=None,
sender=self.user_profile,
allow_deactivated=True,
create=False,
)
except (JsonableError, ValidationError):
raise BadNarrowOperatorError("unknown user in " + str(operand))
except DirectMessageGroup.DoesNotExist:
# DM group doesn't exist, so no messages can exist.
cond = Q(pk__in=[]) # Always false.
return query.filter(maybe_negate(cond))
cond = Q(recipient_id=recipient.id)
return query.filter(maybe_negate(cond))
def _get_direct_message_group_recipients(self, other_user: UserProfile) -> set[int]:
return set(
Subscription.objects.filter(
user_profile=self.user_profile,
recipient__type=Recipient.DIRECT_MESSAGE_GROUP,
)
.values_list("recipient_id", flat=True)
.intersection(
Subscription.objects.filter(
user_profile=other_user,
recipient__type=Recipient.DIRECT_MESSAGE_GROUP,
).values_list("recipient_id", flat=True)
)
)
def by_dm_including(
self, query: QuerySet[Message], operand: str | int, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
# This operator does not support is_web_public_query.
assert not self.is_web_public_query
assert self.user_profile is not None
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_dm_narrow=True)
try:
if isinstance(operand, str):
narrow_user_profile = get_user_including_cross_realm(operand, self.realm)
else:
narrow_user_profile = get_user_by_id_in_realm_including_cross_realm(
operand, self.realm
)
except UserProfile.DoesNotExist:
raise BadNarrowOperatorError("unknown user " + str(operand))
# "dm-including" when combined with the user's own ID/email as the operand
# should return all group and 1:1 direct messages (including direct messages
# with self), so the simplest query to get these messages is the same as "is:dm".
if narrow_user_profile.id == self.user_profile.id:
cond = Q(user_flags__andnz=UserMessage.flags.is_private.mask)
return query.filter(maybe_negate(cond))
# all direct messages including another person (group and 1:1)
direct_message_group_recipient_ids = self._get_direct_message_group_recipients(
narrow_user_profile
)
cond = Q(
user_flags__andnz=UserMessage.flags.is_private.mask,
realm_id=self.realm.id,
recipient_id__in=direct_message_group_recipient_ids,
)
return query.filter(maybe_negate(cond))
def by_mention(
self, query: QuerySet[Message], operand: str | int, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
assert self.user_profile is not None
try:
if isinstance(operand, str):
target_user = get_user_including_cross_realm(operand, self.realm)
else:
target_user = get_user_by_id_in_realm_including_cross_realm(operand, self.realm)
except (JsonableError, UserProfile.DoesNotExist):
raise BadNarrowOperatorError("unknown user " + str(operand))
# Only check for direct (visible) personal mentions here. We
# intentionally do not consider other mention-related flags
# (group_mentioned, stream_wildcard_mentioned,
# topic_wildcard_mentioned) or silent mentions, since this
# operator is defined to only match explicit @-mentions
# directed at notifying this user individually.
#
# Use a subquery on the target user's UserMessage rows,
# since the base query's UserMessage join is constrained to
# the current user, who may differ from the mentioned user.
# We correlate via zerver_message.id (present in both base
# query variants) to avoid ambiguity with the outer query's
# zerver_usermessage table.
cond = Q(
Exists(
UserMessage.objects.filter(
message_id=OuterRef("id"),
user_profile_id=target_user.id,
flags__andnz=UserMessage.flags.mentioned.mask,
)
)
)
return query.filter(maybe_negate(cond))
def by_group_pm_with(
self, query: QuerySet[Message], operand: str | int, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
assert not self.is_web_public_query
assert self.user_profile is not None
self.check_not_both_channel_and_dm_narrow(maybe_negate, is_dm_narrow=True)
try:
if isinstance(operand, str):
narrow_profile = get_user_including_cross_realm(operand, self.realm)
else:
narrow_profile = get_user_by_id_in_realm_including_cross_realm(operand, self.realm)
except UserProfile.DoesNotExist:
raise BadNarrowOperatorError("unknown user " + str(operand))
recipient_ids = self._get_direct_message_group_recipients(narrow_profile)
cond = Q(
user_flags__andnz=UserMessage.flags.is_private.mask,
realm_id=self.realm.id,
recipient_id__in=recipient_ids,
)
return query.filter(maybe_negate(cond))
def by_search(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
if settings.USING_PGROONGA:
return self._by_search_pgroonga(query, operand, maybe_negate)
else:
return self._by_search_tsearch(query, operand, maybe_negate)
def _by_search_pgroonga(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
keywords = SQL("pgroonga_query_extract_keywords(escape_html(%s))")
query = query.annotate(
content_matches=RawSQL( # noqa: S611
SQL("pgroonga_match_positions_character({text}, {keywords})")
.format(
text=Identifier("zerver_message", "rendered_content"),
keywords=keywords,
)
.as_string(connection.connection),
[operand],
),
topic_matches=RawSQL( # noqa: S611
SQL("pgroonga_match_positions_character({text}, {keywords})")
.format(
text=SQL("escape_html({})").format(Identifier("zerver_message", DB_TOPIC_NAME)),
keywords=keywords,
)
.as_string(connection.connection),
[operand],
),
).alias(
_pgroonga_match=RawSQL( # noqa: S611
SQL("{col} &@~ escape_html(%s)")
.format(col=Identifier("zerver_message", "search_pgroonga"))
.as_string(connection.connection),
[operand],
)
)
condition = Q(_pgroonga_match=True)
return query.filter(maybe_negate(condition))
def _by_search_tsearch(
self, query: QuerySet[Message], operand: str, maybe_negate: ConditionTransform
) -> QuerySet[Message]:
query = query.annotate(
content_matches=ts_locs_array(
"zulip.english_us_search", Identifier("zerver_message", "rendered_content"), operand
),
# We HTML-escape the topic in PostgreSQL to avoid doing a server round-trip
topic_matches=ts_locs_array(
"zulip.english_us_search",
SQL("escape_html({})").format(Identifier("zerver_message", DB_TOPIC_NAME)),
operand,
),
)
# Do quoted string matching. We really want phrase
# search here so we can ignore punctuation and do
# stemming, but there isn't a standard phrase search
# mechanism in PostgreSQL
for term in re.findall(r'"[^"]+"|\S+', operand):
if term[0] == '"' and term[-1] == '"':
term = term[1:-1]
cond = Q(content__icontains=term) | Q(
**{f"{DB_TOPIC_NAME}__icontains": term}, is_channel_message=True
)
query = query.filter(maybe_negate(cond))
query = query.alias(
_tsvector_match=RawSQL( # noqa: S611
SQL("{tsvector} @@ plainto_tsquery(%s, %s)")
.format(tsvector=Identifier("zerver_message", "search_tsvector"))
.as_string(connection.connection),
["zulip.english_us_search", operand],
)
)
cond = Q(_tsvector_match=True)
return query.filter(maybe_negate(cond))
def ok_to_include_history(
narrow: list[NarrowParameter] | None,
user_profile: UserProfile | None,
is_web_public_query: bool,
) -> bool:
# There are occasions where we need to find Message rows that
# have no corresponding UserMessage row, because the user is
# reading a public channel that might include messages that
# were sent while the user was not subscribed, but which they are
# allowed to see. We have to be very careful about constructing
# queries in those situations, so this function should return True
# only if we are 100% sure that we're gonna add a clause to the
# query that narrows to a particular public channel on the user's realm.
# If we screw this up, then we can get into a nasty situation of
# polluting our narrow results with messages from other realms.
# For web-public queries, we are always returning history. The
# analogues of the below channel access checks for whether channels
# have is_web_public set and banning is operators in this code
# path are done directly in NarrowBuilder.
if is_web_public_query:
assert user_profile is None
return True
assert user_profile is not None
include_history = False
if narrow is not None:
for term in narrow:
if term.operator in channel_operators and not term.negated:
operand: str | int = term.operand
if isinstance(operand, str):
include_history = can_access_stream_history_by_name(user_profile, operand)
else:
include_history = can_access_stream_history_by_id(user_profile, operand)
elif (
term.operator in channels_operators
and term.operand in ["public", "web-public"]
and not term.negated
and user_profile.can_access_public_streams()
):
include_history = True
# Disable historical messages if the user is narrowing on anything
# that's a property on the UserMessage table. There cannot be
# historical messages in these cases anyway.
for term in narrow:
# NOTE: Needs to be in sync with `Filter.is_personal_filter`.
if term.operator == "is" and term.operand != "resolved":
include_history = False
return include_history
def get_channel_from_narrow_access_unchecked(
narrow: list[NarrowParameter] | None, realm: Realm
) -> Stream | None:
if narrow is not None:
for term in narrow:
if term.operator in channel_operators:
return get_stream_by_narrow_operand_access_unchecked(term.operand, realm)
return None
# This function verifies if the current narrow has the necessary
# terms to point to a channel or a direct message conversation.
def can_narrow_define_conversation(narrow: list[NarrowParameter]) -> bool:
contains_channel_term = False
contains_topic_term = False
for term in narrow:
if term.operator in ["dm", "pm-with"]:
return True
elif term.operator in ["stream", "channel"]:
contains_channel_term = True
elif term.operator == "topic":
contains_topic_term = True
if contains_channel_term and contains_topic_term:
return True
return False
def update_narrow_terms_containing_empty_topic_fallback_name(
narrow: list[NarrowParameter] | None,
) -> list[NarrowParameter] | None:
if narrow is None:
return narrow
for term in narrow:
if term.operator == "topic":
term.operand = maybe_rename_general_chat_to_empty_topic(term.operand)
break
return narrow
# This function implements the core logic of the `with` operator,
# which is designed to support permanent links to a topic that
# robustly function if the topic is moved.
#
# The with operator accepts a message ID as an operand. If the
# message ID does not exist or is otherwise not accessible to the
# current user, then if the remaining narrow terms can point to
# a conversation then the narrow corresponding to it is returned.
# If the remaining terms can not point to a particular conversation,
# then a BadNarrowOperatorError is raised.
#
# Otherwise, the narrow terms are mutated to remove any
# channel/topic/dm operators, replacing them with the appropriate
# operators for the conversation view containing the targeted message.
def update_narrow_terms_containing_with_operator(
realm: Realm,
maybe_user_profile: UserProfile | AnonymousUser,
narrow: list[NarrowParameter] | None,
) -> list[NarrowParameter] | None:
if narrow is None:
return narrow
with_operator_terms = [term for term in narrow if term.operator == "with"]
can_user_access_target_message = True
if len(with_operator_terms) > 1:
raise InvalidOperatorCombinationError(_("Duplicate 'with' operators."))
elif len(with_operator_terms) == 0:
return narrow
with_term = with_operator_terms[0]
narrow.remove(with_term)
try:
message_id = int(with_term.operand)
except ValueError:
# TODO: This probably should be handled earlier.
raise BadNarrowOperatorError(_("Invalid 'with' operator"))
if maybe_user_profile.is_authenticated:
try:
message = access_message(maybe_user_profile, message_id, is_modifying_message=False)
except JsonableError:
can_user_access_target_message = False
else:
try:
message = access_web_public_message(realm, message_id)
except MissingAuthenticationError:
can_user_access_target_message = False
# If the user can not access the target message we fall back to the
# conversation specified by those other operators if they're enough
# to specify a single conversation.
# Else, we raise a BadNarrowOperatorError.
if not can_user_access_target_message:
if can_narrow_define_conversation(narrow):
return narrow
else:
raise BadNarrowOperatorError(_("Invalid 'with' operator"))
# TODO: It would be better if the legacy names here are canonicalized
# while building a NarrowParameter.
filtered_terms = [
term
for term in narrow
if term.operator not in ["stream", "channel", "topic", "dm", "pm-with"]
]
if message.recipient.type == Recipient.STREAM:
channel_id = message.recipient.type_id
topic = message.topic_name()
channel_conversation_terms = [
NarrowParameter(operator="channel", operand=channel_id),
NarrowParameter(operator="topic", operand=topic),
]
return channel_conversation_terms + filtered_terms
elif message.recipient.type == Recipient.DIRECT_MESSAGE_GROUP:
huddle_user_ids = list(get_direct_message_group_user_ids(message.recipient))
dm_conversation_terms = [NarrowParameter(operator="dm", operand=huddle_user_ids)]
return dm_conversation_terms + filtered_terms
raise AssertionError("Invalid recipient type")
def exclude_muting_conditions(user_profile: UserProfile, narrow: list[NarrowParameter] | None) -> Q:
channel_id = None
try:
# Note: It is okay here to not check access to channel
# because we are only using the channel ID to exclude data,
# not to include results.
channel = get_channel_from_narrow_access_unchecked(narrow, user_profile.realm)
if channel is not None:
channel_id = channel.id
except Stream.DoesNotExist:
pass
conditions = exclude_stream_and_topic_mutes(user_profile, channel_id)
# Muted user logic for hiding messages is implemented entirely
# client-side. This is by design, as it allows UI to hint that
# muted messages exist where their absence might make conversation
# difficult to understand. As a result, we do not need to consider
# muted users in this server-side logic for returning messages to
# clients. (We could in theory exclude direct messages from muted
# users, but they're likely to be sufficiently rare to not be worth
# extra logic/testing here).
return conditions
def get_base_query_for_search(
realm_id: int, user_profile: UserProfile | None, *, need_user_message: bool
) -> QuerySet[Message]:
# Handle the simple case where user_message isn't involved first.
if not need_user_message:
return Message.objects.filter(realm_id=realm_id)
assert user_profile is not None
user_recursive_group_ids = []
# We ignore group membership for guests; see the TODO comment in
# has_channel_content_access_helper.
if not user_profile.is_guest:
user_recursive_group_ids = sorted(
get_recursive_membership_groups(user_profile).values_list("id", flat=True)
)
query = Message.objects.annotate(
# Annotate these to prevent Django from joining the UserMessage table
# more than once when we later filter on these fields.
user_profile_id=F("usermessage__user_profile_id"),
user_flags=F("usermessage__flags"),
).filter(
# We don't limit by realm_id despite the join to
# zerver_messages, since the user_profile_id limit in
# usermessage is more selective, and the query planner
# can't know about that cross-table correlation.
user_profile_id=user_profile.id
)
# Mirror the restrictions in bulk_access_stream_messages_query, in order
# to prevent leftover UserMessage rows from granting access to messages
# the user was previously allowed to access but no longer is.
stream_access_filters = Q(pk__in=[]) # Always false.
if user_profile.can_access_public_streams():
stream_access_filters |= Q(invite_only=False)
if user_recursive_group_ids:
stream_access_filters |= Q(can_subscribe_group_id__in=user_recursive_group_ids) | Q(
can_add_subscribers_group_id__in=user_recursive_group_ids
)
return query.filter(
# Include direct messages.
~Q(recipient__type=Recipient.STREAM)
# Include messages where the recipient is a public stream and
# the user can access public streams, or the user is a non-guest
# belonging to a group granting access to the stream.
| Exists(
Stream.objects.filter(recipient=OuterRef("recipient")).filter(stream_access_filters)
)
# Include messages where the user has an active subscription to
# the stream.
| Exists(
Subscription.objects.filter(
user_profile=user_profile, recipient=OuterRef("recipient"), active=True
)
)
)
def add_narrow_conditions(
*,
user_profile: UserProfile | None,
query: QuerySet[Message],
narrow: list[NarrowParameter] | None,
is_web_public_query: bool,
realm: Realm,
) -> tuple[QuerySet[Message], bool, bool]:
is_search = False # for now
if narrow is None:
return (query, is_search, False)
# Build the query for the narrow
builder = NarrowBuilder(
user_profile,
realm,
is_web_public_query=is_web_public_query,
)
search_operands = []
# As we loop through terms, builder does most of the work to extend
# our query, but we need to collect the search operands and handle
# them after the loop.
for term in narrow:
if term.operator == "search":
search_operands.append(term.operand)
else:
query = builder.add_term(query, term)
if search_operands:
# This topic escaping logic ensures consistent escaping of topic names throughout
# the system, ensuring accuracy in string highlighting and avoiding any discrepancies.
#
# When a topic name is fetched from the database, it goes through this logic.
# The `escape_html` function is used to escape the topic name, ensuring that
# special characters are properly escaped. This helps to avoid the need to apply other
# escaping logic to the topic name for string highlighting purposes. As a result, the
# highlighted string will accurately match the actual topic name displayed in the UI.
# This approach prevents any inconsistencies or offsets that could occur if different
# escaping functions were used.
#
# It's important to note that the `process_fts_updates` script, responsible for
# updating the relevant columns in the database, also utilizes the same escaping
# logic. This alignment ensures that the escaped topic names stored in the database
# and the topic names used during string highlighting are in sync. Therefore, there
# is no need for any special handling in `process_fts_updates` to align with this
# escaping logic.
is_search = True
query = query.annotate(
escaped_topic_name=Func(
F(DB_TOPIC_NAME), function="escape_html", output_field=TextField()
),
)
search_term = NarrowParameter(
operator="search",
operand=" ".join(search_operands),
)
query = builder.add_term(query, search_term)
return (query, is_search, builder.is_dm_narrow)
def capture_find_first_unread_anchor_query_for_testing(query: QuerySet[Any]) -> None:
pass
def find_first_unread_anchor(
user_profile: UserProfile | None,
narrow: list[NarrowParameter] | None,
query: QuerySet[Message],
is_dm_narrow: bool,
need_user_message: bool,
) -> int:
# For anonymous web users, all messages are treated as read, and so
# always return LARGER_THAN_MAX_MESSAGE_ID.
if user_profile is None:
return LARGER_THAN_MAX_MESSAGE_ID
# Looking at the name get_base_query_for_search, one would think that
# we can just rebuild the query again with need_user_message set to True
# regardless of the existing value of need_user_message. But,
# get_base_query_for_search executes 1 query which is getting recursive
# user group memberships.
if not need_user_message:
# We always need UserMessage in our query, because it has the unread
# flag for the user.
query = get_base_query_for_search(
realm_id=user_profile.realm_id,
user_profile=user_profile,
need_user_message=True,
)
query, _is_search, is_dm_narrow = add_narrow_conditions(
user_profile=user_profile,
query=query,
narrow=narrow,
is_web_public_query=False,
realm=user_profile.realm,
)
query = query.filter(user_flags__andz=UserMessage.flags.read.mask) # type: ignore[misc] # get_base_query_for_search adds the user_flags annotation
# We exclude messages on muted topics when finding the first unread
# message in this narrow
if not is_dm_narrow:
# Since building the channel/topic muting conditions takes
# extra queries and makes the query potentially much more
# verbose for PostgreSQL to parse, we skip this for searches
# which we know they cannot apply do -- DMs.
muting_conditions = exclude_muting_conditions(user_profile, narrow)
query = query.filter(muting_conditions)
values_query = query.order_by("id").values_list("id", flat=True)
capture_find_first_unread_anchor_query_for_testing(values_query)
anchor = values_query.first()
if anchor is None:
return LARGER_THAN_MAX_MESSAGE_ID
return anchor
def find_date_anchor(
*,
anchor_date: datetime,
query: QuerySet[Message],
realm_id: int,
id_field: str,
) -> int | None:
"""Finds the message to anchor on for a date, translating it into a
message ID so that the narrow search runs on the ID indexes.
"""
boundary_id = (
Message.objects.filter(realm_id=realm_id, date_sent__gte=anchor_date)
.order_by("date_sent", "id")
.values_list("id", flat=True)
.first()
)
if boundary_id is not None:
first_after = (
query.filter(**{f"{id_field}__gte": boundary_id})
.order_by(id_field)
.values_list("id", flat=True)
.first()
)
if first_after is not None:
return first_after
# If nothing is on/after the anchor date, fall back to the newest message.
newest = query.order_by(f"-{id_field}").values_list("id", flat=True).first()
return newest
def parse_anchor_value(
anchor_val: str | None,
use_first_unread_anchor: bool,
anchor_date: str | None = None,
) -> AnchorInfo:
"""Given the anchor and use_first_unread_anchor parameters passed by
the client, computes what anchor type and value the client requested,
handling backwards-compatibility and the various string-valued
fields.
"""
if use_first_unread_anchor:
# Backwards-compatibility: Before we added support for the
# special string-typed anchor values, clients would pass
# anchor=None and use_first_unread_anchor=True to indicate
# what is now expressed as anchor="first_unread".
return AnchorInfo(type="first_unread", value=None)
if anchor_val is None:
# Throw an exception if neither an anchor argument nor
# use_first_unread_anchor was specified.
raise JsonableError(_("Missing 'anchor' argument."))
if anchor_val == "date":
if anchor_date is None:
raise JsonableError(_("Missing 'anchor_date' argument."))
try:
# For date without time, this function will set the time to
# midnight.
anchor_datetime = check_iso_datetime("anchor_date", anchor_date)
except ValidationError as error:
raise JsonableError(error.message)
if anchor_datetime.tzinfo is None:
anchor_datetime = anchor_datetime.replace(tzinfo=timezone.utc)
return AnchorInfo(type="date", value=anchor_datetime)
if anchor_val == "oldest":
return AnchorInfo(type="message_id", value=0)
if anchor_val == "newest":
return AnchorInfo(
type="message_id",
value=LARGER_THAN_MAX_MESSAGE_ID,
)
if anchor_val == "first_unread":
return AnchorInfo(type="first_unread", value=None)
try:
# We don't use `.isnumeric()` to support negative numbers for
# anchor. We don't recommend it in the API (if you want the
# very first message, use 0 or 1), but it used to be supported
# and was used by the web app, so we need to continue
# supporting it for backwards-compatibility
anchor = int(anchor_val)
if anchor < 0:
anchor_value = 0
elif anchor > LARGER_THAN_MAX_MESSAGE_ID:
anchor_value = LARGER_THAN_MAX_MESSAGE_ID
else:
anchor_value = anchor
return AnchorInfo(type="message_id", value=anchor_value)
except ValueError:
raise JsonableError(_("Invalid anchor"))
def limit_query_to_range(
query: QuerySet[Message],
num_before: int,
num_after: int,
anchor: int,
include_anchor: bool,
anchored_to_left: bool,
anchored_to_right: bool,
first_visible_message_id: int,
id_field: str,
) -> QuerySet[Message]:
"""
This code is actually generic enough that we could move it to a
library, but our only caller for now is message search.
id_field is the message-id column to order and bound the range by;
see fetch_messages for why the table it comes from matters.
"""
need_before_query = (not anchored_to_left) and (num_before > 0)
need_after_query = (not anchored_to_right) and (num_after > 0)
need_both_sides = need_before_query and need_after_query
# The semantics of our flags are as follows:
#
# num_before = number of rows < anchor
# num_after = number of rows > anchor
#
# But we may also want the row where id == anchor (if it exists),
# and we don't want to union up to 3 queries. So in some cases
# we do things like `after_limit = num_after + 1` to grab the
# anchor row in the "after" query.
#
# Note that in some cases, if the anchor row isn't found, we
# actually may fetch an extra row at one of the extremes.
if need_both_sides:
before_anchor = anchor - 1
after_anchor = max(anchor, first_visible_message_id)
before_limit = num_before
after_limit = num_after + 1
elif need_before_query:
before_anchor = anchor - (not include_anchor)
before_limit = num_before
if not anchored_to_right:
before_limit += include_anchor
elif need_after_query:
after_anchor = max(anchor + (not include_anchor), first_visible_message_id)
after_limit = num_after + include_anchor
before_query = None
after_query = None
if need_before_query:
before_query = query
if not anchored_to_right:
before_query = before_query.filter(**{f"{id_field}__lte": before_anchor})
before_query = before_query.order_by(f"-{id_field}")[:before_limit]
if need_after_query:
after_query = query
if not anchored_to_left:
after_query = after_query.filter(**{f"{id_field}__gte": after_anchor})
after_query = after_query.order_by(id_field)[:after_limit]
if before_query is not None and after_query is not None:
return before_query.union(after_query, all=True)
elif before_query is not None:
return before_query
elif after_query is not None:
return after_query
else:
# If we don't have either a before_query or after_query, it's because
# some combination of num_before/num_after/anchor are zero or
# use_first_unread_anchor logic found no unread messages.
#
# The most likely reason is somebody is doing an id search, so searching
# for something like `message_id = 42` is exactly what we want. In other
# cases, which could possibly be buggy API clients, at least we will
# return at most one row here.
return query.filter(**{id_field: anchor})
MessageRowT = TypeVar("MessageRowT", bound=Sequence[Any])
@dataclass
class LimitedMessages(Generic[MessageRowT]):
rows: list[MessageRowT]
found_anchor: bool
found_newest: bool
found_oldest: bool
history_limited: bool
def post_process_limited_query(
rows: Sequence[MessageRowT],
num_before: int,
num_after: int,
anchor: int,
anchored_to_left: bool,
anchored_to_right: bool,
first_visible_message_id: int,
) -> LimitedMessages[MessageRowT]:
# Our queries may have fetched extra rows if they added
# "headroom" to the limits, but we want to truncate those
# rows.
#
# Also, in cases where we had non-zero values of num_before or
# num_after, we want to know found_oldest and found_newest, so
# that the clients will know that they got complete results.
if first_visible_message_id > 0:
visible_rows: Sequence[MessageRowT] = [r for r in rows if r[0] >= first_visible_message_id]
else:
visible_rows = rows
rows_limited = len(visible_rows) != len(rows)
before_rows: list[MessageRowT]
anchor_rows: list[MessageRowT]
after_rows: list[MessageRowT]
if anchored_to_right:
num_after = 0
before_rows = list(visible_rows)
anchor_rows = []
after_rows = []
else:
before_rows = []
anchor_rows = []
after_rows = []
for r in visible_rows:
if r[0] < anchor:
before_rows.append(r)
elif r[0] == anchor:
anchor_rows.append(r)
else:
after_rows.append(r)
if num_before:
before_rows = before_rows[-1 * num_before :]
if num_after:
after_rows = after_rows[:num_after]
limited_rows = [*before_rows, *anchor_rows, *after_rows]
found_anchor = len(anchor_rows) == 1
found_oldest = anchored_to_left or (len(before_rows) < num_before)
found_newest = anchored_to_right or (len(after_rows) < num_after)
# BUG: history_limited is incorrect False in the event that we had
# to bump `anchor` up due to first_visible_message_id, and there
# were actually older messages. This may be a rare event in the
# context where history_limited is relevant, because it can only
# happen in one-sided queries with no num_before (see tests tagged
# BUG in PostProcessTest for examples), and we don't generally do
# those from the UI, so this might be OK for now.
#
# The correct fix for this probably involves e.g. making a
# `before_query` when we increase `anchor` just to confirm whether
# messages were hidden.
history_limited = rows_limited and found_oldest
return LimitedMessages(
rows=limited_rows,
found_anchor=found_anchor,
found_newest=found_newest,
found_oldest=found_oldest,
history_limited=history_limited,
)
def clean_narrow_for_message_fetch(
narrow: list[NarrowParameter] | None,
realm: Realm,
maybe_user_profile: UserProfile | AnonymousUser,
) -> list[NarrowParameter] | None:
narrow = update_narrow_terms_containing_empty_topic_fallback_name(narrow)
narrow = update_narrow_terms_containing_with_operator(realm, maybe_user_profile, narrow)
return narrow
@dataclass
class FetchedMessages(LimitedMessages[tuple[Any, ...]]):
anchor: int | None
include_history: bool
is_search: bool
def capture_message_fetch_query_for_testing(query: QuerySet[Any]) -> None:
pass
def fetch_messages(
*,
narrow: list[NarrowParameter] | None,
user_profile: UserProfile | None,
realm: Realm,
is_web_public_query: bool,
anchor_info: AnchorInfo | None,
include_anchor: bool,
num_before: int,
num_after: int,
client_requested_message_ids: list[int] | None = None,
) -> FetchedMessages:
if access_narrow(user_profile, narrow, is_web_public_query, realm) is False:
# If user is requesting messages from a narrow they don't have
# access to, do an early return.
return FetchedMessages(
rows=[],
found_anchor=False,
found_oldest=True,
found_newest=True,
history_limited=False,
anchor=LARGER_THAN_MAX_MESSAGE_ID,
include_history=False,
is_search=False,
)
include_history = ok_to_include_history(narrow, user_profile, is_web_public_query)
if include_history:
# The initial query in this case doesn't use `zerver_usermessage`,
# and isn't yet limited to messages the user is entitled to see!
#
# This is OK only because we've made sure this is a narrow that
# will cause us to limit the query appropriately elsewhere.
# See `ok_to_include_history` for details.
#
# Note that is_web_public_query=True goes here, since
# include_history is semantically correct for is_web_public_query.
need_user_message = False
else:
need_user_message = True
# get_base_query_for_search and ok_to_include_history are responsible for ensuring
# that we only include messages the user has access to.
query = get_base_query_for_search(
realm_id=realm.id,
user_profile=user_profile,
need_user_message=need_user_message,
)
query, is_search, is_dm_narrow = add_narrow_conditions(
user_profile=user_profile,
query=query,
narrow=narrow,
realm=realm,
is_web_public_query=is_web_public_query,
)
if anchor_info is None:
anchor_info = DEFAULT_ANCHOR_INFO
anchored_to_left = False
anchored_to_right = False
anchor_type = anchor_info["type"]
anchor_value = anchor_info["value"]
first_visible_message_id = get_first_visible_message_id(realm)
if client_requested_message_ids is not None:
query = query.filter(id__in=client_requested_message_ids)
else:
if need_user_message:
# Order/bound the anchor search and pagination on the driving
# zerver_usermessage (user_profile_id, message_id) index. PostgreSQL
# won't push a zerver_message.id bound across the outer join, so
# using it would scan the user's whole history to reach an old
# anchor. The alias reuses the existing join rather than adding a
# second one.
query = query.alias(_range_message_id=F("usermessage__message_id"))
id_field = "_range_message_id"
else:
id_field = "id"
if anchor_type == "date":
assert isinstance(anchor_value, datetime)
anchor_value = find_date_anchor(
anchor_date=anchor_value,
query=query,
realm_id=realm.id,
id_field=id_field,
)
# We did not find any message before or after the given timestamp,
# which means there are no messages in this narrow.
if anchor_value is None:
return FetchedMessages(
rows=[],
found_anchor=False,
found_newest=False,
found_oldest=False,
history_limited=False,
# We first tried to find a message that was sent after our
# anchor_date. When we did not find that, we went and looked
# for the newest message i.e LARGER_THAN_MAX_MESSAGE_ID.
anchor=LARGER_THAN_MAX_MESSAGE_ID,
include_history=include_history,
is_search=is_search,
)
if anchor_type == "first_unread":
anchor_value = find_first_unread_anchor(
user_profile,
narrow,
query,
is_dm_narrow,
need_user_message,
)
assert isinstance(anchor_value, int)
anchored_to_left = anchor_value == 0
# Set value that will be used to short circuit the after_query
# altogether and avoid needless conditions in the before_query.
anchored_to_right = anchor_value >= LARGER_THAN_MAX_MESSAGE_ID
if anchored_to_right:
num_after = 0
query = limit_query_to_range(
query=query,
num_before=num_before,
num_after=num_after,
anchor=anchor_value,
include_anchor=include_anchor,
anchored_to_left=anchored_to_left,
anchored_to_right=anchored_to_right,
first_visible_message_id=first_visible_message_id,
id_field=id_field,
)
values_query = query.values_list(
"id",
*["user_flags"] if need_user_message else [],
*["escaped_topic_name", "rendered_content", "content_matches", "topic_matches"]
if is_search
else [],
)
capture_message_fetch_query_for_testing(values_query)
# limit_query_to_range may apply a DESC ordering or union disjoint
# ranges; sort the rows by message_id in Python to match the API
# contract.
rows = sorted(values_query, key=lambda r: r[0])
if client_requested_message_ids is not None:
# We don't need to do any post-processing in this case.
if first_visible_message_id > 0:
visible_rows = [r for r in rows if r[0] >= first_visible_message_id]
else:
visible_rows = rows
return FetchedMessages(
rows=visible_rows,
found_anchor=False,
found_newest=False,
found_oldest=False,
history_limited=False,
anchor=None,
include_history=include_history,
is_search=is_search,
)
assert isinstance(anchor_value, int)
query_info = post_process_limited_query(
rows=rows,
num_before=num_before,
num_after=num_after,
anchor=anchor_value,
anchored_to_left=anchored_to_left,
anchored_to_right=anchored_to_right,
first_visible_message_id=first_visible_message_id,
)
return FetchedMessages(
rows=query_info.rows,
found_anchor=query_info.found_anchor,
found_newest=query_info.found_newest,
found_oldest=query_info.found_oldest,
history_limited=query_info.history_limited,
anchor=anchor_value,
include_history=include_history,
is_search=is_search,
)
def access_narrow(
maybe_user_profile: UserProfile | None,
narrow: list[NarrowParameter] | None,
is_web_public_query: bool,
realm: Realm,
) -> bool | None:
"""
We do an early return if the user doesn't have access to the
channel present in the narrow.
@returns None if we can't determine access here.
"""
if (is_web_public_query is True) or (maybe_user_profile is None):
# Not handled here since we already have channel access
# checks later which will return with status code.
return None
# We only expect one channel term.
channel_requested_by_user: str | int | None = None
if narrow is not None:
for term in narrow:
if term.operator in channel_operators:
if term.negated:
# We don't handle negated channel terms here.
return None
if channel_requested_by_user is not None:
# If there are multiple channel terms, there will
# be no messages since we apply `AND` on them.
return False
channel_requested_by_user = term.operand
if channel_requested_by_user is None:
return None
# Import here to avoid circular imports
from zerver.models.streams import get_realm_stream, get_stream_by_id_in_realm
# Check if channel exists.
try:
if isinstance(channel_requested_by_user, str):
channel = get_realm_stream(channel_requested_by_user, realm.id)
else:
channel = get_stream_by_id_in_realm(channel_requested_by_user, realm)
except Stream.DoesNotExist:
# We don't want to duplicate validation error messages here, so
# just return `None`.
return None
try:
access_stream_common(
maybe_user_profile,
channel,
error="",
require_active_channel=False,
require_content_access=True,
)
return True
except JsonableError:
# User doesn't have access to this existing stream
return False