Merge branch 'develop' into travis/fix-quarantine-list

This commit is contained in:
Travis Ralston
2025-12-17 21:50:46 -07:00
committed by GitHub
12 changed files with 205 additions and 41 deletions

1
changelog.d/19231.bugfix Normal file
View File

@@ -0,0 +1 @@
Fix a bug where Mastodon posts (and possibly other embeds) have the wrong description for URL previews.

View File

@@ -0,0 +1 @@
Implemented pagination for the [MSC2666](https://github.com/matrix-org/matrix-spec-proposals/pull/2666) mutual rooms endpoint. Contributed by @tulir @ Beeper.

View File

@@ -0,0 +1 @@
Admin API: add worker support to `GET /_synapse/admin/v2/users/<user_id>`.

View File

@@ -0,0 +1 @@
Improve proxy support for the `federation_client.py` dev script. Contributed by Denis Kasak (@dkasak).

View File

@@ -255,6 +255,8 @@ information.
^/_matrix/client/(api/v1|r0|v3|unstable)/directory/room/.*$ ^/_matrix/client/(api/v1|r0|v3|unstable)/directory/room/.*$
^/_matrix/client/(r0|v3|unstable)/capabilities$ ^/_matrix/client/(r0|v3|unstable)/capabilities$
^/_matrix/client/(r0|v3|unstable)/notifications$ ^/_matrix/client/(r0|v3|unstable)/notifications$
# Admin API requests
^/_synapse/admin/v1/rooms/[^/]+$ ^/_synapse/admin/v1/rooms/[^/]+$
# Encryption requests # Encryption requests
@@ -300,6 +302,9 @@ Additionally, the following REST endpoints can be handled for GET requests:
# Presence requests # Presence requests
^/_matrix/client/(api/v1|r0|v3|unstable)/presence/ ^/_matrix/client/(api/v1|r0|v3|unstable)/presence/
# Admin API requests
^/_synapse/admin/v2/users/[^/]+$
Pagination requests can also be handled, but all requests for a given Pagination requests can also be handled, but all requests for a given
room must be routed to the same instance. Additionally, care must be taken to room must be routed to the same instance. Additionally, care must be taken to
ensure that the purge history admin API is not used while pagination requests ensure that the purge history admin API is not used while pagination requests

View File

@@ -145,7 +145,7 @@ def request(
print("Requesting %s" % dest, file=sys.stderr) print("Requesting %s" % dest, file=sys.stderr)
s = requests.Session() s = requests.Session()
s.mount("matrix-federation://", MatrixConnectionAdapter()) s.mount("matrix-federation://", MatrixConnectionAdapter(verify_tls=verify_tls))
headers: dict[str, str] = { headers: dict[str, str] = {
"Authorization": authorization_headers[0], "Authorization": authorization_headers[0],
@@ -267,6 +267,17 @@ def read_args_from_config(args: argparse.Namespace) -> None:
class MatrixConnectionAdapter(HTTPAdapter): class MatrixConnectionAdapter(HTTPAdapter):
"""
A Matrix federation-aware HTTP Adapter.
"""
verify_tls: bool
"""whether to verify the remote server's TLS certificate."""
def __init__(self, verify_tls: bool = True) -> None:
self.verify_tls = verify_tls
super().__init__()
def send( def send(
self, self,
request: PreparedRequest, request: PreparedRequest,
@@ -280,7 +291,7 @@ class MatrixConnectionAdapter(HTTPAdapter):
assert isinstance(request.url, str) assert isinstance(request.url, str)
parsed = urlparse.urlsplit(request.url) parsed = urlparse.urlsplit(request.url)
server_name = parsed.netloc server_name = parsed.netloc
well_known = self._get_well_known(parsed.netloc) well_known = self._get_well_known(parsed.netloc, verify_tls=self.verify_tls)
if well_known: if well_known:
server_name = well_known server_name = well_known
@@ -318,6 +329,21 @@ class MatrixConnectionAdapter(HTTPAdapter):
print( print(
f"Connecting to {host}:{port} with SNI {ssl_server_name}", file=sys.stderr f"Connecting to {host}:{port} with SNI {ssl_server_name}", file=sys.stderr
) )
if proxies:
scheme = parsed.scheme
if isinstance(scheme, bytes):
scheme = scheme.decode("utf-8")
proxy_for_scheme = proxies.get(scheme)
if proxy_for_scheme:
return self.proxy_manager_for(proxy_for_scheme).connection_from_host(
host,
port=port,
scheme="https",
pool_kwargs={"server_hostname": ssl_server_name},
)
return self.poolmanager.connection_from_host( return self.poolmanager.connection_from_host(
host, host,
port=port, port=port,
@@ -368,7 +394,7 @@ class MatrixConnectionAdapter(HTTPAdapter):
return server_name, 8448, server_name return server_name, 8448, server_name
@staticmethod @staticmethod
def _get_well_known(server_name: str) -> str | None: def _get_well_known(server_name: str, verify_tls: bool = True) -> str | None:
if ":" in server_name: if ":" in server_name:
# explicit port, or ipv6 literal. Either way, no .well-known # explicit port, or ipv6 literal. Either way, no .well-known
return None return None
@@ -379,7 +405,7 @@ class MatrixConnectionAdapter(HTTPAdapter):
print(f"fetching {uri}", file=sys.stderr) print(f"fetching {uri}", file=sys.stderr)
try: try:
resp = requests.get(uri) resp = requests.get(uri, verify=verify_tls)
if resp.status_code != 200: if resp.status_code != 200:
print("%s gave %i" % (uri, resp.status_code), file=sys.stderr) print("%s gave %i" % (uri, resp.status_code), file=sys.stderr)
return None return None

View File

@@ -331,10 +331,16 @@ class UrlPreviewer:
# response failed or is incomplete. # response failed or is incomplete.
og_from_html = parse_html_to_open_graph(tree) og_from_html = parse_html_to_open_graph(tree)
# Compile the Open Graph response by using the scraped # Compile an Open Graph response by combining the oEmbed response
# information from the HTML and overlaying any information # and the information from the HTML, with information in the HTML
# from the oEmbed response. # preferred.
og = {**og_from_html, **og_from_oembed} #
# The ordering here is intentional: certain websites (especially
# SPA JavaScript-based ones) including Mastodon and YouTube provide
# almost complete OpenGraph descriptions but only stubs for oEmbed,
# with further oEmbed information being populated with JavaScript,
# that Synapse won't execute.
og = og_from_oembed | og_from_html
await self._precache_image_url(user, media_info, og) await self._precache_image_url(user, media_info, og)
else: else:

View File

@@ -119,6 +119,7 @@ from synapse.rest.admin.users import (
UserRegisterServlet, UserRegisterServlet,
UserReplaceMasterCrossSigningKeyRestServlet, UserReplaceMasterCrossSigningKeyRestServlet,
UserRestServletV2, UserRestServletV2,
UserRestServletV2Get,
UsersRestServletV2, UsersRestServletV2,
UsersRestServletV3, UsersRestServletV3,
UserTokenRestServlet, UserTokenRestServlet,
@@ -281,6 +282,8 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
# matrix_authentication_service integration uses the dedicated MAS API. # matrix_authentication_service integration uses the dedicated MAS API.
if hs.config.experimental.msc3861.enabled: if hs.config.experimental.msc3861.enabled:
register_servlets_for_msc3861_delegation(hs, http_server) register_servlets_for_msc3861_delegation(hs, http_server)
else:
UserRestServletV2Get(hs).register(http_server)
return return

View File

@@ -210,7 +210,7 @@ class UsersRestServletV3(UsersRestServletV2):
return parse_boolean(request, "deactivated") return parse_boolean(request, "deactivated")
class UserRestServletV2(RestServlet): class UserRestServletV2Get(RestServlet):
PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)$", "v2") PATTERNS = admin_patterns("/users/(?P<user_id>[^/]*)$", "v2")
"""Get request to list user details. """Get request to list user details.
@@ -220,22 +220,6 @@ class UserRestServletV2(RestServlet):
returns: returns:
200 OK with user details if success otherwise an error. 200 OK with user details if success otherwise an error.
Put request to allow an administrator to add or modify a user.
This needs user to have administrator access in Synapse.
We use PUT instead of POST since we already know the id of the user
object to create. POST could be used to create guests.
PUT /_synapse/admin/v2/users/<user_id>
{
"password": "secret",
"displayname": "User"
}
returns:
201 OK with new user object if user was created or
200 OK with modified user object if user was modified
otherwise an error.
""" """
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
@@ -267,6 +251,28 @@ class UserRestServletV2(RestServlet):
return HTTPStatus.OK, user_info_dict return HTTPStatus.OK, user_info_dict
class UserRestServletV2(UserRestServletV2Get):
"""
Put request to allow an administrator to add or modify a user.
This needs user to have administrator access in Synapse.
We use PUT instead of POST since we already know the id of the user
object to create. POST could be used to create guests.
Note: This inherits from `UserRestServletV2Get`, so also supports the `GET` route.
PUT /_synapse/admin/v2/users/<user_id>
{
"password": "secret",
"displayname": "User"
}
returns:
201 OK with new user object if user was created or
200 OK with modified user object if user was modified
otherwise an error.
"""
async def on_PUT( async def on_PUT(
self, request: SynapseRequest, user_id: str self, request: SynapseRequest, user_id: str
) -> tuple[int, JsonMapping]: ) -> tuple[int, JsonMapping]:

View File

@@ -19,9 +19,12 @@
# #
# #
import logging import logging
from bisect import bisect
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unpaddedbase64 import decode_base64, encode_base64
from synapse.api.errors import Codes, SynapseError from synapse.api.errors import Codes, SynapseError
from synapse.http.server import HttpServer from synapse.http.server import HttpServer
from synapse.http.servlet import RestServlet, parse_strings_from_args from synapse.http.servlet import RestServlet, parse_strings_from_args
@@ -35,10 +38,34 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MUTUAL_ROOMS_BATCH_LIMIT = 100
def _parse_mutual_rooms_batch_token_args(args: dict[bytes, list[bytes]]) -> str | None:
from_batches = parse_strings_from_args(args, "from")
if not from_batches:
return None
if len(from_batches) > 1:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Duplicate from query parameter",
errcode=Codes.INVALID_PARAM,
)
if from_batches[0]:
try:
return decode_base64(from_batches[0]).decode("utf-8")
except Exception:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Malformed from token",
errcode=Codes.INVALID_PARAM,
)
return None
class UserMutualRoomsServlet(RestServlet): class UserMutualRoomsServlet(RestServlet):
""" """
GET /uk.half-shot.msc2666/user/mutual_rooms?user_id={user_id} HTTP/1.1 GET /uk.half-shot.msc2666/user/mutual_rooms?user_id={user_id}&from={token} HTTP/1.1
""" """
PATTERNS = client_patterns( PATTERNS = client_patterns(
@@ -56,6 +83,7 @@ class UserMutualRoomsServlet(RestServlet):
args: dict[bytes, list[bytes]] = request.args # type: ignore args: dict[bytes, list[bytes]] = request.args # type: ignore
user_ids = parse_strings_from_args(args, "user_id", required=True) user_ids = parse_strings_from_args(args, "user_id", required=True)
from_batch = _parse_mutual_rooms_batch_token_args(args)
if len(user_ids) > 1: if len(user_ids) > 1:
raise SynapseError( raise SynapseError(
@@ -64,29 +92,52 @@ class UserMutualRoomsServlet(RestServlet):
errcode=Codes.INVALID_PARAM, errcode=Codes.INVALID_PARAM,
) )
# We don't do batching, so a batch token is illegal by default
if b"batch_token" in args:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"Unknown batch_token",
errcode=Codes.INVALID_PARAM,
)
user_id = user_ids[0] user_id = user_ids[0]
requester = await self.auth.get_user_by_req(request) requester = await self.auth.get_user_by_req(request)
if user_id == requester.user.to_string(): if user_id == requester.user.to_string():
raise SynapseError( raise SynapseError(
HTTPStatus.UNPROCESSABLE_ENTITY, HTTPStatus.BAD_REQUEST,
"You cannot request a list of shared rooms with yourself", "You cannot request a list of shared rooms with yourself",
errcode=Codes.INVALID_PARAM, errcode=Codes.UNKNOWN,
) )
rooms = await self.store.get_mutual_rooms_between_users( # Sort here instead of the database function, so that we don't expose
# clients to any unrelated changes to the sorting algorithm.
rooms = sorted(
await self.store.get_mutual_rooms_between_users(
frozenset((requester.user.to_string(), user_id)) frozenset((requester.user.to_string(), user_id))
) )
)
return 200, {"joined": list(rooms)} if from_batch:
# A from_batch token was provided, so cut off any rooms where the ID is
# lower than or equal to the token. This method doesn't care whether the
# provided token room still exists, nor whether it's even a real room ID.
#
# However, if rooms with a lower ID are added after the token was issued,
# they will not be included until the client makes a new request without a
# from token. This is considered acceptable, as clients generally won't
# persist these results for long periods.
rooms = rooms[bisect(rooms, from_batch) :]
if len(rooms) <= MUTUAL_ROOMS_BATCH_LIMIT:
# We've reached the end of the list, don't return a batch token
return 200, {"joined": rooms}
rooms = rooms[:MUTUAL_ROOMS_BATCH_LIMIT]
# We use urlsafe unpadded base64 encoding for the batch token in order to
# handle funny room IDs in old pre-v12 rooms properly. We also truncate it
# to stay within the 255-character limit of opaque tokens.
next_batch = encode_base64(rooms[-1].encode("utf-8"), urlsafe=True)[:255]
# Due to the truncation, it is technically possible to have conflicting next
# batches by creating hundreds of rooms with the same 191 character prefix
# in the room ID. In the event that some silly user does that, don't let
# them paginate further.
if next_batch == from_batch:
return 200, {"joined": rooms}
return 200, {"joined": list(rooms), "next_batch": next_batch}
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:

View File

@@ -55,12 +55,16 @@ class UserMutualRoomsTest(unittest.HomeserverTestCase):
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main self.store = hs.get_datastores().main
mutual_rooms.MUTUAL_ROOMS_BATCH_LIMIT = 10
def _get_mutual_rooms(self, token: str, other_user: str) -> FakeChannel: def _get_mutual_rooms(
self, token: str, other_user: str, since_token: str | None = None
) -> FakeChannel:
return self.make_request( return self.make_request(
"GET", "GET",
"/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms" "/_matrix/client/unstable/uk.half-shot.msc2666/user/mutual_rooms"
f"?user_id={quote(other_user)}", f"?user_id={quote(other_user)}"
+ (f"&from={quote(since_token)}" if since_token else ""),
access_token=token, access_token=token,
) )
@@ -141,6 +145,52 @@ class UserMutualRoomsTest(unittest.HomeserverTestCase):
for room_id_id in channel.json_body["joined"]: for room_id_id in channel.json_body["joined"]:
self.assertIn(room_id_id, [room_id_one, room_id_two]) self.assertIn(room_id_id, [room_id_one, room_id_two])
def _create_rooms_for_pagination_test(
self, count: int
) -> tuple[str, str, list[str]]:
u1 = self.register_user("user1", "pass")
u1_token = self.login(u1, "pass")
u2 = self.register_user("user2", "pass")
u2_token = self.login(u2, "pass")
room_ids = []
for i in range(count):
room_id = self.helper.create_room_as(u1, is_public=i % 2 == 0, tok=u1_token)
self.helper.invite(room_id, src=u1, targ=u2, tok=u1_token)
self.helper.join(room_id, user=u2, tok=u2_token)
room_ids.append(room_id)
room_ids.sort()
return u1_token, u2, room_ids
def test_shared_room_list_pagination_two_pages(self) -> None:
u1_token, u2, room_ids = self._create_rooms_for_pagination_test(15)
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids[0:10])
self.assertIn("next_batch", channel.json_body)
channel = self._get_mutual_rooms(u1_token, u2, channel.json_body["next_batch"])
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids[10:20])
self.assertNotIn("next_batch", channel.json_body)
def test_shared_room_list_pagination_one_page(self) -> None:
u1_token, u2, room_ids = self._create_rooms_for_pagination_test(10)
channel = self._get_mutual_rooms(u1_token, u2)
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(channel.json_body["joined"], room_ids)
self.assertNotIn("next_batch", channel.json_body)
def test_shared_room_list_pagination_invalid_token(self) -> None:
u1_token, u2, room_ids = self._create_rooms_for_pagination_test(10)
channel = self._get_mutual_rooms(u1_token, u2, "!<>##faketoken")
self.assertEqual(400, channel.code, channel.result)
self.assertEqual(
"M_INVALID_PARAM", channel.json_body["errcode"], channel.result
)
def test_shared_room_list_after_leave(self) -> None: def test_shared_room_list_after_leave(self) -> None:
""" """
A room should no longer be considered shared if the other A room should no longer be considered shared if the other
@@ -172,3 +222,14 @@ class UserMutualRoomsTest(unittest.HomeserverTestCase):
channel = self._get_mutual_rooms(u2_token, u1) channel = self._get_mutual_rooms(u2_token, u1)
self.assertEqual(200, channel.code, channel.result) self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 0) self.assertEqual(len(channel.json_body["joined"]), 0)
def test_shared_room_list_nonexistent_user(self) -> None:
u1 = self.register_user("user1", "pass")
u1_token = self.login(u1, "pass")
# Check shared rooms from user1's perspective.
# We should see the one room in common
channel = self._get_mutual_rooms(u1_token, "@meow:example.com")
self.assertEqual(200, channel.code, channel.result)
self.assertEqual(len(channel.json_body["joined"]), 0)
self.assertNotIn("next_batch", channel.json_body)

View File

@@ -198,7 +198,9 @@ def default_config(
"rc_invites": { "rc_invites": {
"per_room": {"per_second": 10000, "burst_count": 10000}, "per_room": {"per_second": 10000, "burst_count": 10000},
"per_user": {"per_second": 10000, "burst_count": 10000}, "per_user": {"per_second": 10000, "burst_count": 10000},
"per_issuer": {"per_second": 10000, "burst_count": 10000},
}, },
"rc_room_creation": {"per_second": 10000, "burst_count": 10000},
"rc_3pid_validation": {"per_second": 10000, "burst_count": 10000}, "rc_3pid_validation": {"per_second": 10000, "burst_count": 10000},
"rc_presence": {"per_user": {"per_second": 10000, "burst_count": 10000}}, "rc_presence": {"per_user": {"per_second": 10000, "burst_count": 10000}},
"saml2_enabled": False, "saml2_enabled": False,