Files
upstream-zulip/zerver/lib/url_encoding.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

226 lines
7.1 KiB
Python

# See the Zulip URL spec at https://zulip.com/api/zulip-urls
import urllib.parse
from typing import Any
from urllib.parse import urlsplit
import re2
from zerver.lib.topic import get_topic_from_message_info
from zerver.lib.types import UserDisplayRecipient
from zerver.models import Realm, Stream, UserProfile
hash_replacements = {
"%": ".",
"(": ".28",
")": ".29",
".": ".2E",
}
def encode_hash_component(s: str) -> str:
encoded = urllib.parse.quote(s, safe="")
return "".join(hash_replacements.get(c, c) for c in encoded)
def encode_channel(channel_id: int, channel_name: str, with_operator: bool = False) -> str:
"""
This encodes the given `channel_id` and `channel_name`
into a recipient slug string that can be used to
construct a narrow URL.
e.g., 9, "Verona" -> "99-Verona"
The `with_operator` parameter decides whether to append
the "channel" operator to the recipient slug or not.
e.g., "channel/99-Verona"
"""
channel_name = channel_name.replace(" ", "-")
encoded_channel = str(channel_id) + "-" + encode_hash_component(channel_name)
if with_operator:
return f"channel/{encoded_channel}"
return encoded_channel
def encode_user_ids(
user_ids: list[int],
with_operator: bool = False,
) -> str:
"""
This encodes the given `user_ids` into recipient slug
string that can be used to construct a narrow URL.
e.g., [13, 23, 9] -> "13,23,9-group"
The `with_operator` parameter decides whether to append
the "dm" operator to the recipient slug or not.
e.g., "dm/13,23,9-group"
"""
assert len(user_ids) > 0
# For 3 or more user ids we use the "-group" decoration tag.
# If we're only working with 1-2 user ids, it's either a
# one-on-one direct message or direct message to ones self.
# In this case, we don't include any decoration tag to the
# slug.
decoration_tag = ""
if len(user_ids) >= 3:
decoration_tag = "-group"
direct_message_slug = ",".join([str(user_id) for user_id in sorted(user_ids)]) + decoration_tag
if with_operator:
return f"dm/{direct_message_slug}"
return direct_message_slug
def encode_user_full_name_and_id(full_name: str, user_id: int, with_operator: bool = False) -> str:
"""
This encodes the given `full_name` and `user_id` into a
recipient slug string that can be used to construct a
narrow URL.
e.g., 9, "King Hamlet" -> "9-King-Hamlet"
The `with_operator` parameter decides whether to append
the "dm" operator to the recipient slug or not.
e.g., "dm/9-King-Hamlet"
"""
encoded_user_name = re2.sub(r'[ "%\/<>`\p{C}]+', "-", full_name.strip())
direct_message_slug = str(user_id) + "-" + encoded_user_name
if with_operator:
return f"dm/{direct_message_slug}"
return direct_message_slug
def personal_narrow_url(*, realm: Realm, sender_id: int, sender_full_name: str) -> str:
base_url = f"{realm.url}/#narrow/dm/"
direct_message_slug = encode_user_full_name_and_id(sender_full_name, sender_id)
return base_url + direct_message_slug
def direct_message_group_narrow_url(
*, user: UserProfile, display_recipient: list[UserDisplayRecipient]
) -> str:
realm = user.realm
if len(display_recipient) == 1:
# For self-DMs, we use the personal narrow URL format.
return personal_narrow_url(realm=realm, sender_id=user.id, sender_full_name=user.full_name)
if len(display_recipient) == 2:
# For 1:1 DMs, we use the personal narrow URL format.
other_user = next(r for r in display_recipient if r["id"] != user.id)
return personal_narrow_url(
realm=realm, sender_id=other_user["id"], sender_full_name=other_user["full_name"]
)
# For group DMs with more than 2 users, we use other user IDs to create a slug.
other_user_ids = [r["id"] for r in display_recipient if r["id"] != user.id]
direct_message_slug = encode_user_ids(other_user_ids)
# `encode_user_ids` adds the decoration tag "-group" if length
# of the user_ids passed to it is 3 or more. `other_user_ids`
# has 1 less user ID, so we manually add the tag here.
if len(other_user_ids) == 2:
direct_message_slug += "-group"
base_url = f"{realm.url}/#narrow/dm/"
return base_url + direct_message_slug
def stream_narrow_url(realm: Realm, stream: Stream) -> str:
base_url = f"{realm.url}/#narrow/channel/"
return base_url + encode_channel(stream.id, stream.name)
def topic_narrow_url(*, realm: Realm, stream: Stream, topic_name: str) -> str:
base_url = f"{realm.url}/#narrow/channel/"
return f"{base_url}{encode_channel(stream.id, stream.name)}/topic/{encode_hash_component(topic_name)}"
def message_link_url(
realm: Realm, message: dict[str, Any], *, conversation_link: bool = False
) -> str:
if message["type"] == "stream":
url = stream_message_url(
realm=realm,
message=message,
conversation_link=conversation_link,
)
return url
url = pm_message_url(
realm=realm,
message=message,
conversation_link=conversation_link,
)
return url
def stream_message_url(
realm: Realm | None,
message: dict[str, Any],
*,
conversation_link: bool = False,
include_base_url: bool = True,
include_channel_name: bool = True,
) -> str:
if include_base_url and realm is None:
raise ValueError("realm is required when include_base_url=True")
if conversation_link:
with_or_near = "with"
else:
with_or_near = "near"
message_id = str(message["id"])
stream_id = message["stream_id"]
stream_name = message["display_recipient"]
topic_name = get_topic_from_message_info(message)
encoded_topic_name = encode_hash_component(topic_name)
if include_channel_name:
encoded_stream = encode_channel(stream_id, stream_name)
else:
# Used when the URL must not reveal the channel name (e.g. for a
# write-only bot); Zulip still resolves the channel from the ID alone.
encoded_stream = str(stream_id)
narrow_fragments = (
f"#narrow/channel/{encoded_stream}/topic/{encoded_topic_name}/{with_or_near}/{message_id}"
)
if include_base_url is True:
assert realm is not None
return f"{realm.url}/{narrow_fragments}"
return narrow_fragments
def pm_message_url(
realm: Realm, message: dict[str, Any], *, conversation_link: bool = False
) -> str:
if conversation_link:
with_or_near = "with"
else:
with_or_near = "near"
message_id = str(message["id"])
user_ids = [recipient["id"] for recipient in message["display_recipient"]]
direct_message_slug = encode_user_ids(user_ids)
parts = [
realm.url,
"#narrow",
"dm",
direct_message_slug,
with_or_near,
message_id,
]
full_url = "/".join(parts)
return full_url
def append_url_query_string(original_url: str, query: str) -> str:
u = urlsplit(original_url)
query = u.query + ("&" if u.query and query else "") + query
return u._replace(query=query).geturl()