From 594fe5f98f2941221a1ef6f447c36b89c206c4a8 Mon Sep 17 00:00:00 2001 From: Matthias Ahouansou Date: Mon, 31 Mar 2025 00:39:19 +0100 Subject: [PATCH] feat(media): blocking --- Cargo.lock | 69 ++++++ Cargo.toml | 1 + src/api/client_server/media.rs | 6 + src/api/server_server.rs | 8 + src/database/key_value/media.rs | 408 ++++++++++++++++++++++++++++++-- src/database/mod.rs | 2 + src/service/admin/mod.rs | 168 ++++++++++++- src/service/media/data.rs | 33 ++- src/service/media/mod.rs | 62 ++++- 9 files changed, 738 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6764c0c5..c5d5695b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstyle" version = "1.0.7" @@ -430,6 +445,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -495,6 +524,7 @@ dependencies = [ "axum-server", "base64 0.22.1", "bytes", + "chrono", "clap", "directories", "figment", @@ -1296,6 +1326,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.4.0" @@ -3558,6 +3612,21 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 7ee97b4f..c70055f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,6 +123,7 @@ thread_local = "1.1.7" hmac = "0.12.1" sha-1 = "0.10.1" # used for conduit's CLI and admin room command parsing +chrono = "0.4" clap = { version = "4.3.0", default-features = false, features = [ "derive", "error-context", diff --git a/src/api/client_server/media.rs b/src/api/client_server/media.rs index 93975475..d4c8738d 100644 --- a/src/api/client_server/media.rs +++ b/src/api/client_server/media.rs @@ -199,6 +199,8 @@ async fn get_content( allow_remote: bool, authenticated: bool, ) -> Result { + services().media.check_blocked(server_name, &media_id)?; + if let Ok(Some(FileMeta { content_disposition, content_type, @@ -278,6 +280,8 @@ async fn get_content_as_filename( allow_remote: bool, authenticated: bool, ) -> Result { + services().media.check_blocked(server_name, &media_id)?; + if let Ok(Some(FileMeta { file, content_type, .. })) = services() @@ -371,6 +375,8 @@ async fn get_content_thumbnail( allow_remote: bool, authenticated: bool, ) -> Result { + services().media.check_blocked(server_name, &media_id)?; + if let Some(FileMeta { file, content_type, diff --git a/src/api/server_server.rs b/src/api/server_server.rs index 3f780ebd..5cd46e26 100644 --- a/src/api/server_server.rs +++ b/src/api/server_server.rs @@ -2221,6 +2221,10 @@ pub async fn create_invite_route( pub async fn get_content_route( body: Ruma, ) -> Result { + services() + .media + .check_blocked(services().globals.server_name(), &body.media_id)?; + if let Some(FileMeta { content_disposition, content_type, @@ -2249,6 +2253,10 @@ pub async fn get_content_route( pub async fn get_content_thumbnail_route( body: Ruma, ) -> Result { + services() + .media + .check_blocked(services().globals.server_name(), &body.media_id)?; + let Some(FileMeta { file, content_type, diff --git a/src/database/key_value/media.rs b/src/database/key_value/media.rs index 6f835b5b..f1a3f6e8 100644 --- a/src/database/key_value/media.rs +++ b/src/database/key_value/media.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, ops::Range}; +use std::{collections::BTreeMap, ops::Range, slice::Split}; use ruma::{api::client::error::ErrorKind, OwnedServerName, ServerName, UserId}; use sha2::{digest::Output, Sha256}; @@ -6,7 +6,10 @@ use tracing::error; use crate::{ database::KeyValueDatabase, - service::{self, media::DbFileMeta}, + service::{ + self, + media::{BlockedMediaInfo, DbFileMeta}, + }, utils, Error, Result, }; @@ -20,11 +23,14 @@ impl service::media::Data for KeyValueDatabase { filename: Option<&str>, content_type: Option<&str>, user_id: Option<&UserId>, + is_blocked_filehash: bool, ) -> Result<()> { - let metadata = FilehashMetadata::new(file_size); + if !is_blocked_filehash { + let metadata = FilehashMetadata::new(file_size); - self.filehash_metadata - .insert(&sha256_digest, metadata.value())?; + self.filehash_metadata + .insert(&sha256_digest, metadata.value())?; + }; let mut key = sha256_digest.to_vec(); key.extend_from_slice(servername.as_bytes()); @@ -167,9 +173,12 @@ impl service::media::Data for KeyValueDatabase { value.truncate(32); let sha256_digest = value; + let is_blocked = self.is_blocked_filehash(&sha256_digest)?; let sha256_hex = hex::encode(&sha256_digest); - self.purge_filehash(sha256_digest, false)?; + // If the file is blocked, we want to keep the metadata about it so it can be viewed, + // as well as filehashes blocked + self.purge_filehash(sha256_digest, is_blocked)?; Ok(sha256_hex) }; @@ -195,11 +204,14 @@ impl service::media::Data for KeyValueDatabase { files.push(purge(value)); } } else { - match self.purge_mediaid(server_name, media_id, false) { - Ok(f) => { + match self + .is_blocked(server_name, media_id) + .map(|is_blocked| self.purge_mediaid(server_name, media_id, is_blocked)) + { + Ok(Ok(f)) => { files.append(&mut f.into_iter().map(Ok).collect()); } - Err(e) => files.push(Err(e)), + Ok(Err(e)) | Err(e) => files.push(Err(e)), } } } @@ -221,8 +233,11 @@ impl service::media::Data for KeyValueDatabase { let purge_filehash = |sha256_digest: Vec| { let sha256_hex = hex::encode(&sha256_digest); + let is_blocked = self.is_blocked_filehash(&sha256_digest)?; - self.purge_filehash(sha256_digest, false)?; + // If the file is blocked, we want to keep the metadata about it so it can be viewed, + // as well as filehashes blocked + self.purge_filehash(sha256_digest, is_blocked)?; Ok(sha256_hex) }; @@ -310,11 +325,15 @@ impl service::media::Data for KeyValueDatabase { files.push(purge_filehash(sha256_digest)); } } else { - match self.purge_mediaid(user_id.server_name(), &media_id, false) { - Ok(f) => { + match self + .is_blocked(user_id.server_name(), &media_id) + .map(|is_blocked| { + self.purge_mediaid(user_id.server_name(), &media_id, is_blocked) + }) { + Ok(Ok(f)) => { files.append(&mut f.into_iter().map(Ok).collect()); } - Err(e) => files.push(Err(e)), + Ok(Err(e)) | Err(e) => files.push(Err(e)), } } } @@ -355,8 +374,11 @@ impl service::media::Data for KeyValueDatabase { } let sha256_hex = hex::encode(&sha256_digest); + let is_blocked = self.is_blocked_filehash(&sha256_digest)?; - self.purge_filehash(sha256_digest, false)?; + // If the file is blocked, we want to keep the metadata about it so it can be viewed, + // as well as filehashes blocked + self.purge_filehash(sha256_digest, is_blocked)?; files.push(Ok(sha256_hex)); Ok(()) @@ -379,9 +401,11 @@ impl service::media::Data for KeyValueDatabase { .map(utils::string_from_bytes)? .map_err(|_| Error::bad_database("Invalid Media ID String in metadata key"))?; + let is_blocked = self.is_blocked(&server_name, &media_id)?; + files.append( &mut self - .purge_mediaid(&server_name, &media_id, false)? + .purge_mediaid(&server_name, &media_id, is_blocked)? .into_iter() .map(Ok) .collect(), @@ -406,9 +430,363 @@ impl service::media::Data for KeyValueDatabase { files } + + fn is_blocked(&self, server_name: &ServerName, media_id: &str) -> Result { + let blocked_via_hash = || { + let mut key = server_name.as_bytes().to_vec(); + key.push(0xff); + key.extend_from_slice(media_id.as_bytes()); + + let Some(metadata) = self.servernamemediaid_metadata.get(&key)? else { + return Ok(false); + }; + + let sha256_digest = parse_metadata(&metadata).inspect_err(|e| { + error!("Error parsing metadata for \"mxc://{server_name}/{media_id}\" from servernamemediaid_metadata: {e}"); + })?.sha256_digest; + + self.is_blocked_filehash(&sha256_digest) + }; + + Ok(self.is_directly_blocked(server_name, media_id)? || blocked_via_hash()?) + } + + fn block( + &self, + media: &[(OwnedServerName, String)], + unix_secs: u64, + reason: Option, + ) -> Vec { + let reason = reason.unwrap_or_default(); + let unix_secs = unix_secs.to_be_bytes(); + + let mut errors = Vec::new(); + + for (server_name, media_id) in media { + let mut key = server_name.as_bytes().to_vec(); + key.push(0xff); + key.extend_from_slice(media_id.as_bytes()); + + let mut value = unix_secs.to_vec(); + value.extend_from_slice(reason.as_bytes()); + + if let Err(e) = self.blocked_servername_mediaid.insert(&key, &value) { + errors.push(e); + } + } + + errors + } + + fn block_from_user( + &self, + user_id: &UserId, + now: u64, + reason: &str, + after: Option, + ) -> Vec { + let mut prefix = user_id.server_name().as_bytes().to_vec(); + prefix.push(0xff); + prefix.extend_from_slice(user_id.localpart().as_bytes()); + prefix.push(0xff); + + let mut value = now.to_be_bytes().to_vec(); + value.extend_from_slice(reason.as_bytes()); + + self.servername_userlocalpart_mediaid + .scan_prefix(prefix) + .map(|(k, _)| { + let parts = k.split(|&b| b == 0xff); + + let media_id = parts.last().ok_or_else(|| { + Error::bad_database("Invalid format of key in blocked_servername_mediaid") + })?; + + let mut key = user_id.server_name().as_bytes().to_vec(); + key.push(0xff); + key.extend_from_slice(media_id); + + let Some(mut meta) = self.servernamemediaid_metadata.get(&key)? else { + return Err(Error::bad_database( + "Invalid format of metadata in servernamemediaid_metadata", + )); + }; + meta.truncate(32); + let sha256_digest = meta; + + let Some(metadata) = self + .filehash_metadata + .get(&sha256_digest)? + .map(FilehashMetadata::from_vec) + else { + return Ok(()); + }; + + if after + .map(|after| Ok::(metadata.creation(&sha256_digest)? > after)) + .transpose()? + .unwrap_or(true) + { + self.blocked_servername_mediaid.insert(&key, &value) + } else { + Ok(()) + } + }) + .filter_map(Result::err) + .collect() + } + + fn unblock(&self, media: &[(OwnedServerName, String)]) -> Vec { + let maybe_remove_remaining_metadata = |metadata: &DbFileMeta, errors: &mut Vec| { + for (k, _) in self + .filehash_servername_mediaid + .scan_prefix(metadata.sha256_digest.clone()) + { + if let Some(servername_mediaid) = k.get(32..) { + if let Err(e) = self.blocked_servername_mediaid.remove(servername_mediaid) { + errors.push(e); + } + } else { + error!( + "Invalid format of key in filehash_servername_mediaid for media with sha256 content hash of {}", + hex::encode(&metadata.sha256_digest) + ); + errors.push(Error::BadDatabase( + "Invalid format of key in filehash_servername_mediaid", + )); + } + } + + let thumbnail_id_error = || { + error!( + "Invalid format of key in filehash_thumbnail_id for media with sha256 content hash of {}", + hex::encode(&metadata.sha256_digest) + ); + Error::BadDatabase("Invalid format of value in filehash_thumbnailid") + }; + + for (k, _) in self + .filehash_thumbnailid + .scan_prefix(metadata.sha256_digest.clone()) + { + if let Some(end) = k.len().checked_sub(9) { + if let Some(servername_mediaid) = k.get(32..end) { + if let Err(e) = self.blocked_servername_mediaid.remove(servername_mediaid) { + errors.push(e); + } + } else { + errors.push(thumbnail_id_error()); + } + errors.push(thumbnail_id_error()); + }; + } + + // If we don't have the actual file downloaded anymore, remove the remaining + // metadata of the file + match self + .filehash_metadata + .get(&metadata.sha256_digest) + .map(|opt| opt.is_none()) + { + Err(e) => errors.push(e), + Ok(true) => { + if let Err(e) = self.purge_filehash(metadata.sha256_digest.clone(), false) { + errors.push(e); + } + } + Ok(false) => (), + } + }; + + let mut errors = Vec::new(); + + for (server_name, media_id) in media { + let mut key = server_name.as_bytes().to_vec(); + key.push(0xff); + key.extend_from_slice(media_id.as_bytes()); + + match self + .servernamemediaid_metadata + .get(&key) + .map(|opt| opt.as_deref().map(parse_metadata)) + { + Err(e) => { + errors.push(e); + continue; + } + Ok(None) => (), + Ok(Some(Err(e))) => { + error!("Error parsing metadata for \"mxc://{server_name}/{media_id}\" from servernamemediaid_metadata: {e}"); + errors.push(e); + continue; + } + Ok(Some(Ok(metadata))) => { + maybe_remove_remaining_metadata(&metadata, &mut errors); + } + } + + key.push(0xff); + for (_, v) in self.thumbnailid_metadata.scan_prefix(key) { + match parse_metadata(&v) { + Ok(metadata) => { + maybe_remove_remaining_metadata(&metadata, &mut errors); + } + Err(e) => { + error!("Error parsing metadata for thumbnail of \"mxc://{server_name}/{media_id}\" from thumbnailid_metadata: {e}"); + errors.push(e); + } + } + } + } + + errors + } + + fn list_blocked(&self) -> Vec> { + let parse_servername = |parts: &mut Split<_, _>| { + OwnedServerName::try_from( + utils::string_from_bytes(parts.next().ok_or_else(|| { + Error::BadDatabase("Invalid format of metadata of blocked media") + })?) + .map_err(|_| Error::BadDatabase("Invalid server_name String of blocked data"))?, + ) + .map_err(|_| Error::BadDatabase("Invalid ServerName in blocked_servername_mediaid")) + }; + + let parse_string = + |parts: &mut Split<_, _>| { + utils::string_from_bytes(parts.next().ok_or_else(|| { + Error::BadDatabase("Invalid format of metadata of blocked media") + })?) + .map_err(|_| Error::BadDatabase("Invalid string in blocked media metadata")) + }; + + let splitter = |b: &u8| *b == 0xff; + + self.blocked_servername_mediaid + .iter() + .map(|(k, v)| { + let mut parts = k.split(splitter); + + // Using map_err, as inspect_err causes lifetime issues + // "implementation of `FnOnce` is not general enough" + let log_error = |e| { + error!("Error parsing key of blocked media: {e}"); + e + }; + + let server_name = parse_servername(&mut parts).map_err(log_error)?; + + let media_id = parse_string(&mut parts).map_err(log_error)?; + + let (unix_secs, reason) = v + .split_at_checked(8) + .map(|(secs, reason)| -> Result<(u64, Option)> { + Ok(( + secs.try_into() + .map_err(|_| { + Error::bad_database( + "Invalid block time in blocked_servername_mediaid ", + ) + }) + .map(u64::from_be_bytes)?, + if reason.is_empty() { + None + } else { + Some(utils::string_from_bytes(reason).map_err(|_| { + Error::bad_database("Invalid string in blocked media metadata") + })?) + }, + )) + }) + .ok_or_else(|| { + Error::bad_database("Invalid format of value in blocked_servername_mediaid") + })??; + + let sha256_hex = self.servernamemediaid_metadata.get(&k)?.map(|mut meta| { + meta.truncate(32); + hex::encode(meta) + }); + + Ok(BlockedMediaInfo { + server_name, + media_id, + unix_secs, + reason, + sha256_hex, + }) + }) + .collect() + } + + fn is_blocked_filehash(&self, sha256_digest: &[u8]) -> Result { + for (filehash_servername_mediaid, _) in self + .filehash_servername_mediaid + .scan_prefix(sha256_digest.to_owned()) + { + let servername_mediaid = filehash_servername_mediaid.get(32..).ok_or_else(|| { + error!( + "Invalid format of key in filehash_servername_mediaid for media with sha256 content hash of {}", + hex::encode(sha256_digest) + ); + Error::BadDatabase("Invalid format of key in filehash_servername_mediaid") + })?; + + if self + .blocked_servername_mediaid + .get(servername_mediaid)? + .is_some() + { + return Ok(true); + } + } + + let thumbnail_id_error = || { + error!( + "Invalid format of key in filehash_thumbnail_id for media with sha256 content hash of {}", + hex::encode(sha256_digest) + ); + Error::BadDatabase("Invalid format of value in filehash_thumbnailid") + }; + + for (thumbnail_id, _) in self + .filehash_thumbnailid + .scan_prefix(sha256_digest.to_owned()) + { + let servername_mediaid = thumbnail_id + .get( + 32..thumbnail_id + .len() + .checked_sub(9) + .ok_or_else(thumbnail_id_error)?, + ) + .ok_or_else(thumbnail_id_error)?; + + if self + .blocked_servername_mediaid + .get(servername_mediaid)? + .is_some() + { + return Ok(true); + } + } + + Ok(false) + } } impl KeyValueDatabase { + /// Only checks whether the media id itself is blocked, and not associated filehashes + fn is_directly_blocked(&self, server_name: &ServerName, media_id: &str) -> Result { + let mut key = server_name.as_bytes().to_vec(); + key.push(0xff); + key.extend_from_slice(media_id.as_bytes()); + + self.blocked_servername_mediaid + .get(&key) + .map(|x| x.is_some()) + } + fn purge_mediaid( &self, server_name: &ServerName, diff --git a/src/database/mod.rs b/src/database/mod.rs index 925d636c..b564833b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -179,6 +179,7 @@ pub struct KeyValueDatabase { pub(super) servernamemediaid_metadata: Arc, // Servername + MediaID -> content sha256 + Filename + ContentType + extra 0xff byte if media is allowed on unauthenticated endpoints pub(super) filehash_servername_mediaid: Arc, // sha256 of content + Servername + MediaID, used to delete dangling references to filehashes from servernamemediaid pub(super) filehash_metadata: Arc, // sha256 of content -> file size + creation time + last access time + pub(super) blocked_servername_mediaid: Arc, // Servername + MediaID of blocked media -> time of block + reason pub(super) servername_userlocalpart_mediaid: Arc, // Servername + User Localpart + MediaID pub(super) servernamemediaid_userlocalpart: Arc, // Servername + MediaID -> User Localpart, used to remove keys from above when files are deleted by unrelated means pub(super) thumbnailid_metadata: Arc, // ThumbnailId = Servername + MediaID + width + height -> Filename + ContentType + extra 0xff byte if media is allowed on unauthenticated endpoints @@ -389,6 +390,7 @@ impl KeyValueDatabase { servernamemediaid_metadata: builder.open_tree("servernamemediaid_metadata")?, filehash_servername_mediaid: builder.open_tree("filehash_servername_mediaid")?, filehash_metadata: builder.open_tree("filehash_metadata")?, + blocked_servername_mediaid: builder.open_tree("blocked_servername_mediaid")?, servername_userlocalpart_mediaid: builder .open_tree("servername_userlocalpart_mediaid")?, servernamemediaid_userlocalpart: builder diff --git a/src/service/admin/mod.rs b/src/service/admin/mod.rs index 0e3cef1e..2044c0ad 100644 --- a/src/service/admin/mod.rs +++ b/src/service/admin/mod.rs @@ -1,10 +1,12 @@ use std::{ + borrow::Cow, collections::BTreeMap, convert::TryFrom, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +use chrono::DateTime; use clap::{Args, Parser}; use regex::Regex; use ruma::{ @@ -37,7 +39,7 @@ use crate::{ Error, PduEvent, Result, }; -use super::pdu::PduBuilder; +use super::{media::BlockedMediaInfo, pdu::PduBuilder}; #[cfg_attr(test, derive(Debug))] #[derive(Parser)] @@ -180,6 +182,55 @@ enum AdminCommand { force_filehash: bool, }, + /// Prevents the list of media from being accessed, but does not delete the media if it + /// is already downloaded. If the media has already been downloaded, the sha256 hash + /// is blocked, meaning that any other current or future uploads/downloads of the exact same + /// content cannot be accessed either. + /// + /// There should be one MXC URI per line, all contained within a code-block + BlockMedia { + #[arg(long, short)] + /// Prevents the specified media from being downloaded in the future + /// + /// Note: This will also delete identical media uploaded by other users, so + /// only use this all the media is known to be undesirable + and_purge: bool, + #[arg(long, short)] + /// Optional reason as to why this media should be blocked + reason: Option, + }, + + /// Prevents all media uploaded by the local users, listed in a code-block, from being accessed + /// + /// Note: This will also block media with the same SHA256 hash, so + /// only use this when all media uploaded by the user is undesirable (or if + /// you only plan for the bloackage to be temporary) + BlockMediaFromUsers { + #[arg( + long, short, + value_parser = humantime::parse_duration + )] + /// Only block media uploaded in the last {timeframe} + /// + /// Should be in the form specified by humantime::parse_duration + /// (e.g. 48h, 60min, 10days etc.) + // --help is unformatted + #[allow(rustdoc::bare_urls)] + /// https://docs.rs/humantime/2.2.0/humantime/fn.parse_duration.html + from_last: Option, + #[arg(long, short)] + /// Optional reason as to why this media should be blocked + reason: Option, + }, + + /// Lists all media that is currently blocked + ListBlockedMedia, + + /// Allows previously blocked media to be accessed again. Will also unblock media with the + /// same SHA256 hash + /// There should be one MXC URI per line, all contained within a code-block + UnblockMedia, + /// Get the auth_chain of a PDU GetAuthChain { /// An event ID (the $ character followed by the base64 reference hash) @@ -986,6 +1037,121 @@ impl Service { )) } } + AdminCommand::BlockMedia { and_purge, reason } => media_from_body(body).map_or_else( + |message| message, + |media| { + let failed_count = services().media.block(&media, reason).len(); + let failed_purge_count = if and_purge { + services().media.purge(&media, true).len() + } else { + 0 + }; + + match (failed_count == 0, failed_purge_count == 0) { + (true, true) => RoomMessageEventContent::text_plain("Successfully blocked media"), + (false, true) => RoomMessageEventContent::text_plain(format!( + "Failed to block {failed_count} media, check logs for more details" + )), + (true, false ) => RoomMessageEventContent::text_plain(format!( + "Failed to purge {failed_purge_count} media, check logs for more details" + )), + (false, false) => RoomMessageEventContent::text_plain(format!( + "Failed to block {failed_count}, and purge {failed_purge_count} media, check logs for more details" + )) + } + }, + ), + AdminCommand::BlockMediaFromUsers { from_last, reason } => { + let after = from_last.map(unix_secs_from_duration).transpose()?; + + if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```" + { + let user_ids = match userids_from_body(&body)? { + Ok(v) => v, + Err(message) => return Ok(message), + }; + + let mut failed_count = 0; + + for user_id in user_ids { + let reason = reason.as_ref().map_or_else( + || Cow::Owned(format!("uploaded by {user_id}")), + Cow::Borrowed, + ); + + failed_count += services() + .media + .block_from_user(user_id, &reason, after) + .len(); + } + + if failed_count == 0 { + RoomMessageEventContent::text_plain("Successfully blocked media") + } else { + RoomMessageEventContent::text_plain(format!( + "Failed to block {failed_count} media, check logs for more details" + )) + } + } else { + RoomMessageEventContent::text_plain( + "Expected code block in command body. Add --help for details.", + ) + } + } + AdminCommand::ListBlockedMedia => { + let mut markdown_message = String::from( + "| SHA256 hash | MXC URI | Time Blocked | Reason |\n| --- | --- | --- | --- |", + ); + let mut html_message = String::from( + r#""#, + ); + + for media in services().media.list_blocked() { + let Ok(BlockedMediaInfo { + server_name, + media_id, + unix_secs, + reason, + sha256_hex, + }) = media else { + continue; + }; + + let sha256_hex = sha256_hex.unwrap_or_default(); + let reason = reason.unwrap_or_default(); + + let time = i64::try_from(unix_secs) + .map(|unix_secs| DateTime::from_timestamp(unix_secs, 0)) + .ok() + .flatten() + .expect("Time is valid"); + + markdown_message + .push_str(&format!("\n| {sha256_hex} | mxc://{server_name}/{media_id} | {time} | {reason} |")); + + html_message.push_str(&format!( + "", + )) + } + + html_message.push_str("
SHA256 hashMXC URITime BlockedReason
{sha256_hex}mxc://{server_name}/{media_id}{time}{reason}
"); + + RoomMessageEventContent::text_html(markdown_message, html_message) + } + AdminCommand::UnblockMedia => media_from_body(body).map_or_else( + |message| message, + |media| { + let failed_count = services().media.unblock(&media).len(); + + if failed_count == 0 { + RoomMessageEventContent::text_plain("Successfully unblocked media") + } else { + RoomMessageEventContent::text_plain(format!( + "Failed to unblock {failed_count} media, check logs for more details" + )) + } + }, + ), AdminCommand::SignJson => { if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```" { diff --git a/src/service/media/data.rs b/src/service/media/data.rs index 97074d30..f6da1788 100644 --- a/src/service/media/data.rs +++ b/src/service/media/data.rs @@ -1,7 +1,9 @@ use ruma::{OwnedServerName, ServerName, UserId}; use sha2::{digest::Output, Sha256}; -use crate::Result; +use crate::{Error, Result}; + +use super::BlockedMediaInfo; use super::DbFileMeta; @@ -16,6 +18,7 @@ pub trait Data: Send + Sync { filename: Option<&str>, content_type: Option<&str>, user_id: Option<&UserId>, + is_blocked_filehash: bool, ) -> Result<()>; fn search_file_metadata(&self, servername: &ServerName, media_id: &str) -> Result; @@ -62,4 +65,32 @@ pub trait Data: Send + Sync { force_filehash: bool, after: Option, ) -> Vec>; + + fn is_blocked(&self, server_name: &ServerName, media_id: &str) -> Result; + + fn block( + &self, + media: &[(OwnedServerName, String)], + unix_secs: u64, + reason: Option, + ) -> Vec; + + fn block_from_user( + &self, + user_id: &UserId, + now: u64, + reason: &str, + after: Option, + ) -> Vec; + + fn unblock(&self, media: &[(OwnedServerName, String)]) -> Vec; + + /// Returns a Vec of: + /// - The server the media is from + /// - The media id + /// - The time it was blocked, in unix seconds + /// - The optional reason why it was blocked + fn list_blocked(&self) -> Vec>; + + fn is_blocked_filehash(&self, sha256_digest: &[u8]) -> Result; } diff --git a/src/service/media/mod.rs b/src/service/media/mod.rs index 8cf1d6b5..d9ae2b22 100644 --- a/src/service/media/mod.rs +++ b/src/service/media/mod.rs @@ -12,7 +12,7 @@ use tracing::error; use crate::{ config::{DirectoryStructure, MediaConfig}, - services, Error, Result, + services, utils, Error, Result, }; use image::imageops::FilterType; @@ -38,6 +38,14 @@ pub struct Service { pub db: &'static dyn Data, } +pub struct BlockedMediaInfo { + pub server_name: OwnedServerName, + pub media_id: String, + pub unix_secs: u64, + pub reason: Option, + pub sha256_hex: Option, +} + impl Service { /// Uploads a file. pub async fn create( @@ -59,9 +67,16 @@ impl Service { filename, content_type, user_id, + self.db.is_blocked_filehash(&sha256_digest)?, )?; - create_file(&sha256_hex, file).await + if !self.db.is_blocked_filehash(&sha256_digest)? { + create_file(&sha256_hex, file).await + } else if user_id.is_none() { + Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) + } else { + Ok(()) + } } /// Uploads or replaces a file thumbnail. @@ -358,6 +373,49 @@ impl Service { purge_files(hashes) } + + /// Checks whether the media has been blocked by administrators, returning either + /// a database error, or a not found error if it is blocked + pub fn check_blocked(&self, server_name: &ServerName, media_id: &str) -> Result<()> { + if self.db.is_blocked(server_name, media_id)? { + Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) + } else { + Ok(()) + } + } + + /// Marks the specified media as blocked, preventing them from being accessed + pub fn block(&self, media: &[(OwnedServerName, String)], reason: Option) -> Vec { + let now = utils::secs_since_unix_epoch(); + + self.db.block(media, now, reason) + } + + /// Marks the media uploaded by a local user as blocked, preventing it from being accessed + pub fn block_from_user( + &self, + user_id: &UserId, + reason: &str, + after: Option, + ) -> Vec { + let now = utils::secs_since_unix_epoch(); + + self.db.block_from_user(user_id, now, reason, after) + } + + /// Unblocks the specified media, allowing them from being accessed again + pub fn unblock(&self, media: &[(OwnedServerName, String)]) -> Vec { + self.db.unblock(media) + } + + /// Returns a Vec of: + /// - The server the media is from + /// - The media id + /// - The time it was blocked, in unix seconds + /// - The optional reason why it was blocked + pub fn list_blocked(&self) -> Vec> { + self.db.list_blocked() + } } /// Creates the media file, using the configured media backend