users: Allow spectators to access /users API endpoint.

We need this to support faster initial loading time for spectators.
This commit is contained in:
Aman Agrawal
2025-05-15 18:50:49 +05:30
committed by Tim Abbott
parent 078a27def2
commit 1dc845f07b
7 changed files with 74 additions and 9 deletions

View File

@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0
**Feature level 387**
* [`GET /users`](/api/get-users): This endpoint no longer requires
authentication for organizations using the [public access
option](/help/public-access-option).
**Feature level 386**
* [`PATCH /user_groups/{user_group_id}`](/api/update-user-group):

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 386
API_FEATURE_LEVEL = 387
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@@ -9526,6 +9526,11 @@ paths:
Optionally includes values of [custom profile fields](/help/custom-profile-fields).
You can also [fetch details on a single user](/api/get-user).
**Changes**: This endpoint did not support unauthenticated
access in organizations using the [public access
option](/help/public-access-option) prior to Zulip 11.0
(feature level 387).
x-curl-examples-parameters:
oneOf:
- type: include

View File

@@ -1440,9 +1440,9 @@ class RestAPITest(ZulipTestCase):
self.assertEqual(str(result["Allow"]), "DELETE, GET, HEAD, PATCH")
def test_http_accept_redirect(self) -> None:
result = self.client_get("/json/users", HTTP_ACCEPT="text/html")
result = self.client_get("/json/attachments", HTTP_ACCEPT="text/html")
self.assertEqual(result.status_code, 302)
self.assertTrue(result["Location"].endswith("/login/?next=%2Fjson%2Fusers"))
self.assertTrue(result["Location"].endswith("/login/?next=%2Fjson%2Fattachments"))
class TestUserAgentParsing(ZulipTestCase):

View File

@@ -2164,7 +2164,7 @@ class ActivateTest(ZulipTestCase):
session_key = self.client.session.session_key
self.assertTrue(session_key)
result = self.client_get("/json/users")
result = self.client_get("/json/attachments")
self.assert_json_success(result)
self.assertEqual(Session.objects.filter(pk=session_key).count(), 1)
@@ -2172,7 +2172,7 @@ class ActivateTest(ZulipTestCase):
do_deactivate_user(user, acting_user=None)
self.assertEqual(Session.objects.filter(pk=session_key).count(), 0)
result = self.client_get("/json/users")
result = self.client_get("/json/attachments")
self.assert_json_error(
result, "Not logged in: API authentication or user session required", 401
)
@@ -3195,6 +3195,43 @@ class GetProfileTest(ZulipTestCase):
)
self.assertEqual(inaccessible_user_ids, {othello.id})
def test_get_users_for_spectators(self) -> None:
# Checks that spectators can fetch users data.
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
# Try with a realm with no web-public channels.
with self.assert_database_query_count(2):
result = self.client_get("/json/users", subdomain="lear")
self.assert_json_error(
result,
"Not logged in: API authentication or user session required",
status_code=401,
)
with self.assert_database_query_count(4):
result = self.client_get("/json/users")
self.assert_json_success(result)
result_dict = orjson.loads(result.content)
all_fetched_users = result_dict["members"]
self.assertEqual(
len(all_fetched_users), UserProfile.objects.filter(realm=hamlet.realm).count()
)
user_ids_to_fetch = [hamlet.id, othello.id]
with self.assert_database_query_count(4):
result_dict = orjson.loads(
self.client_get(
"/json/users", {"user_ids": orjson.dumps(user_ids_to_fetch).decode()}
).content
)
all_fetched_users = result_dict["members"]
self.assertCountEqual(
[user["user_id"] for user in all_fetched_users],
user_ids_to_fetch,
)
class DeleteUserTest(ZulipTestCase):
def test_do_delete_user(self) -> None:

View File

@@ -91,6 +91,7 @@ from zerver.models.realms import (
DomainNotAllowedForRealmError,
EmailContainsPlusError,
InvalidFakeEmailDomainError,
Realm,
)
from zerver.models.users import (
get_user_by_delivery_email,
@@ -722,12 +723,13 @@ def get_bots_backend(request: HttpRequest, user_profile: UserProfile) -> HttpRes
def get_user_data(
user_profile: UserProfile,
user_profile: UserProfile | None,
include_custom_profile_fields: bool,
client_gravatar: bool,
*,
target_user: UserProfile | None = None,
user_ids: list[int] | None = None,
realm: Realm | None = None,
) -> dict[str, Any]:
"""
The client_gravatar field here is set to True by default assuming that clients
@@ -735,6 +737,8 @@ def get_user_data(
an optimization than it might seem because gravatar URLs contain MD5 hashes that
compress very poorly compared to other data.
"""
if realm is None:
assert user_profile is not None
realm = user_profile.realm
members = get_users_for_api(
@@ -780,17 +784,28 @@ def get_member_backend(
@typed_endpoint
def get_members_backend(
request: HttpRequest,
user_profile: UserProfile,
maybe_user_profile: UserProfile | AnonymousUser,
*,
user_ids: Json[list[int]] | None = None,
include_custom_profile_fields: Json[bool] = False,
client_gravatar: Json[bool] = True,
) -> HttpResponse:
if isinstance(maybe_user_profile, UserProfile):
user_profile = maybe_user_profile
realm = user_profile.realm
else:
realm = get_valid_realm_from_request(request)
if not realm.allow_web_public_streams_access():
raise MissingAuthenticationError
user_profile = None
data = get_user_data(
user_profile,
include_custom_profile_fields,
client_gravatar,
user_ids=user_ids,
realm=realm,
)
return json_success(request, data)

View File

@@ -322,7 +322,9 @@ v1_api_and_json_patterns = [
# realm/deactivate -> zerver.views.deactivate_realm
rest_path("realm/deactivate", POST=deactivate_realm),
# users -> zerver.views.users
rest_path("users", GET=get_members_backend, POST=create_user_backend),
rest_path(
"users", GET=(get_members_backend, {"allow_anonymous_user_web"}), POST=create_user_backend
),
rest_path("users/me", GET=get_profile_backend, DELETE=deactivate_user_own_backend),
rest_path("users/<int:user_id>/reactivate", POST=reactivate_user_backend),
rest_path(