mirror of https://github.com/zulip/zulip.git
users: Allow spectators to view user avatars.
If realm is web_public, spectators can now view avatar of other users. There is a special exception we had to introduce in rest model to allow `/avatar` type of urls for `anonymous` access, because they don't have the /api/v1 prefix. Fixes #19838.
This commit is contained in:
parent
d6541c4724
commit
3e689ebae9
|
@ -147,7 +147,10 @@ def rest_dispatch(request: HttpRequest, **kwargs: Any) -> HttpResponse:
|
||||||
target_function = authenticated_rest_api_view(
|
target_function = authenticated_rest_api_view(
|
||||||
allow_webhook_access="allow_incoming_webhooks" in view_flags,
|
allow_webhook_access="allow_incoming_webhooks" in view_flags,
|
||||||
)(target_function)
|
)(target_function)
|
||||||
elif request.path.startswith("/json") and "allow_anonymous_user_web" in view_flags:
|
elif (
|
||||||
|
request.path.startswith(("/json", "/avatar"))
|
||||||
|
and "allow_anonymous_user_web" in view_flags
|
||||||
|
):
|
||||||
# For endpoints that support anonymous web access, we do that.
|
# For endpoints that support anonymous web access, we do that.
|
||||||
# TODO: Allow /api calls when this is stable enough.
|
# TODO: Allow /api calls when this is stable enough.
|
||||||
auth_kwargs = dict(allow_unauthenticated=True)
|
auth_kwargs = dict(allow_unauthenticated=True)
|
||||||
|
|
|
@ -1047,28 +1047,44 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
|
|
||||||
self.logout()
|
self.logout()
|
||||||
|
|
||||||
# Test /avatar/<email_or_id> endpoint with HTTP basic auth.
|
with self.settings(WEB_PUBLIC_STREAMS_ENABLED=False):
|
||||||
response = self.api_get(hamlet, "/avatar/cordelia@zulip.com", {"foo": "bar"})
|
# Test /avatar/<email_or_id> endpoint with HTTP basic auth.
|
||||||
redirect_url = response["Location"]
|
response = self.api_get(hamlet, "/avatar/cordelia@zulip.com", {"foo": "bar"})
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
|
redirect_url = response["Location"]
|
||||||
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
|
||||||
|
|
||||||
response = self.api_get(hamlet, f"/avatar/{cordelia.id}", {"foo": "bar"})
|
response = self.api_get(hamlet, f"/avatar/{cordelia.id}", {"foo": "bar"})
|
||||||
redirect_url = response["Location"]
|
redirect_url = response["Location"]
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
|
||||||
|
|
||||||
# Test cross_realm_bot avatar access using email.
|
# Test cross_realm_bot avatar access using email.
|
||||||
response = self.api_get(hamlet, "/avatar/welcome-bot@zulip.com", {"foo": "bar"})
|
response = self.api_get(hamlet, "/avatar/welcome-bot@zulip.com", {"foo": "bar"})
|
||||||
redirect_url = response["Location"]
|
redirect_url = response["Location"]
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
|
||||||
|
|
||||||
# Test cross_realm_bot avatar access using id.
|
# Test cross_realm_bot avatar access using id.
|
||||||
response = self.api_get(hamlet, f"/avatar/{cross_realm_bot.id}", {"foo": "bar"})
|
response = self.api_get(hamlet, f"/avatar/{cross_realm_bot.id}", {"foo": "bar"})
|
||||||
redirect_url = response["Location"]
|
redirect_url = response["Location"]
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
|
||||||
|
|
||||||
|
# Without spectators enabled, no unauthenticated access.
|
||||||
|
response = self.client_get("/avatar/cordelia@zulip.com", {"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.
|
||||||
|
response = self.client_get(f"/avatar/{cordelia.id}", {"foo": "bar"})
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
|
||||||
|
# Disallow unauthenticated/spectator requests by email.
|
||||||
response = self.client_get("/avatar/cordelia@zulip.com", {"foo": "bar"})
|
response = self.client_get("/avatar/cordelia@zulip.com", {"foo": "bar"})
|
||||||
self.assert_json_error(
|
self.assert_json_error(
|
||||||
response, "Not logged in: API authentication or user session required", status_code=401
|
response,
|
||||||
|
"Not logged in: API authentication or user session required",
|
||||||
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_user_avatar_medium(self) -> None:
|
def test_get_user_avatar_medium(self) -> None:
|
||||||
|
@ -1090,18 +1106,34 @@ class AvatarTest(UploadSerializeMixin, ZulipTestCase):
|
||||||
|
|
||||||
self.logout()
|
self.logout()
|
||||||
|
|
||||||
# Test /avatar/<email_or_id>/medium endpoint with HTTP basic auth.
|
with self.settings(WEB_PUBLIC_STREAMS_ENABLED=False):
|
||||||
response = self.api_get(hamlet, "/avatar/cordelia@zulip.com/medium", {"foo": "bar"})
|
# Test /avatar/<email_or_id>/medium endpoint with HTTP basic auth.
|
||||||
redirect_url = response["Location"]
|
response = self.api_get(hamlet, "/avatar/cordelia@zulip.com/medium", {"foo": "bar"})
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia, True)) + "&foo=bar"))
|
redirect_url = response["Location"]
|
||||||
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia, True)) + "&foo=bar"))
|
||||||
|
|
||||||
response = self.api_get(hamlet, f"/avatar/{cordelia.id}/medium", {"foo": "bar"})
|
response = self.api_get(hamlet, f"/avatar/{cordelia.id}/medium", {"foo": "bar"})
|
||||||
redirect_url = response["Location"]
|
redirect_url = response["Location"]
|
||||||
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia, True)) + "&foo=bar"))
|
self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia, True)) + "&foo=bar"))
|
||||||
|
|
||||||
|
# Without spectators enabled, no unauthenticated access.
|
||||||
|
response = self.client_get("/avatar/cordelia@zulip.com/medium", {"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.
|
||||||
|
response = self.client_get(f"/avatar/{cordelia.id}/medium", {"foo": "bar"})
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
|
||||||
|
# Disallow unauthenticated/spectator requests by email.
|
||||||
response = self.client_get("/avatar/cordelia@zulip.com/medium", {"foo": "bar"})
|
response = self.client_get("/avatar/cordelia@zulip.com/medium", {"foo": "bar"})
|
||||||
self.assert_json_error(
|
self.assert_json_error(
|
||||||
response, "Not logged in: API authentication or user session required", status_code=401
|
response,
|
||||||
|
"Not logged in: API authentication or user session required",
|
||||||
|
status_code=401,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_non_valid_user_avatar(self) -> None:
|
def test_non_valid_user_avatar(self) -> None:
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from zerver.context_processors import get_valid_realm_from_request
|
||||||
from zerver.decorator import require_member_or_admin, require_realm_admin
|
from zerver.decorator import require_member_or_admin, require_realm_admin
|
||||||
from zerver.forms import PASSWORD_TOO_WEAK_ERROR, CreateUserForm
|
from zerver.forms import PASSWORD_TOO_WEAK_ERROR, CreateUserForm
|
||||||
from zerver.lib.actions import (
|
from zerver.lib.actions import (
|
||||||
|
@ -32,6 +34,7 @@ from zerver.lib.email_validation import email_allowed_for_realm
|
||||||
from zerver.lib.exceptions import (
|
from zerver.lib.exceptions import (
|
||||||
CannotDeactivateLastUserError,
|
CannotDeactivateLastUserError,
|
||||||
JsonableError,
|
JsonableError,
|
||||||
|
MissingAuthenticationError,
|
||||||
OrganizationOwnerRequired,
|
OrganizationOwnerRequired,
|
||||||
)
|
)
|
||||||
from zerver.lib.integrations import EMBEDDED_BOTS
|
from zerver.lib.integrations import EMBEDDED_BOTS
|
||||||
|
@ -219,7 +222,10 @@ def update_user_backend(
|
||||||
|
|
||||||
|
|
||||||
def avatar(
|
def avatar(
|
||||||
request: HttpRequest, user_profile: UserProfile, email_or_id: str, medium: bool = False
|
request: HttpRequest,
|
||||||
|
maybe_user_profile: Union[UserProfile, AnonymousUser],
|
||||||
|
email_or_id: str,
|
||||||
|
medium: bool = False,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
"""Accepts an email address or user ID and returns the avatar"""
|
"""Accepts an email address or user ID and returns the avatar"""
|
||||||
is_email = False
|
is_email = False
|
||||||
|
@ -228,8 +234,25 @@ def avatar(
|
||||||
except ValueError:
|
except ValueError:
|
||||||
is_email = True
|
is_email = True
|
||||||
|
|
||||||
|
if not maybe_user_profile.is_authenticated:
|
||||||
|
# Allow anonynous access to avatars only if spectators are
|
||||||
|
# enabled in the organization.
|
||||||
|
realm = get_valid_realm_from_request(request)
|
||||||
|
# TODO: Replace with realm.allow_web_public_streams_access()
|
||||||
|
# when the method is available.
|
||||||
|
if not realm.has_web_public_streams():
|
||||||
|
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()
|
||||||
|
else:
|
||||||
|
realm = maybe_user_profile.realm
|
||||||
|
|
||||||
try:
|
try:
|
||||||
realm = user_profile.realm
|
|
||||||
if is_email:
|
if is_email:
|
||||||
avatar_user_profile = get_user_including_cross_realm(email_or_id, realm)
|
avatar_user_profile = get_user_including_cross_realm(email_or_id, realm)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -669,9 +669,14 @@ urls += [
|
||||||
rest_path("thumbnail", GET=(backend_serve_thumbnail, {"override_api_url_scheme"})),
|
rest_path("thumbnail", GET=(backend_serve_thumbnail, {"override_api_url_scheme"})),
|
||||||
# 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("avatar/<email_or_id>", GET=(avatar, {"override_api_url_scheme"})),
|
|
||||||
rest_path(
|
rest_path(
|
||||||
"avatar/<email_or_id>/medium", {"medium": True}, GET=(avatar, {"override_api_url_scheme"})
|
"avatar/<email_or_id>",
|
||||||
|
GET=(avatar, {"override_api_url_scheme", "allow_anonymous_user_web"}),
|
||||||
|
),
|
||||||
|
rest_path(
|
||||||
|
"avatar/<email_or_id>/medium",
|
||||||
|
{"medium": True},
|
||||||
|
GET=(avatar, {"override_api_url_scheme", "allow_anonymous_user_web"}),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue