Implement MSC2815: allow room moderators to view redacted event content (#12427)
Implements matrix-org/matrix-spec-proposals#2815 Signed-off-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
parent
eed38c5027
commit
4bc8cb4669
|
@ -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.
|
|
@ -79,6 +79,8 @@ class Codes:
|
||||||
UNABLE_AUTHORISE_JOIN = "M_UNABLE_TO_AUTHORISE_JOIN"
|
UNABLE_AUTHORISE_JOIN = "M_UNABLE_TO_AUTHORISE_JOIN"
|
||||||
UNABLE_TO_GRANT_JOIN = "M_UNABLE_TO_GRANT_JOIN"
|
UNABLE_TO_GRANT_JOIN = "M_UNABLE_TO_GRANT_JOIN"
|
||||||
|
|
||||||
|
UNREDACTED_CONTENT_DELETED = "FI.MAU.MSC2815_UNREDACTED_CONTENT_DELETED"
|
||||||
|
|
||||||
|
|
||||||
class CodeMessageException(RuntimeError):
|
class CodeMessageException(RuntimeError):
|
||||||
"""An exception with integer code and message string attributes.
|
"""An exception with integer code and message string attributes.
|
||||||
|
@ -483,6 +485,22 @@ class RequestSendFailed(RuntimeError):
|
||||||
self.can_retry = can_retry
|
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":
|
def cs_error(msg: str, code: str = Codes.UNKNOWN, **kwargs: Any) -> "JsonDict":
|
||||||
"""Utility method for constructing an error response for client-server
|
"""Utility method for constructing an error response for client-server
|
||||||
interactions.
|
interactions.
|
||||||
|
|
|
@ -78,3 +78,6 @@ class ExperimentalConfig(Config):
|
||||||
|
|
||||||
# MSC2654: Unread counts
|
# MSC2654: Unread counts
|
||||||
self.msc2654_enabled: bool = experimental.get("msc2654_enabled", False)
|
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)
|
||||||
|
|
|
@ -21,6 +21,7 @@ from synapse.api.errors import AuthError, SynapseError
|
||||||
from synapse.events import EventBase
|
from synapse.events import EventBase
|
||||||
from synapse.events.utils import SerializeEventConfig
|
from synapse.events.utils import SerializeEventConfig
|
||||||
from synapse.handlers.presence import format_user_presence_state
|
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.streams.config import PaginationConfig
|
||||||
from synapse.types import JsonDict, UserID
|
from synapse.types import JsonDict, UserID
|
||||||
from synapse.visibility import filter_events_for_client
|
from synapse.visibility import filter_events_for_client
|
||||||
|
@ -141,7 +142,11 @@ class EventHandler:
|
||||||
self.storage = hs.get_storage()
|
self.storage = hs.get_storage()
|
||||||
|
|
||||||
async def get_event(
|
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]:
|
) -> Optional[EventBase]:
|
||||||
"""Retrieve a single specified event.
|
"""Retrieve a single specified event.
|
||||||
|
|
||||||
|
@ -150,6 +155,7 @@ class EventHandler:
|
||||||
room_id: The expected room id. We'll return None if the
|
room_id: The expected room id. We'll return None if the
|
||||||
event's room does not match.
|
event's room does not match.
|
||||||
event_id: The event ID to obtain.
|
event_id: The event ID to obtain.
|
||||||
|
show_redacted: Should the full content of redacted events be returned?
|
||||||
Returns:
|
Returns:
|
||||||
An event, or None if there is no event matching this ID.
|
An event, or None if there is no event matching this ID.
|
||||||
Raises:
|
Raises:
|
||||||
|
@ -157,7 +163,12 @@ class EventHandler:
|
||||||
AuthError if the user does not have the rights to inspect this
|
AuthError if the user does not have the rights to inspect this
|
||||||
event.
|
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:
|
if not event:
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -21,6 +21,7 @@ from urllib import parse as urlparse
|
||||||
|
|
||||||
from twisted.web.server import Request
|
from twisted.web.server import Request
|
||||||
|
|
||||||
|
from synapse import event_auth
|
||||||
from synapse.api.constants import EventTypes, Membership
|
from synapse.api.constants import EventTypes, Membership
|
||||||
from synapse.api.errors import (
|
from synapse.api.errors import (
|
||||||
AuthError,
|
AuthError,
|
||||||
|
@ -29,6 +30,7 @@ from synapse.api.errors import (
|
||||||
MissingClientTokenError,
|
MissingClientTokenError,
|
||||||
ShadowBanError,
|
ShadowBanError,
|
||||||
SynapseError,
|
SynapseError,
|
||||||
|
UnredactedContentDeletedError,
|
||||||
)
|
)
|
||||||
from synapse.api.filtering import Filter
|
from synapse.api.filtering import Filter
|
||||||
from synapse.events.utils import format_event_for_client_v2
|
from synapse.events.utils import format_event_for_client_v2
|
||||||
|
@ -643,18 +645,55 @@ class RoomEventServlet(RestServlet):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.clock = hs.get_clock()
|
self.clock = hs.get_clock()
|
||||||
self._store = hs.get_datastores().main
|
self._store = hs.get_datastores().main
|
||||||
|
self._state = hs.get_state_handler()
|
||||||
self.event_handler = hs.get_event_handler()
|
self.event_handler = hs.get_event_handler()
|
||||||
self._event_serializer = hs.get_event_client_serializer()
|
self._event_serializer = hs.get_event_client_serializer()
|
||||||
self._relations_handler = hs.get_relations_handler()
|
self._relations_handler = hs.get_relations_handler()
|
||||||
self.auth = hs.get_auth()
|
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(
|
async def on_GET(
|
||||||
self, request: SynapseRequest, room_id: str, event_id: str
|
self, request: SynapseRequest, room_id: str, event_id: str
|
||||||
) -> Tuple[int, JsonDict]:
|
) -> Tuple[int, JsonDict]:
|
||||||
requester = await self.auth.get_user_by_req(request, allow_guest=True)
|
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)
|
||||||
|
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:
|
try:
|
||||||
event = await self.event_handler.get_event(
|
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:
|
except AuthError:
|
||||||
# This endpoint is supposed to return a 404 when the requester does
|
# This endpoint is supposed to return a 404 when the requester does
|
||||||
|
@ -663,6 +702,11 @@ class RoomEventServlet(RestServlet):
|
||||||
raise SynapseError(404, "Event not found.", errcode=Codes.NOT_FOUND)
|
raise SynapseError(404, "Event not found.", errcode=Codes.NOT_FOUND)
|
||||||
|
|
||||||
if event:
|
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.
|
# Ensure there are bundled aggregations available.
|
||||||
aggregations = await self._relations_handler.get_bundled_aggregations(
|
aggregations = await self._relations_handler.get_bundled_aggregations(
|
||||||
[event], requester.user.to_string()
|
[event], requester.user.to_string()
|
||||||
|
|
|
@ -101,6 +101,8 @@ class VersionsRestServlet(RestServlet):
|
||||||
"org.matrix.msc3030": self.config.experimental.msc3030_enabled,
|
"org.matrix.msc3030": self.config.experimental.msc3030_enabled,
|
||||||
# Adds support for thread relations, per MSC3440.
|
# Adds support for thread relations, per MSC3440.
|
||||||
"org.matrix.msc3440.stable": True, # TODO: remove when "v1.3" is added above
|
"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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -303,6 +303,24 @@ class EventsWorkerStore(SQLBaseStore):
|
||||||
desc="get_received_ts",
|
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
|
# Inform mypy that if allow_none is False (the default) then get_event
|
||||||
# always returns an EventBase.
|
# always returns an EventBase.
|
||||||
@overload
|
@overload
|
||||||
|
|
Loading…
Reference in New Issue