From ffcdac4f348ef763843017f7cc46ed63e9285183 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 8 Apr 2022 21:41:13 +0300 Subject: [PATCH 1/5] Allow room moderators to view redacted event content Implements matrix-org/matrix-spec-proposals#2815 Signed-off-by: Tulir Asokan --- synapse/api/errors.py | 18 ++++++++ synapse/handlers/events.py | 15 ++++++- synapse/rest/client/room.py | 45 ++++++++++++++++++- .../storage/databases/main/events_worker.py | 9 ++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/synapse/api/errors.py b/synapse/api/errors.py index e92db29f6dc6..f52dcbf6dbda 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -79,6 +79,8 @@ class Codes: UNABLE_AUTHORISE_JOIN = "M_UNABLE_TO_AUTHORISE_JOIN" UNABLE_TO_GRANT_JOIN = "M_UNABLE_TO_GRANT_JOIN" + UNREDACTED_CONTENT_DELETED = "FI.MAU.MSC2815_UNREDACTED_CONTENT_DELETED" + class CodeMessageException(RuntimeError): """An exception with integer code and message string attributes. @@ -483,6 +485,22 @@ def __init__(self, inner_exception: BaseException, can_retry: bool): self.can_retry = can_retry +class UnredactedContentDeleted(SynapseError): + def __init__(self, content_keep_ms: Optional[int] = None): + super().__init__( + 404, + "The content for that event has already been erased from the database", + errcode=Codes.UNREDACTED_CONTENT_DELETED, + ) + self.content_keep_ms = content_keep_ms + + def error_dict(self) -> "JsonDict": + extra = {} + if self.content_keep_ms is not None: + extra = {"fi.mau.msc2815.content_keep_ms": self.content_keep_ms} + return cs_error(self.msg, self.errcode, **extra) + + def cs_error(msg: str, code: str = Codes.UNKNOWN, **kwargs: Any) -> "JsonDict": """Utility method for constructing an error response for client-server interactions. diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py index d2ccb5c5d311..4f226008741b 100644 --- a/synapse/handlers/events.py +++ b/synapse/handlers/events.py @@ -21,6 +21,7 @@ from synapse.events import EventBase from synapse.events.utils import SerializeEventConfig from synapse.handlers.presence import format_user_presence_state +from synapse.storage.databases.main.events_worker import EventRedactBehaviour from synapse.streams.config import PaginationConfig from synapse.types import JsonDict, UserID from synapse.visibility import filter_events_for_client @@ -139,7 +140,11 @@ def __init__(self, hs: "HomeServer"): self.storage = hs.get_storage() async def get_event( - self, user: UserID, room_id: Optional[str], event_id: str + self, + user: UserID, + room_id: Optional[str], + event_id: str, + show_redacted: bool = False, ) -> Optional[EventBase]: """Retrieve a single specified event. @@ -148,6 +153,7 @@ async def get_event( room_id: The expected room id. We'll return None if the event's room does not match. event_id: The event ID to obtain. + show_redacted: Should the full content of redacted events be returned? Returns: An event, or None if there is no event matching this ID. Raises: @@ -155,7 +161,12 @@ async def get_event( AuthError if the user does not have the rights to inspect this event. """ - event = await self.store.get_event(event_id, check_room_id=room_id) + redact_behaviour = ( + EventRedactBehaviour.AS_IS if show_redacted else EventRedactBehaviour.REDACT + ) + event = await self.store.get_event( + event_id, check_room_id=room_id, redact_behaviour=redact_behaviour + ) if not event: return None diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 47e152c8cc7a..581d2f29b297 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -21,6 +21,7 @@ from twisted.web.server import Request +from synapse import event_auth from synapse.api.constants import EventTypes, Membership from synapse.api.errors import ( AuthError, @@ -29,6 +30,7 @@ MissingClientTokenError, ShadowBanError, SynapseError, + UnredactedContentDeleted, ) from synapse.api.filtering import Filter from synapse.events.utils import format_event_for_client_v2 @@ -643,18 +645,54 @@ def __init__(self, hs: "HomeServer"): super().__init__() self.clock = hs.get_clock() self._store = hs.get_datastores().main + self._state = hs.get_state_handler() self.event_handler = hs.get_event_handler() self._event_serializer = hs.get_event_client_serializer() self._relations_handler = hs.get_relations_handler() self.auth = hs.get_auth() + self.content_keep_ms = hs.config.server.redaction_retention_period async def on_GET( self, request: SynapseRequest, room_id: str, event_id: str ) -> Tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request, allow_guest=True) + + include_unredacted_content = ( + parse_string( + request, + "fi.mau.msc2815.include_unredacted_content", + allowed_values=("true", "false"), + ) + == "true" + ) + if include_unredacted_content and not await self.auth.is_server_admin( + requester.user + ): + power_level_event = await self._state.get_current_state( + room_id, EventTypes.PowerLevels, "" + ) + + auth_events = {} + if power_level_event: + auth_events[(EventTypes.PowerLevels, "")] = power_level_event + + redact_level = event_auth.get_named_level(auth_events, "redact", 50) + user_level = event_auth.get_user_power_level( + requester.user.to_string(), auth_events + ) + if user_level < redact_level: + raise SynapseError( + 403, + "You don't have permission to view redacted events in this room.", + errcode=Codes.FORBIDDEN, + ) + try: event = await self.event_handler.get_event( - requester.user, room_id, event_id + requester.user, + room_id, + event_id, + show_redacted=include_unredacted_content, ) except AuthError: # This endpoint is supposed to return a 404 when the requester does @@ -663,6 +701,11 @@ async def on_GET( raise SynapseError(404, "Event not found.", errcode=Codes.NOT_FOUND) if event: + if include_unredacted_content and await self._store.have_censored_event( + event_id + ): + raise UnredactedContentDeleted(self.content_keep_ms) + # Ensure there are bundled aggregations available. aggregations = await self._relations_handler.get_bundled_aggregations( [event], requester.user.to_string() diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index a60e3f4fdde0..6d6dd07b6bb0 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -303,6 +303,15 @@ async def get_received_ts(self, event_id: str) -> Optional[int]: desc="get_received_ts", ) + async def have_censored_event(self, event_id: str) -> Optional[bool]: + return await self.db_pool.simple_select_one_onecol( + table="redactions", + keyvalues={"redacts": event_id}, + retcol="have_censored", + desc="get_have_censored", + allow_none=True, + ) + # Inform mypy that if allow_none is False (the default) then get_event # always returns an EventBase. @overload From fff5752605d9c965bf63c68f9bbb171b9cdede69 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 9 Apr 2022 00:15:36 +0300 Subject: [PATCH 2/5] Hide MSC2815 implementation behind experimental flag Signed-off-by: Tulir Asokan --- synapse/config/experimental.py | 3 +++ synapse/rest/client/room.py | 3 ++- synapse/rest/client/versions.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index 43db5fcdd909..7d4e6eee1b36 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -81,3 +81,6 @@ def read_config(self, config: JsonDict, **kwargs): # MSC2654: Unread counts self.msc2654_enabled: bool = experimental.get("msc2654_enabled", False) + + # MSC2815 (allow room moderators to view redacted event content) + self.msc2815_enabled: bool = experimental.get("msc2815_enabled", False) diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 581d2f29b297..1047f0b5d4bc 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -651,13 +651,14 @@ def __init__(self, hs: "HomeServer"): self._relations_handler = hs.get_relations_handler() self.auth = hs.get_auth() self.content_keep_ms = hs.config.server.redaction_retention_period + self.msc2815_enabled = hs.config.experimental.msc2815_enabled async def on_GET( self, request: SynapseRequest, room_id: str, event_id: str ) -> Tuple[int, JsonDict]: requester = await self.auth.get_user_by_req(request, allow_guest=True) - include_unredacted_content = ( + include_unredacted_content = self.msc2815_enabled and ( parse_string( request, "fi.mau.msc2815.include_unredacted_content", diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py index 9a65aa484360..54ff1857bfb5 100644 --- a/synapse/rest/client/versions.py +++ b/synapse/rest/client/versions.py @@ -102,6 +102,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]: # Adds support for thread relations, per MSC3440. "org.matrix.msc3440": self.config.experimental.msc3440_enabled, "org.matrix.msc3440.stable": True, # TODO: remove when "v1.3" is added above + # Allows moderators to fetch redacted event content as described in MSC2815 + "fi.mau.msc2815": self.config.experimental.msc2815_enabled, }, }, ) From 20b1d3d892d99e4324b6c915a2049580a349fed2 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 9 Apr 2022 00:26:47 +0300 Subject: [PATCH 3/5] Add newsfile Signed-off-by: Tulir Asokan --- changelog.d/12427.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/12427.feature diff --git a/changelog.d/12427.feature b/changelog.d/12427.feature new file mode 100644 index 000000000000..e6913c8c0973 --- /dev/null +++ b/changelog.d/12427.feature @@ -0,0 +1 @@ +Implement [MSC2815](https://github.com/matrix-org/matrix-spec-proposals/pull/2815) to allow room moderators to view redacted event content. Contributed by @tulir. From e03197368cc0ad38a8fc2a90856222b700e71258 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 16 Apr 2022 19:03:49 +0300 Subject: [PATCH 4/5] Rename UnredactedContentDeleted to UnredactedContentDeletedError Signed-off-by: Tulir Asokan --- synapse/api/errors.py | 2 +- synapse/rest/client/room.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/api/errors.py b/synapse/api/errors.py index f52dcbf6dbda..cb3b7323d568 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -485,7 +485,7 @@ def __init__(self, inner_exception: BaseException, can_retry: bool): self.can_retry = can_retry -class UnredactedContentDeleted(SynapseError): +class UnredactedContentDeletedError(SynapseError): def __init__(self, content_keep_ms: Optional[int] = None): super().__init__( 404, diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index 1047f0b5d4bc..c69af6670e17 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -30,7 +30,7 @@ MissingClientTokenError, ShadowBanError, SynapseError, - UnredactedContentDeleted, + UnredactedContentDeletedError, ) from synapse.api.filtering import Filter from synapse.events.utils import format_event_for_client_v2 @@ -705,7 +705,7 @@ async def on_GET( if include_unredacted_content and await self._store.have_censored_event( event_id ): - raise UnredactedContentDeleted(self.content_keep_ms) + raise UnredactedContentDeletedError(self.content_keep_ms) # Ensure there are bundled aggregations available. aggregations = await self._relations_handler.get_bundled_aggregations( From 522243b81cfeff4891e4e0a85405590540425262 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 16 Apr 2022 19:04:11 +0300 Subject: [PATCH 5/5] Fix have_censored_event if there are multiple redactions Signed-off-by: Tulir Asokan --- synapse/storage/databases/main/events_worker.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/synapse/storage/databases/main/events_worker.py b/synapse/storage/databases/main/events_worker.py index 6d6dd07b6bb0..551955f425d2 100644 --- a/synapse/storage/databases/main/events_worker.py +++ b/synapse/storage/databases/main/events_worker.py @@ -303,14 +303,23 @@ async def get_received_ts(self, event_id: str) -> Optional[int]: desc="get_received_ts", ) - async def have_censored_event(self, event_id: str) -> Optional[bool]: - return await self.db_pool.simple_select_one_onecol( + async def have_censored_event(self, event_id: str) -> bool: + """Check if an event has been censored, i.e. if the content of the event has been erased + from the database due to a redaction. + + Args: + event_id: The event ID that was redacted. + + Returns: + True if the event has been censored, False otherwise. + """ + censored_redactions_list = await self.db_pool.simple_select_onecol( table="redactions", keyvalues={"redacts": event_id}, retcol="have_censored", desc="get_have_censored", - allow_none=True, ) + return any(censored_redactions_list) # Inform mypy that if allow_none is False (the default) then get_event # always returns an EventBase.