diff --git a/crates/core/database/src/drivers/reference.rs b/crates/core/database/src/drivers/reference.rs index 6587b851..680e7959 100644 --- a/crates/core/database/src/drivers/reference.rs +++ b/crates/core/database/src/drivers/reference.rs @@ -6,10 +6,10 @@ use std::{ use futures::lock::Mutex; use crate::{ - AdminAuditItem, AdminCase, AdminCaseComment, AdminObjectNote, AdminStrike, AdminToken, - AdminUser, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, - Member, MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, - Snapshot, User, UserSettings, Webhook, + AdminAuditItem, AdminCase, AdminComment, AdminStrike, AdminToken, AdminUser, Bot, Channel, + ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member, MemberCompositeKey, + Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot, User, UserSettings, + Webhook, }; database_derived!( @@ -18,8 +18,7 @@ database_derived!( pub struct ReferenceDb { pub admin_audits: Arc>>, pub admin_cases: Arc>>, - pub admin_case_comments: Arc>>, - pub admin_object_notes: Arc>>, + pub admin_comments: Arc>>, pub admin_strikes: Arc>>, pub admin_tokens: Arc>>, pub admin_users: Arc>>, diff --git a/crates/core/database/src/models/admin_audits/models.rs b/crates/core/database/src/models/admin_audits/models.rs index 67f5045a..accc10cc 100644 --- a/crates/core/database/src/models/admin_audits/models.rs +++ b/crates/core/database/src/models/admin_audits/models.rs @@ -1,3 +1,6 @@ +use iso8601_timestamp::Timestamp; +use revolt_models::v0::AdminAuditItemActions; + use crate::util::basic::transform_optional_string; auto_derived! { @@ -7,7 +10,7 @@ auto_derived! { pub id: String, /// The moderator who performed the action pub mod_id: String, - /// The action performed (previously 'permission') + /// The action performed (previously 'permission'). Should be one of v0::AdminAuditActionAction pub action: String, /// The relevant case ID, if applicable pub case_id: Option, @@ -15,13 +18,15 @@ auto_derived! { pub target_id: Option, /// The context of the action, if applicable (eg. search phrases) pub context: Option, + /// When the action occurred, in iso8601. + pub timestamp: String } } impl AdminAuditItem { pub fn new( mod_id: &str, - action: &str, + action: AdminAuditItemActions, case_id: Option<&str>, target_id: Option<&str>, context: Option<&str>, @@ -34,6 +39,7 @@ impl AdminAuditItem { case_id: transform_optional_string(case_id), target_id: transform_optional_string(target_id), context: transform_optional_string(context), + timestamp: Timestamp::now_utc().format_short().to_string(), } } } diff --git a/crates/core/database/src/models/admin_case_comments/models.rs b/crates/core/database/src/models/admin_case_comments/models.rs deleted file mode 100644 index 0429d9ce..00000000 --- a/crates/core/database/src/models/admin_case_comments/models.rs +++ /dev/null @@ -1,54 +0,0 @@ -auto_derived_partial! { - pub struct AdminCaseComment { - /// The comment ID - #[serde(rename = "_id")] - pub id: String, - /// The ID of the case this comment is attached to - pub case_id: String, - /// The author ID - pub user_id: String, - /// When the comment was edited, if applicable, in iso8601 - pub edited_at: Option, - /// The content - pub content: String - }, - "PartialAdminCaseComment" -} - -impl AdminCaseComment { - pub fn new(case_id: &str, user_id: &str, content: &str) -> AdminCaseComment { - let id = ulid::Ulid::new().to_string(); - AdminCaseComment { - id, - case_id: case_id.to_string(), - user_id: user_id.to_string(), - edited_at: None, - content: content.to_string(), - } - } - - /// Edit the comment, updating the edited_at time as well - pub fn edit(&mut self, content: &str) { - self.content = content.to_string(); - self.edited_at = Some( - iso8601_timestamp::Timestamp::now_utc() - .format_short() - .to_string(), - ); - } -} - -impl PartialAdminCaseComment { - pub fn new() -> PartialAdminCaseComment { - PartialAdminCaseComment::default() - } - /// Edit the comment, updating the edited_at time as well - pub fn edit(&mut self, content: &str) { - self.content = Some(content.to_string()); - self.edited_at = Some( - iso8601_timestamp::Timestamp::now_utc() - .format_short() - .to_string(), - ); - } -} diff --git a/crates/core/database/src/models/admin_case_comments/ops.rs b/crates/core/database/src/models/admin_case_comments/ops.rs deleted file mode 100644 index 7f555a7d..00000000 --- a/crates/core/database/src/models/admin_case_comments/ops.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod mongodb; -mod reference; - -use revolt_result::Result; - -use crate::{models::admin_case_comments::AdminCaseComment, PartialAdminCaseComment}; - -#[async_trait] -pub trait AbstractAdminCaseComments: Sync + Send { - async fn admin_case_comment_insert(&self, comment: AdminCaseComment) -> Result<()>; - - async fn admin_case_comment_update( - &self, - comment_id: &str, - partial: &PartialAdminCaseComment, - ) -> Result<()>; - - async fn admin_case_comment_fetch(&self, case_id: &str) -> Result>; -} diff --git a/crates/core/database/src/models/admin_case_comments/ops/mongodb.rs b/crates/core/database/src/models/admin_case_comments/ops/mongodb.rs deleted file mode 100644 index b01c0082..00000000 --- a/crates/core/database/src/models/admin_case_comments/ops/mongodb.rs +++ /dev/null @@ -1,36 +0,0 @@ -use revolt_result::Result; - -use crate::MongoDb; -use crate::{AdminCaseComment, PartialAdminCaseComment}; - -use super::AbstractAdminCaseComments; - -static COL: &str = "admin_case_comments"; - -#[async_trait] -impl AbstractAdminCaseComments for MongoDb { - async fn admin_case_comment_insert(&self, comment: AdminCaseComment) -> Result<()> { - query!(self, insert_one, COL, comment).map(|_| ()) - } - - async fn admin_case_comment_update( - &self, - comment_id: &str, - partial: &PartialAdminCaseComment, - ) -> Result<()> { - query!( - self, - update_one_by_id, - COL, - comment_id, - partial, - vec![], - None - ) - .map(|_| ()) - } - - async fn admin_case_comment_fetch(&self, case_id: &str) -> Result> { - query!(self, find, COL, doc! {"case_id": case_id}) - } -} diff --git a/crates/core/database/src/models/admin_case_comments/ops/reference.rs b/crates/core/database/src/models/admin_case_comments/ops/reference.rs deleted file mode 100644 index 25570edf..00000000 --- a/crates/core/database/src/models/admin_case_comments/ops/reference.rs +++ /dev/null @@ -1,49 +0,0 @@ -use iso8601_timestamp::Timestamp; -use revolt_result::Result; - -use crate::ReferenceDb; -use crate::{AdminCaseComment, PartialAdminCaseComment}; - -use super::AbstractAdminCaseComments; - -#[async_trait] -impl AbstractAdminCaseComments for ReferenceDb { - async fn admin_case_comment_insert(&self, comment: AdminCaseComment) -> Result<()> { - let mut admin_case_comments = self.admin_case_comments.lock().await; - if admin_case_comments.contains_key(&comment.id) { - Err(create_database_error!("insert", "admin_case_comments")) - } else { - admin_case_comments.insert(comment.id.to_string(), comment.clone()); - Ok(()) - } - } - - async fn admin_case_comment_update( - &self, - comment_id: &str, - partial: &PartialAdminCaseComment, - ) -> Result<()> { - let mut admin_case_comments = self.admin_case_comments.lock().await; - if let Some(comment) = admin_case_comments.get_mut(comment_id) { - comment.apply_options(partial.clone()); - comment.edited_at = Some(Timestamp::now_utc().format().to_string()); - Ok(()) - } else { - Err(create_error!(NotFound)) - } - } - - async fn admin_case_comment_fetch(&self, case_id: &str) -> Result> { - let admin_case_comments = self.admin_case_comments.lock().await; - Ok(admin_case_comments - .iter() - .filter_map(|(_, c)| { - if c.case_id == case_id { - Some(c.clone()) - } else { - None - } - }) - .collect()) - } -} diff --git a/crates/core/database/src/models/admin_cases/ops.rs b/crates/core/database/src/models/admin_cases/ops.rs index b13a062b..1b3f0039 100644 --- a/crates/core/database/src/models/admin_cases/ops.rs +++ b/crates/core/database/src/models/admin_cases/ops.rs @@ -14,6 +14,8 @@ pub trait AbstractAdminCases: Sync + Send { async fn admin_case_fetch(&self, case_id: &str) -> Result; + async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result; + /// title is fuzzy, the rest of the arguments are direct matches /// before_id and limit are for paginating async fn admin_case_search( diff --git a/crates/core/database/src/models/admin_cases/ops/mongodb.rs b/crates/core/database/src/models/admin_cases/ops/mongodb.rs index f2aa3ac7..f0f38757 100644 --- a/crates/core/database/src/models/admin_cases/ops/mongodb.rs +++ b/crates/core/database/src/models/admin_cases/ops/mongodb.rs @@ -35,6 +35,11 @@ impl AbstractAdminCases for MongoDb { .ok_or_else(|| create_database_error!("find_one", COL)) } + async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result { + query!(self, find_one, COL, doc! {"short_id": short_id})? + .ok_or_else(|| create_database_error!("find_one", COL)) + } + /// title is fuzzy, the rest of the arguments are direct matches async fn admin_case_search( &self, diff --git a/crates/core/database/src/models/admin_cases/ops/reference.rs b/crates/core/database/src/models/admin_cases/ops/reference.rs index a0d4657e..5a1b5491 100644 --- a/crates/core/database/src/models/admin_cases/ops/reference.rs +++ b/crates/core/database/src/models/admin_cases/ops/reference.rs @@ -46,6 +46,15 @@ impl AbstractAdminCases for ReferenceDb { } } + async fn admin_case_fetch_from_shorthand(&self, short_id: &str) -> Result { + let admin_cases = self.admin_cases.lock().await; + if let Some((_, case)) = admin_cases.iter().find(|(_, c)| c.short_id == short_id) { + Ok(case.clone()) + } else { + Err(create_error!(NotFound)) + } + } + /// title is fuzzy, the rest of the arguments are direct matches /// before_id and limit are for paginating async fn admin_case_search( diff --git a/crates/core/database/src/models/admin_case_comments/mod.rs b/crates/core/database/src/models/admin_comments/mod.rs similarity index 100% rename from crates/core/database/src/models/admin_case_comments/mod.rs rename to crates/core/database/src/models/admin_comments/mod.rs diff --git a/crates/core/database/src/models/admin_comments/models.rs b/crates/core/database/src/models/admin_comments/models.rs new file mode 100644 index 00000000..42658c1d --- /dev/null +++ b/crates/core/database/src/models/admin_comments/models.rs @@ -0,0 +1,95 @@ +use revolt_models::v0; + +auto_derived_partial! { + pub struct AdminComment { + /// The comment ID + #[serde(rename = "_id")] + pub id: String, + /// The Object this comment is attached to + pub object_id: String, + /// The ID of the case this comment is attached to, if applicable + pub case_id: Option, + /// The author ID + pub user_id: String, + /// When the comment was edited, if applicable, in iso8601 + pub edited_at: Option, + /// The content, if not a system event + pub content: Option, + /// The system event that occurred, if applicable + pub system_message: Option, + /// The system message target + pub system_message_target: Option, + /// The system message raw context + pub system_message_context: Option, + }, + "PartialAdminComment" +} + +impl AdminComment { + pub fn new( + object_id: &str, + user_id: &str, + content: &str, + case_id: Option<&str>, + ) -> AdminComment { + let id = ulid::Ulid::new().to_string(); + AdminComment { + id, + object_id: object_id.to_string(), + case_id: case_id.map(|c| c.to_string()), + user_id: user_id.to_string(), + edited_at: None, + content: Some(content.to_string()), + system_message: None, + system_message_context: None, + system_message_target: None, + } + } + + pub fn new_system_message( + object_id: &str, + user_id: &str, + case_id: &str, + system_message_kind: v0::AdminAuditItemActions, + context: Option<&str>, + target: &str, + ) -> AdminComment { + let id = ulid::Ulid::new().to_string(); + AdminComment { + id, + object_id: object_id.to_string(), + case_id: Some(case_id.to_string()), + user_id: user_id.to_string(), + edited_at: None, + content: None, + system_message: Some(serde_json::to_string(&system_message_kind).unwrap()), // if this explodes i'll eat my hat. + system_message_context: context.map(|c| c.to_string()), + system_message_target: Some(target.to_string()), + } + } + + /// Edit the comment, updating the edited_at time as well + pub fn edit(&mut self, content: &str) { + self.content = Some(content.to_string()); + self.edited_at = Some( + iso8601_timestamp::Timestamp::now_utc() + .format_short() + .to_string(), + ); + } +} + +impl PartialAdminComment { + pub fn new() -> PartialAdminComment { + PartialAdminComment::default() + } + /// Edit the comment, updating the edited_at time as well + pub fn edit(&mut self, content: &str) { + self.content = Some(content.to_string()); + self.edited_at = Some( + iso8601_timestamp::Timestamp::now_utc() + .format_short() + .to_string(), + ); + } +} diff --git a/crates/core/database/src/models/admin_comments/ops.rs b/crates/core/database/src/models/admin_comments/ops.rs new file mode 100644 index 00000000..524ae5b1 --- /dev/null +++ b/crates/core/database/src/models/admin_comments/ops.rs @@ -0,0 +1,28 @@ +mod mongodb; +mod reference; + +use revolt_result::Result; + +use crate::{models::admin_comments::AdminComment, PartialAdminComment}; + +#[async_trait] +pub trait AbstractAdminComments: Sync + Send { + async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()>; + + async fn admin_comment_update( + &self, + comment_id: &str, + partial: &PartialAdminComment, + ) -> Result<()>; + + async fn admin_comment_fetch(&self, id: &str) -> Result; + + /// Fetch all comments related to the case. This includes comments made on other objects. + async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result>; + + /// Fetch all comments on an object + async fn admin_comment_fetch_object_comments( + &self, + object_id: &str, + ) -> Result>; +} diff --git a/crates/core/database/src/models/admin_comments/ops/mongodb.rs b/crates/core/database/src/models/admin_comments/ops/mongodb.rs new file mode 100644 index 00000000..a253a0a9 --- /dev/null +++ b/crates/core/database/src/models/admin_comments/ops/mongodb.rs @@ -0,0 +1,47 @@ +use revolt_result::Result; + +use crate::MongoDb; +use crate::{AdminComment, PartialAdminComment}; + +use super::AbstractAdminComments; + +static COL: &str = "admin_comments"; + +#[async_trait] +impl AbstractAdminComments for MongoDb { + async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()> { + query!(self, insert_one, COL, comment).map(|_| ()) + } + + async fn admin_comment_update( + &self, + comment_id: &str, + partial: &PartialAdminComment, + ) -> Result<()> { + query!( + self, + update_one_by_id, + COL, + comment_id, + partial, + vec![], + None + ) + .map(|_| ()) + } + + async fn admin_comment_fetch(&self, id: &str) -> Result { + query!(self, find_one, COL, doc! {"_id": id})?.ok_or_else(|| create_error!(NotFound)) + } + + async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result> { + query!(self, find, COL, doc! {"case_id": case_id}) + } + + async fn admin_comment_fetch_object_comments( + &self, + object_id: &str, + ) -> Result> { + query!(self, find, COL, doc! {"object_id": object_id}) + } +} diff --git a/crates/core/database/src/models/admin_comments/ops/reference.rs b/crates/core/database/src/models/admin_comments/ops/reference.rs new file mode 100644 index 00000000..f3b81331 --- /dev/null +++ b/crates/core/database/src/models/admin_comments/ops/reference.rs @@ -0,0 +1,73 @@ +use iso8601_timestamp::Timestamp; +use revolt_result::Result; + +use crate::ReferenceDb; +use crate::{AdminComment, PartialAdminComment}; + +use super::AbstractAdminComments; + +#[async_trait] +impl AbstractAdminComments for ReferenceDb { + async fn admin_comment_insert(&self, comment: AdminComment) -> Result<()> { + let mut admin_comments = self.admin_comments.lock().await; + if admin_comments.contains_key(&comment.id) { + Err(create_database_error!("insert", "admin_comments")) + } else { + admin_comments.insert(comment.id.to_string(), comment.clone()); + Ok(()) + } + } + + async fn admin_comment_update( + &self, + comment_id: &str, + partial: &PartialAdminComment, + ) -> Result<()> { + let mut admin_comments = self.admin_comments.lock().await; + if let Some(comment) = admin_comments.get_mut(comment_id) { + comment.apply_options(partial.clone()); + comment.edited_at = Some(Timestamp::now_utc().format().to_string()); + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } + + async fn admin_comment_fetch(&self, id: &str) -> Result { + let admin_comments = self.admin_comments.lock().await; + admin_comments + .get(id) + .map_or(Err(create_error!(NotFound)), |ac| Ok(ac.clone())) + } + + async fn admin_comment_fetch_related_case(&self, case_id: &str) -> Result> { + let admin_comments = self.admin_comments.lock().await; + Ok(admin_comments + .iter() + .filter_map(|(_, c)| { + if c.case_id.as_ref().is_some_and(|c| c == case_id) { + Some(c.clone()) + } else { + None + } + }) + .collect()) + } + + async fn admin_comment_fetch_object_comments( + &self, + object_id: &str, + ) -> Result> { + let admin_comments = self.admin_comments.lock().await; + Ok(admin_comments + .iter() + .filter_map(|(_, c)| { + if c.object_id == object_id { + Some(c.clone()) + } else { + None + } + }) + .collect()) + } +} diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb.rs index 703e378a..7db525f0 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb.rs @@ -28,6 +28,20 @@ impl AbstractMigrations for MongoDb { init::create_database(self).await; } + let config = revolt_config::config().await; + + if config.features.admin_api_enabled { + let db = self.db(); + let colls = db + .list_collection_names() + .await + .expect("Failed to fetch collection names."); + if !colls.iter().any(|x| x == "admin_audits") { + info!("You've enabled the admin api for the first time. Setting up database..."); + init::create_admin_database(&db).await; + } + } + Ok(()) } } diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs index 4a422cbb..d20a5c02 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/init.rs @@ -8,6 +8,8 @@ pub async fn create_database(db: &MongoDb) { info!("Creating database."); let db = db.db(); + let config = revolt_config::config().await; + db.create_collection("accounts") .await .expect("Failed to create accounts collection."); @@ -263,5 +265,135 @@ pub async fn create_database(db: &MongoDb) { .await .expect("Failed to create ratelimit_events index."); + // Only create these tables if the admin api is enabled + if config.features.admin_api_enabled { + create_admin_database(&db).await; + } + info!("Created database."); } + +pub async fn create_admin_database(db: &mongodb::Database) { + db.create_collection("admin_audits") + .await + .expect("Failed to create admin_audits collection."); + + db.create_collection("admin_comments") + .await + .expect("Failed to create admin_comments collection."); + + db.create_collection("admin_cases") + .await + .expect("Failed to create admin_cases collection."); + + db.create_collection("admin_strikes") + .await + .expect("Failed to create admin_strikes collection."); + + db.create_collection("admin_tokens") + .await + .expect("Failed to create admin_tokens collection."); + + db.create_collection("admin_users") + .await + .expect("Failed to create admin_users collection."); + + db.run_command(doc! { + "createIndexes": "admin_comments", + "indexes": [ + { + "key": { + "case_id": 1_i32, + }, + "unique": false, + "name": "case_id" + }, + { + "key": { + "object_id": 1_i32, + }, + "unique": false, + "name": "object_id" + } + ] + }) + .await + .expect("Failed to create admin_comments index."); + + db.run_command(doc! { + "createIndexes": "admin_cases", + "indexes": [ + { + "key": { + "id": 1_i32, + }, + "unique": true, + "name": "id" + }, + { + "key": { + "short_id": 1_i32, + }, + "unique": true, + "name": "short_id" + } + ] + }) + .await + .expect("Failed to create admin_cases index."); + + db.run_command(doc! { + "createIndexes": "admin_users", + "indexes": [ + { + "key": { + "email": 1_i32, + }, + "unique": true, + "name": "case_id" + }, + ] + }) + .await + .expect("Failed to create admin_comments index."); + + db.run_command(doc! { + "createIndexes": "admin_audits", + "indexes": [ + { + "key": { + "mod_id": 1_i32, + }, + "unique": false, + "name": "mod_id" + }, + { + "key": { + "target_id": 1_i32, + }, + "unique": false, + "name": "target_id" + } + ] + }) + .await + .expect("Failed to create admin_audits index."); + + // indexes on regular data that are only needed for admin queries + db.run_command(doc! { + "createIndexes": "server_members", + "indexes": [ + { + "key": { + "joined_at": 1_i32, + }, + "unique": false, + "name": "joined_at" + }, + ] + }) + .await + .expect("Failed to create server_members::joined_at index."); + + info!("Created admin database."); +} diff --git a/crates/core/database/src/models/admin_notes/mod.rs b/crates/core/database/src/models/admin_notes/mod.rs deleted file mode 100644 index ead17df0..00000000 --- a/crates/core/database/src/models/admin_notes/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod models; -mod ops; - -pub use models::*; -pub use ops::*; diff --git a/crates/core/database/src/models/admin_notes/models.rs b/crates/core/database/src/models/admin_notes/models.rs deleted file mode 100644 index f4be8229..00000000 --- a/crates/core/database/src/models/admin_notes/models.rs +++ /dev/null @@ -1,35 +0,0 @@ -auto_derived_partial! { - pub struct AdminObjectNote { - /// The ID of the note, which is the same as the ID of the object it's attached to - #[serde(rename = "_id")] - pub id: String, - /// When the note was edited, in iso8601 - pub edited_at: String, - /// The last user to edit the note - pub last_edited_by_id: String, - /// The content of the note - pub content: String, - }, - "PartialAdminObjectNote" -} - -impl AdminObjectNote { - pub fn new(object_id: &str, user_id: &str, content: &str) -> AdminObjectNote { - AdminObjectNote { - id: object_id.to_string(), - edited_at: iso8601_timestamp::Timestamp::now_utc() - .format_short() - .to_string(), - last_edited_by_id: user_id.to_string(), - content: content.to_string(), - } - } - pub fn to_partial(&self) -> PartialAdminObjectNote { - PartialAdminObjectNote { - id: Some(self.id.clone()), - edited_at: Some(self.edited_at.clone()), - last_edited_by_id: Some(self.last_edited_by_id.clone()), - content: Some(self.content.clone()), - } - } -} diff --git a/crates/core/database/src/models/admin_notes/ops.rs b/crates/core/database/src/models/admin_notes/ops.rs deleted file mode 100644 index d45da92f..00000000 --- a/crates/core/database/src/models/admin_notes/ops.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod mongodb; -mod reference; -use revolt_result::Result; - -use crate::models::admin_notes::AdminObjectNote; - -#[async_trait] -pub trait AbstractAdminNotes: Sync + Send { - async fn admin_note_update(&self, note: AdminObjectNote) -> Result<()>; - - async fn admin_note_fetch(&self, target_id: &str) -> Result; -} diff --git a/crates/core/database/src/models/admin_notes/ops/mongodb.rs b/crates/core/database/src/models/admin_notes/ops/mongodb.rs deleted file mode 100644 index f34ff664..00000000 --- a/crates/core/database/src/models/admin_notes/ops/mongodb.rs +++ /dev/null @@ -1,34 +0,0 @@ -use revolt_result::Result; - -use crate::AdminObjectNote; -use crate::MongoDb; - -use super::AbstractAdminNotes; - -static COL: &str = "admin_notes"; - -#[async_trait] -impl AbstractAdminNotes for MongoDb { - async fn admin_note_update(&self, note: AdminObjectNote) -> Result<()> { - let resp: Result<()> = query!(self, insert_one, COL, note.clone()).map(|_| ()); - if resp.is_err() { - query!( - self, - update_one_by_id, - COL, - note.id.as_str(), - note.to_partial(), - vec![], - None - ) - .map(|_| ()) - } else { - Ok(()) - } - } - - async fn admin_note_fetch(&self, target_id: &str) -> Result { - query!(self, find_one_by_id, COL, target_id)? - .ok_or_else(|| create_database_error!("find_one", COL)) - } -} diff --git a/crates/core/database/src/models/admin_notes/ops/reference.rs b/crates/core/database/src/models/admin_notes/ops/reference.rs deleted file mode 100644 index 370b3744..00000000 --- a/crates/core/database/src/models/admin_notes/ops/reference.rs +++ /dev/null @@ -1,29 +0,0 @@ -use revolt_result::Result; - -use crate::AdminObjectNote; -use crate::ReferenceDb; - -use super::AbstractAdminNotes; - -#[async_trait] -impl AbstractAdminNotes for ReferenceDb { - async fn admin_note_update(&self, note: AdminObjectNote) -> Result<()> { - let mut admin_notes = self.admin_object_notes.lock().await; - if let Some(existing_note) = admin_notes.get_mut(¬e.id) { - existing_note.apply_options(note.to_partial()); - Ok(()) - } else { - admin_notes.insert(note.id.clone(), note); - Ok(()) - } - } - - async fn admin_note_fetch(&self, target_id: &str) -> Result { - let admin_notes = self.admin_object_notes.lock().await; - if let Some(note) = admin_notes.get(target_id) { - Ok(note.clone()) - } else { - Err(create_error!(NotFound)) - } - } -} diff --git a/crates/core/database/src/models/channel_invites/ops/mongodb.rs b/crates/core/database/src/models/channel_invites/ops/mongodb.rs index d628be8e..5d1f32ce 100644 --- a/crates/core/database/src/models/channel_invites/ops/mongodb.rs +++ b/crates/core/database/src/models/channel_invites/ops/mongodb.rs @@ -12,7 +12,14 @@ static COL: &str = "channel_invites"; impl AbstractChannelInvites for MongoDb { /// Insert a new invite into the database async fn insert_invite(&self, invite: &Invite) -> Result<()> { - query!(self, insert_one, COL, &invite).map(|_| ()) + self.insert_one(COL, &invite).await.or_else(|e| { + if e.to_string().contains("duplicate") { + return Err(create_error!(InviteExists)); + } else { + return Err(create_database_error!("insert_one", COL)); + } + })?; + Ok(()) } /// Fetch an invite by the code diff --git a/crates/core/database/src/models/mod.rs b/crates/core/database/src/models/mod.rs index af990d57..8eacf8c9 100644 --- a/crates/core/database/src/models/mod.rs +++ b/crates/core/database/src/models/mod.rs @@ -1,8 +1,7 @@ mod admin_audits; -mod admin_case_comments; mod admin_cases; +mod admin_comments; mod admin_migrations; -mod admin_notes; mod admin_strikes; mod admin_tokens; mod admin_users; @@ -26,10 +25,9 @@ mod user_settings; mod users; pub use admin_audits::*; -pub use admin_case_comments::*; pub use admin_cases::*; +pub use admin_comments::*; pub use admin_migrations::*; -pub use admin_notes::*; pub use admin_strikes::*; pub use admin_tokens::*; pub use admin_users::*; @@ -58,9 +56,8 @@ pub trait AbstractDatabase: Sync + Send + admin_audits::AbstractAdminAudits - + admin_case_comments::AbstractAdminCaseComments + admin_cases::AbstractAdminCases - + admin_notes::AbstractAdminNotes + + admin_comments::AbstractAdminComments + admin_strikes::AbstractAdminStrikes + admin_users::AbstractAdminUsers + admin_tokens::AbstractAdminTokens diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index 25b6979a..d1d51c19 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -81,6 +81,7 @@ impl Member { server: &Server, user: &User, channels: Option>, + create_alert: bool, ) -> Result<(Member, Vec)> { if db.fetch_ban(&server.id, &user.id).await.is_ok() { return Err(create_error!(Banned)); @@ -141,18 +142,20 @@ impl Member { .private(user.id.clone()) .await; - if let Some(id) = server - .system_messages - .as_ref() - .and_then(|x| x.user_joined.as_ref()) - { - SystemMessage::UserJoined { - id: user.id.clone(), + if create_alert { + if let Some(id) = server + .system_messages + .as_ref() + .and_then(|x| x.user_joined.as_ref()) + { + SystemMessage::UserJoined { + id: user.id.clone(), + } + .into_message(id.to_string()) + .send_without_notifications(db, None, None, false, false, false) + .await + .ok(); } - .into_message(id.to_string()) - .send_without_notifications(db, None, None, false, false, false) - .await - .ok(); } Ok((member, channels)) diff --git a/crates/core/database/src/models/server_members/ops.rs b/crates/core/database/src/models/server_members/ops.rs index 1d5a045f..b3121fcf 100644 --- a/crates/core/database/src/models/server_members/ops.rs +++ b/crates/core/database/src/models/server_members/ops.rs @@ -117,4 +117,18 @@ pub trait AbstractServerMembers: Sync + Send { /// Delete a server member by their id async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>; + + /// Fetch all participants of a server. + async fn fetch_server_participants( + &self, + server_id: &str, + ) -> Result)>>; + + /// Fetch all members of a server + async fn fetch_server_members( + &self, + server_id: &str, + page_size: u8, + after: Option, + ) -> Result>; } diff --git a/crates/core/database/src/models/server_members/ops/mongodb.rs b/crates/core/database/src/models/server_members/ops/mongodb.rs index 07d8a819..6437591f 100644 --- a/crates/core/database/src/models/server_members/ops/mongodb.rs +++ b/crates/core/database/src/models/server_members/ops/mongodb.rs @@ -1,8 +1,10 @@ +use std::collections::HashMap; + use futures::StreamExt; use mongodb::options::ReadConcern; use revolt_result::Result; -use crate::{FieldsMember, Member, MemberCompositeKey, PartialMember}; +use crate::{AbstractUsers, FieldsMember, Member, MemberCompositeKey, PartialMember}; use crate::{IntoDocumentPath, MongoDb}; use super::{AbstractServerMembers, ChunkedServerMembersGenerator}; @@ -238,6 +240,88 @@ impl AbstractServerMembers for MongoDb { ) .map(|_| ()) } + + async fn fetch_server_participants( + &self, + server_id: &str, + ) -> Result)>> { + let server: crate::Server = query!(self, find_one, "servers", doc! {"_id": server_id})? + .ok_or_else(|| create_database_error!("find", "servers"))?; + let ids: Vec = self + .db() + .collection::("messages") + .distinct("author", doc! {"channel": {"$in": server.channels}}) + .await + .map_err(|_| create_database_error!("distinct", "messages"))? + .iter() + .map(|b| bson::from_bson::(b.clone()).unwrap()) // json encoded string for some logic-defying reason + .collect(); + + println!("{:?}", &ids); + + let users = self.fetch_users(&ids).await?; + let members = self + .fetch_members(server_id, &ids) + .await? + .iter() + .map(|f| (f.id.user.clone(), f.clone())) + .collect::>(); + + Ok(users + .iter() + .map(|u| (u.clone(), members.get(&u.id).cloned())) + .collect()) + } + + async fn fetch_server_members( + &self, + server_id: &str, + page_size: u8, + after: Option, + ) -> Result> { + let mut members_stream = if let Some(after) = after { + self.col::(COL) + .find(doc! {"_id.server": server_id, "joined_at": {"$gt": after as i64}}) + .sort(doc! {"joined_at": -1}) + .limit(page_size as i64) + .await + .map_err(|_| create_database_error!("find", COL))? + } else { + self.col::(COL) + .find(doc! {"_id.server": server_id}) + .sort(doc! {"joined_at": -1}) + .limit(page_size as i64) + .await + .map_err(|_| create_database_error!("find", COL))? + }; + + let mut members = vec![]; + if members_stream.advance().await.is_ok_and(|f| f) { + loop { + if let Ok(x) = members_stream.deserialize_current() { + members.push(x); + if let Ok(r) = members_stream.advance().await { + if !r { + break; + } + } else { + break; + } + } + } + } + + let user_ids: Vec = members.iter().map(|m| m.id.user.clone()).collect(); + let mut set = HashMap::new(); + self.fetch_users(&user_ids).await?.iter().for_each(|f| { + set.insert(f.id.clone(), f.clone()); + }); + + return Ok(members + .iter() + .map(|f| (set.remove(&f.id.user).unwrap(), f.clone())) + .collect()); + } } impl IntoDocumentPath for FieldsMember { diff --git a/crates/core/database/src/models/server_members/ops/reference.rs b/crates/core/database/src/models/server_members/ops/reference.rs index ac15d977..a13b9108 100644 --- a/crates/core/database/src/models/server_members/ops/reference.rs +++ b/crates/core/database/src/models/server_members/ops/reference.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; + +use iso8601_timestamp::Timestamp; use revolt_result::Result; use crate::ReferenceDb; @@ -178,4 +181,91 @@ impl AbstractServerMembers for ReferenceDb { Err(create_error!(NotFound)) } } + + async fn fetch_server_participants( + &self, + server_id: &str, + ) -> Result)>> { + let servers = self.servers.lock().await; + let server = servers.get(server_id).ok_or(create_error!(NotFound))?; + + let mut hash = HashSet::new(); + server.channels.iter().for_each(|c| { + hash.insert(c.clone()); + }); + + drop(servers); + + let messages = self.messages.lock().await; + let userids = messages + .iter() + .filter(|(_, message)| hash.contains(&message.channel)) + .map(|(_, message)| message.author.clone()) + .collect::>(); + + drop(messages); + + let users = self.users.lock().await; + let members = self.server_members.lock().await; + + let resp_users: Vec<(crate::User, Option)> = users + .iter() + .filter(|(uid, _)| userids.contains(*uid)) + .map(|(uid, user)| { + let key = MemberCompositeKey { + server: server_id.to_string(), + user: uid.clone(), + }; + // fuck it we ball + (user.clone(), members.get(&key).map(|f| f.clone())) + }) + .collect(); + + Ok(resp_users) + } + + /// Fetch all members of a server + async fn fetch_server_members( + &self, + server_id: &str, + page_size: u8, + after: Option, + ) -> Result> { + let members = self.server_members.lock().await; + let users = self.users.lock().await; + + let mut server_members: Vec<&Member> = members + .iter() + .filter(|(_, f)| f.id.server == server_id) + .map(|(_, m)| m) + .collect(); + + server_members.sort_by(|a, b| a.joined_at.cmp(&b.joined_at)); + let iterator = server_members.iter(); + + let mut resp: Vec<&&Member> = if let Some(after) = after { + iterator + .skip_while(|f| { + f.joined_at + .duration_since(Timestamp::UNIX_EPOCH) + .whole_milliseconds() + <= after as i128 + }) + .collect() + } else { + iterator.collect() + }; + + resp.truncate(page_size as usize); + + Ok(resp + .iter() + .map(|f| { + ( + users.get(&f.id.user).unwrap().clone(), + f.clone().clone().clone(), + ) + }) + .collect()) + } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index d0cd5521..bbf8d03d 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -1447,12 +1447,38 @@ impl From for AdminToken { } } -impl From for AdminObjectNote { - fn from(value: crate::AdminObjectNote) -> Self { - AdminObjectNote { - edited_at: Timestamp::parse(&value.edited_at).unwrap(), // if it's invalid it shouldn't have made it to this point. - last_edited_by_id: value.last_edited_by_id, +impl From for AdminComment { + fn from(value: crate::AdminComment) -> Self { + AdminComment { + id: value.id, + case_id: value.case_id, + object_id: value.object_id, + user_id: value.user_id, + edited_at: value.edited_at.map(|f| Timestamp::parse(&f).unwrap()), content: value.content, + system_message: value + .system_message + .map(|f| serde_json::from_str(&f).unwrap()), + system_message_target: value.system_message_target, + system_message_context: value.system_message_context, + } + } +} + +impl From for crate::AdminComment { + fn from(value: AdminComment) -> Self { + crate::AdminComment { + id: value.id, + object_id: value.object_id, + case_id: value.case_id, + user_id: value.user_id, + edited_at: value.edited_at.map(|f| f.format_short().to_string()), + content: value.content, + system_message: value + .system_message + .map(|f| serde_json::to_string(&f).unwrap()), // i'll eat my hat if this fails + system_message_target: value.system_message_target, + system_message_context: value.system_message_context, } } } diff --git a/crates/core/database/src/util/reference.rs b/crates/core/database/src/util/reference.rs index f3d4537d..5d2a9152 100644 --- a/crates/core/database/src/util/reference.rs +++ b/crates/core/database/src/util/reference.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use revolt_result::Result; #[cfg(feature = "rocket-impl")] -use rocket::request::FromParam; +use rocket::{form::FromFormField, request::FromParam}; #[cfg(feature = "rocket-impl")] use schemars::{ schema::{InstanceType, Schema, SchemaObject, SingleOrVec}, @@ -133,3 +133,10 @@ impl JsonSchema for Reference { }) } } + +#[cfg(feature = "rocket-impl")] +impl<'r> FromFormField<'r> for Reference { + fn from_value(field: rocket::form::ValueField<'r>) -> rocket::form::Result<'r, Self> { + Ok(Reference::from_unchecked(field.value.into())) + } +} diff --git a/crates/core/models/src/v0/admin.rs b/crates/core/models/src/v0/admin.rs index ed8a6a07..c9c9c98a 100644 --- a/crates/core/models/src/v0/admin.rs +++ b/crates/core/models/src/v0/admin.rs @@ -1,6 +1,6 @@ use iso8601_timestamp::Timestamp; -use crate::v0::{Report, User}; +use crate::v0::{self, Report, User}; auto_derived! { #[derive(Default)] @@ -28,7 +28,7 @@ auto_derived! { #[cfg_attr(feature="serde", serde(rename="mod"))] pub mod_id: String, /// The action performed (previously 'permission') - pub action: String, + pub action: AdminAuditItemActions, /// The relevant case ID, if applicable #[cfg_attr(feature="serde", serde(rename="case"))] pub case_id: String, @@ -37,14 +37,19 @@ auto_derived! { pub target_id: Option, /// The context attached to the action, if applicable (eg. search phrases) pub context: Option, + /// The time the action occurred at. + pub timestamp: Timestamp, } #[derive(Default)] #[cfg_attr(feature = "validator", derive(validator::Validate))] - pub struct AdminCaseCommentCreate { - /// The ID of the case this comment is attached to + pub struct AdminCommentCreate { + /// The ID of the case this comment is attached to, if applicable. This should be the 7 character short code. #[cfg_attr(feature="serde", serde(rename="case"))] - pub case_id: String, + pub case_id: Option, + /// The ID of the object this comment is attached to. Use full case ULID when creating a case comment. + #[cfg_attr(feature="serde", serde(rename="object"))] + pub object_id: String, /// The content #[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))] pub content: String @@ -52,25 +57,39 @@ auto_derived! { #[derive(Default)] #[cfg_attr(feature = "validator", derive(validator::Validate))] - pub struct AdminCaseCommentEdit { + pub struct AdminCommentEdit { /// The new content #[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))] pub content: String } - pub struct AdminCaseComment { + pub struct AdminComment { /// The comment ID pub id: String, - /// The ID of the case this comment is attached to + /// The ID of the case this comment is attached to, if applicable. This should be the 7 character short code #[cfg_attr(feature="serde", serde(rename="case"))] - pub case_id: String, - /// The user who posted the comment + #[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))] + pub case_id: Option, + /// The object this comment is attached to. If this is referencing a case this is the full case ID + #[cfg_attr(feature="serde", serde(rename="object"))] + pub object_id: String, + /// The user who posted the comment/action #[cfg_attr(feature="serde", serde(rename="user"))] pub user_id: String, /// When the comment was edited, if applicable, in iso8601 pub edited_at: Option, - /// The content - pub content: String + /// The content, if this is not a system event + #[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))] + pub content: Option, + /// The system event that happened, if this is not a comment (only applicable for cases) + #[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))] + pub system_message: Option, + /// The system message target + #[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))] + pub system_message_target: Option, + /// The system message raw context + #[cfg_attr(feature="serde", serde(skip_serializing_if="Option::is_none"))] + pub system_message_context: Option, } #[derive(Default)] @@ -119,23 +138,6 @@ auto_derived! { pub reports: Vec } - - pub struct AdminObjectNote { - /// When the note was edited, in iso8601 - pub edited_at: Timestamp, - /// The last user to edit the note - #[cfg_attr(feature="serde", serde(rename="last_edited_by"))] - pub last_edited_by_id: String, - /// The content of the note - pub content: String, - } - - #[cfg_attr(feature = "validator", derive(validator::Validate))] - pub struct AdminObjectNoteEdit { - #[cfg_attr(feature = "validator", validate(length(max = 2000)))] - pub content: String - } - pub struct AdminStrike { /// The strike ID pub id: String, @@ -259,9 +261,11 @@ auto_derived! { pub permissions: Option, } - #[repr(u64)] + // Flags + + #[repr(u32)] pub enum AdminUserPermissionFlags { - ObjectNotes = 0, + Comments = 0, ManageAdminUsers = 1, CreateTokens = 2, @@ -285,4 +289,88 @@ auto_derived! { ManageNotes = 15, Discover = 16, } + + pub enum AdminAuditItemActions { + // Meta + CreateAdminUser, + EditAdminUser, + CreateToken, + RevokeToken, + + // Comments + CommentCreate, + CommentEdit, + CommentFetchForObject, + + // Servers + ServerFetch, + ServerFetchParticipants, + ServerFetchMembers, + ServerAddMember, + ServerChangeOwner, + ServerCreateInvite, + ServerDeleteInvite, + ServerDeleteAllInvites, + ServerDelete, + ServerEdit, + ServerRemoveMember, + ServerSetFlags, + ServerInstanceBanAllMembers, + ServerBanMember, + ServerUnbanMember + } + + // Joiner payloads + pub struct AdminServerResponse { + pub server: v0::Server, + pub owner: v0::User, + pub comments: Vec, + } + + pub struct AdminServerParticipantsResponse { + pub users: Vec, + pub members: Vec, + pub sort_strategy: String + } + + pub struct AdminMemberWithUserAndOffsetResponse { + pub after: Option, + pub users: Vec + } +} + +impl AdminAuditItemActions { + /// Does this action create a case comment? + pub fn makes_comment(&self) -> bool { + match self { + AdminAuditItemActions::CreateAdminUser => false, + AdminAuditItemActions::EditAdminUser => false, + AdminAuditItemActions::CreateToken => false, + AdminAuditItemActions::RevokeToken => false, + AdminAuditItemActions::CommentCreate => false, + AdminAuditItemActions::CommentEdit => false, + AdminAuditItemActions::CommentFetchForObject => false, + AdminAuditItemActions::ServerFetch => false, + AdminAuditItemActions::ServerFetchParticipants => false, + AdminAuditItemActions::ServerAddMember => true, + AdminAuditItemActions::ServerChangeOwner => true, + AdminAuditItemActions::ServerCreateInvite => true, + AdminAuditItemActions::ServerDeleteInvite => true, + AdminAuditItemActions::ServerDeleteAllInvites => true, + AdminAuditItemActions::ServerDelete => true, + AdminAuditItemActions::ServerEdit => true, + AdminAuditItemActions::ServerRemoveMember => true, + AdminAuditItemActions::ServerSetFlags => true, + AdminAuditItemActions::ServerInstanceBanAllMembers => true, + AdminAuditItemActions::ServerBanMember => true, + AdminAuditItemActions::ServerUnbanMember => true, + AdminAuditItemActions::ServerFetchMembers => false, + } + } +} + +impl std::fmt::Display for AdminAuditItemActions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } } diff --git a/crates/core/models/src/v0/server_members.rs b/crates/core/models/src/v0/server_members.rs index 5b26b88a..6ccd8492 100644 --- a/crates/core/models/src/v0/server_members.rs +++ b/crates/core/models/src/v0/server_members.rs @@ -127,4 +127,9 @@ auto_derived!( #[cfg_attr(feature = "validator", validate(length(min = 1)))] pub remove: Option>, } + + pub struct MemberWithUserResponse { + pub user: crate::v0::User, + pub member: Member, + } ); diff --git a/crates/core/result/src/axum.rs b/crates/core/result/src/axum.rs index e9bee77a..70ca594b 100644 --- a/crates/core/result/src/axum.rs +++ b/crates/core/result/src/axum.rs @@ -34,8 +34,10 @@ impl IntoResponse for Error { ErrorType::GroupTooLarge { .. } => StatusCode::FORBIDDEN, ErrorType::AlreadyInGroup => StatusCode::CONFLICT, ErrorType::NotInGroup => StatusCode::NOT_FOUND, + ErrorType::NotAMember => StatusCode::BAD_REQUEST, ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST, ErrorType::NotPinned => StatusCode::BAD_REQUEST, + ErrorType::InviteExists => StatusCode::BAD_REQUEST, ErrorType::UnknownServer => StatusCode::NOT_FOUND, ErrorType::InvalidRole => StatusCode::NOT_FOUND, diff --git a/crates/core/result/src/lib.rs b/crates/core/result/src/lib.rs index 6e58fcab..bc76590b 100644 --- a/crates/core/result/src/lib.rs +++ b/crates/core/result/src/lib.rs @@ -101,11 +101,13 @@ pub enum ErrorType { NotInGroup, AlreadyPinned, NotPinned, + InviteExists, // ? Server related errors UnknownServer, InvalidRole, Banned, + NotAMember, TooManyServers { max: usize, }, diff --git a/crates/core/result/src/rocket.rs b/crates/core/result/src/rocket.rs index 9c0260f0..a14cf3da 100644 --- a/crates/core/result/src/rocket.rs +++ b/crates/core/result/src/rocket.rs @@ -40,9 +40,11 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::GroupTooLarge { .. } => Status::Forbidden, ErrorType::AlreadyInGroup => Status::Conflict, ErrorType::NotInGroup => Status::NotFound, + ErrorType::NotAMember => Status::BadRequest, ErrorType::AlreadyPinned => Status::BadRequest, ErrorType::NotPinned => Status::BadRequest, ErrorType::InvalidFlagValue => Status::BadRequest, + ErrorType::InviteExists => Status::BadRequest, ErrorType::UnknownServer => Status::NotFound, ErrorType::InvalidRole => Status::NotFound, diff --git a/crates/delta/src/routes/admin/comments/comment_create.rs b/crates/delta/src/routes/admin/comments/comment_create.rs new file mode 100644 index 00000000..d1051f7a --- /dev/null +++ b/crates/delta/src/routes/admin/comments/comment_create.rs @@ -0,0 +1,44 @@ +use revolt_database::{AdminAuthorization, AdminComment, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Create a comment on an object or case. Requires Comments permission. +#[openapi(tag = "Admin")] +#[post("/comments", data = "")] +pub async fn admin_comment_create( + db: &State, + auth: AdminAuthorization, + body: Json, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::Comments) { + return Err(create_error!(MissingPermission { + permission: "Comments".to_string() + })); + } + + let comment = AdminComment::new( + &body.object_id, + &user.id, + &body.content, + body.case_id.as_deref(), + ); + db.admin_comment_insert(comment.clone()).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::CommentCreate, + None, + Some(&body.object_id), + None, + ) + .await?; + + Ok(Json(comment.into())) +} diff --git a/crates/delta/src/routes/admin/comments/comment_edit.rs b/crates/delta/src/routes/admin/comments/comment_edit.rs new file mode 100644 index 00000000..b6027e36 --- /dev/null +++ b/crates/delta/src/routes/admin/comments/comment_edit.rs @@ -0,0 +1,56 @@ +use iso8601_timestamp::Timestamp; +use revolt_database::{ + util::reference::Reference, AdminAuthorization, Database, PartialAdminComment, +}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Create a comment on an object or case. Requires Comments permission. +#[openapi(tag = "Admin")] +#[patch("/comments/", data = "")] +pub async fn admin_comment_edit( + db: &State, + auth: AdminAuthorization, + id: Reference, + body: Json, +) -> Result { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::Comments) { + return Err(create_error!(MissingPermission { + permission: "Comments".to_string() + })); + } + + let existing = db.admin_comment_fetch(&id.id).await?; + if existing.user_id != user.id { + return Err(create_error!(MissingPermission { + permission: "CannotEditOthersComment".to_string() + })); + } + + let partial = PartialAdminComment { + content: Some(body.content.clone()), + edited_at: Some(Timestamp::now_utc().format_short().to_string()), + ..Default::default() + }; + db.admin_comment_update(&id.id, &partial).await?; + + let context = format!("comment id {:}, object id {:}", &id.id, &existing.object_id); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::CommentEdit, + None, + Some(&existing.object_id), + Some(&context), + ) + .await?; + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/comments/comment_fetch_case.rs b/crates/delta/src/routes/admin/comments/comment_fetch_case.rs new file mode 100644 index 00000000..aeb80474 --- /dev/null +++ b/crates/delta/src/routes/admin/comments/comment_fetch_case.rs @@ -0,0 +1,43 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Fetch all comments related to a case. id should be the 7 character short code. Requires Comments permission. +#[openapi(tag = "Admin")] +#[get("/comments/case/")] +pub async fn admin_comment_fetch_case( + db: &State, + auth: AdminAuthorization, + id: Reference, +) -> Result>> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::Comments) { + return Err(create_error!(MissingPermission { + permission: "Comments".to_string() + })); + } + + let comments: Vec = db + .admin_comment_fetch_object_comments(&id.id) + .await? + .iter() + .map(|f| f.clone().into()) + .collect(); + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::CommentFetchForObject, + Some(&id.id), + None, + None, + ) + .await?; + + Ok(Json(comments)) +} diff --git a/crates/delta/src/routes/admin/comments/comment_fetch_object.rs b/crates/delta/src/routes/admin/comments/comment_fetch_object.rs new file mode 100644 index 00000000..3fdd3057 --- /dev/null +++ b/crates/delta/src/routes/admin/comments/comment_fetch_object.rs @@ -0,0 +1,43 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Fetch comments on an object. Requires Comments permission. +#[openapi(tag = "Admin")] +#[get("/comments/object/")] +pub async fn admin_comment_fetch_object( + db: &State, + auth: AdminAuthorization, + id: Reference, +) -> Result>> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::Comments) { + return Err(create_error!(MissingPermission { + permission: "Comments".to_string() + })); + } + + let comments: Vec = db + .admin_comment_fetch_object_comments(&id.id) + .await? + .iter() + .map(|f| f.clone().into()) + .collect(); + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::CommentFetchForObject, + None, + Some(&id.id), + None, + ) + .await?; + + Ok(Json(comments)) +} diff --git a/crates/delta/src/routes/admin/comments/mod.rs b/crates/delta/src/routes/admin/comments/mod.rs new file mode 100644 index 00000000..b6c4adaf --- /dev/null +++ b/crates/delta/src/routes/admin/comments/mod.rs @@ -0,0 +1,4 @@ +pub mod comment_create; +pub mod comment_edit; +pub mod comment_fetch_case; +pub mod comment_fetch_object; diff --git a/crates/delta/src/routes/admin/meta/create_token.rs b/crates/delta/src/routes/admin/meta/create_token.rs index 11f16d20..d639cce9 100644 --- a/crates/delta/src/routes/admin/meta/create_token.rs +++ b/crates/delta/src/routes/admin/meta/create_token.rs @@ -18,7 +18,9 @@ pub async fn admin_create_token( &auth.on_behalf_of, v0::AdminUserPermissionFlags::CreateTokens, ) { - return Err(create_error!(NotFound)); + return Err(create_error!(MissingPermission { + permission: "CreateTokens".to_string() + })); } if body.expiry > Timestamp::now_utc() + Duration::days(30) { diff --git a/crates/delta/src/routes/admin/meta/create_user.rs b/crates/delta/src/routes/admin/meta/create_user.rs index 93e05f56..c8f2e917 100644 --- a/crates/delta/src/routes/admin/meta/create_user.rs +++ b/crates/delta/src/routes/admin/meta/create_user.rs @@ -15,7 +15,9 @@ pub async fn admin_create_user( ) -> Result> { let user = flatten_authorized_user(&auth); if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageAdminUsers) { - return Err(create_error!(NotFound)); + return Err(create_error!(MissingPermission { + permission: "ManageAdminUsers".to_string() + })); } // TODO: technically there's a privilege escalation here since anyone with the manageAdminUsers permission can assign whatever permissions they want. diff --git a/crates/delta/src/routes/admin/meta/edit_user.rs b/crates/delta/src/routes/admin/meta/edit_user.rs index 4d138cbf..68b982e5 100644 --- a/crates/delta/src/routes/admin/meta/edit_user.rs +++ b/crates/delta/src/routes/admin/meta/edit_user.rs @@ -17,7 +17,9 @@ pub async fn admin_edit_user( ) -> Result { let user = flatten_authorized_user(&auth); if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageAdminUsers) { - return Err(create_error!(NotFound)); + return Err(create_error!(MissingPermission { + permission: "ManageAdminUsers".to_string() + })); } // TODO: technically there's a privilege escalation here since anyone with the manageAdminUsers permission can assign whatever permissions they want. diff --git a/crates/delta/src/routes/admin/meta/revoke_token.rs b/crates/delta/src/routes/admin/meta/revoke_token.rs index 7df8ad19..4c0bea9c 100644 --- a/crates/delta/src/routes/admin/meta/revoke_token.rs +++ b/crates/delta/src/routes/admin/meta/revoke_token.rs @@ -19,7 +19,9 @@ pub async fn admin_revoke_token( &auth.on_behalf_of, v0::AdminUserPermissionFlags::CreateTokens, ) { - return Err(create_error!(NotFound)); + return Err(create_error!(MissingPermission { + permission: "CreateTokens".to_string() + })); } let token = token.as_admin_token(db).await?; diff --git a/crates/delta/src/routes/admin/mod.rs b/crates/delta/src/routes/admin/mod.rs index 127c1b6d..b2862ee8 100644 --- a/crates/delta/src/routes/admin/mod.rs +++ b/crates/delta/src/routes/admin/mod.rs @@ -3,24 +3,38 @@ use rocket::Route; mod accounts; mod cases; +mod comments; mod meta; mod reports; mod roles; mod search; +mod servers; mod users; -mod object_edit_note; -mod object_get_note; mod util; pub fn routes() -> (Vec, OpenApi) { openapi_get_routes_spec![ - object_edit_note::admin_object_edit_note, - object_get_note::admin_object_get_note, meta::create_user::admin_create_user, meta::edit_user::admin_edit_user, meta::fetch_users::admin_fetch_users, meta::create_token::admin_create_token, - meta::revoke_token::admin_revoke_token + meta::revoke_token::admin_revoke_token, + comments::comment_create::admin_comment_create, + comments::comment_edit::admin_comment_edit, + comments::comment_fetch_case::admin_comment_fetch_case, + comments::comment_fetch_object::admin_comment_fetch_object, + servers::fetch::server_get::admin_server_get, + servers::fetch::server_get_participants::admin_server_get_participants, + servers::fetch::server_get_all_members::admin_server_get_members, + servers::actions::server_add_members::admin_server_add_member, + servers::actions::server_ban_members::admin_server_ban_member, + servers::actions::server_unban_members::admin_server_unban_member, + servers::actions::server_change_owner::admin_server_change_owner, + servers::actions::server_create_invite::admin_server_create_invite, + servers::actions::server_delete_invites::admin_server_delete_invites, + servers::actions::server_delete::admin_server_delete, + servers::actions::server_edit::admin_server_edit, + servers::actions::server_remove_members::admin_server_remove_members ] } diff --git a/crates/delta/src/routes/admin/object_edit_note.rs b/crates/delta/src/routes/admin/object_edit_note.rs deleted file mode 100644 index fb619f6f..00000000 --- a/crates/delta/src/routes/admin/object_edit_note.rs +++ /dev/null @@ -1,28 +0,0 @@ -use revolt_database::{util::reference::Reference, AdminAuthorization, AdminObjectNote, Database}; -use revolt_models::v0::{self}; -use revolt_result::{create_error, Result}; -use rocket::{serde::json::Json, State}; -use rocket_empty::EmptyResponse; - -use crate::routes::admin::util::{flatten_authorized_user, user_has_permission}; - -/// Edit an object note. Requires ObjectNotes permission. -#[openapi(tag = "Admin")] -#[post("/notes/", data = "")] -pub async fn admin_object_edit_note( - db: &State, - auth: AdminAuthorization, - object: Reference, - body: Json, -) -> Result { - let user = flatten_authorized_user(&auth); - if !user_has_permission(user, v0::AdminUserPermissionFlags::ObjectNotes) { - return Err(create_error!(NotFound)); - } - - // Unfortunately due to how our system works, we can't limit users to editing objects they have permissions for (eg users, servers, etc.). - // Hence it being tied to its own permission - db.admin_note_update(AdminObjectNote::new(&object.id, &user.id, &body.content)) - .await?; - Ok(EmptyResponse) -} diff --git a/crates/delta/src/routes/admin/object_get_note.rs b/crates/delta/src/routes/admin/object_get_note.rs deleted file mode 100644 index 2b3cf0b3..00000000 --- a/crates/delta/src/routes/admin/object_get_note.rs +++ /dev/null @@ -1,24 +0,0 @@ -use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; -use revolt_models::v0::{self}; -use revolt_result::{create_error, Result}; -use rocket::{serde::json::Json, State}; - -use crate::routes::admin::util::{flatten_authorized_user, user_has_permission}; - -/// Edit an object note. Requires ObjectNotes permission. -#[openapi(tag = "Admin")] -#[get("/notes/")] -pub async fn admin_object_get_note( - db: &State, - auth: AdminAuthorization, - object: Reference, -) -> Result> { - let user = flatten_authorized_user(&auth); - if !user_has_permission(user, v0::AdminUserPermissionFlags::ObjectNotes) { - return Err(create_error!(NotFound)); - } - - // Unfortunately due to how our system works, we can't limit users to editing objects they have permissions for (eg users, servers, etc.). - // Hence it being tied to its own permission - Ok(Json(db.admin_note_fetch(&object.id).await?.into())) -} diff --git a/crates/delta/src/routes/admin/servers/actions/mod.rs b/crates/delta/src/routes/admin/servers/actions/mod.rs index e69de29b..aadd11d8 100644 --- a/crates/delta/src/routes/admin/servers/actions/mod.rs +++ b/crates/delta/src/routes/admin/servers/actions/mod.rs @@ -0,0 +1,9 @@ +pub mod server_add_members; +pub mod server_ban_members; +pub mod server_change_owner; +pub mod server_create_invite; +pub mod server_delete; +pub mod server_delete_invites; +pub mod server_edit; +pub mod server_remove_members; +pub mod server_unban_members; diff --git a/crates/delta/src/routes/admin/servers/actions/server_add_members.rs b/crates/delta/src/routes/admin/servers/actions/server_add_members.rs index e69de29b..86bfde23 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_add_members.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_add_members.rs @@ -0,0 +1,48 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database, Member}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Add server member +/// +/// Add a user to a server, optionally disabling notifications +#[openapi(tag = "Admin")] +#[post("/servers//members?&&")] +pub async fn admin_server_add_member( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + user_id: Reference, + suppress_alerts: bool, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let user = user_id.as_user(db).await?; + let (member, _) = Member::create(db, &server, &user, None, !suppress_alerts).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerAddMember, + case, + Some(&id.id), + None, + ) + .await?; + + Ok(Json(v0::MemberWithUserResponse { + user: user.into_self(false).await, + member: member.into(), + })) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_ban_members.rs b/crates/delta/src/routes/admin/servers/actions/server_ban_members.rs new file mode 100644 index 00000000..8ed09526 --- /dev/null +++ b/crates/delta/src/routes/admin/servers/actions/server_ban_members.rs @@ -0,0 +1,83 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database, ServerBan}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use validator::Validate; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Ban a server member +/// +/// Ban a member who is not the owner +#[openapi(tag = "Admin")] +#[post( + "/servers//ban?&&", + data = "" +)] +pub async fn admin_server_ban_member( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + user_id: Reference, + suppress_alerts: bool, + body: Json, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let body = body.into_inner(); + body.validate().map_err(|error| { + create_error!(FailedValidation { + error: error.to_string() + }) + })?; + + let server = id.as_server(db).await?; + let target = user_id.as_user(db).await?; + + if target.id == server.owner { + return Err(create_error!(InvalidOperation)); + } + + let resp = if let Ok(member) = user_id.as_member(db, &id.id).await { + member + .remove( + db, + &server, + revolt_database::RemovalIntention::Ban, + suppress_alerts, + ) + .await?; + + Ok(ServerBan::create(db, &server, &target.id, body.reason.clone()).await?) + } else { + Err(create_error!(NotAMember)) + }?; + + let context = format!( + "user id: {:}, server id: {:}, reason: {:}", + &user_id.id, + &id.id, + body.reason + .clone() + .unwrap_or_else(|| "None Provided".to_string()) + ); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerBanMember, + case, + Some(&id.id), + Some(&context), + ) + .await?; + + Ok(Json(resp.into())) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_change_owner.rs b/crates/delta/src/routes/admin/servers/actions/server_change_owner.rs index e69de29b..dcb9da4c 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_change_owner.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_change_owner.rs @@ -0,0 +1,55 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database, PartialServer}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::State; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Change Server Owner +/// +/// Change the ownership of a server. +#[openapi(tag = "Admin")] +#[post("/servers//owner?&")] +pub async fn admin_server_change_owner( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + user_id: Reference, +) -> Result { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let target = user_id.as_user(db).await?; + + if target.id == server.owner { + return Err(create_error!(InvalidOperation)); + } + + let partial = PartialServer { + owner: Some(target.id), + ..Default::default() + }; + db.update_server(&server.id, &partial, vec![]).await?; + + let context = format!("user id: {:}, server id: {:}", &user_id.id, &id.id); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerChangeOwner, + case, + Some(&id.id), + Some(&context), + ) + .await?; + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_create_invite.rs b/crates/delta/src/routes/admin/servers/actions/server_create_invite.rs index e69de29b..f6d734a1 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_create_invite.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_create_invite.rs @@ -0,0 +1,66 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database, Invite}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Create Server Invite +/// +/// Create an invite to a server. Optionally include a slug to create a vanity URL. +#[openapi(tag = "Admin")] +#[post("/servers//invites?&&")] +pub async fn admin_server_create_invite( + db: &State, + auth: AdminAuthorization, + id: Reference, + channel_id: Reference, + case: Option<&str>, + slug: Option<&str>, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let channel = channel_id.as_channel(db).await?; + let creator = db.fetch_user(&server.owner).await?; + + let invite: Invite; + + if let Some(slug) = slug { + invite = Invite::Server { + code: slug.to_string(), + creator: creator.id.clone(), + server: server.id.clone(), + channel: channel.id().to_string(), + }; + #[allow(clippy::disallowed_methods)] + db.insert_invite(&invite).await?; + } else { + invite = Invite::create_channel_invite(db, &creator, &channel).await?; + } + + let context = format!( + "server id: {:}, invite slug: {:}, channel: {:}", + &id.id, + invite.code(), + channel.id() + ); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerCreateInvite, + case, + Some(&id.id), + Some(&context), + ) + .await?; + + Ok(Json(invite.into())) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_delete.rs b/crates/delta/src/routes/admin/servers/actions/server_delete.rs index e69de29b..022c2b36 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_delete.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_delete.rs @@ -0,0 +1,41 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::State; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Get a list of admin users. Any active user may use this endpoint. +/// Typically the client should cache this data. +#[openapi(tag = "Admin")] +#[delete("/servers/?")] +pub async fn admin_server_delete( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, +) -> Result { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + db.delete_server(&id.id).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerDelete, + case, + Some(&id.id), + None, + ) + .await?; + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_delete_invites.rs b/crates/delta/src/routes/admin/servers/actions/server_delete_invites.rs index e69de29b..a5838c01 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_delete_invites.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_delete_invites.rs @@ -0,0 +1,62 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::State; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Delete Server Invite +/// +/// Delete a server invite, or all of a server's invites. +#[openapi(tag = "Admin")] +#[delete("/servers//invites?&")] +pub async fn admin_server_delete_invites( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + slug: Option, +) -> Result { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + if let Some(slug) = slug { + let invite = slug.as_invite(db).await?; + + db.delete_invite(&slug.id).await?; + + let context = format!("server id: {:}, invite slug: {:}", &id.id, invite.code()); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerDeleteInvite, + case, + Some(&id.id), + Some(&context), + ) + .await?; + } else { + let invites = db.fetch_invites_for_server(&id.id).await?; + for invite in invites { + db.delete_invite(invite.code()).await?; + } + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerDeleteAllInvites, + case, + Some(&id.id), + None, + ) + .await?; + } + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_edit.rs b/crates/delta/src/routes/admin/servers/actions/server_edit.rs index e69de29b..0e1f465f 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_edit.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_edit.rs @@ -0,0 +1,52 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use validator::Validate; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Edit server +/// +/// Edit any attributes of a server. +#[openapi(tag = "Admin")] +#[patch("/servers/?", data = "")] +pub async fn admin_server_edit( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + body: Json, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let body = body.into_inner(); + body.validate().map_err(|error| { + create_error!(FailedValidation { + error: error.to_string() + }) + })?; + + let mut server = id.as_server(db).await?; + let user = db.fetch_user(&server.owner).await?; + crate::routes::servers::server_edit::edit_data(body, db, &mut server, &user).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerEdit, + case, + Some(&id.id), + None, + ) + .await?; + + Ok(Json(server.into())) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_remove_members.rs b/crates/delta/src/routes/admin/servers/actions/server_remove_members.rs index e69de29b..c17779aa 100644 --- a/crates/delta/src/routes/admin/servers/actions/server_remove_members.rs +++ b/crates/delta/src/routes/admin/servers/actions/server_remove_members.rs @@ -0,0 +1,61 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::State; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// # Ban a server member +/// +/// Ban a member who is not the owner +#[openapi(tag = "Admin")] +#[post("/servers//remove?&&")] +pub async fn admin_server_remove_members( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + user_id: Vec, + suppress_alerts: bool, +) -> Result { + let admin_user = flatten_authorized_user(&auth); + if !user_has_permission(admin_user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + for uid in user_id { + let target = uid.as_member(db, &id.id).await?; + + if target.id.user == server.owner { + return Err(create_error!(InvalidOperation)); + } + + target + .remove( + db, + &server, + revolt_database::RemovalIntention::Kick, + suppress_alerts, + ) + .await?; + + let context = format!("user id: {:}, server id: {:}", &uid.id, &id.id); + create_audit_action( + db, + &admin_user.id, + v0::AdminAuditItemActions::ServerRemoveMember, + case, + Some(&id.id), + Some(&context), + ) + .await?; + } + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/servers/actions/server_set_flags.rs b/crates/delta/src/routes/admin/servers/actions/server_set_flags.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/crates/delta/src/routes/admin/servers/actions/server_unban_members.rs b/crates/delta/src/routes/admin/servers/actions/server_unban_members.rs new file mode 100644 index 00000000..622a0194 --- /dev/null +++ b/crates/delta/src/routes/admin/servers/actions/server_unban_members.rs @@ -0,0 +1,60 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use rocket_empty::EmptyResponse; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Get a list of admin users. Any active user may use this endpoint. +/// Typically the client should cache this data. +#[openapi(tag = "Admin")] +#[post( + "/servers//unban?&&", + data = "" +)] +pub async fn admin_server_unban_member( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + user_id: Reference, + suppress_alerts: bool, + body: Json, +) -> Result { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let ban = db + .fetch_ban(&id.id, &user_id.id) + .await + .map_err(|_| create_error!(NotFound))?; + + db.delete_ban(&ban.id).await?; + + let context = format!( + "user id: {:}, server id: {:}, reason: {:}", + &user_id.id, + &id.id, + body.reason + .clone() + .unwrap_or_else(|| "None Provided".to_string()) + ); + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerUnbanMember, + case, + Some(&id.id), + Some(&context), + ) + .await?; + + Ok(EmptyResponse) +} diff --git a/crates/delta/src/routes/admin/servers/fetch/mod.rs b/crates/delta/src/routes/admin/servers/fetch/mod.rs index e69de29b..279ae510 100644 --- a/crates/delta/src/routes/admin/servers/fetch/mod.rs +++ b/crates/delta/src/routes/admin/servers/fetch/mod.rs @@ -0,0 +1,3 @@ +pub mod server_get; +pub mod server_get_all_members; +pub mod server_get_participants; diff --git a/crates/delta/src/routes/admin/servers/fetch/server_get.rs b/crates/delta/src/routes/admin/servers/fetch/server_get.rs index e69de29b..5794a487 100644 --- a/crates/delta/src/routes/admin/servers/fetch/server_get.rs +++ b/crates/delta/src/routes/admin/servers/fetch/server_get.rs @@ -0,0 +1,51 @@ +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Get a list of admin users. Any active user may use this endpoint. +/// Typically the client should cache this data. +#[openapi(tag = "Admin")] +#[get("/servers/?")] +pub async fn admin_server_get( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let owner = db.fetch_user(&server.owner).await?.into_self(false).await; + let comments: Vec = db + .admin_comment_fetch_object_comments(&server.id) + .await? + .iter() + .map(|f| f.clone().into()) + .collect(); + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerFetch, + case, + Some(&id.id), + None, + ) + .await?; + + Ok(Json(v0::AdminServerResponse { + server: server.into(), + owner, + comments, + })) +} diff --git a/crates/delta/src/routes/admin/servers/fetch/server_get_all_members.rs b/crates/delta/src/routes/admin/servers/fetch/server_get_all_members.rs new file mode 100644 index 00000000..75881f69 --- /dev/null +++ b/crates/delta/src/routes/admin/servers/fetch/server_get_all_members.rs @@ -0,0 +1,67 @@ +use futures::future::join_all; +use iso8601_timestamp::Timestamp; +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Get a list of admin users. Any active user may use this endpoint. +/// Typically the client should cache this data. +#[openapi(tag = "Admin")] +#[get("/servers//members?&")] +pub async fn admin_server_get_members( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, + after: Option, +) -> Result> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let members = db.fetch_server_members(&server.id, 100, after).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerFetchMembers, + case, + Some(&id.id), + None, + ) + .await?; + + let members_and_users = join_all(members.iter().map(|(u, m)| async move { + let user: v0::User = u.clone().into_self(false).await; + let member: v0::Member = m.clone().into(); + v0::MemberWithUserResponse { user, member } + })) + .await; + + let mut resp = v0::AdminMemberWithUserAndOffsetResponse { + after: None, + users: vec![], + }; + + if let Some(last) = members_and_users.last() { + resp.after = Some( + last.member + .joined_at + .duration_since(Timestamp::UNIX_EPOCH) + .whole_milliseconds() as usize, + ); + } + + resp.users = members_and_users; + + Ok(Json(resp)) +} diff --git a/crates/delta/src/routes/admin/servers/fetch/server_get_participants.rs b/crates/delta/src/routes/admin/servers/fetch/server_get_participants.rs index e69de29b..bcd43a37 100644 --- a/crates/delta/src/routes/admin/servers/fetch/server_get_participants.rs +++ b/crates/delta/src/routes/admin/servers/fetch/server_get_participants.rs @@ -0,0 +1,51 @@ +use futures::future::join_all; +use revolt_database::{util::reference::Reference, AdminAuthorization, Database}; +use revolt_models::v0::{self}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; + +use crate::routes::admin::util::{ + create_audit_action, flatten_authorized_user, user_has_permission, +}; + +/// Get a list of admin users. Any active user may use this endpoint. +/// Typically the client should cache this data. +#[openapi(tag = "Admin")] +#[get("/servers//participants?")] +pub async fn admin_server_get_participants( + db: &State, + auth: AdminAuthorization, + id: Reference, + case: Option<&str>, +) -> Result)>>> { + let user = flatten_authorized_user(&auth); + if !user_has_permission(user, v0::AdminUserPermissionFlags::ManageServers) { + return Err(create_error!(MissingPermission { + permission: "ManageServers".to_string() + })); + } + + let server = id.as_server(db).await?; + let participants = db.fetch_server_participants(&server.id).await?; + + create_audit_action( + db, + &user.id, + v0::AdminAuditItemActions::ServerFetchParticipants, + case, + Some(&id.id), + None, + ) + .await?; + + println!("{:?}", &participants); + + let resp = join_all(participants.iter().map(|(u, m)| async move { + let user: v0::User = u.clone().into_self(false).await; + let member: Option = m.clone().map(|mu| mu.into()); + (user, member) + })) + .await; + + Ok(Json(resp)) +} diff --git a/crates/delta/src/routes/admin/servers/mod.rs b/crates/delta/src/routes/admin/servers/mod.rs index f6d4d6e6..d0a73f79 100644 --- a/crates/delta/src/routes/admin/servers/mod.rs +++ b/crates/delta/src/routes/admin/servers/mod.rs @@ -1,2 +1,2 @@ -mod actions; -mod fetch; +pub mod actions; +pub mod fetch; diff --git a/crates/delta/src/routes/admin/util.rs b/crates/delta/src/routes/admin/util.rs index 0fc72128..479a7f9e 100644 --- a/crates/delta/src/routes/admin/util.rs +++ b/crates/delta/src/routes/admin/util.rs @@ -1,5 +1,9 @@ -use revolt_database::{AdminAuthorization, AdminUser, AdminUserPermissionFlagsValue}; -use revolt_models::v0::AdminUserPermissionFlags; +use revolt_database::{ + AdminAuditItem, AdminAuthorization, AdminComment, AdminUser, AdminUserPermissionFlagsValue, + Database, +}; +use revolt_models::v0::{AdminAuditItemActions, AdminUserPermissionFlags}; +use revolt_result::Result; pub fn user_has_permission(user: &AdminUser, permission: AdminUserPermissionFlags) -> bool { let flags = AdminUserPermissionFlagsValue(user.permissions); @@ -7,9 +11,40 @@ pub fn user_has_permission(user: &AdminUser, permission: AdminUserPermissionFlag flags.has(permission) } -pub fn flatten_authorized_user<'a>(auth: &'a AdminAuthorization) -> &'a AdminUser { +pub fn flatten_authorized_user(auth: &AdminAuthorization) -> &AdminUser { match auth { - AdminAuthorization::AdminUser(admin_user) => &admin_user, + AdminAuthorization::AdminUser(admin_user) => admin_user, AdminAuthorization::AdminMachine(admin_machine_token) => &admin_machine_token.on_behalf_of, } } + +/// Creates audit logs, and if applicable, adds a comment to the referenced case. +pub async fn create_audit_action( + db: &Database, + mod_id: &str, + action: AdminAuditItemActions, + short_case_id: Option<&str>, + target_id: Option<&str>, + context: Option<&str>, +) -> Result<()> { + let audit = AdminAuditItem::new(mod_id, action.clone(), short_case_id, target_id, context); + db.admin_audit_insert(audit).await?; + + if action.makes_comment() { + if let Some(short_case_id) = short_case_id { + if let Some(target_id) = target_id { + let case = db.admin_case_fetch_from_shorthand(short_case_id).await?; + db.admin_comment_insert(AdminComment::new_system_message( + &case.id, + mod_id, + short_case_id, + action, + context, + target_id, + )) + .await?; + } + } + } + Ok(()) +} diff --git a/crates/delta/src/routes/bots/invite.rs b/crates/delta/src/routes/bots/invite.rs index 10c09eed..f1ea7c3d 100644 --- a/crates/delta/src/routes/bots/invite.rs +++ b/crates/delta/src/routes/bots/invite.rs @@ -43,7 +43,7 @@ pub async fn invite_bot( .await .throw_if_lacking_channel_permission(ChannelPermission::ManageServer)?; - Member::create(db, &server, &bot_user, None) + Member::create(db, &server, &bot_user, None, true) .await .map(|_| EmptyResponse) } diff --git a/crates/delta/src/routes/invites/invite_join.rs b/crates/delta/src/routes/invites/invite_join.rs index 83b59195..0999eb1f 100644 --- a/crates/delta/src/routes/invites/invite_join.rs +++ b/crates/delta/src/routes/invites/invite_join.rs @@ -24,7 +24,7 @@ pub async fn join( match &invite { Invite::Server { server, .. } => { let server = db.fetch_server(server).await?; - let (_, channels) = Member::create(db, &server, &user, None).await?; + let (_, channels) = Member::create(db, &server, &user, None, true).await?; Ok(Json(InviteJoinResponse::Server { channels: channels.into_iter().map(|c| c.into()).collect(), diff --git a/crates/delta/src/routes/servers/mod.rs b/crates/delta/src/routes/servers/mod.rs index a7553625..12571840 100644 --- a/crates/delta/src/routes/servers/mod.rs +++ b/crates/delta/src/routes/servers/mod.rs @@ -21,7 +21,7 @@ mod roles_fetch; mod server_ack; mod server_create; mod server_delete; -mod server_edit; +pub mod server_edit; mod server_fetch; pub fn routes() -> (Vec, OpenApi) { diff --git a/crates/delta/src/routes/servers/server_create.rs b/crates/delta/src/routes/servers/server_create.rs index 82f0a8aa..a2ba1c45 100644 --- a/crates/delta/src/routes/servers/server_create.rs +++ b/crates/delta/src/routes/servers/server_create.rs @@ -30,7 +30,7 @@ pub async fn create_server( user.can_acquire_server(db).await?; let (server, channels) = Server::create(db, data, &user, true).await?; - let (_, channels) = Member::create(db, &server, &user, Some(channels)).await?; + let (_, channels) = Member::create(db, &server, &user, Some(channels), true).await?; Ok(Json(v0::CreateServerLegacyResponse { server: server.into(), diff --git a/crates/delta/src/routes/servers/server_edit.rs b/crates/delta/src/routes/servers/server_edit.rs index 05688473..eb67624c 100644 --- a/crates/delta/src/routes/servers/server_edit.rs +++ b/crates/delta/src/routes/servers/server_edit.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, File, PartialServer, User, + Database, File, PartialServer, Server, User, }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -69,6 +69,16 @@ pub async fn edit( permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; } + edit_data(data, db, &mut server, &user).await?; + Ok(Json(server.into())) +} + +pub async fn edit_data( + data: v0::DataEditServer, + db: &Database, + server: &mut Server, + user: &User, +) -> Result<()> { let v0::DataEditServer { name, description, @@ -158,5 +168,5 @@ pub async fn edit( ) .await?; - Ok(Json(server.into())) + Ok(()) } diff --git a/crates/delta/src/util/test.rs b/crates/delta/src/util/test.rs index 98254e47..9b75d862 100644 --- a/crates/delta/src/util/test.rs +++ b/crates/delta/src/util/test.rs @@ -167,7 +167,7 @@ impl TestHarness { server: &Server, channels: Vec, ) -> (Channel, Member, Message) { - let (member, channels) = Member::create(&self.db, server, user, Some(channels)) + let (member, channels) = Member::create(&self.db, server, user, Some(channels), true) .await .expect("Failed to create member"); let channel = &channels[0]; diff --git a/scripts/email_users_via_zammad.py b/scripts/email_users_via_zammad.py new file mode 100644 index 00000000..e9eb2a66 --- /dev/null +++ b/scripts/email_users_via_zammad.py @@ -0,0 +1,52 @@ +# requires httpx off pypi +import httpx +import math +from os import getenv +import asyncio + +URL = getenv("ZAMMAD_URL") +TOKEN = getenv("ZAMMAD_TOKEN") +EMAIL_FILE = getenv("EMAILS_FILE") +PAYLOAD_TEMPLATE = getenv("PAYLOAD_FILE") + +assert URL and TOKEN and EMAIL_FILE and PAYLOAD_TEMPLATE, ValueError("Expected env variables EMAIL_FILE, PAYLOAD_FILE, ZAMMAD_URL and ZAMMAD_TOKEN.") + +URL = URL.strip("/") + "/api/v1/tickets" + +async def worker(payload: dict, emails: list[str]) -> None: + async with httpx.AsyncClient(headers={"Authorization": f"Token token={TOKEN}"}) as client: + assert URL # redundant but makes pyright happy + + for email in emails: + payload["customer_id"] = f"guess:{email}" + payload["article"]["to"] = email + print(email) + resp = await client.post(URL, json=payload) + if resp.status_code > 300: + raise RuntimeError(f"Failed to create ticket: {resp.status_code}\n{resp.read()}") + +async def main(): + assert EMAIL_FILE and PAYLOAD_TEMPLATE # pyright happy time + + with open(EMAIL_FILE) as f: + emails = [x.strip() for x in f.read().split("\n")] + + with open(PAYLOAD_TEMPLATE) as f: + p = {} + exec(f.read(), p) + payload = p["PAYLOAD"] + + # WORKER_COUNT = min(max(round(len(emails) / 10), 1), 10) + # EMAILS_PER = math.ceil(len(emails) / WORKER_COUNT) + # print(WORKER_COUNT, EMAILS_PER) + # tasks = [] + + # for worker_id in range(WORKER_COUNT): + # worker_emails = emails[EMAILS_PER * worker_id:EMAILS_PER * worker_id + 1] + # tasks.append(asyncio.create_task(worker(payload, worker_emails))) + + # await asyncio.gather(*tasks) + + await worker(payload, emails) + +asyncio.run(main()) \ No newline at end of file