mirror of https://github.com/zulip/zulip.git
avatars: Split email-based and user ID-based avatar endpoints.
Separate `avatars/<email_or_id>/medium?` endpoints into distinct endpoints for email-based and user ID-based access. This change aligns avatar endpoints with Zulip’s existing API path conventions (e.g., the `users/` endpoint).
This commit is contained in:
parent
10f03fce11
commit
7fc9fc32d1
|
@ -1275,6 +1275,14 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
"Not logged in: API authentication or user session required",
|
"Not logged in: API authentication or user session required",
|
||||||
status_code=401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
# Disallow access by id for spectators with unauthenticated access
|
||||||
|
# when realm public streams is false.
|
||||||
|
response = self.client_get(f"/avatar/{cordelia.id}", {"foo": "bar"})
|
||||||
|
self.assert_json_error(
|
||||||
|
response,
|
||||||
|
"Not logged in: API authentication or user session required",
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
# Allow unauthenticated/spectator requests by ID.
|
# Allow unauthenticated/spectator requests by ID.
|
||||||
response = self.client_get(f"/avatar/{cordelia.id}", {"foo": "bar"})
|
response = self.client_get(f"/avatar/{cordelia.id}", {"foo": "bar"})
|
||||||
|
@ -1301,6 +1309,12 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
redirect_url = response["Location"]
|
redirect_url = response["Location"]
|
||||||
self.assertTrue(redirect_url.endswith("images/unknown-user-avatar.png?foo=bar"))
|
self.assertTrue(redirect_url.endswith("images/unknown-user-avatar.png?foo=bar"))
|
||||||
|
|
||||||
|
invalid_user_id = 999
|
||||||
|
response = self.client_get(f"/avatar/{invalid_user_id}", {"foo": "bar"})
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
redirect_url = response["Location"]
|
||||||
|
self.assertTrue(redirect_url.endswith("images/unknown-user-avatar.png?foo=bar"))
|
||||||
|
|
||||||
def test_get_user_avatar_medium(self) -> None:
|
def test_get_user_avatar_medium(self) -> None:
|
||||||
hamlet = self.example_user("hamlet")
|
hamlet = self.example_user("hamlet")
|
||||||
self.login_user(hamlet)
|
self.login_user(hamlet)
|
||||||
|
|
|
@ -336,18 +336,13 @@ def update_user_backend(
|
||||||
return json_success(request)
|
return json_success(request)
|
||||||
|
|
||||||
|
|
||||||
def avatar(
|
def avatar_by_id(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
maybe_user_profile: UserProfile | AnonymousUser,
|
maybe_user_profile: UserProfile | AnonymousUser,
|
||||||
email_or_id: str,
|
user_id: int,
|
||||||
medium: bool = False,
|
medium: bool = False,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
"""Accepts an email address or user ID and returns the avatar"""
|
"""Accepts a user ID and returns the avatar"""
|
||||||
is_email = False
|
|
||||||
try:
|
|
||||||
int(email_or_id)
|
|
||||||
except ValueError:
|
|
||||||
is_email = True
|
|
||||||
|
|
||||||
if not maybe_user_profile.is_authenticated:
|
if not maybe_user_profile.is_authenticated:
|
||||||
# Allow anonymous access to avatars only if spectators are
|
# Allow anonymous access to avatars only if spectators are
|
||||||
|
@ -356,27 +351,14 @@ def avatar(
|
||||||
if not realm.allow_web_public_streams_access():
|
if not realm.allow_web_public_streams_access():
|
||||||
raise MissingAuthenticationError
|
raise MissingAuthenticationError
|
||||||
|
|
||||||
# We only allow the ID format for accessing a user's avatar
|
|
||||||
# for spectators. This is mainly for defense in depth, since
|
|
||||||
# email_address_visibility should mean spectators only
|
|
||||||
# interact with fake email addresses anyway.
|
|
||||||
if is_email:
|
|
||||||
raise MissingAuthenticationError
|
|
||||||
|
|
||||||
if settings.RATE_LIMITING:
|
if settings.RATE_LIMITING:
|
||||||
unique_avatar_key = f"{realm.id}/{email_or_id}/{medium}"
|
unique_avatar_key = f"{realm.id}/{user_id}/{medium}"
|
||||||
rate_limit_spectator_attachment_access_by_file(unique_avatar_key)
|
rate_limit_spectator_attachment_access_by_file(unique_avatar_key)
|
||||||
else:
|
else:
|
||||||
realm = maybe_user_profile.realm
|
realm = maybe_user_profile.realm
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_email:
|
avatar_user_profile = get_user_by_id_in_realm_including_cross_realm(user_id, realm)
|
||||||
avatar_user_profile = get_user_including_cross_realm(email_or_id, realm)
|
|
||||||
else:
|
|
||||||
avatar_user_profile = get_user_by_id_in_realm_including_cross_realm(
|
|
||||||
int(email_or_id), realm
|
|
||||||
)
|
|
||||||
|
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
if maybe_user_profile.is_authenticated and not check_can_access_user(
|
if maybe_user_profile.is_authenticated and not check_can_access_user(
|
||||||
avatar_user_profile, maybe_user_profile
|
avatar_user_profile, maybe_user_profile
|
||||||
|
@ -386,9 +368,43 @@ def avatar(
|
||||||
# If there is a valid user account passed in, use its avatar
|
# If there is a valid user account passed in, use its avatar
|
||||||
url = avatar_url(avatar_user_profile, medium=medium)
|
url = avatar_url(avatar_user_profile, medium=medium)
|
||||||
assert url is not None
|
assert url is not None
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
url = get_avatar_for_inaccessible_user()
|
||||||
|
|
||||||
|
assert url is not None
|
||||||
|
if request.META["QUERY_STRING"]:
|
||||||
|
url = append_url_query_string(url, request.META["QUERY_STRING"])
|
||||||
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_by_email(
|
||||||
|
request: HttpRequest,
|
||||||
|
maybe_user_profile: UserProfile | AnonymousUser,
|
||||||
|
email: str,
|
||||||
|
medium: bool = False,
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""Accepts an email address and returns the avatar"""
|
||||||
|
|
||||||
|
if not maybe_user_profile.is_authenticated:
|
||||||
|
# We only allow the ID format for accessing a user's avatar
|
||||||
|
# for spectators. This is mainly for defense in depth, since
|
||||||
|
# email_address_visibility should mean spectators only
|
||||||
|
# interact with fake email addresses anyway.
|
||||||
|
raise MissingAuthenticationError
|
||||||
|
|
||||||
|
realm = maybe_user_profile.realm
|
||||||
|
|
||||||
|
try:
|
||||||
|
avatar_user_profile = get_user_including_cross_realm(email, realm)
|
||||||
|
url: str | None = None
|
||||||
|
if not check_can_access_user(avatar_user_profile, maybe_user_profile):
|
||||||
|
url = get_avatar_for_inaccessible_user()
|
||||||
|
else:
|
||||||
|
# If there is a valid user account passed in, use its avatar
|
||||||
|
url = avatar_url(avatar_user_profile, medium=medium)
|
||||||
|
assert url is not None
|
||||||
except UserProfile.DoesNotExist:
|
except UserProfile.DoesNotExist:
|
||||||
# If there is no such user, treat it as a new gravatar
|
# If there is no such user, treat it as a new gravatar
|
||||||
email = email_or_id
|
|
||||||
avatar_version = 1
|
avatar_version = 1
|
||||||
url = get_gravatar_url(email, avatar_version, medium)
|
url = get_gravatar_url(email, avatar_version, medium)
|
||||||
|
|
||||||
|
@ -399,9 +415,16 @@ def avatar(
|
||||||
|
|
||||||
|
|
||||||
def avatar_medium(
|
def avatar_medium(
|
||||||
request: HttpRequest, maybe_user_profile: UserProfile | AnonymousUser, email_or_id: str
|
request: HttpRequest,
|
||||||
|
maybe_user_profile: UserProfile | AnonymousUser,
|
||||||
|
email: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
return avatar(request, maybe_user_profile, email_or_id, medium=True)
|
if email:
|
||||||
|
return avatar_by_email(request, maybe_user_profile, email, medium=True)
|
||||||
|
else:
|
||||||
|
assert user_id is not None
|
||||||
|
return avatar_by_id(request, maybe_user_profile, user_id, medium=True)
|
||||||
|
|
||||||
|
|
||||||
def get_stream_name(stream: Stream | None) -> str | None:
|
def get_stream_name(stream: Stream | None) -> str | None:
|
||||||
|
|
|
@ -218,7 +218,8 @@ from zerver.views.user_settings import (
|
||||||
from zerver.views.user_topics import update_muted_topic, update_user_topic
|
from zerver.views.user_topics import update_muted_topic, update_user_topic
|
||||||
from zerver.views.users import (
|
from zerver.views.users import (
|
||||||
add_bot_backend,
|
add_bot_backend,
|
||||||
avatar,
|
avatar_by_email,
|
||||||
|
avatar_by_id,
|
||||||
avatar_medium,
|
avatar_medium,
|
||||||
create_user_backend,
|
create_user_backend,
|
||||||
deactivate_bot_backend,
|
deactivate_bot_backend,
|
||||||
|
@ -680,11 +681,22 @@ urls += [
|
||||||
# Avatars have the same constraint because their URLs are included
|
# Avatars have the same constraint because their URLs are included
|
||||||
# in API data structures used by both the mobile and web clients.
|
# in API data structures used by both the mobile and web clients.
|
||||||
rest_path(
|
rest_path(
|
||||||
"avatar/<email_or_id>",
|
"avatar/<int:user_id>",
|
||||||
GET=(avatar, {"override_api_url_scheme", "allow_anonymous_user_web"}),
|
GET=(avatar_by_id, {"override_api_url_scheme", "allow_anonymous_user_web"}),
|
||||||
),
|
),
|
||||||
rest_path(
|
rest_path(
|
||||||
"avatar/<email_or_id>/medium",
|
"avatar/<email>",
|
||||||
|
GET=(avatar_by_email, {"override_api_url_scheme", "allow_anonymous_user_web"}),
|
||||||
|
),
|
||||||
|
rest_path(
|
||||||
|
"avatar/<int:user_id>/medium",
|
||||||
|
GET=(
|
||||||
|
avatar_medium,
|
||||||
|
{"override_api_url_scheme", "allow_anonymous_user_web"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
rest_path(
|
||||||
|
"avatar/<email>/medium",
|
||||||
GET=(
|
GET=(
|
||||||
avatar_medium,
|
avatar_medium,
|
||||||
{"override_api_url_scheme", "allow_anonymous_user_web"},
|
{"override_api_url_scheme", "allow_anonymous_user_web"},
|
||||||
|
|
Loading…
Reference in New Issue