From 98fe280de42edcd63104c4a54747f2f9ab870d09 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 13 Sep 2026 19:19:16 +0200 Subject: [PATCH] feat: Allow room moderators to view redacted event content (MSC2815) --- .../+msc2815-unredacted-content.feature.md | 1 + conduwuit-example.toml | 9 +++ src/api/client/room/event.rs | 73 ++++++++++++++++++- src/api/client/unversioned.rs | 5 ++ src/core/config/mod.rs | 11 +++ src/database/maps.rs | 5 ++ src/service/rooms/timeline/data.rs | 17 +++++ src/service/rooms/timeline/mod.rs | 6 ++ src/service/rooms/timeline/redact.rs | 9 +++ 9 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 changelog.d/+msc2815-unredacted-content.feature.md diff --git a/changelog.d/+msc2815-unredacted-content.feature.md b/changelog.d/+msc2815-unredacted-content.feature.md new file mode 100644 index 000000000..09cb3f3ea --- /dev/null +++ b/changelog.d/+msc2815-unredacted-content.feature.md @@ -0,0 +1 @@ +Added [MSC2815](https://github.com/matrix-org/matrix-spec-proposals/pull/2815), letting room moderators fetch the content of redacted events, behind the `allow_moderators_to_view_redacted_content` config option. Contributed by @eleboucher. diff --git a/conduwuit-example.toml b/conduwuit-example.toml index e52fb7463..cc2ac8d75 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -1664,6 +1664,15 @@ # env: CONTINUWUITY_ALLOW_STICKY_EVENTS #allow_sticky_events = false +# Allow room moderators to view redacted event content ([MSC2815]). +# +# Redacted content is kept in the database indefinitely, and readable by +# users at or above the room's `redact` power level, and server admins. +# +# MSC2815: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 +# +#allow_moderators_to_view_redacted_content = false + # Maximum time federation user can indicate typing. # # env: CONTINUWUITY_TYPING_FEDERATION_TIMEOUT_S diff --git a/src/api/client/room/event.rs b/src/api/client/room/event.rs index c316a7813..91626d636 100644 --- a/src/api/client/room/event.rs +++ b/src/api/client/room/event.rs @@ -1,14 +1,28 @@ -use axum::extract::State; -use conduwuit::{Err, Event, Result, debug_warn, err}; -use ruma::api::client::room::get_room_event; +use axum::extract::{Query, State}; +use conduwuit::{Err, Error, Event, PduEvent, Result, debug_warn, err}; +use futures::{FutureExt, TryFutureExt, future::try_join}; +use http::StatusCode; +use ruma::{ + UserId, + api::{client::room::get_room_event, error::ErrorKind}, +}; +use serde::Deserialize; +use serde_json::json; use crate::{Ruma, client::is_ignored_pdu}; +#[derive(Deserialize)] +pub(crate) struct Msc2815Params { + #[serde(rename = "fi.mau.msc2815.include_unredacted_content", default)] + include_unredacted_content: Option, +} + /// # `GET /_matrix/client/r0/rooms/{roomId}/event/{eventId}` /// /// Gets a single event. pub(crate) async fn get_room_event_route( State(ref services): State, + Query(params): Query, ref body: Ruma, ) -> Result { let sender_user = body.identity.expect_sender_user()?; @@ -54,6 +68,13 @@ pub(crate) async fn get_room_event_route( return Err!(Request(Forbidden("You don't have permission to view this event."))); } + if services.config.allow_moderators_to_view_redacted_content + && params.include_unredacted_content.as_deref() == Some("true") + && event.is_redacted() + { + unredact(services, sender_user, &mut event).await?; + } + if let Err(e) = services .rooms .pdu_metadata @@ -72,3 +93,49 @@ pub(crate) async fn get_room_event_route( Ok(get_room_event::v3::Response::new(event.into_format())) } + +async fn unredact(services: &crate::State, sender_user: &UserId, event: &mut PduEvent) -> Result { + let power_levels = services + .rooms + .state_accessor + .get_room_power_levels(&event.room_id_or_hash()) + .await; + + if power_levels.for_user(sender_user) < power_levels.redact + && !services.users.is_admin(sender_user).await + { + return Err!(Request(Forbidden( + "You don't have permission to view redacted content in this room." + ))); + } + + let Ok(pdu_id) = services.rooms.timeline.get_pdu_id(event.event_id()).await else { + return Err(content_unavailable()); + }; + + let Ok(content) = services + .rooms + .timeline + .get_unredacted_content(&pdu_id) + .await + else { + return Err(content_unavailable()); + }; + + event.content = content; + + Ok(()) +} + +fn content_unavailable() -> Error { + let kind = serde_json::from_value::( + json!({ "errcode": "FI.MAU.MSC2815_UNREDACTED_CONTENT_DELETED" }), + ) + .expect("errcode is a valid custom error kind"); + + Error::Request( + kind, + "The unredacted content of this event is not available.".into(), + StatusCode::NOT_FOUND, + ) +} diff --git a/src/api/client/unversioned.rs b/src/api/client/unversioned.rs index 207bcb620..f35a074ac 100644 --- a/src/api/client/unversioned.rs +++ b/src/api/client/unversioned.rs @@ -34,6 +34,11 @@ pub(crate) async fn get_supported_versions_route( unstable_features.insert("org.matrix.msc4354".to_owned(), true); } + if services.config.allow_moderators_to_view_redacted_content { + // moderators can view redacted content (https://github.com/matrix-org/matrix-spec-proposals/pull/2815) + unstable_features.insert("fi.mau.msc2815".to_owned(), true); + } + Ok(assign!(get_supported_versions::Response::new(versions()), { unstable_features })) diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 2c58cd14f..2935da3e7 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -1776,6 +1776,17 @@ pub struct Config { #[serde(default)] pub allow_sticky_events: bool, + /// Allow room moderators to view redacted event content ([MSC2815]). + /// + /// Redacted content is kept in the database indefinitely, and readable by + /// users at or above the room's `redact` power level, and server admins. + /// + /// MSC2815: https://github.com/matrix-org/matrix-spec-proposals/pull/2815 + /// + /// default: false + #[serde(default)] + pub allow_moderators_to_view_redacted_content: bool, + /// Maximum time federation user can indicate typing. /// /// default: 30 diff --git a/src/database/maps.rs b/src/database/maps.rs index 013fc9270..183355d06 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -153,6 +153,11 @@ pub(super) static MAPS: &[Descriptor] = &[ index_size: 512, ..descriptor::SEQUENTIAL }, + Descriptor { + name: "pduid_unredactedcontent", + key_size_hint: Some(16), + ..descriptor::SEQUENTIAL + }, Descriptor { name: "publicroomids", ..descriptor::RANDOM_SMALL diff --git a/src/service/rooms/timeline/data.rs b/src/service/rooms/timeline/data.rs index 560bbae84..853465044 100644 --- a/src/service/rooms/timeline/data.rs +++ b/src/service/rooms/timeline/data.rs @@ -8,6 +8,7 @@ use conduwuit::{ use database::{Database, Deserialized, Json, KeyVal, Map}; use futures::{FutureExt, Stream, TryFutureExt, TryStreamExt, future::select_ok, pin_mut}; use ruma::{CanonicalJsonObject, EventId, OwnedUserId, RoomId, api::Direction}; +use serde_json::value::RawValue as RawJsonValue; use super::{PduId, RawPduId}; use crate::{Dep, rooms, rooms::short::ShortRoomId}; @@ -16,6 +17,7 @@ pub(super) struct Data { eventid_outlierpdu: Arc, eventid_pduid: Arc, pduid_pdu: Arc, + pduid_unredactedcontent: Arc, roomid_lasttimelinecount: Arc, userroomid_highlightcount: Arc, userroomid_notificationcount: Arc, @@ -36,6 +38,7 @@ impl Data { eventid_outlierpdu: db["eventid_outlierpdu"].clone(), eventid_pduid: db["eventid_pduid"].clone(), pduid_pdu: db["pduid_pdu"].clone(), + pduid_unredactedcontent: db["pduid_unredactedcontent"].clone(), roomid_lasttimelinecount: db["roomid_lasttimelinecount"].clone(), userroomid_highlightcount: db["userroomid_highlightcount"].clone(), userroomid_notificationcount: db["userroomid_notificationcount"].clone(), @@ -234,6 +237,20 @@ impl Data { Ok(()) } + pub(super) fn save_unredacted_content(&self, pdu_id: &RawPduId, content: &RawJsonValue) { + self.pduid_unredactedcontent.raw_put(pdu_id, Json(content)); + } + + pub(super) async fn get_unredacted_content( + &self, + pdu_id: &RawPduId, + ) -> Result> { + self.pduid_unredactedcontent + .get(pdu_id) + .await + .deserialized() + } + /// Returns an iterator over all events and their tokens in a room that /// happened before the event with id `until` in reverse-chronological /// order. diff --git a/src/service/rooms/timeline/mod.rs b/src/service/rooms/timeline/mod.rs index 715d52855..1e68f21ad 100644 --- a/src/service/rooms/timeline/mod.rs +++ b/src/service/rooms/timeline/mod.rs @@ -25,6 +25,7 @@ use ruma::{ events::room::encrypted::Relation, }; use serde::Deserialize; +use serde_json::value::RawValue as RawJsonValue; use self::data::Data; pub use self::{create::pdu_fits, data::PdusIterItem}; @@ -242,6 +243,11 @@ impl Service { self.db.replace_pdu(pdu_id, pdu_json).await } + #[inline] + pub async fn get_unredacted_content(&self, pdu_id: &RawPduId) -> Result> { + self.db.get_unredacted_content(pdu_id).await + } + /// Returns an iterator over all PDUs in a room. Unknown rooms produce no /// items. #[inline] diff --git a/src/service/rooms/timeline/redact.rs b/src/service/rooms/timeline/redact.rs index 92e91cffb..6fa9fb020 100644 --- a/src/service/rooms/timeline/redact.rs +++ b/src/service/rooms/timeline/redact.rs @@ -52,6 +52,15 @@ impl super::Service { .get_room_version(&pdu.room_id_or_hash()) .await?; + if !pdu.is_redacted() + && self + .services + .config + .allow_moderators_to_view_redacted_content + { + self.db.save_unredacted_content(&pdu_id, &pdu.content); + } + pdu.redact(&room_version_id, Some(reason.event_id()))?; let obj = utils::to_canonical_object(&pdu).map_err(|e| {