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
1683 lines
63 KiB
Python
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
|