Repository: zulip/zulip

Issue:
Make avatars publically accessible in organizations with web public streams enabled
At present, the prototype web-public streams system (#13172 / #18532) is not able to display large-size avatars for users, and likely cannot display small-size avatars for users who've uploaded a custom avatar (i.e. not Gravatar).

At the same time, we want to preserve the model that user-uploaded avatars are only available to users (not the Internet) in organizations that are not using web-public streams.

I think addressing this properly means making the `GET /avatar/{user_id}` endpoints allow access to unauthenticated requests if `realm.has_web_public_streams()`, and not otherwise.


Discussion:

Resolving patch:
diff --git a/zerver/lib/rest.py b/zerver/lib/rest.py
--- a/zerver/lib/rest.py
+++ b/zerver/lib/rest.py
@@ -147,7 +147,10 @@ def rest_dispatch(request: HttpRequest, **kwargs: Any) -> HttpResponse:
             target_function = authenticated_rest_api_view(
                 allow_webhook_access="allow_incoming_webhooks" in view_flags,
             )(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.
             # TODO: Allow /api calls when this is stable enough.
             auth_kwargs = dict(allow_unauthenticated=True)
diff --git a/zerver/views/users.py b/zerver/views/users.py
--- a/zerver/views/users.py
+++ b/zerver/views/users.py
@@ -1,10 +1,12 @@
 from typing import Any, Dict, List, Optional, Union
 
 from django.conf import settings
+from django.contrib.auth.models import AnonymousUser
 from django.http import HttpRequest, HttpResponse
 from django.shortcuts import redirect
 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.forms import PASSWORD_TOO_WEAK_ERROR, CreateUserForm
 from zerver.lib.actions import (
@@ -32,6 +34,7 @@
 from zerver.lib.exceptions import (
     CannotDeactivateLastUserError,
     JsonableError,
+    MissingAuthenticationError,
     OrganizationOwnerRequired,
 )
 from zerver.lib.integrations import EMBEDDED_BOTS
@@ -219,7 +222,10 @@ def update_user_backend(
 
 
 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:
     """Accepts an email address or user ID and returns the avatar"""
     is_email = False
@@ -228,8 +234,25 @@ def avatar(
     except ValueError:
         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:
-        realm = user_profile.realm
         if is_email:
             avatar_user_profile = get_user_including_cross_realm(email_or_id, realm)
         else:
diff --git a/zproject/urls.py b/zproject/urls.py
--- a/zproject/urls.py
+++ b/zproject/urls.py
@@ -669,9 +669,14 @@
     rest_path("thumbnail", GET=(backend_serve_thumbnail, {"override_api_url_scheme"})),
     # Avatars have the same constraint because their URLs are included
     # 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(
-        "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"}),
     ),
 ]
 


Test patch:
diff --git a/frontend_tests/node_tests/popovers.js b/frontend_tests/node_tests/popovers.js
--- a/frontend_tests/node_tests/popovers.js
+++ b/frontend_tests/node_tests/popovers.js
@@ -52,6 +52,7 @@ const alice = {
     is_guest: false,
     is_admin: false,
     role: 400,
+    date_joined: "2021-11-01T16:32:16.458735+00:00",
 };
 
 const me = {
@@ -111,6 +112,7 @@ function test_ui(label, f) {
 }
 
 test_ui("sender_hover", ({override, mock_template}) => {
+    page_params.is_spectator = false;
     override($.fn, "popover", noop);
     override(emoji, "get_emoji_details_by_name", noop);
 
@@ -162,7 +164,7 @@ test_ui("sender_hover", ({override, mock_template}) => {
 
     mock_template("user_info_popover_title.hbs", false, (opts) => {
         assert.deepEqual(opts, {
-            user_avatar: "avatar/alice@example.com",
+            user_avatar: "http://zulip.zulipdev.com/avatar/42?s=50",
             user_is_guest: false,
         });
         return "title-html";
@@ -196,6 +198,8 @@ test_ui("sender_hover", ({override, mock_template}) => {
             status_text: "on the beach",
             status_emoji_info,
             user_mention_syntax: "@**Alice Smith**",
+            date_joined: undefined,
+            spectator_view: false,
         });
         return "content-html";
     });
diff --git a/zerver/tests/test_upload.py b/zerver/tests/test_upload.py
--- a/zerver/tests/test_upload.py
+++ b/zerver/tests/test_upload.py
@@ -1047,28 +1047,44 @@ def test_get_user_avatar(self) -> None:
 
         self.logout()
 
-        # Test /avatar/<email_or_id> endpoint with HTTP basic auth.
-        response = self.api_get(hamlet, "/avatar/cordelia@zulip.com", {"foo": "bar"})
-        redirect_url = response["Location"]
-        self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
+        with self.settings(WEB_PUBLIC_STREAMS_ENABLED=False):
+            # Test /avatar/<email_or_id> endpoint with HTTP basic auth.
+            response = self.api_get(hamlet, "/avatar/cordelia@zulip.com", {"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"})
-        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"})
+            redirect_url = response["Location"]
+            self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia)) + "&foo=bar"))
 
-        # Test cross_realm_bot avatar access using email.
-        response = self.api_get(hamlet, "/avatar/welcome-bot@zulip.com", {"foo": "bar"})
-        redirect_url = response["Location"]
-        self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
+            # Test cross_realm_bot avatar access using email.
+            response = self.api_get(hamlet, "/avatar/welcome-bot@zulip.com", {"foo": "bar"})
+            redirect_url = response["Location"]
+            self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
 
-        # Test cross_realm_bot avatar access using id.
-        response = self.api_get(hamlet, f"/avatar/{cross_realm_bot.id}", {"foo": "bar"})
-        redirect_url = response["Location"]
-        self.assertTrue(redirect_url.endswith(str(avatar_url(cross_realm_bot)) + "&foo=bar"))
+            # Test cross_realm_bot avatar access using id.
+            response = self.api_get(hamlet, f"/avatar/{cross_realm_bot.id}", {"foo": "bar"})
+            redirect_url = response["Location"]
+            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"})
         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:
@@ -1090,18 +1106,34 @@ def test_get_user_avatar_medium(self) -> None:
 
         self.logout()
 
-        # Test /avatar/<email_or_id>/medium endpoint with HTTP basic auth.
-        response = self.api_get(hamlet, "/avatar/cordelia@zulip.com/medium", {"foo": "bar"})
-        redirect_url = response["Location"]
-        self.assertTrue(redirect_url.endswith(str(avatar_url(cordelia, True)) + "&foo=bar"))
+        with self.settings(WEB_PUBLIC_STREAMS_ENABLED=False):
+            # Test /avatar/<email_or_id>/medium endpoint with HTTP basic auth.
+            response = self.api_get(hamlet, "/avatar/cordelia@zulip.com/medium", {"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"})
-        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"})
+            redirect_url = response["Location"]
+            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"})
         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: