Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Implement MSC2815: allow room moderators to view redacted event content #12427

Merged
merged 5 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/12427.feature
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions synapse/api/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -483,6 +485,22 @@ def __init__(self, inner_exception: BaseException, can_retry: bool):
self.can_retry = can_retry


class UnredactedContentDeletedError(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.
Expand Down
3 changes: 3 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
15 changes: 13 additions & 2 deletions synapse/handlers/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -148,14 +153,20 @@ 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:
SynapseError if there was a problem retrieving this event, or
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
Expand Down
46 changes: 45 additions & 1 deletion synapse/rest/client/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +30,7 @@
MissingClientTokenError,
ShadowBanError,
SynapseError,
UnredactedContentDeletedError,
)
from synapse.api.filtering import Filter
from synapse.events.utils import format_event_for_client_v2
Expand Down Expand Up @@ -643,18 +645,55 @@ 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
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 = self.msc2815_enabled and (
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)
squahtx marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -663,6 +702,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 UnredactedContentDeletedError(self.content_keep_ms)

# Ensure there are bundled aggregations available.
aggregations = await self._relations_handler.get_bundled_aggregations(
[event], requester.user.to_string()
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
)
Expand Down
18 changes: 18 additions & 0 deletions synapse/storage/databases/main/events_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,24 @@ async def get_received_ts(self, event_id: str) -> Optional[int]:
desc="get_received_ts",
)

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",
)
return any(censored_redactions_list)

# Inform mypy that if allow_none is False (the default) then get_event
# always returns an EventBase.
@overload
Expand Down