Skip to content

Commit 6e3c79b

Browse files
clokepanoadragon453
authored andcommitted
Implement MSC4133 to support custom profile fields. (#17488)
Implementation of [MSC4133](matrix-org/matrix-spec-proposals#4133) to support custom profile fields. It is behind an experimental flag and includes tests. ### Pull Request Checklist <!-- Please read https://element-hq.github.io/synapse/latest/development/contributing_guide.html before submitting your pull request --> * [x] Pull request is based on the develop branch * [x] Pull request includes a [changelog file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog). The entry should: - Be a short description of your change which makes sense to users. "Fixed a bug that prevented receiving messages from other servers." instead of "Moved X method from `EventStore` to `EventWorkerStore`.". - Use markdown where necessary, mostly for `code blocks`. - End with either a period (.) or an exclamation mark (!). - Start with a capital letter. - Feel free to credit yourself, by adding a sentence "Contributed by @github_username." or "Contributed by [Your Name]." to the end of the entry. * [x] [Code style](https://element-hq.github.io/synapse/latest/code_style.html) is correct (run the [linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters)) --------- Co-authored-by: Andrew Morgan <[email protected]>
1 parent c95e552 commit 6e3c79b

File tree

13 files changed

+1039
-26
lines changed

13 files changed

+1039
-26
lines changed

changelog.d/17488.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement [MSC4133](https://github.com/matrix-org/matrix-spec-proposals/pull/4133) for custom profile fields.

synapse/api/errors.py

+4
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ class Codes(str, Enum):
132132
# connection.
133133
UNKNOWN_POS = "M_UNKNOWN_POS"
134134

135+
# Part of MSC4133
136+
PROFILE_TOO_LARGE = "M_PROFILE_TOO_LARGE"
137+
KEY_TOO_LARGE = "M_KEY_TOO_LARGE"
138+
135139

136140
class CodeMessageException(RuntimeError):
137141
"""An exception with integer code, a message string attributes and optional headers.

synapse/config/experimental.py

+3
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,9 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
436436
("experimental", "msc4108_delegation_endpoint"),
437437
)
438438

439+
# MSC4133: Custom profile fields
440+
self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False)
441+
439442
# MSC4210: Remove legacy mentions
440443
self.msc4210_enabled: bool = experimental.get("msc4210_enabled", False)
441444

synapse/handlers/profile.py

+133-5
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
SynapseError,
3333
)
3434
from synapse.storage.databases.main.media_repository import LocalMedia, RemoteMedia
35-
from synapse.types import JsonDict, Requester, UserID, create_requester
35+
from synapse.types import JsonDict, JsonValue, Requester, UserID, create_requester
3636
from synapse.util.caches.descriptors import cached
3737
from synapse.util.stringutils import parse_and_validate_mxc_uri
3838

@@ -43,6 +43,8 @@
4343

4444
MAX_DISPLAYNAME_LEN = 256
4545
MAX_AVATAR_URL_LEN = 1000
46+
# Field name length is specced at 255 bytes.
47+
MAX_CUSTOM_FIELD_LEN = 255
4648

4749

4850
class ProfileHandler:
@@ -90,7 +92,15 @@ async def get_profile(self, user_id: str, ignore_backoff: bool = True) -> JsonDi
9092

9193
if self.hs.is_mine(target_user):
9294
profileinfo = await self.store.get_profileinfo(target_user)
93-
if profileinfo.display_name is None and profileinfo.avatar_url is None:
95+
extra_fields = {}
96+
if self.hs.config.experimental.msc4133_enabled:
97+
extra_fields = await self.store.get_profile_fields(target_user)
98+
99+
if (
100+
profileinfo.display_name is None
101+
and profileinfo.avatar_url is None
102+
and not extra_fields
103+
):
94104
raise SynapseError(404, "Profile was not found", Codes.NOT_FOUND)
95105

96106
# Do not include display name or avatar if unset.
@@ -99,6 +109,9 @@ async def get_profile(self, user_id: str, ignore_backoff: bool = True) -> JsonDi
99109
ret[ProfileFields.DISPLAYNAME] = profileinfo.display_name
100110
if profileinfo.avatar_url is not None:
101111
ret[ProfileFields.AVATAR_URL] = profileinfo.avatar_url
112+
if extra_fields:
113+
ret.update(extra_fields)
114+
102115
return ret
103116
else:
104117
try:
@@ -403,6 +416,110 @@ async def check_avatar_size_and_mime_type(self, mxc: str) -> bool:
403416

404417
return True
405418

419+
async def get_profile_field(
420+
self, target_user: UserID, field_name: str
421+
) -> JsonValue:
422+
"""
423+
Fetch a user's profile from the database for local users and over federation
424+
for remote users.
425+
426+
Args:
427+
target_user: The user ID to fetch the profile for.
428+
field_name: The field to fetch the profile for.
429+
430+
Returns:
431+
The value for the profile field or None if the field does not exist.
432+
"""
433+
if self.hs.is_mine(target_user):
434+
try:
435+
field_value = await self.store.get_profile_field(
436+
target_user, field_name
437+
)
438+
except StoreError as e:
439+
if e.code == 404:
440+
raise SynapseError(404, "Profile was not found", Codes.NOT_FOUND)
441+
raise
442+
443+
return field_value
444+
else:
445+
try:
446+
result = await self.federation.make_query(
447+
destination=target_user.domain,
448+
query_type="profile",
449+
args={"user_id": target_user.to_string(), "field": field_name},
450+
ignore_backoff=True,
451+
)
452+
except RequestSendFailed as e:
453+
raise SynapseError(502, "Failed to fetch profile") from e
454+
except HttpResponseException as e:
455+
raise e.to_synapse_error()
456+
457+
return result.get(field_name)
458+
459+
async def set_profile_field(
460+
self,
461+
target_user: UserID,
462+
requester: Requester,
463+
field_name: str,
464+
new_value: JsonValue,
465+
by_admin: bool = False,
466+
deactivation: bool = False,
467+
) -> None:
468+
"""Set a new profile field for a user.
469+
470+
Args:
471+
target_user: the user whose profile is to be changed.
472+
requester: The user attempting to make this change.
473+
field_name: The name of the profile field to update.
474+
new_value: The new field value for this user.
475+
by_admin: Whether this change was made by an administrator.
476+
deactivation: Whether this change was made while deactivating the user.
477+
"""
478+
if not self.hs.is_mine(target_user):
479+
raise SynapseError(400, "User is not hosted on this homeserver")
480+
481+
if not by_admin and target_user != requester.user:
482+
raise AuthError(403, "Cannot set another user's profile")
483+
484+
await self.store.set_profile_field(target_user, field_name, new_value)
485+
486+
# Custom fields do not propagate into the user directory *or* rooms.
487+
profile = await self.store.get_profileinfo(target_user)
488+
await self._third_party_rules.on_profile_update(
489+
target_user.to_string(), profile, by_admin, deactivation
490+
)
491+
492+
async def delete_profile_field(
493+
self,
494+
target_user: UserID,
495+
requester: Requester,
496+
field_name: str,
497+
by_admin: bool = False,
498+
deactivation: bool = False,
499+
) -> None:
500+
"""Delete a field from a user's profile.
501+
502+
Args:
503+
target_user: the user whose profile is to be changed.
504+
requester: The user attempting to make this change.
505+
field_name: The name of the profile field to remove.
506+
by_admin: Whether this change was made by an administrator.
507+
deactivation: Whether this change was made while deactivating the user.
508+
"""
509+
if not self.hs.is_mine(target_user):
510+
raise SynapseError(400, "User is not hosted on this homeserver")
511+
512+
if not by_admin and target_user != requester.user:
513+
raise AuthError(400, "Cannot set another user's profile")
514+
515+
await self.store.delete_profile_field(target_user, field_name)
516+
517+
# Custom fields do not propagate into the user directory *or* rooms.
518+
profile = await self.store.get_profileinfo(target_user)
519+
await self._third_party_rules.on_profile_update(
520+
target_user.to_string(), profile, by_admin, deactivation
521+
)
522+
406523
async def on_profile_query(self, args: JsonDict) -> JsonDict:
407524
"""Handles federation profile query requests."""
408525

@@ -419,13 +536,24 @@ async def on_profile_query(self, args: JsonDict) -> JsonDict:
419536

420537
just_field = args.get("field", None)
421538

422-
response = {}
539+
response: JsonDict = {}
423540
try:
424-
if just_field is None or just_field == "displayname":
541+
if just_field is None or just_field == ProfileFields.DISPLAYNAME:
425542
response["displayname"] = await self.store.get_profile_displayname(user)
426543

427-
if just_field is None or just_field == "avatar_url":
544+
if just_field is None or just_field == ProfileFields.AVATAR_URL:
428545
response["avatar_url"] = await self.store.get_profile_avatar_url(user)
546+
547+
if self.hs.config.experimental.msc4133_enabled:
548+
if just_field is None:
549+
response.update(await self.store.get_profile_fields(user))
550+
elif just_field not in (
551+
ProfileFields.DISPLAYNAME,
552+
ProfileFields.AVATAR_URL,
553+
):
554+
response[just_field] = await self.store.get_profile_field(
555+
user, just_field
556+
)
429557
except StoreError as e:
430558
if e.code == 404:
431559
raise SynapseError(404, "Profile was not found", Codes.NOT_FOUND)

synapse/rest/client/capabilities.py

+17
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
9292
"enabled": self.config.experimental.msc3664_enabled,
9393
}
9494

95+
if self.config.experimental.msc4133_enabled:
96+
response["capabilities"]["uk.tcpip.msc4133.profile_fields"] = {
97+
"enabled": True,
98+
}
99+
100+
# Ensure this is consistent with the legacy m.set_displayname and
101+
# m.set_avatar_url.
102+
disallowed = []
103+
if not self.config.registration.enable_set_displayname:
104+
disallowed.append("displayname")
105+
if not self.config.registration.enable_set_avatar_url:
106+
disallowed.append("avatar_url")
107+
if disallowed:
108+
response["capabilities"]["uk.tcpip.msc4133.profile_fields"][
109+
"disallowed"
110+
] = disallowed
111+
95112
return HTTPStatus.OK, response
96113

97114

0 commit comments

Comments
 (0)