mirror of
https://gitlab.com/famedly/conduit.git
synced 2025-04-22 14:10:16 +03:00
feat(admin): commands for managing media
TODO: Don't use Box for arguments & pull relevant changes from next commit
This commit is contained in:
parent
69aa435f61
commit
5c941ed72c
7 changed files with 568 additions and 86 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
@ -503,6 +503,7 @@ dependencies = [
|
|||
"hickory-resolver",
|
||||
"hmac",
|
||||
"http 1.1.0",
|
||||
"humantime",
|
||||
"hyper 1.3.1",
|
||||
"hyper-util",
|
||||
"image",
|
||||
|
@ -1195,6 +1196,12 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.29"
|
||||
|
|
|
@ -131,6 +131,8 @@ clap = { version = "4.3.0", default-features = false, features = [
|
|||
"string",
|
||||
"usage",
|
||||
] }
|
||||
humantime = "2"
|
||||
|
||||
futures-util = { version = "0.3.28", default-features = false }
|
||||
# Used for reading the configuration from conduit.toml & environment variables
|
||||
figment = { version = "0.10.8", features = ["env", "toml"] }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use ruma::{api::client::error::ErrorKind, ServerName, UserId};
|
||||
use ruma::{api::client::error::ErrorKind, OwnedServerName, ServerName, UserId};
|
||||
use sha2::digest::Output;
|
||||
use sha2::Sha256;
|
||||
|
||||
|
@ -59,10 +59,9 @@ impl service::media::Data for KeyValueDatabase {
|
|||
let mut key = servername.as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(user_id.as_bytes());
|
||||
|
||||
self.servername_mediaid_userlocalpart.insert(&key, &[])?;
|
||||
self.servernamemediaid_userlocalpart
|
||||
.insert(&key, user_id.as_bytes())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -225,4 +224,196 @@ impl service::media::Data for KeyValueDatabase {
|
|||
unauthenticated_access_permitted,
|
||||
))
|
||||
}
|
||||
|
||||
fn purge_and_get_hashes(&self, media: Vec<(OwnedServerName, String)>) -> Vec<Result<String>> {
|
||||
media
|
||||
.into_iter()
|
||||
.map(|(server_name, media_id)| {
|
||||
let mut key = server_name.as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
if let Some(localpart) = self.servernamemediaid_userlocalpart.get(&key)? {
|
||||
self.servernamemediaid_userlocalpart.remove(&key)?;
|
||||
let mut key = server_name.as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(&localpart);
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
|
||||
self.servername_userlocalpart_mediaid.remove(&key)?;
|
||||
}
|
||||
|
||||
let value = self.servernamemediaid_metadata.get(&key)?.unwrap();
|
||||
self.servernamemediaid_metadata.remove(&key)?;
|
||||
|
||||
let mut parts = value.split(|&b| b == 0xff);
|
||||
let sha256_digest = parts.nth(0).map(|b| b.to_vec()).unwrap();
|
||||
|
||||
self.filehash_metadata.remove(&sha256_digest)?;
|
||||
|
||||
let mut key = sha256_digest.clone();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(server_name.as_bytes());
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
self.filehash_servername_mediaid.remove(&key)?;
|
||||
|
||||
for (key, _) in self.filehash_thumbnailid.scan_prefix(sha256_digest.clone()) {
|
||||
self.filehash_thumbnailid.remove(&key)?;
|
||||
// 64 would be 0xff
|
||||
let (_, key) = key.split_at(65);
|
||||
self.thumbnailid_metadata.remove(key)?;
|
||||
}
|
||||
|
||||
Ok(hex::encode(sha256_digest))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn purge_and_get_hashes_from_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
after: Option<u64>,
|
||||
) -> Vec<Result<String>> {
|
||||
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 stuff = self.purge_and_get_hashes(prefix, user_id.server_name(), after);
|
||||
|
||||
stuff
|
||||
.into_iter()
|
||||
.map(|r| match r {
|
||||
Ok((digest, media_id)) => {
|
||||
let mut key = user_id.server_name().as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
|
||||
self.servernamemediaid_userlocalpart.remove(&key)?;
|
||||
|
||||
let mut key = user_id.server_name().as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(user_id.localpart().as_bytes());
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
|
||||
self.servername_userlocalpart_mediaid.remove(&key)?;
|
||||
|
||||
Ok(hex::encode(digest))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn purge_and_get_hashes_from_server(
|
||||
&self,
|
||||
server_name: &ServerName,
|
||||
after: Option<u64>,
|
||||
) -> Vec<Result<String>> {
|
||||
let mut prefix = server_name.as_bytes().to_vec();
|
||||
prefix.push(0xff);
|
||||
|
||||
self.purge_and_get_hashes(prefix, server_name, after)
|
||||
.into_iter()
|
||||
.map(|r| r.map(|(digest, _)| hex::encode(digest)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyValueDatabase {
|
||||
/// Purges all references to the given media in the database (apart from user localparts),
|
||||
/// returning a Vec of sha256 digests and media_ids
|
||||
fn purge_and_get_hashes(
|
||||
&self,
|
||||
prefix: Vec<u8>,
|
||||
server_name: &ServerName,
|
||||
after: Option<u64>,
|
||||
) -> Vec<Result<(Vec<u8>, String)>> {
|
||||
let keys = self
|
||||
.servername_userlocalpart_mediaid
|
||||
.scan_prefix(prefix)
|
||||
.map(|(k, _)| k);
|
||||
|
||||
keys.map(|k| {
|
||||
let mut parts = k.split(|&b| b == 0xff);
|
||||
let x = parts.nth(2).map_or_else(
|
||||
|| {
|
||||
Err(Error::bad_database(
|
||||
"Invalid format for key of servername_userlocalpart_mediaid",
|
||||
))
|
||||
},
|
||||
|media_id_bytes| Ok(media_id_bytes.to_vec()),
|
||||
)?;
|
||||
|
||||
let media_id = utils::string_from_bytes(&x).map_err(|_| {
|
||||
Error::bad_database("Invalid media_id string in servername_userlocalpart_mediaid")
|
||||
})?;
|
||||
|
||||
let mut key = server_name.as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
|
||||
let metadata = self.servernamemediaid_metadata.get(&key)?.ok_or_else(|| {
|
||||
Error::bad_database("Missing metadata for media id and server_name")
|
||||
})?;
|
||||
let mut parts = metadata.split(|&b| b == 0xff);
|
||||
|
||||
let sha256_digest = parts.nth(0).map(|bytes| bytes.to_vec()).ok_or_else(|| {
|
||||
Error::bad_database("Invalid format of metadata in servernamemediaid_metadata")
|
||||
})?;
|
||||
|
||||
if if let Some(after) = after {
|
||||
let metadata = self.filehash_metadata.get(&sha256_digest)?.ok_or_else(|| {
|
||||
Error::bad_database("Missing metadata for media content sha256")
|
||||
})?;
|
||||
|
||||
let mut parts = metadata.split(|&b| b == 0xff);
|
||||
|
||||
let unix_secs = parts
|
||||
.nth(1)
|
||||
.map(|bytes| {
|
||||
bytes.try_into().map_err(|_| {
|
||||
Error::bad_database("Invalid creation time in filehash_metadata ")
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::bad_database("Invalid format of metadata in filehash_metadata")
|
||||
})??;
|
||||
|
||||
u64::from_be_bytes(unix_secs) > after
|
||||
} else {
|
||||
true
|
||||
} {
|
||||
self.filehash_metadata.remove(&sha256_digest)?;
|
||||
|
||||
for (key, _) in self
|
||||
.filehash_servername_mediaid
|
||||
.scan_prefix(sha256_digest.clone())
|
||||
{
|
||||
self.filehash_servername_mediaid.remove(&key)?;
|
||||
}
|
||||
|
||||
for (key, _) in self.filehash_thumbnailid.scan_prefix(sha256_digest.clone()) {
|
||||
self.filehash_thumbnailid.remove(&key)?;
|
||||
// 64 would be 0xff
|
||||
let (_, key) = key.split_at(65);
|
||||
self.thumbnailid_metadata.remove(key)?;
|
||||
}
|
||||
|
||||
let mut key = server_name.as_bytes().to_vec();
|
||||
key.push(0xff);
|
||||
key.extend_from_slice(media_id.as_bytes());
|
||||
|
||||
self.servernamemediaid_metadata.remove(&key)?;
|
||||
|
||||
Ok(Some((sha256_digest, media_id)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.filter_map(Result::transpose)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -159,7 +159,7 @@ pub struct KeyValueDatabase {
|
|||
pub(super) filehash_servername_mediaid: Arc<dyn KvTree>, // sha256 of content + Servername + MediaID, used to delete dangling references to filehashes from servernamemediaid
|
||||
pub(super) filehash_metadata: Arc<dyn KvTree>, // sha256 of content -> file size + creation time + last access time
|
||||
pub(super) servername_userlocalpart_mediaid: Arc<dyn KvTree>, // Servername + User Localpart + MediaID
|
||||
pub(super) servername_mediaid_userlocalpart: Arc<dyn KvTree>, // Servername + MediaID + User Localpart, used to remove keys from above when files are deleted by unrelated means
|
||||
pub(super) servernamemediaid_userlocalpart: Arc<dyn KvTree>, // Servername + MediaID -> User Localpart, used to remove keys from above when files are deleted by unrelated means
|
||||
pub(super) thumbnailid_metadata: Arc<dyn KvTree>, // ThumbnailId = Servername + MediaID + width + height -> Filename + ContentType + extra 0xff byte if media is allowed on unauthenticated endpoints
|
||||
pub(super) filehash_thumbnailid: Arc<dyn KvTree>, // sha256 of content + "ThumbnailId", as defined above. Used to dangling references to filehashes from thumbnailIds
|
||||
//pub key_backups: key_backups::KeyBackups,
|
||||
|
@ -372,8 +372,8 @@ impl KeyValueDatabase {
|
|||
filehash_metadata: builder.open_tree("filehash_metadata")?,
|
||||
servername_userlocalpart_mediaid: builder
|
||||
.open_tree("servername_userlocalpart_mediaid")?,
|
||||
servername_mediaid_userlocalpart: builder
|
||||
.open_tree("servername_mediaid_userlocalpart")?,
|
||||
servernamemediaid_userlocalpart: builder
|
||||
.open_tree("servernamemediaid_userlocalpart")?,
|
||||
thumbnailid_metadata: builder.open_tree("thumbnailid_metadata")?,
|
||||
filehash_thumbnailid: builder.open_tree("filehash_thumbnailid")?,
|
||||
backupid_algorithm: builder.open_tree("backupid_algorithm")?,
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
use std::{collections::BTreeMap, convert::TryFrom, sync::Arc, time::Instant};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
convert::TryFrom,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
use clap::{Args, Parser};
|
||||
use regex::Regex;
|
||||
use ruma::{
|
||||
api::appservice::Registration,
|
||||
|
@ -19,8 +24,8 @@ use ruma::{
|
|||
},
|
||||
TimelineEventType,
|
||||
},
|
||||
EventId, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId, RoomAliasId, RoomId,
|
||||
RoomVersionId, ServerName, UserId,
|
||||
EventId, MilliSecondsSinceUnixEpoch, MxcUri, OwnedRoomAliasId, OwnedRoomId, RoomAliasId,
|
||||
RoomId, RoomVersionId, ServerName, UserId,
|
||||
};
|
||||
use serde_json::value::to_raw_value;
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
|
@ -82,11 +87,15 @@ enum AdminCommand {
|
|||
/// Deactivate a user
|
||||
///
|
||||
/// User will not be removed from all rooms by default.
|
||||
/// Use --leave-rooms to force the user to leave all rooms
|
||||
/// Use --leave-rooms to force the user to leave all rooms.
|
||||
/// Use either --purge-all-media or --purge-media-from-last to either delete all media uploaded
|
||||
/// by them (in the last <specified timeframe>, if any)
|
||||
DeactivateUser {
|
||||
#[arg(short, long)]
|
||||
leave_rooms: bool,
|
||||
user_id: Box<UserId>,
|
||||
#[command(flatten)]
|
||||
purge_media: PurgeMediaArgs,
|
||||
},
|
||||
|
||||
#[command(verbatim_doc_comment)]
|
||||
|
@ -94,6 +103,8 @@ enum AdminCommand {
|
|||
///
|
||||
/// Recommended to use in conjunction with list-local-users.
|
||||
///
|
||||
/// Use either --purge-all-media or --purge-media-from-last to either delete all media uploaded
|
||||
/// by them (in the last <specified timeframe>, if any)
|
||||
/// Users will not be removed from joined rooms by default.
|
||||
/// Can be overridden with --leave-rooms flag.
|
||||
/// Removing a mass amount of users from a room may cause a significant amount of leave events.
|
||||
|
@ -110,6 +121,33 @@ enum AdminCommand {
|
|||
#[arg(short, long)]
|
||||
/// Also deactivate admin accounts
|
||||
force: bool,
|
||||
#[command(flatten)]
|
||||
purge_media: PurgeMediaArgs,
|
||||
},
|
||||
|
||||
/// Purge a list of media, formatted as MXC URIs
|
||||
/// There should be one URI per line, all contained within a code-block
|
||||
PurgeMedia,
|
||||
|
||||
/// Purges all media uploaded by the local users listed in a code-block.
|
||||
/// Optionally, only delete media uploaded in the last <time-frame> with --from-last
|
||||
PurgeMediaFromUsers {
|
||||
#[arg(
|
||||
long, short,
|
||||
value_parser = humantime::parse_duration
|
||||
)]
|
||||
from_last: Option<Duration>,
|
||||
},
|
||||
|
||||
/// Purges all media from the specified server
|
||||
/// Optionally, only delete media downloaded in the last <time-frame> with --from-last
|
||||
PurgeMediaFromServer {
|
||||
server_id: Box<ServerName>,
|
||||
#[arg(
|
||||
long, short,
|
||||
value_parser = humantime::parse_duration
|
||||
)]
|
||||
from_last: Option<Duration>,
|
||||
},
|
||||
|
||||
/// Get the auth_chain of a PDU
|
||||
|
@ -181,6 +219,19 @@ enum AdminCommand {
|
|||
HashAndSignEvent { room_version_id: RoomVersionId },
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
#[group(required = false, multiple = false)]
|
||||
// Not an enum because https://github.com/clap-rs/clap/issues/2621
|
||||
pub struct PurgeMediaArgs {
|
||||
#[arg(long, short)]
|
||||
purge_all_media: bool,
|
||||
#[arg(
|
||||
long, short,
|
||||
value_parser = humantime::parse_duration
|
||||
)]
|
||||
purge_media_from_last: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AdminRoomEvent {
|
||||
ProcessMessage(String),
|
||||
|
@ -690,6 +741,7 @@ impl Service {
|
|||
AdminCommand::DeactivateUser {
|
||||
leave_rooms,
|
||||
user_id,
|
||||
purge_media,
|
||||
} => {
|
||||
let user_id = Arc::<UserId>::from(user_id);
|
||||
if !services().users.exists(&user_id)? {
|
||||
|
@ -711,78 +763,41 @@ impl Service {
|
|||
leave_all_rooms(&user_id).await?;
|
||||
}
|
||||
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"User {user_id} has been deactivated"
|
||||
let failed_purged_media = if purge_media.purge_media_from_last.is_some()
|
||||
|| purge_media.purge_all_media
|
||||
{
|
||||
let after = purge_media
|
||||
.purge_media_from_last
|
||||
.map(unix_secs_from_duration)
|
||||
.transpose()?;
|
||||
|
||||
services().media.purge_from_user(&user_id, after).len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if failed_purged_media == 0 {
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"User {user_id} has been deactivated"
|
||||
))
|
||||
} else {
|
||||
RoomMessageEventContent ::text_plain(format!(
|
||||
"User {user_id} has been deactivated, but {failed_purged_media} media failed to be purged, check the logs for more details"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
AdminCommand::DeactivateAll { leave_rooms, force } => {
|
||||
AdminCommand::DeactivateAll {
|
||||
leave_rooms,
|
||||
force,
|
||||
purge_media,
|
||||
} => {
|
||||
if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```"
|
||||
{
|
||||
let users = body.clone().drain(1..body.len() - 1).collect::<Vec<_>>();
|
||||
|
||||
let mut user_ids = Vec::new();
|
||||
let mut remote_ids = Vec::new();
|
||||
let mut non_existent_ids = Vec::new();
|
||||
let mut invalid_users = Vec::new();
|
||||
|
||||
for &user in &users {
|
||||
match <&UserId>::try_from(user) {
|
||||
Ok(user_id) => {
|
||||
if user_id.server_name() != services().globals.server_name() {
|
||||
remote_ids.push(user_id)
|
||||
} else if !services().users.exists(user_id)? {
|
||||
non_existent_ids.push(user_id)
|
||||
} else {
|
||||
user_ids.push(user_id)
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
invalid_users.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut markdown_message = String::new();
|
||||
let mut html_message = String::new();
|
||||
if !invalid_users.is_empty() {
|
||||
markdown_message.push_str("The following user ids are not valid:\n```\n");
|
||||
html_message.push_str("The following user ids are not valid:\n<pre>\n");
|
||||
for invalid_user in invalid_users {
|
||||
markdown_message.push_str(&format!("{invalid_user}\n"));
|
||||
html_message.push_str(&format!("{invalid_user}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !remote_ids.is_empty() {
|
||||
markdown_message
|
||||
.push_str("The following users are not from this server:\n```\n");
|
||||
html_message
|
||||
.push_str("The following users are not from this server:\n<pre>\n");
|
||||
for remote_id in remote_ids {
|
||||
markdown_message.push_str(&format!("{remote_id}\n"));
|
||||
html_message.push_str(&format!("{remote_id}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !non_existent_ids.is_empty() {
|
||||
markdown_message.push_str("The following users do not exist:\n```\n");
|
||||
html_message.push_str("The following users do not exist:\n<pre>\n");
|
||||
for non_existent_id in non_existent_ids {
|
||||
markdown_message.push_str(&format!("{non_existent_id}\n"));
|
||||
html_message.push_str(&format!("{non_existent_id}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !markdown_message.is_empty() {
|
||||
return Ok(RoomMessageEventContent::text_html(
|
||||
markdown_message,
|
||||
html_message,
|
||||
));
|
||||
}
|
||||
let mut user_ids = match userids_from_body(&body)? {
|
||||
Ok(v) => v,
|
||||
Err(message) => return Ok(message),
|
||||
};
|
||||
|
||||
let mut deactivation_count = 0;
|
||||
let mut admins = Vec::new();
|
||||
|
@ -812,12 +827,64 @@ impl Service {
|
|||
}
|
||||
}
|
||||
|
||||
if admins.is_empty() {
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"Deactivated {deactivation_count} accounts."
|
||||
let mut failed_count = 0;
|
||||
|
||||
if purge_media.purge_media_from_last.is_some() || purge_media.purge_all_media {
|
||||
let after = purge_media
|
||||
.purge_media_from_last
|
||||
.map(unix_secs_from_duration)
|
||||
.transpose()?;
|
||||
|
||||
for user_id in user_ids {
|
||||
failed_count += services().media.purge_from_user(user_id, after).len();
|
||||
}
|
||||
}
|
||||
|
||||
let mut message = format!("Deactivated {deactivation_count} accounts.");
|
||||
if !admins.is_empty() {
|
||||
message.push_str(&format!("\nSkipped admin accounts: {:?}. Use --force to deactivate admin accounts",admins.join(", ")));
|
||||
}
|
||||
if failed_count != 0 {
|
||||
message.push_str(&format!(
|
||||
"\nFailed to delete {failed_count} media, check logs for more details"
|
||||
))
|
||||
}
|
||||
|
||||
RoomMessageEventContent::text_plain(message)
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(
|
||||
"Expected code block in command body. Add --help for details.",
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminCommand::PurgeMedia => {
|
||||
if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```"
|
||||
{
|
||||
let mut invalid = Vec::new();
|
||||
|
||||
let media = body
|
||||
.clone()
|
||||
.drain(1..body.len() - 1)
|
||||
.map(<Box<MxcUri>>::from)
|
||||
.filter_map(|mxc| match mxc.parts() {
|
||||
Ok((server_name, media_id)) => {
|
||||
Some((server_name.to_owned(), media_id.to_owned()))
|
||||
}
|
||||
Err(e) => {
|
||||
invalid.push(e);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let failed_count = services().media.purge(media).len();
|
||||
|
||||
if failed_count == 0 {
|
||||
RoomMessageEventContent::text_plain("Successfully purged media")
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(format!("Deactivated {} accounts.\nSkipped admin accounts: {:?}. Use --force to deactivate admin accounts", deactivation_count, admins.join(", ")))
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"Failed to delete {failed_count} media, check logs for more details"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(
|
||||
|
@ -825,6 +892,62 @@ impl Service {
|
|||
)
|
||||
}
|
||||
}
|
||||
AdminCommand::PurgeMediaFromUsers { from_last } => {
|
||||
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 {
|
||||
failed_count += services().media.purge_from_user(user_id, after).len();
|
||||
}
|
||||
|
||||
if failed_count == 0 {
|
||||
RoomMessageEventContent::text_plain("Successfully purged media")
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"Failed to purge {failed_count} media, check logs for more details"
|
||||
))
|
||||
}
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(
|
||||
"Expected code block in command body. Add --help for details.",
|
||||
)
|
||||
}
|
||||
}
|
||||
AdminCommand::PurgeMediaFromServer {
|
||||
server_id: server_name,
|
||||
from_last,
|
||||
} => {
|
||||
if server_name == services().globals.server_name() {
|
||||
return Err(Error::AdminCommand(
|
||||
"Cannot purge all media from your own homeserver",
|
||||
));
|
||||
}
|
||||
|
||||
let after = from_last.map(unix_secs_from_duration).transpose()?;
|
||||
|
||||
let failed_count = services()
|
||||
.media
|
||||
.purge_from_server(&server_name, after)
|
||||
.len();
|
||||
|
||||
if failed_count == 0 {
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"All media from {server_name} has successfully been purged"
|
||||
))
|
||||
} else {
|
||||
RoomMessageEventContent::text_plain(format!(
|
||||
"Failed to purge {failed_count} media, check logs for more details"
|
||||
))
|
||||
}
|
||||
}
|
||||
AdminCommand::SignJson => {
|
||||
if body.len() > 2 && body[0].trim() == "```" && body.last().unwrap().trim() == "```"
|
||||
{
|
||||
|
@ -1456,6 +1579,84 @@ impl Service {
|
|||
}
|
||||
}
|
||||
|
||||
fn userids_from_body<'a>(
|
||||
body: &'a [&'a str],
|
||||
) -> Result<Result<Vec<&'a UserId>, RoomMessageEventContent>, Error> {
|
||||
let users = body.to_owned().drain(1..body.len() - 1).collect::<Vec<_>>();
|
||||
|
||||
let mut user_ids = Vec::new();
|
||||
let mut remote_ids = Vec::new();
|
||||
let mut non_existent_ids = Vec::new();
|
||||
let mut invalid_users = Vec::new();
|
||||
|
||||
for &user in &users {
|
||||
match <&UserId>::try_from(user) {
|
||||
Ok(user_id) => {
|
||||
if user_id.server_name() != services().globals.server_name() {
|
||||
remote_ids.push(user_id)
|
||||
} else if !services().users.exists(user_id)? {
|
||||
non_existent_ids.push(user_id)
|
||||
} else {
|
||||
user_ids.push(user_id)
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
invalid_users.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut markdown_message = String::new();
|
||||
let mut html_message = String::new();
|
||||
if !invalid_users.is_empty() {
|
||||
markdown_message.push_str("The following user ids are not valid:\n```\n");
|
||||
html_message.push_str("The following user ids are not valid:\n<pre>\n");
|
||||
for invalid_user in invalid_users {
|
||||
markdown_message.push_str(&format!("{invalid_user}\n"));
|
||||
html_message.push_str(&format!("{invalid_user}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !remote_ids.is_empty() {
|
||||
markdown_message.push_str("The following users are not from this server:\n```\n");
|
||||
html_message.push_str("The following users are not from this server:\n<pre>\n");
|
||||
for remote_id in remote_ids {
|
||||
markdown_message.push_str(&format!("{remote_id}\n"));
|
||||
html_message.push_str(&format!("{remote_id}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !non_existent_ids.is_empty() {
|
||||
markdown_message.push_str("The following users do not exist:\n```\n");
|
||||
html_message.push_str("The following users do not exist:\n<pre>\n");
|
||||
for non_existent_id in non_existent_ids {
|
||||
markdown_message.push_str(&format!("{non_existent_id}\n"));
|
||||
html_message.push_str(&format!("{non_existent_id}\n"));
|
||||
}
|
||||
markdown_message.push_str("```\n\n");
|
||||
html_message.push_str("</pre>\n\n");
|
||||
}
|
||||
if !markdown_message.is_empty() {
|
||||
return Ok(Err(RoomMessageEventContent::text_html(
|
||||
markdown_message,
|
||||
html_message,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Ok(user_ids))
|
||||
}
|
||||
|
||||
fn unix_secs_from_duration(duration: Duration) -> Result<u64> {
|
||||
SystemTime::now()
|
||||
.checked_sub(duration).ok_or_else(||Error::AdminCommand("Given timeframe cannot be represented as system time, please try again with a shorter time-frame"))
|
||||
.map(|time| time
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time is after unix epoch")
|
||||
.as_secs())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use ruma::{ServerName, UserId};
|
||||
use ruma::{OwnedServerName, ServerName, UserId};
|
||||
use sha2::{digest::Output, Sha256};
|
||||
|
||||
use crate::Result;
|
||||
|
@ -46,4 +46,18 @@ pub trait Data: Send + Sync {
|
|||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(String, Option<String>, Option<String>, bool)>;
|
||||
|
||||
fn purge_and_get_hashes(&self, media: Vec<(OwnedServerName, String)>) -> Vec<Result<String>>;
|
||||
|
||||
fn purge_and_get_hashes_from_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
after: Option<u64>,
|
||||
) -> Vec<Result<String>>;
|
||||
|
||||
fn purge_and_get_hashes_from_server(
|
||||
&self,
|
||||
server_name: &ServerName,
|
||||
after: Option<u64>,
|
||||
) -> Vec<Result<String>>;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
mod data;
|
||||
use std::io::Cursor;
|
||||
use std::{fs, io::Cursor};
|
||||
|
||||
pub use data::Data;
|
||||
use ruma::{
|
||||
api::client::{error::ErrorKind, media::is_safe_inline_content_type},
|
||||
http_headers::{ContentDisposition, ContentDispositionType},
|
||||
ServerName, UserId,
|
||||
OwnedServerName, ServerName, UserId,
|
||||
};
|
||||
use sha2::{digest::Output, Digest, Sha256};
|
||||
|
||||
|
@ -270,6 +270,38 @@ impl Service {
|
|||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Purges all of the specified media.
|
||||
///
|
||||
/// Returns errors for all the files that were failed to be deleted, if any.
|
||||
pub fn purge(&self, media: Vec<(OwnedServerName, String)>) -> Vec<Error> {
|
||||
let hashes = self.db.purge_and_get_hashes(media);
|
||||
|
||||
purge_files(hashes)
|
||||
}
|
||||
|
||||
/// Purges all (past a certain time in unix seconds, if specified) media
|
||||
/// sent by a user.
|
||||
///
|
||||
/// Returns errors for all the files that were failed to be deleted, if any.
|
||||
///
|
||||
/// Note: it only currently works for local users, as we cannot determine who
|
||||
/// exactly uploaded the file when it comes to remove users.
|
||||
pub fn purge_from_user(&self, user_id: &UserId, after: Option<u64>) -> Vec<Error> {
|
||||
let hashes = self.db.purge_and_get_hashes_from_user(user_id, after);
|
||||
|
||||
purge_files(hashes)
|
||||
}
|
||||
|
||||
/// Purges all (past a certain time in unix seconds, if specified) media
|
||||
/// obtained from the specified server (due to the MXC URI).
|
||||
///
|
||||
/// Returns errors for all the files that were failed to be deleted, if any.
|
||||
pub fn purge_from_server(&self, server_name: &ServerName, after: Option<u64>) -> Vec<Error> {
|
||||
let hashes = self.db.purge_and_get_hashes_from_server(server_name, after);
|
||||
|
||||
purge_files(hashes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the media file, using the configured media backend
|
||||
|
@ -312,6 +344,41 @@ async fn get_file(sha256_hex: &str) -> Result<Vec<u8>> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Purges the given files from the media backend
|
||||
/// Returns a `Vec` of errors that occurred when attempting to delete the files
|
||||
///
|
||||
/// Note: this does NOT remove the related metadata from the database
|
||||
fn purge_files(hashes: Vec<Result<String>>) -> Vec<Error> {
|
||||
hashes
|
||||
.into_iter()
|
||||
.map(|hash| match hash {
|
||||
Ok(v) => delete_file(&v),
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
.filter_map(|r| if let Err(e) = r { Some(e) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Deletes the given file from the media backend
|
||||
///
|
||||
/// Note: this does NOT remove the related metadata from the database
|
||||
fn delete_file(sha256_hex: &str) -> Result<()> {
|
||||
match &services().globals.config.media {
|
||||
MediaConfig::FileSystem {
|
||||
path,
|
||||
directory_structure,
|
||||
} => {
|
||||
let path = services()
|
||||
.globals
|
||||
.get_media_path(path, directory_structure, sha256_hex)?;
|
||||
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a content disposition with the given `filename`, using the `content_type` to determine whether
|
||||
/// the disposition should be `inline` or `attachment`
|
||||
fn content_disposition(
|
||||
|
|
Loading…
Reference in a new issue