1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
Half-Shot
7aaa267b54 Add origin server 2025-09-02 06:25:21 +01:00
Half-Shot
13ffee45ce Merge remote-tracking branch 'origin/develop' into hs/as-profiles 2025-08-29 12:27:52 +01:00
Half-Shot
5abb4cf7a6 fixups 2025-08-28 18:19:57 +01:00
Half-Shot
c213505b8c Initial work to support appservice provided profiles 2025-07-15 11:17:10 +01:00
10 changed files with 168 additions and 28 deletions

View File

@@ -88,6 +88,7 @@ class ApplicationService:
supports_ephemeral: bool = False,
msc3202_transaction_extensions: bool = False,
msc4190_device_management: bool = False,
supports_profile_lookup: bool = False,
):
self.token = token
self.url = (
@@ -102,6 +103,7 @@ class ApplicationService:
self.id = id
self.ip_range_whitelist = ip_range_whitelist
self.supports_ephemeral = supports_ephemeral
self.supports_profile_lookup = supports_profile_lookup
self.msc3202_transaction_extensions = msc3202_transaction_extensions
self.msc4190_device_management = msc4190_device_management

View File

@@ -51,6 +51,9 @@ from synapse.logging import opentracing
from synapse.metrics import SERVER_NAME_LABEL
from synapse.types import DeviceListUpdates, JsonDict, JsonMapping, ThirdPartyInstanceID
from synapse.util.caches.response_cache import ResponseCache
from synapse.types import (
UserID,
)
if TYPE_CHECKING:
from synapse.server import HomeServer
@@ -259,6 +262,53 @@ class ApplicationServiceApi(SimpleHttpClient):
logger.warning("query_3pe to %s threw exception %s", service.url, ex)
return []
async def query_profile(
self,
service: "ApplicationService",
user_id: str,
from_user_id: Optional[UserID] = None,
key: Optional[str] = None,
origin_server: Optional[str] = None,
) -> Optional[JsonDict]:
if service.url is None:
return None
# This is required by the configuration.
assert service.hs_token is not None
try:
args = {}
if self.config.use_appservice_legacy_authorization:
args["access_token"] = service.hs_token
if from_user_id:
args["from_user_id"] = from_user_id.to_string()
if origin_server:
args["origin_server"] = origin_server
url = f"{service.url}{APP_SERVICE_PREFIX}/profile/{urllib.parse.quote(user_id)}"
if key:
url += f"/{urllib.parse.quote(key)}"
response = await self.get_json(
url,
args,
headers=self._get_headers(service),
)
if key:
if key in response:
return {key: response[key]}
else:
raise Exception(f"Missing {key} in response to profile request")
return response
except CodeMessageException as e:
if e.code == 404:
return None
logger.warning("query_user to %s received %s", service.url, e.code)
except Exception as ex:
logger.warning("query_user to %s threw exception %s", service.url, ex)
return None
async def get_3pe_protocol(
self, service: "ApplicationService", protocol: str
) -> Optional[JsonDict]:

View File

@@ -170,6 +170,7 @@ def _load_appservice(
ip_range_whitelist = IPSet(as_info.get("ip_range_whitelist"))
supports_ephemeral = as_info.get("de.sorunome.msc2409.push_ephemeral", False)
lookup_profiles = as_info.get("uk.half-shot.lookup_profile", False)
# Opt-in flag for the MSC3202-specific transactional behaviour.
# When enabled, appservice transactions contain the following information:
@@ -205,6 +206,7 @@ def _load_appservice(
rate_limited=rate_limited,
ip_range_whitelist=ip_range_whitelist,
supports_ephemeral=supports_ephemeral,
supports_profile_lookup=lookup_profiles,
msc3202_transaction_extensions=msc3202_transaction_extensions,
msc4190_device_management=msc4190_enabled,
)

View File

@@ -736,6 +736,27 @@ class ApplicationServicesHandler:
return True
return False
async def query_profile(
self, user_id: str, from_user_id: Optional[UserID] = None, key: Optional[str] = None, origin_server: Optional[str] = None
) -> Optional[JsonDict]:
"""Check if any application service knows this user_id exists.
Args:
user_id: The user to query if they exist on any AS.
Returns:
True if this user exists on at least one application service.
"""
user_query_services = self._get_services_for_user(user_id=user_id)
accumulated_profile = {}
for user_service in user_query_services:
if user_service.supports_profile_lookup:
profile = await self.appservice_api.query_profile(
user_service, user_id, from_user_id, key, origin_server
)
if profile:
accumulated_profile.update(profile)
return accumulated_profile
async def query_room_alias_exists(
self, room_alias: RoomAlias
) -> Optional[RoomAliasMapping]:

View File

@@ -492,6 +492,10 @@ class EventCreationHandler:
self.store = hs.get_datastores().main
self._storage_controllers = hs.get_storage_controllers()
self.state = hs.get_state_handler()
self.clock = hs.get_clock()
self.validator = EventValidator()
self.event_builder_factory = hs.get_event_builder_factory()
self.server_name = hs.hostname
self.profile_handler = hs.get_profile_handler()
self.notifier = hs.get_notifier()
self.config = hs.config

View File

@@ -77,7 +77,14 @@ class ProfileHandler:
self._third_party_rules = hs.get_module_api_callbacks().third_party_event_rules
async def get_profile(self, user_id: str, ignore_backoff: bool = True) -> JsonDict:
self._as = hs.get_application_service_handler()
async def get_profile(
self,
user_id: str,
ignore_backoff: bool = True,
from_user_id: Optional[UserID] = None,
) -> JsonDict:
"""
Get a user's profile as a JSON dictionary.
@@ -90,6 +97,7 @@ class ProfileHandler:
fields, if set. For remote queries it may contain arbitrary information.
"""
target_user = UserID.from_string(user_id)
ret = {}
if self.hs.is_mine(target_user):
profileinfo = await self.store.get_profileinfo(target_user)
@@ -103,15 +111,12 @@ class ProfileHandler:
raise SynapseError(404, "Profile was not found", Codes.NOT_FOUND)
# Do not include display name or avatar if unset.
ret = {}
if profileinfo.display_name is not None:
ret[ProfileFields.DISPLAYNAME] = profileinfo.display_name
if profileinfo.avatar_url is not None:
ret[ProfileFields.AVATAR_URL] = profileinfo.avatar_url
if extra_fields:
ret.update(extra_fields)
return ret
else:
try:
result = await self.federation.make_query(
@@ -120,7 +125,7 @@ class ProfileHandler:
args={"user_id": user_id},
ignore_backoff=ignore_backoff,
)
return result
ret = result
except RequestSendFailed as e:
raise SynapseError(502, "Failed to fetch profile") from e
except HttpResponseException as e:
@@ -133,7 +138,17 @@ class ProfileHandler:
raise SynapseError(502, "Failed to fetch profile")
raise e.to_synapse_error()
async def get_displayname(self, target_user: UserID) -> Optional[str]:
# Check whether the appservice has any information about the user.
logger.info(f"query_profile {user_id} {from_user_id}")
as_profile = await self._as.query_profile(user_id, from_user_id)
ret.update(as_profile)
return ret
async def get_displayname(
self, target_user: UserID, from_user_id: Optional[UserID] = None
) -> Optional[str]:
"""
Fetch a user's display name from their profile.
@@ -143,6 +158,14 @@ class ProfileHandler:
Returns:
The user's display name or None if unset.
"""
# Check whether the appservice has any information about the user.
as_profile = await self._as.query_profile(
target_user.to_string(), from_user_id, "displayname"
)
if "displayname" in as_profile:
return as_profile.displayname
if self.hs.is_mine(target_user):
try:
displayname = await self.store.get_profile_displayname(target_user)
@@ -238,7 +261,9 @@ class ProfileHandler:
if propagate:
await self._update_join_states(requester, target_user)
async def get_avatar_url(self, target_user: UserID) -> Optional[str]:
async def get_avatar_url(
self, target_user: UserID, from_user_id: Optional[UserID]
) -> Optional[str]:
"""
Fetch a user's avatar URL from their profile.
@@ -248,6 +273,14 @@ class ProfileHandler:
Returns:
The user's avatar URL or None if unset.
"""
# Check whether the appservice has any information about the user.
as_profile = await self._as.query_profile(
target_user.to_string(), from_user_id, "avatar_url"
)
if "avatar_url" in as_profile:
return as_profile.avatar_url
if self.hs.is_mine(target_user):
try:
avatar_url = await self.store.get_profile_avatar_url(target_user)
@@ -416,7 +449,10 @@ class ProfileHandler:
return True
async def get_profile_field(
self, target_user: UserID, field_name: str
self,
target_user: UserID,
field_name: str,
from_user_id: Optional[UserID] = None,
) -> JsonValue:
"""
Fetch a user's profile from the database for local users and over federation
@@ -429,6 +465,15 @@ class ProfileHandler:
Returns:
The value for the profile field or None if the field does not exist.
"""
logger.info(f"get_profile_field {field_name} {from_user_id}")
# Check whether the appservice has any information about the user.
as_profile = await self._as.query_profile(
target_user.to_string(), from_user_id, field_name
)
if field_name in as_profile:
return as_profile[field_name]
if self.hs.is_mine(target_user):
try:
field_value = await self.store.get_profile_field(
@@ -533,7 +578,15 @@ class ProfileHandler:
if not self.hs.is_mine(user):
raise SynapseError(400, "User is not hosted on this homeserver")
just_field = args.get("field", None)
just_field = args.get("field")
origin_server = args.get("origin")
assert origin_server
# Check whether the appservice has any information about the user.
as_profile = await self._as.query_profile(user.to_string(), key=just_field, origin_server=origin_server)
if just_field and just_field in as_profile:
return as_profile[just_field]
response: JsonDict = {}
try:
@@ -564,6 +617,8 @@ class ProfileHandler:
raise SynapseError(404, "Profile was not found", Codes.NOT_FOUND)
raise
response.update(as_profile)
return response
async def _update_join_states(

View File

@@ -43,7 +43,7 @@ import attr
from twisted.web.iweb import IRequest
from twisted.web.server import Request
from synapse.api.constants import LoginType, ProfileFields
from synapse.api.constants import LoginType
from synapse.api.errors import Codes, NotFoundError, RedirectException, SynapseError
from synapse.config.sso import SsoAttributeRequirement
from synapse.handlers.register import init_counters_for_auth_provider
@@ -813,9 +813,9 @@ class SsoHandler:
upload_name = "sso_avatar_" + hashlib.sha256(picture.read()).hexdigest()
# bail if user already has the same avatar
profile = await self._profile_handler.get_profile(user_id)
if ProfileFields.AVATAR_URL in profile:
avatar_url_parts = profile[ProfileFields.AVATAR_URL].split("/")
avatar_url = await self._profile_handler.get_avatar_url(user_id)
if avatar_url:
avatar_url_parts = avatar_url.split("/")
server_name = avatar_url_parts[-2]
media_id = avatar_url_parts[-1]
if self._is_mine_server_name(server_name):

View File

@@ -1199,7 +1199,7 @@ class ModuleApi:
try:
# Try to fetch the user's profile.
profile = await self._hs.get_profile_handler().get_profile(
target_user_id.to_string(),
target_user_id.to_string(), requester.user
)
except SynapseError as e:
# If the profile couldn't be found, use default values.

View File

@@ -134,6 +134,7 @@ class Mailer:
self.macaroon_gen = self.hs.get_macaroon_generator()
self.state_handler = self.hs.get_state_handler()
self._storage_controllers = hs.get_storage_controllers()
self._profile_handler = hs.get_profile_handler()
self.app_name = app_name
self.email_subjects: EmailSubjectConfig = hs.config.email.email_subjects
@@ -296,7 +297,7 @@ class Mailer:
state_by_room = {}
try:
user_display_name = await self.store.get_profile_displayname(
user_display_name = await self._profile_handler.get_displayname(
UserID.from_string(user_id)
)
if user_display_name is None:

View File

@@ -84,7 +84,7 @@ class ProfileRestServlet(RestServlet):
user = UserID.from_string(user_id)
await self.profile_handler.check_profile_query_allowed(user, requester_user)
ret = await self.profile_handler.get_profile(user_id)
ret = await self.profile_handler.get_profile(user_id, requester_user)
return 200, ret
@@ -113,11 +113,8 @@ class ProfileFieldRestServlet(RestServlet):
async def on_GET(
self, request: SynapseRequest, user_id: str, field_name: str
) -> Tuple[int, JsonDict]:
requester_user = None
if self.hs.config.server.require_auth_for_profile_requests:
requester = await self.auth.get_user_by_req(request)
requester_user = requester.user
requester = await self.auth.get_user_by_req(request)
requester_user = requester.user
if not UserID.is_valid(user_id):
raise SynapseError(
@@ -137,14 +134,20 @@ class ProfileFieldRestServlet(RestServlet):
)
user = UserID.from_string(user_id)
await self.profile_handler.check_profile_query_allowed(user, requester_user)
await self.profile_handler.check_profile_query_allowed(user, requester_user if self.hs.config.server.require_auth_for_profile_requests else None)
if field_name == ProfileFields.DISPLAYNAME:
field_value: JsonValue = await self.profile_handler.get_displayname(user)
field_value: JsonValue = await self.profile_handler.get_displayname(
user, requester_user
)
elif field_name == ProfileFields.AVATAR_URL:
field_value = await self.profile_handler.get_avatar_url(user)
field_value = await self.profile_handler.get_avatar_url(
user, requester_user
)
else:
field_value = await self.profile_handler.get_profile_field(user, field_name)
field_value = await self.profile_handler.get_profile_field(
user, field_name, requester_user
)
return 200, {field_name: field_value}
@@ -272,9 +275,11 @@ class ProfileFieldRestServlet(RestServlet):
class UnstableProfileFieldRestServlet(ProfileFieldRestServlet):
re.compile(
r"^/_matrix/client/unstable/uk\.tcpip\.msc4133/profile/(?P<user_id>[^/]*)/(?P<field_name>[^/]*)"
)
PATTERNS = [
re.compile(
r"^/_matrix/client/unstable/uk\.tcpip\.msc4133/profile/(?P<user_id>[^/]*)/(?P<field_name>[^/]*)"
)
]
def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: