diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index 5fe64cd0..0861fd3e 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -88,6 +88,10 @@ max_concurrent_connections = 50 # How long to ring devices for when calling in dms/groups, in seconds call_ring_duration = 30 +[api.audit_logs] +# How long audit log entries last before being removed, in seconds +expires_after = 2592000 # 30d + [api.livekit.nodes] [api.users] diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 93789208..41b5e3dd 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -260,6 +260,12 @@ pub struct ApiUsers { pub min_username_length: usize, } +#[derive(Deserialize, Debug, Clone)] +pub struct ApiAuditLogs { + /// How long audit log entries last before being removed, in seconds + pub expires_after: u64, +} + #[derive(Deserialize, Debug, Clone)] pub struct Api { pub registration: ApiRegistration, @@ -268,6 +274,7 @@ pub struct Api { pub workers: ApiWorkers, pub livekit: ApiLiveKit, pub users: ApiUsers, + pub audit_logs: ApiAuditLogs, } #[derive(Deserialize, Debug, Clone)] diff --git a/crates/core/database/src/drivers/reference.rs b/crates/core/database/src/drivers/reference.rs index 66c9e9b6..014f3fbe 100644 --- a/crates/core/database/src/drivers/reference.rs +++ b/crates/core/database/src/drivers/reference.rs @@ -3,15 +3,16 @@ use std::{collections::HashMap, sync::Arc}; use futures::lock::Mutex; use crate::{ - Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member, - MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot, - User, UserSettings, Webhook, Account, AccountInvite, Session, MFATicket + Account, AccountInvite, AuditLogEntry, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, + File, FileHash, Invite, MFATicket, Member, MemberCompositeKey, Message, PolicyChange, + RatelimitEvent, Report, Server, ServerBan, Session, Snapshot, User, UserSettings, Webhook, }; database_derived!( /// Reference implementation #[derive(Default, Debug)] pub struct ReferenceDb { + pub audit_logs: Arc>>, pub bots: Arc>>, pub channels: Arc>>, pub channel_invites: Arc>>, diff --git a/crates/core/database/src/lib.rs b/crates/core/database/src/lib.rs index 0ab9b6ce..1af4b8af 100644 --- a/crates/core/database/src/lib.rs +++ b/crates/core/database/src/lib.rs @@ -77,6 +77,78 @@ macro_rules! auto_derived_partial { }; } +/// Internal macro for `generate_diff!`, you should not need to use this yourself. +macro_rules! generate_field_diff { + (optional, $remove:ident, $fieldsmember:path, $self:ident, $before:ident, $partial:ident, $field:ident) => { + if $partial.$field.is_some() || $remove.contains(&$fieldsmember) { + $before.$field = $self.$field.clone(); + }; + }; + + (optional, default, $remove:ident, $fieldsmember:path, $self:ident, $before:ident, $partial:ident, $field:ident) => { + if $partial.$field.is_some() || $remove.contains(&$fieldsmember) { + $before.$field = Some($self.$field.clone()); + }; + }; + + ($self:ident, $before:ident, $partial:ident, $field:ident) => { + if $partial.$field.is_some() { + $before.$field = Some($self.$field.clone()); + }; + }; +} + +/// Generates a partial model containing the data which has changed in an update +/// +/// ## Usage: +/// `before` is the "output" containing what the model had before being updated, +/// this will corraspond to `partial` which is what the data is being changed too. +/// +/// ```rs +/// let mut before = PartialModel::default(); +/// +/// generate_diff!( +/// self, // database model +/// before, // mutable empty partial corrasponding to the current model +/// partial, // partial containing what is being updated +/// remove, // slice of fields being removed +/// ( +/// name, // regular non-nullable non-removable field +/// (FieldsEnum::Nickname) nickname, // optional removable field +/// ((default) FieldsEnum::Roles) roles, // optional removable field with custom default +/// ) +/// ); +/// ``` +/// +/// See `Member::generate_diff` `Server::generate_diff` `Role::generate_diff` for full examples +macro_rules! generate_diff { + ( + $self:ident, + $before:ident, + $partial:ident, + $remove:ident, + ( + $( + $( + $(@$optional:tt)? ( + $($(@$default:tt)? (default))? + $fieldsmember:path + ) + )? + $field: ident + ),* + $(,)? + ) + ) => { + $( + generate_field_diff!( + $( $($optional)? optional, $($($default)? default,)? $remove, $fieldsmember,)? + $self, $before, $partial, $field + ); + )* + } +} + mod drivers; pub use drivers::*; @@ -115,7 +187,6 @@ pub use amqp::amqp::AMQP; #[cfg(feature = "voice")] pub mod voice; - /// Utility function to check if a boolean value is false pub fn if_false(t: &bool) -> bool { !t 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 c3bfc5a8..eadceaf5 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 @@ -98,6 +98,9 @@ pub async fn create_database(db: &MongoDb) { .await .expect("Failed to create pubsub collection."); + db.create_collection("audit_logs") + .await + .expect("Failed to create audit_logs collection"); db.create_collection("sessions") .await .expect("Failed to create sessions collection."); @@ -275,6 +278,58 @@ pub async fn create_database(db: &MongoDb) { .await .expect("Failed to create ratelimit_events index."); + db.run_command(doc! { + "createIndexes": "audit_logs", + "indexes": [ + { + "key": { + "expires_at": 1_i32, + }, + "name": "expires_at_ttl", + // We set the expire after to 0 because we store when it expires instead of when the document was inserted, + // this is because mongo cant read the timestamp from the ulid so we need to do this workaround. + // relevant docs: https://www.mongodb.com/docs/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time + "expireAfterSeconds": 0 + }, + { + "key": { + "server": 1_i32, + "user": 1_i32, + "action.type": 1_i32, + }, + "name": "audit_log_filters", + }, + ] + }) + .await + .expect("Failed to create audit_logs index"); + + db.run_command(doc! { + "createIndexes": "audit_logs", + "indexes": [ + { + "key": { + "expires_at": 1_i32, + }, + "name": "expires_at_ttl", + // We set the expire after to 0 because we store when it expires instead of when the document was inserted, + // this is because mongo cant read the timestamp from the ulid so we need to do this workaround. + // relevant docs: https://www.mongodb.com/docs/manual/tutorial/expire-data/#expire-documents-at-a-specific-clock-time + "expireAfterSeconds": 0 + }, + { + "key": { + "server": 1_i32, + "user": 1_i32, + "action.type": 1_i32, + }, + "name": "audit_log_filters", + }, + ] + }) + .await + .expect("Failed to create audit_logs index"); + db.run_command(doc! { "createIndexes": "accounts", "indexes": [ diff --git a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs index ab3053f5..e7c4ea89 100644 --- a/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs +++ b/crates/core/database/src/models/admin_migrations/ops/mongodb/scripts.rs @@ -26,7 +26,7 @@ struct MigrationInfo { revision: i32, } -pub const LATEST_REVISION: i32 = 51; // MUST BE +1 to last migration +pub const LATEST_REVISION: i32 = 52; // MUST BE +1 to last migration pub async fn migrate_database(db: &MongoDb) { let migrations = db.col::("migrations"); @@ -1490,6 +1490,39 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .unwrap(); } + if revision >= 51 { + info!("Running migration [revision 51 / 28-11-2025]: Add audit logs collection"); + + db.db() + .create_collection("audit_logs") + .await + .expect("Failed to create audit_logs collection"); + + db.db() + .run_command(doc! { + "createIndexes": "audit_logs", + "indexes": [ + { + "key": { + "expires_at": 1_i32, + }, + "name": "expires_at_ttl", + "expireAfterSeconds": 0 + }, + { + "key": { + "server": 1_i32, + "user": 1_i32, + "action.type": 1_i32, + }, + "name": "audit_log_filters", + }, + ] + }) + .await + .expect("Failed to create audit_logs index"); + }; + // Reminder to update LATEST_REVISION when adding new migrations. LATEST_REVISION.max(revision) } diff --git a/crates/core/database/src/models/audit_logs/mod.rs b/crates/core/database/src/models/audit_logs/mod.rs new file mode 100644 index 00000000..4d801b73 --- /dev/null +++ b/crates/core/database/src/models/audit_logs/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/audit_logs/model.rs b/crates/core/database/src/models/audit_logs/model.rs new file mode 100644 index 00000000..bc962f07 --- /dev/null +++ b/crates/core/database/src/models/audit_logs/model.rs @@ -0,0 +1,275 @@ +use std::{collections::HashSet, time::Duration}; + +use iso8601_timestamp::Timestamp; +use revolt_config::config; +use ulid::Ulid; + +use crate::{Database, PartialChannel, PartialMember, PartialRole, PartialServer, User, PartialEmoji}; +use revolt_models::v0; +use revolt_permissions::OverrideField; +use revolt_result::Result; + +auto_derived!( + /// Audit log entry + pub struct AuditLogEntry { + /// Unique ID + #[serde(rename = "_id")] + pub id: String, + + /// When the audit log entry gets auto-deleted + /// + /// This is only stored in the database and not given to users. + pub expires_at: Timestamp, + + /// The server the entry happened in + pub server: String, + /// User provided reason + pub reason: Option, + /// User who ran the action + pub user: String, + /// User this action is targetting + pub target: Option, + /// The action ran + pub action: AuditLogEntryAction, + } + + /// Indivual audit log action + #[serde(tag = "type")] + #[allow(clippy::large_enum_variant)] + pub enum AuditLogEntryAction { + MessageDelete { + author: String, + channel: String, + }, + MessageBulkDelete { + channel: String, + count: usize, + }, + MessagePin { + message: String, + author: String, + channel: String, + }, + MessageUnpin { + message: String, + author: String, + channel: String, + }, + BanCreate { + user: String, + }, + BanDelete { + user: String, + }, + ChannelCreate { + channel: String, + name: String, + }, + ChannelEdit { + channel: String, + before: PartialChannel, + after: PartialChannel, + }, + ChannelRolePermissionsEdit { + channel: String, + role: String, + permissions: OverrideField, + }, + ChannelDelete { + channel: String, + name: String, + }, + MemberEdit { + user: String, + before: PartialMember, + after: PartialMember, + }, + MemberKick { + user: String, + }, + ServerEdit { + before: PartialServer, + after: PartialServer, + }, + RoleEdit { + role: String, + before: PartialRole, + after: PartialRole, + }, + RoleCreate { + role: String, + name: String, + }, + RoleDelete { + role: String, + name: String, + }, + RolesReorder { + before: Vec, + after: Vec, + }, + InviteCreate { + invite: String, + channel: String, + }, + InviteDelete { + invite: String, + channel: String, + }, + WebhookCreate { + webhook: String, + name: String, + channel: String, + }, + WebhookDelete { + webhook: String, + name: String, + channel: String, + }, + EmojiCreate { + emoji: String, + name: String, + }, + EmojiUpdate { + emoji: String, + before: PartialEmoji, + after: PartialEmoji, + }, + EmojiDelete { + emoji: String, + name: String, + }, + } + + /// Audit Log Query + pub struct AuditLogQuery { + /// Filter by who ran the action + pub user: Option, + /// Filter by who the action is targetting + pub target: Option, + /// Filter by the action type + pub r#type: Option>, + /// Entries before a certain entry id + pub before: Option, + /// Entries after a certain entry id + pub after: Option, + /// Maximum number of entries to fetch + pub limit: i64, + } +); + +impl AuditLogEntryAction { + // TODO: migrate this to a rabbitmq queue to avoid spawning lots of tasks + /// Generates an `AuditLogEntry` for the current action and inserts it into the database + pub async fn insert>>( + self, + db: &Database, + server: String, + reason: R, + user: String, + target: Option, + ) -> AuditLogEntry { + let config = config().await; + + let id = Ulid::new(); + let expires_at = id + .datetime() + .checked_add(Duration::from_secs(config.api.audit_logs.expires_after)) + .unwrap() + .into(); + + let entry = AuditLogEntry { + id: id.to_string(), + expires_at, + server, + reason: reason.into(), + user, + target, + action: self, + }; + + // running the insert inside a task can cause race conditions in the test so for now just dont use a task for tests for now + // this will need to be redone for when we migrate to using rabbitmq here anyway. + #[cfg(not(test))] + tokio::task::spawn({ + let db = db.clone(); + let entry = entry.clone(); + + async move { revolt_config::report_internal_error!(db.insert_audit_log_entry(&entry).await) } + }); + + #[cfg(test)] + db.insert_audit_log_entry(&entry).await.unwrap(); + + entry + } +} + +impl AuditLogEntry { + /// Fetches the corrasponding users and members for each audit log entry + pub async fn with_users( + db: &Database, + server_id: &str, + user: &User, + entries: &[Self], + ) -> Result<(Vec, Vec)> { + let mut user_ids = HashSet::new(); + + for entry in entries { + user_ids.insert(entry.user.clone()); + + match &entry.action { + AuditLogEntryAction::MessageDelete { author, .. } => { + user_ids.insert(author.clone()); + } + AuditLogEntryAction::BanCreate { user } => { + user_ids.insert(user.clone()); + } + AuditLogEntryAction::BanDelete { user } => { + user_ids.insert(user.clone()); + } + AuditLogEntryAction::ChannelCreate { .. } => {} + AuditLogEntryAction::MemberEdit { user, .. } => { + user_ids.insert(user.clone()); + } + AuditLogEntryAction::MemberKick { user } => { + user_ids.insert(user.clone()); + } + AuditLogEntryAction::MessagePin { author, .. } => { + user_ids.insert(author.clone()); + } + AuditLogEntryAction::MessageUnpin { author, .. } => { + user_ids.insert(author.clone()); + } + AuditLogEntryAction::ServerEdit { .. } => {} + AuditLogEntryAction::RoleEdit { .. } => {} + AuditLogEntryAction::RoleCreate { .. } => {} + AuditLogEntryAction::RoleDelete { .. } => {} + AuditLogEntryAction::RolesReorder { .. } => {} + AuditLogEntryAction::MessageBulkDelete { .. } => {} + AuditLogEntryAction::ChannelEdit { .. } => {} + AuditLogEntryAction::ChannelRolePermissionsEdit { .. } => {} + AuditLogEntryAction::ChannelDelete { .. } => {} + AuditLogEntryAction::InviteCreate { .. } => {} + AuditLogEntryAction::InviteDelete { .. } => {} + AuditLogEntryAction::WebhookCreate { .. } => {} + AuditLogEntryAction::WebhookDelete { .. } => {} + AuditLogEntryAction::EmojiCreate { .. } => {} + AuditLogEntryAction::EmojiUpdate { .. } => {} + AuditLogEntryAction::EmojiDelete { .. } => {} + }; + } + + let user_ids = user_ids.into_iter().collect::>(); + + let users = User::fetch_many_ids_as_mutuals(db, user, &user_ids).await?; + let members = db + .fetch_members(server_id, &user_ids) + .await? + .into_iter() + .map(Into::into) + .collect(); + + Ok((users, members)) + } +} diff --git a/crates/core/database/src/models/audit_logs/ops.rs b/crates/core/database/src/models/audit_logs/ops.rs new file mode 100644 index 00000000..527b3a0a --- /dev/null +++ b/crates/core/database/src/models/audit_logs/ops.rs @@ -0,0 +1,20 @@ +use revolt_result::Result; + +use crate::{AuditLogEntry, AuditLogQuery}; + +#[cfg(feature = "mongodb")] +mod mongodb; +mod reference; + +#[async_trait] +pub trait AbstractAuditLogs: Sync + Send { + /// Inserts an entry into the server's audit log + async fn insert_audit_log_entry(&self, entry: &AuditLogEntry) -> Result<()>; + + /// Fetches a server's audit logs using the provided query options + async fn get_server_audit_logs( + &self, + server: &str, + query: AuditLogQuery, + ) -> Result>; +} diff --git a/crates/core/database/src/models/audit_logs/ops/mongodb.rs b/crates/core/database/src/models/audit_logs/ops/mongodb.rs new file mode 100644 index 00000000..0ee4f277 --- /dev/null +++ b/crates/core/database/src/models/audit_logs/ops/mongodb.rs @@ -0,0 +1,66 @@ +use mongodb::options::FindOptions; +use revolt_result::Result; + +use crate::{AuditLogEntry, AuditLogQuery, MongoDb}; + +use super::AbstractAuditLogs; + +static COL: &str = "audit_logs"; + +#[async_trait] +impl AbstractAuditLogs for MongoDb { + /// Inserts an entry into the server's audit log + async fn insert_audit_log_entry(&self, entry: &AuditLogEntry) -> Result<()> { + query!(self, insert_one, COL, entry).map(|_| ()) + } + + /// Fetches a server's audit logs using the provided query options + async fn get_server_audit_logs( + &self, + server: &str, + query: AuditLogQuery, + ) -> Result> { + let mut filter = doc! { + "server": server + }; + + if let Some(user) = query.user { + filter.insert("user", user); + }; + + if let Some(target) = query.target { + filter.insert("target", target); + } + + if let Some(types) = query.r#type { + filter.insert("action.type", doc! { "$in": types }); + }; + + if let Some(doc) = match (query.before, query.after) { + (Some(before), Some(after)) => Some(doc! { + "$lt": before, + "$gt": after + }), + (Some(before), _) => Some(doc! { + "$lt": before + }), + (_, Some(after)) => Some(doc! { + "$gt": after + }), + _ => None, + } { + filter.insert("_id", doc); + }; + + self.find_with_options( + COL, + filter, + FindOptions::builder() + .limit(query.limit) + .sort(doc! { "_id": -1 }) + .build(), + ) + .await + .map_err(|_| create_database_error!("find", COL)) + } +} diff --git a/crates/core/database/src/models/audit_logs/ops/reference.rs b/crates/core/database/src/models/audit_logs/ops/reference.rs new file mode 100644 index 00000000..ea0bd8c0 --- /dev/null +++ b/crates/core/database/src/models/audit_logs/ops/reference.rs @@ -0,0 +1,81 @@ +use revolt_result::Result; + +use crate::{AuditLogEntry, AuditLogQuery, ReferenceDb}; + +use super::AbstractAuditLogs; + +#[async_trait] +impl AbstractAuditLogs for ReferenceDb { + /// Inserts an entry into the server's audit log + async fn insert_audit_log_entry(&self, entry: &AuditLogEntry) -> Result<()> { + self.audit_logs + .lock() + .await + .insert(entry.id.clone(), entry.clone()); + + Ok(()) + } + + /// Fetches a server's audit logs using the provided query options + async fn get_server_audit_logs( + &self, + server: &str, + query: AuditLogQuery, + ) -> Result> { + let lock = self.audit_logs.lock().await; + + let mut logs = lock + .values() + .filter(|entry| { + if entry.server != server { + return false; + }; + + if let Some(user) = &query.user { + if &entry.user != user { + return false; + } + } + + if query.target.is_some() && entry.target != query.target { + return false; + } + + if let Some(before) = &query.before { + if &entry.id > before { + return false; + }; + }; + + if let Some(after) = &query.after { + if &entry.id < after { + return false; + }; + }; + + if let Some(action_types) = &query.r#type { + let entry_type = serde_json::to_value(entry.action.clone()) + .unwrap() + .as_object() + .unwrap() + .get("type") + .unwrap() + .as_str() + .unwrap() + .to_string(); + + if !action_types.contains(&entry_type) { + return false; + } + }; + + true + }) + .cloned() + .collect::>(); + + logs.sort_by(|a, b| b.id.cmp(&a.id)); + logs.truncate(query.limit as usize); + Ok(logs) + } +} diff --git a/crates/core/database/src/models/channels/model.rs b/crates/core/database/src/models/channels/model.rs index 2d275395..8a87d2a6 100644 --- a/crates/core/database/src/models/channels/model.rs +++ b/crates/core/database/src/models/channels/model.rs @@ -655,6 +655,122 @@ impl Channel { } } + /// Generates a PartialChannel containing the data which has changed in an update + pub fn generate_diff( + &self, + partial: &PartialChannel, + remove: &[FieldsChannel], + ) -> PartialChannel { + let mut before = PartialChannel::default(); + + match self { + Channel::SavedMessages { .. } => {} + Channel::DirectMessage { + active, + last_message_id, + .. + } => { + if partial.active.is_some() { + before.active = Some(*active); + }; + + if partial.last_message_id.is_some() { + before.last_message_id = last_message_id.clone() + }; + } + Channel::Group { + name, + owner, + description, + icon, + last_message_id, + permissions, + nsfw, + .. + } => { + if partial.name.is_some() { + before.name = Some(name.clone()); + }; + + if partial.owner.is_some() { + before.owner = Some(owner.clone()); + }; + + if partial.description.is_some() || remove.contains(&FieldsChannel::Description) { + before.description = description.clone(); + }; + + if partial.icon.is_some() || remove.contains(&FieldsChannel::Icon) { + before.icon = icon.clone(); + }; + + if partial.last_message_id.is_some() { + before.last_message_id = last_message_id.clone() + }; + + if partial.permissions.is_some() { + before.permissions = *permissions; + }; + + if partial.nsfw.is_some() { + before.nsfw = Some(*nsfw); + }; + } + Channel::TextChannel { + name, + description, + icon, + last_message_id, + default_permissions, + role_permissions, + nsfw, + voice, + slowmode, + .. + } => { + if partial.name.is_some() { + before.name = Some(name.clone()); + }; + + if partial.description.is_some() || remove.contains(&FieldsChannel::Description) { + before.description = description.clone(); + }; + + if partial.icon.is_some() || remove.contains(&FieldsChannel::Icon) { + before.icon = icon.clone(); + }; + + if partial.last_message_id.is_some() { + before.last_message_id = last_message_id.clone() + }; + + if partial.default_permissions.is_some() + || remove.contains(&FieldsChannel::DefaultPermissions) + { + before.default_permissions = *default_permissions; + }; + + if partial.role_permissions.is_some() { + before.role_permissions = Some(role_permissions.clone()); + }; + + if partial.nsfw.is_some() { + before.nsfw = Some(*nsfw); + }; + + if partial.voice.is_some() || remove.contains(&FieldsChannel::Voice) { + before.voice = voice.clone(); + }; + + if partial.slowmode.is_some() { + before.slowmode = *slowmode; + } + } + } + + before + } + /// Acknowledge a message pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> { EventV1::ChannelAck { diff --git a/crates/core/database/src/models/emojis/model.rs b/crates/core/database/src/models/emojis/model.rs index 55d13e8c..6764c319 100644 --- a/crates/core/database/src/models/emojis/model.rs +++ b/crates/core/database/src/models/emojis/model.rs @@ -16,7 +16,7 @@ static PERMISSIBLE_EMOJIS: Lazy> = Lazy::new(|| { .collect() }); -auto_derived!( +auto_derived_partial!( /// Emoji pub struct Emoji { /// Unique Id @@ -34,20 +34,17 @@ auto_derived!( /// Whether the emoji is marked as nsfw #[serde(skip_serializing_if = "crate::if_false", default)] pub nsfw: bool, - } + }, + "PartialEmoji" +); +auto_derived!( /// Parent Id of the emoji #[serde(tag = "type")] pub enum EmojiParent { Server { id: String }, Detached, } - - /// Partial representation of an emoji - pub struct PartialEmoji { - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - } ); #[allow(clippy::disallowed_methods)] @@ -72,14 +69,14 @@ impl Emoji { } /// Delete an emoji - pub async fn delete(self, db: &Database) -> Result<()> { + pub async fn delete(&self, db: &Database) -> Result<()> { EventV1::EmojiDelete { id: self.id.to_string(), } .p(self.parent().to_string()) .await; - db.detach_emoji(&self).await + db.detach_emoji(self).await } /// Update an emoji @@ -112,4 +109,18 @@ impl Emoji { Ok(PERMISSIBLE_EMOJIS.contains(&sanitized_emoji)) } } + + /// Generates a PartialEmoji containing the data which has changed in an update + pub fn generate_diff(&self, partial: &PartialEmoji) -> PartialEmoji { + let mut before = PartialEmoji::default(); + + generate_diff!( + self, before, partial, remove, + ( + name, + ) + ); + + before + } } diff --git a/crates/core/database/src/models/messages/model.rs b/crates/core/database/src/models/messages/model.rs index bacb3c53..04df0c73 100644 --- a/crates/core/database/src/models/messages/model.rs +++ b/crates/core/database/src/models/messages/model.rs @@ -998,11 +998,13 @@ impl Message { } /// Delete a message - pub async fn delete(self, db: &Database) -> Result<()> { - let file_ids: Vec = self + pub async fn delete(&self, db: &Database) -> Result<()> { + let file_ids = self .attachments - .map(|files| files.iter().map(|file| file.id.to_string()).collect()) - .unwrap_or_default(); + .iter() + .flatten() + .map(|file| file.id.clone()) + .collect::>(); if !file_ids.is_empty() { db.mark_attachments_as_deleted(&file_ids).await?; @@ -1011,10 +1013,10 @@ impl Message { db.delete_message(&self.id).await?; EventV1::MessageDelete { - id: self.id, + id: self.id.clone(), channel: self.channel.clone(), } - .p(self.channel) + .p(self.channel.clone()) .await; Ok(()) } diff --git a/crates/core/database/src/models/mod.rs b/crates/core/database/src/models/mod.rs index 0852adfc..cf9bca31 100644 --- a/crates/core/database/src/models/mod.rs +++ b/crates/core/database/src/models/mod.rs @@ -1,4 +1,5 @@ mod admin_migrations; +mod audit_logs; mod bots; mod channel_invites; mod channel_unreads; @@ -23,6 +24,7 @@ mod sessions; mod mfa_tickets; pub use admin_migrations::*; +pub use audit_logs::*; pub use bots::*; pub use channel_invites::*; pub use channel_unreads::*; @@ -55,6 +57,7 @@ pub trait AbstractDatabase: Sync + Send + admin_migrations::AbstractMigrations + + audit_logs::AbstractAuditLogs + bots::AbstractBots + channels::AbstractChannels + channel_invites::AbstractChannelInvites diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index 18db5396..158874e6 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -245,6 +245,26 @@ impl Member { } } + /// Generates a PartialMember containing the data which has changed in an update + pub fn generate_diff(&self, partial: &PartialMember, remove: &[FieldsMember]) -> PartialMember { + let mut before = PartialMember::default(); + + generate_diff!( + self, before, partial, remove, + ( + (FieldsMember::Nickname) nickname, + (FieldsMember::Avatar) avatar, + (FieldsMember::Timeout) timeout, + (FieldsMember::Pronouns) pronouns, + ((default) FieldsMember::Roles) roles, + ((default) FieldsMember::CanPublish) can_publish, + ((default) FieldsMember::CanReceive) can_receive, + ) + ); + + before + } + /// Get this user's current ranking pub fn get_ranking(&self, server: &Server) -> i64 { let mut value = i64::MAX; @@ -270,7 +290,7 @@ impl Member { /// Remove member from server pub async fn remove( - self, + &self, db: &Database, server: &Server, intention: RemovalIntention, @@ -297,9 +317,9 @@ impl Member { }) { match intention { - RemovalIntention::Leave => SystemMessage::UserLeft { id: self.id.user }, - RemovalIntention::Kick => SystemMessage::UserKicked { id: self.id.user }, - RemovalIntention::Ban => SystemMessage::UserBanned { id: self.id.user }, + RemovalIntention::Leave => SystemMessage::UserLeft { id: self.id.user.clone() }, + RemovalIntention::Kick => SystemMessage::UserKicked { id: self.id.user.clone() }, + RemovalIntention::Ban => SystemMessage::UserBanned { id: self.id.user.clone() }, } .into_message(id.to_string()) // TODO: support notifications here in the future? diff --git a/crates/core/database/src/models/servers/model.rs b/crates/core/database/src/models/servers/model.rs index bf74c410..59b15000 100644 --- a/crates/core/database/src/models/servers/model.rs +++ b/crates/core/database/src/models/servers/model.rs @@ -235,6 +235,31 @@ impl Server { } } + /// Generates a PartialServer containing the data which has changed in an update + pub fn generate_diff(&self, partial: &PartialServer, remove: &[FieldsServer]) -> PartialServer { + let mut before = PartialServer::default(); + + generate_diff!( + self, before, partial, remove, + ( + owner, + name, + (FieldsServer::Description) description, + (FieldsServer::Categories) categories, + (FieldsServer::SystemMessages) system_messages, + roles, + default_permissions, + (FieldsServer::Icon) icon, + (FieldsServer::Banner) banner, + nsfw, + analytics, + discoverable, + ) + ); + + before + } + /// Ordered roles list pub fn ordered_roles(&self) -> Vec<(String, Role)> { let mut ordered_roles = self.roles.clone().into_iter().collect::>(); @@ -377,8 +402,27 @@ impl Role { } } + /// Generates a PartialRole containing the data which has changed in an update + pub fn generate_diff(&self, partial: &PartialRole, remove: &[FieldsRole]) -> PartialRole { + let mut before = PartialRole::default(); + + generate_diff!( + self, before, partial, remove, + ( + name, + permissions, + (FieldsRole::Colour) colour, + hoist, + rank, + (FieldsRole::Icon) icon, + ) + ); + + before + } + /// Delete a role - pub async fn delete(self, db: &Database, server_id: &str) -> Result<()> { + pub async fn delete(&self, db: &Database, server_id: &str) -> Result<()> { EventV1::ServerRoleDelete { id: server_id.to_string(), role_id: self.id.clone(), diff --git a/crates/core/database/src/models/servers/ops/mongodb.rs b/crates/core/database/src/models/servers/ops/mongodb.rs index 21183d3f..ef62ca97 100644 --- a/crates/core/database/src/models/servers/ops/mongodb.rs +++ b/crates/core/database/src/models/servers/ops/mongodb.rs @@ -259,6 +259,13 @@ impl MongoDb { }) .await?; + self.col::("audit_logs") + .delete_many(doc! { + "server": &server_id + }) + .await + .map_err(|_| create_database_error!("delete_many", "audit_logs"))?; + Ok(()) } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 102bdf3b..acb0b4c5 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -1423,6 +1423,14 @@ impl From for crate::FieldsMessage { } } +impl From for VoiceInformation { + fn from(value: crate::VoiceInformation) -> Self { + VoiceInformation { + max_users: value.max_users, + } + } +} + impl From for crate::VoiceInformation { fn from(value: VoiceInformation) -> Self { crate::VoiceInformation { @@ -1431,10 +1439,151 @@ impl From for crate::VoiceInformation { } } -impl From for VoiceInformation { - fn from(value: crate::VoiceInformation) -> Self { - VoiceInformation { - max_users: value.max_users, +impl From for AuditLogEntryAction { + fn from(value: crate::AuditLogEntryAction) -> Self { + match value { + crate::AuditLogEntryAction::MessageDelete { author, channel } => { + AuditLogEntryAction::MessageDelete { author, channel } + } + crate::AuditLogEntryAction::BanCreate { user } => { + AuditLogEntryAction::BanCreate { user } + } + crate::AuditLogEntryAction::BanDelete { user } => { + AuditLogEntryAction::BanDelete { user } + } + crate::AuditLogEntryAction::ChannelCreate { channel, name } => { + AuditLogEntryAction::ChannelCreate { channel, name } + } + crate::AuditLogEntryAction::MemberEdit { + user, + before, + after, + } => AuditLogEntryAction::MemberEdit { + user, + before: before.into(), + after: after.into(), + }, + crate::AuditLogEntryAction::MemberKick { user } => { + AuditLogEntryAction::MemberKick { user } + } + crate::AuditLogEntryAction::ServerEdit { before, after } => { + AuditLogEntryAction::ServerEdit { + before: before.into(), + after: after.into(), + } + } + crate::AuditLogEntryAction::RoleEdit { + role, + before, + after, + } => AuditLogEntryAction::RoleEdit { + role, + before: before.into(), + after: after.into(), + }, + crate::AuditLogEntryAction::RoleCreate { role, name } => { + AuditLogEntryAction::RoleCreate { role, name } + } + crate::AuditLogEntryAction::RoleDelete { role, name } => { + AuditLogEntryAction::RoleDelete { role, name } + } + crate::AuditLogEntryAction::RolesReorder { before, after } => { + AuditLogEntryAction::RolesReorder { before, after } + } + crate::AuditLogEntryAction::MessageBulkDelete { channel, count } => { + AuditLogEntryAction::MessageBulkDelete { channel, count } + } + crate::AuditLogEntryAction::ChannelEdit { + channel, + before, + after, + } => AuditLogEntryAction::ChannelEdit { + channel, + before: before.into(), + after: after.into(), + }, + crate::AuditLogEntryAction::ChannelRolePermissionsEdit { + channel, + role, + permissions, + } => AuditLogEntryAction::ChannelRolePermissionsEdit { + channel, + role, + permissions: permissions.into(), + }, + crate::AuditLogEntryAction::ChannelDelete { channel, name } => { + AuditLogEntryAction::ChannelDelete { channel, name } + } + crate::AuditLogEntryAction::InviteDelete { invite, channel } => { + AuditLogEntryAction::InviteDelete { invite, channel } + } + crate::AuditLogEntryAction::WebhookCreate { + webhook, + name, + channel, + } => AuditLogEntryAction::WebhookCreate { + webhook, + name, + channel, + }, + crate::AuditLogEntryAction::WebhookDelete { + webhook, + name, + channel, + } => AuditLogEntryAction::WebhookDelete { + webhook, + name, + channel, + }, + crate::AuditLogEntryAction::EmojiCreate { emoji, name } => { + AuditLogEntryAction::EmojiCreate { emoji, name } + } + crate::AuditLogEntryAction::EmojiUpdate { + emoji, + before, + after, + } => AuditLogEntryAction::EmojiUpdate { + emoji, + before: before.into(), + after: after.into(), + }, + crate::AuditLogEntryAction::EmojiDelete { emoji, name } => { + AuditLogEntryAction::EmojiDelete { emoji, name } + } + crate::AuditLogEntryAction::MessagePin { + message, + author, + channel, + } => AuditLogEntryAction::MessagePin { + message, + author, + channel, + }, + crate::AuditLogEntryAction::MessageUnpin { + message, + author, + channel, + } => AuditLogEntryAction::MessageUnpin { + message, + author, + channel, + }, + crate::AuditLogEntryAction::InviteCreate { invite, channel } => { + AuditLogEntryAction::InviteCreate { invite, channel } + } + } + } +} + +impl From for AuditLogEntry { + fn from(value: crate::AuditLogEntry) -> Self { + AuditLogEntry { + id: value.id, + server: value.server, + reason: value.reason, + user: value.user, + target: value.target, + action: value.action.into(), } } } @@ -1527,3 +1676,9 @@ impl From for crate::WebPushSubscription { } } } + +impl From for PartialEmoji { + fn from(value: crate::PartialEmoji) -> Self { + PartialEmoji { name: value.name } + } +} diff --git a/crates/core/models/src/v0/audit_logs.rs b/crates/core/models/src/v0/audit_logs.rs new file mode 100644 index 00000000..eedff19c --- /dev/null +++ b/crates/core/models/src/v0/audit_logs.rs @@ -0,0 +1,163 @@ +use crate::v0::{Member, PartialChannel, PartialEmoji, PartialMember, PartialRole, PartialServer, User}; +use revolt_permissions::Override; + +auto_derived!( + /// Audit log entry + pub struct AuditLogEntry { + /// Unique ID + #[serde(rename = "_id")] + pub id: String, + + /// The server the entry happened in + pub server: String, + /// User provided reason + pub reason: Option, + /// User who ran the action + pub user: String, + /// User this action is targetting + pub target: Option, + /// The action ran + pub action: AuditLogEntryAction, + } + + /// Indivual action stored on the audit log + #[serde(tag = "type")] + #[allow(clippy::large_enum_variant)] + pub enum AuditLogEntryAction { + MessageDelete { + author: String, + channel: String, + }, + MessageBulkDelete { + channel: String, + count: usize, + }, + MessagePin { + message: String, + author: String, + channel: String, + }, + MessageUnpin { + message: String, + author: String, + channel: String, + }, + BanCreate { + user: String, + }, + BanDelete { + user: String, + }, + ChannelCreate { + channel: String, + name: String, + }, + ChannelEdit { + channel: String, + before: PartialChannel, + after: PartialChannel, + }, + ChannelRolePermissionsEdit { + channel: String, + role: String, + permissions: Override, + }, + ChannelDelete { + channel: String, + name: String, + }, + MemberEdit { + user: String, + before: PartialMember, + after: PartialMember, + }, + MemberKick { + user: String, + }, + ServerEdit { + before: PartialServer, + after: PartialServer, + }, + RoleEdit { + role: String, + before: PartialRole, + after: PartialRole, + }, + RoleCreate { + role: String, + name: String, + }, + RoleDelete { + role: String, + name: String, + }, + RolesReorder { + before: Vec, + after: Vec, + }, + InviteCreate { + invite: String, + channel: String, + }, + InviteDelete { + invite: String, + channel: String, + }, + WebhookCreate { + webhook: String, + name: String, + channel: String, + }, + WebhookDelete { + webhook: String, + name: String, + channel: String, + }, + EmojiCreate { + emoji: String, + name: String, + }, + EmojiUpdate { + emoji: String, + before: PartialEmoji, + after: PartialEmoji, + }, + EmojiDelete { + emoji: String, + name: String, + }, + } + + /// Audit log query filters + #[cfg_attr(feature = "validator", derive(validator::Validate))] + #[cfg_attr(feature = "rocket", derive(rocket::FromForm))] + pub struct OptionsAuditLogQuery { + /// Filter by who ran the action + #[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))] + pub user: Option, + /// Filter by who the action is targetting + #[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))] + pub target: Option, + /// Filter by the action type + pub r#type: Option>, + /// Entries before a certain entry id + #[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))] + pub before: Option, + /// Entries after a certain entry id + #[cfg_attr(feature = "validator", validate(length(min = 26, max = 26)))] + pub after: Option, + /// Maximum number of entries to fetch + #[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))] + pub limit: Option, + } + + /// Response containing the audit log entries and the users involved + pub struct AuditLogQueryResponse { + /// List of audit logs + pub audit_logs: Vec, + /// List of users + pub users: Vec, + /// List of members + pub members: Vec, + } +); diff --git a/crates/core/models/src/v0/mod.rs b/crates/core/models/src/v0/mod.rs index 47e70507..c6fc7f7c 100644 --- a/crates/core/models/src/v0/mod.rs +++ b/crates/core/models/src/v0/mod.rs @@ -1,3 +1,4 @@ +mod audit_logs; mod bots; mod channel_invites; mod channel_unreads; @@ -18,6 +19,7 @@ mod accounts; mod mfa_tickets; mod sessions; +pub use audit_logs::*; pub use bots::*; pub use channel_invites::*; pub use channel_unreads::*; diff --git a/crates/core/permissions/src/models/channel.rs b/crates/core/permissions/src/models/channel.rs index fc6fe49a..45414d4d 100644 --- a/crates/core/permissions/src/models/channel.rs +++ b/crates/core/permissions/src/models/channel.rs @@ -100,8 +100,11 @@ pub enum ChannelPermission { /// Mention roles MentionRoles = 1 << 38, + /// Access server audit logs + ViewAuditLogs = 1 << 40, + // * Misc. permissions - // % Bits 39 to 52: free area + // % Bits 41 to 52: free area // % Bits 53 to 64: do not use // * Grant all permissions diff --git a/crates/core/result/src/axum.rs b/crates/core/result/src/axum.rs index 50df6150..12695e1a 100644 --- a/crates/core/result/src/axum.rs +++ b/crates/core/result/src/axum.rs @@ -90,6 +90,7 @@ impl IntoResponse for Error { ErrorType::UnknownNode => StatusCode::BAD_REQUEST, ErrorType::InvalidFlagValue => StatusCode::BAD_REQUEST, ErrorType::FeatureDisabled { .. } => StatusCode::BAD_REQUEST, + ErrorType::HeaderTooLarge => StatusCode::BAD_REQUEST, ErrorType::ProxyError => StatusCode::BAD_REQUEST, ErrorType::FileTooSmall => StatusCode::UNPROCESSABLE_ENTITY, diff --git a/crates/core/result/src/lib.rs b/crates/core/result/src/lib.rs index abf829b2..e82d8d33 100644 --- a/crates/core/result/src/lib.rs +++ b/crates/core/result/src/lib.rs @@ -164,6 +164,7 @@ pub enum ErrorType { FailedValidation { error: String, }, + HeaderTooLarge, OperationFailed, IncorrectData { with: String, diff --git a/crates/core/result/src/rocket.rs b/crates/core/result/src/rocket.rs index 796ae89d..8a1ff841 100644 --- a/crates/core/result/src/rocket.rs +++ b/crates/core/result/src/rocket.rs @@ -91,6 +91,7 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::NotConnected => Status::BadRequest, ErrorType::UnknownNode => Status::BadRequest, ErrorType::FeatureDisabled { .. } => Status::BadRequest, + ErrorType::HeaderTooLarge => Status::BadRequest, ErrorType::ProxyError => Status::BadRequest, ErrorType::FileTooSmall => Status::UnprocessableEntity, diff --git a/crates/delta/src/routes/channels/channel_delete.rs b/crates/delta/src/routes/channels/channel_delete.rs index 82ed4137..33520718 100644 --- a/crates/delta/src/routes/channels/channel_delete.rs +++ b/crates/delta/src/routes/channels/channel_delete.rs @@ -4,7 +4,7 @@ use revolt_database::{ delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel, UserVoiceChannel, VoiceClient, }, - Channel, Database, PartialChannel, User, AMQP, + AuditLogEntryAction, Channel, Database, PartialChannel, User, AMQP, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -12,6 +12,8 @@ use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Close Channel /// /// Deletes a server channel, leaves a group or closes a group. @@ -22,6 +24,7 @@ pub async fn delete( voice_client: &State, amqp: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, options: v0::OptionsChannelDelete, ) -> Result { @@ -63,10 +66,17 @@ pub async fn delete( remove_user_from_voice_channel(voice_client, &user_voice_channel, &user.id).await?; }; } - Channel::TextChannel { .. } => { + Channel::TextChannel { name, server, .. } => { permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; channel.delete(db).await?; + AuditLogEntryAction::ChannelDelete { + channel: channel.id().to_string(), + name: name.clone(), + } + .insert(db, server.clone(), reason, user.id, None) + .await; + delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?; } }; diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index 8ef439fa..006e828c 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -1,7 +1,8 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{delete_voice_channel, UserVoiceChannel, VoiceClient}, - Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, + AuditLogEntryAction, Channel, Database, FieldsChannel, File, PartialChannel, SystemMessage, + User, AMQP, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -9,6 +10,8 @@ use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edit Channel /// /// Edit a channel object by its id. @@ -19,6 +22,7 @@ pub async fn edit( voice_client: &State, amqp: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, ) -> Result> { @@ -91,6 +95,8 @@ pub async fn edit( .ok(); } + let before_channel = channel.clone(); + match &mut channel { Channel::Group { id, @@ -261,17 +267,33 @@ pub async fn edit( _ => return Err(create_error!(InvalidOperation)), }; - channel - .update( - db, - partial, - data.remove.into_iter().map(|f| f.into()).collect(), - ) - .await?; + let remove = data + .remove + .into_iter() + .map(|f| f.into()) + .collect::>(); + + let before = if before_channel.server().is_some() { + Some(before_channel.generate_diff(&partial, &remove)) + } else { + None + }; + + channel.update(db, partial.clone(), remove).await?; if channel.voice().is_none() { delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?; } + if let Some(before) = before { + AuditLogEntryAction::ChannelEdit { + channel: channel.id().to_string(), + before, + after: partial, + } + .insert(db, channel.server().unwrap().to_string(), reason, user.id, None) + .await; + }; + Ok(Json(channel.into())) } diff --git a/crates/delta/src/routes/channels/group_remove_member.rs b/crates/delta/src/routes/channels/group_remove_member.rs index 05221629..e0ccdaed 100644 --- a/crates/delta/src/routes/channels/group_remove_member.rs +++ b/crates/delta/src/routes/channels/group_remove_member.rs @@ -1,5 +1,7 @@ use revolt_database::{ - AMQP, Channel, Database, User, util::reference::Reference, voice::{UserVoiceChannel, VoiceClient, is_in_voice_channel, remove_user_from_voice_channel} + util::reference::Reference, + voice::{is_in_voice_channel, remove_user_from_voice_channel, UserVoiceChannel, VoiceClient}, + Channel, Database, User, AMQP, }; use revolt_permissions::ChannelPermission; use revolt_result::{create_error, Result}; diff --git a/crates/delta/src/routes/channels/invite_create.rs b/crates/delta/src/routes/channels/invite_create.rs index 104f3398..796c5db7 100644 --- a/crates/delta/src/routes/channels/invite_create.rs +++ b/crates/delta/src/routes/channels/invite_create.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, Invite, User, + AuditLogEntryAction, Database, Invite, User, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -8,6 +8,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Create Invite /// /// Creates an invite to this channel. @@ -18,6 +20,7 @@ use rocket::{serde::json::Json, State}; pub async fn create_invite( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, ) -> Result> { if user.bot.is_some() { @@ -30,8 +33,16 @@ pub async fn create_invite( .await .throw_if_lacking_channel_permission(ChannelPermission::InviteOthers)?; - Invite::create_channel_invite(db, &user, &channel) - .await - .map(|invite| invite.into()) - .map(Json) + let invite = Invite::create_channel_invite(db, &user, &channel).await?; + + if let Some(server_id) = channel.server() { + AuditLogEntryAction::InviteCreate { + invite: invite.code().to_string(), + channel: channel.id().to_string(), + } + .insert(db, server_id.to_string(), reason, user.id, None) + .await; + } + + Ok(Json(invite.into())) } diff --git a/crates/delta/src/routes/channels/message_bulk_delete.rs b/crates/delta/src/routes/channels/message_bulk_delete.rs index 72578ee4..40581cae 100644 --- a/crates/delta/src/routes/channels/message_bulk_delete.rs +++ b/crates/delta/src/routes/channels/message_bulk_delete.rs @@ -2,7 +2,7 @@ use std::time::Duration; use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, Message, User, + AuditLogEntryAction, Database, Message, User, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -11,6 +11,8 @@ use rocket::{serde::json::Json, State}; use rocket_empty::EmptyResponse; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Bulk Delete Messages /// /// Delete multiple messages you've sent or one you have permission to delete. @@ -23,6 +25,7 @@ use validator::Validate; pub async fn bulk_delete_messages( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, options: Json, ) -> Result { @@ -51,7 +54,16 @@ pub async fn bulk_delete_messages( .await .throw_if_lacking_channel_permission(ChannelPermission::ManageMessages)?; - Message::bulk_delete(db, target.id, options.ids) - .await - .map(|_| EmptyResponse) + Message::bulk_delete(db, target.id, options.ids.clone()).await?; + + if let Some(server) = channel.server() { + AuditLogEntryAction::MessageBulkDelete { + channel: channel.id().to_string(), + count: options.ids.len(), + } + .insert(db, server.to_string(), reason, user.id, None) + .await; + }; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/channels/message_delete.rs b/crates/delta/src/routes/channels/message_delete.rs index 437c80ee..5fc7c661 100644 --- a/crates/delta/src/routes/channels/message_delete.rs +++ b/crates/delta/src/routes/channels/message_delete.rs @@ -1,12 +1,14 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, User, + AuditLogEntryAction, Database, User, }; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::Result; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Delete Message /// /// Delete a message you've sent or one you have permission to delete. @@ -15,18 +17,40 @@ use rocket_empty::EmptyResponse; pub async fn delete( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, msg: Reference<'_>, ) -> Result { let message = msg.as_message_in_channel(db, target.id).await?; - if message.author != user.id { + let channel = if message.author != user.id { let channel = target.as_channel(db).await?; let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel); calculate_channel_permissions(&mut query) .await .throw_if_lacking_channel_permission(ChannelPermission::ManageMessages)?; - } - message.delete(db).await.map(|_| EmptyResponse) + Some(channel) + } else { + None + }; + + message.delete(db).await?; + + if let Some(server) = channel.and_then(|c| c.server().map(|s| s.to_string())) { + AuditLogEntryAction::MessageDelete { + author: message.author.clone(), + channel: message.channel.clone(), + } + .insert( + db, + server.to_string(), + reason, + user.id.clone(), + Some(message.author), + ) + .await; + }; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/channels/message_pin.rs b/crates/delta/src/routes/channels/message_pin.rs index bcc97341..a8ba4827 100644 --- a/crates/delta/src/routes/channels/message_pin.rs +++ b/crates/delta/src/routes/channels/message_pin.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, PartialMessage, SystemMessage, User, AMQP, + AuditLogEntryAction, Channel, Database, PartialMessage, SystemMessage, User, AMQP, }; use revolt_models::v0::MessageAuthor; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -8,6 +8,8 @@ use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Pins a message /// /// Pins a message by its id. @@ -17,6 +19,7 @@ pub async fn message_pin( db: &State, amqp: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, msg: Reference<'_>, ) -> Result { @@ -65,6 +68,22 @@ pub async fn message_pin( ) .await?; + if let Some(server_id) = channel.server() { + AuditLogEntryAction::MessagePin { + message: message.id.clone(), + author: message.author.clone(), + channel: message.channel.clone(), + } + .insert( + db, + server_id.to_string(), + reason, + user.id, + Some(message.author), + ) + .await; + } + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/channels/message_unpin.rs b/crates/delta/src/routes/channels/message_unpin.rs index 42184895..2cc20217 100644 --- a/crates/delta/src/routes/channels/message_unpin.rs +++ b/crates/delta/src/routes/channels/message_unpin.rs @@ -1,6 +1,7 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, FieldsMessage, PartialMessage, SystemMessage, User, AMQP, + AuditLogEntryAction, Channel, Database, FieldsMessage, PartialMessage, SystemMessage, User, + AMQP, }; use revolt_models::v0::MessageAuthor; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -8,6 +9,8 @@ use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Unpins a message /// /// Unpins a message by its id. @@ -17,6 +20,7 @@ pub async fn message_unpin( db: &State, amqp: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, msg: Reference<'_>, ) -> Result { @@ -58,6 +62,22 @@ pub async fn message_unpin( ) .await?; + if let Some(server_id) = channel.server() { + AuditLogEntryAction::MessageUnpin { + message: message.id.clone(), + author: message.author.clone(), + channel: message.channel.clone(), + } + .insert( + db, + server_id.to_string(), + reason, + user.id, + Some(message.author), + ) + .await; + } + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/channels/permissions_set.rs b/crates/delta/src/routes/channels/permissions_set.rs index c0d89f11..40c3f0f8 100644 --- a/crates/delta/src/routes/channels/permissions_set.rs +++ b/crates/delta/src/routes/channels/permissions_set.rs @@ -1,11 +1,15 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User + util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, + AuditLogEntryAction, Database, User, }; use revolt_models::v0; -use revolt_permissions::{calculate_channel_permissions, ChannelPermission, Override, PermissionQuery}; +use revolt_permissions::{ChannelPermission, Override, OverrideField, PermissionQuery, calculate_channel_permissions}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Set Role Permission /// /// Sets permissions for the specified role in this channel. @@ -17,13 +21,15 @@ pub async fn set_role_permissions( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, role_id: String, data: Json, ) -> Result> { let channel = target.as_channel(db).await?; let mut query = DatabasePermissionQuery::new(db, &user).channel(&channel); - let permissions: revolt_permissions::PermissionValue = calculate_channel_permissions(&mut query).await; + let permissions: revolt_permissions::PermissionValue = + calculate_channel_permissions(&mut query).await; query.set_server_from_channel().await; @@ -43,12 +49,23 @@ pub async fn set_role_permissions( .await?; let mut new_channel = channel.clone(); + let override_field: OverrideField = data.permissions.clone().into(); + let server_id = server.id.clone(); new_channel .set_role_permission(db, &role_id, data.permissions.clone().into()) .await?; - sync_voice_permissions(db, voice_client, &new_channel, Some(server), Some(&role_id)).await?; + sync_voice_permissions(db, voice_client, &new_channel, Some(server), Some(&role_id)) + .await?; + + AuditLogEntryAction::ChannelRolePermissionsEdit { + channel: new_channel.id().to_string(), + role: role_id, + permissions: override_field, + } + .insert(db, server_id, reason, user.id, None) + .await; Ok(Json(new_channel.into())) } else { diff --git a/crates/delta/src/routes/channels/permissions_set_default.rs b/crates/delta/src/routes/channels/permissions_set_default.rs index ad14a88e..9a401d55 100644 --- a/crates/delta/src/routes/channels/permissions_set_default.rs +++ b/crates/delta/src/routes/channels/permissions_set_default.rs @@ -1,11 +1,15 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Channel, Database, PartialChannel, User + util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, + AuditLogEntryAction, Channel, Database, PartialChannel, User, }; use revolt_models::v0::{self, DataDefaultChannelPermissions}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Set Default Permission /// /// Sets permissions for the default role in this channel. @@ -17,6 +21,7 @@ pub async fn set_default_channel_permissions( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, ) -> Result> { @@ -46,6 +51,8 @@ pub async fn set_default_channel_permissions( } } Channel::TextChannel { + id, + server, default_permissions, .. } => { @@ -54,16 +61,25 @@ pub async fn set_default_channel_permissions( .throw_permission_override(default_permissions.map(|x| x.into()), &field) .await?; - channel - .update( - db, - PartialChannel { - default_permissions: Some(field.into()), - ..Default::default() - }, - vec![], - ) - .await?; + let partial = PartialChannel { + default_permissions: Some(field.into()), + ..Default::default() + }; + + let id = id.clone(); + let server = server.clone(); + + let before = channel.generate_diff(&partial, &[]); + + channel.update(db, partial.clone(), vec![]).await?; + + AuditLogEntryAction::ChannelEdit { + channel: id, + before, + after: partial, + } + .insert(db, server, reason, user.id, None) + .await; } else { return Err(create_error!(InvalidOperation)); } @@ -73,7 +89,7 @@ pub async fn set_default_channel_permissions( let server = match channel.server() { Some(server_id) => Some(Reference::from_unchecked(server_id).as_server(db).await?), - None => None + None => None, }; sync_voice_permissions(db, voice_client, &channel, server.as_ref(), None).await?; diff --git a/crates/delta/src/routes/channels/webhook_create.rs b/crates/delta/src/routes/channels/webhook_create.rs index dc8cad64..271a3751 100644 --- a/crates/delta/src/routes/channels/webhook_create.rs +++ b/crates/delta/src/routes/channels/webhook_create.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Channel, Database, File, User, Webhook, + AuditLogEntryAction, Channel, Database, File, User, Webhook, }; use revolt_models::v0; use revolt_permissions::{ @@ -11,6 +11,8 @@ use rocket::{serde::json::Json, State}; use ulid::Ulid; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Creates a webhook /// /// Creates a webhook which 3rd party platforms can use to send messages @@ -19,6 +21,7 @@ use validator::Validate; pub async fn create_webhook( db: &State, user: User, + reason: AuditLogReason, channel_id: Reference<'_>, data: Json, ) -> Result> { @@ -51,7 +54,7 @@ pub async fn create_webhook( id: webhook_id, name: data.name, avatar, - creator_id: user.id, + creator_id: user.id.clone(), channel_id: channel.id().to_string(), permissions: *DEFAULT_WEBHOOK_PERMISSIONS, token: Some(nanoid::nanoid!(64)), @@ -59,5 +62,15 @@ pub async fn create_webhook( webhook.create(db).await?; + if let Some(server_id) = channel.server() { + AuditLogEntryAction::WebhookCreate { + webhook: webhook.id.clone(), + name: webhook.name.clone(), + channel: webhook.channel_id.clone(), + } + .insert(db, server_id.to_string(), reason, user.id, None) + .await; + }; + Ok(Json(webhook.into())) } diff --git a/crates/delta/src/routes/customisation/emoji_create.rs b/crates/delta/src/routes/customisation/emoji_create.rs index 52b5c904..5115b7c0 100644 --- a/crates/delta/src/routes/customisation/emoji_create.rs +++ b/crates/delta/src/routes/customisation/emoji_create.rs @@ -1,5 +1,5 @@ use revolt_config::config; -use revolt_database::{util::permissions::DatabasePermissionQuery, Database, Emoji, File, User}; +use revolt_database::{AuditLogEntryAction, Database, Emoji, File, User, util::permissions::DatabasePermissionQuery}; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; @@ -7,6 +7,8 @@ use validator::Validate; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Create New Emoji /// /// Create an emoji by its Autumn upload id. @@ -15,6 +17,7 @@ use rocket::{serde::json::Json, State}; pub async fn create_emoji( db: &State, user: User, + reason: AuditLogReason, emoji_id: String, data: Json, ) -> Result> { @@ -55,8 +58,8 @@ pub async fn create_emoji( // Create the emoji object let emoji = Emoji { id: emoji_id, - parent: data.parent.into(), - creator_id: user.id, + parent: data.parent.clone().into(), + creator_id: user.id.clone(), name: data.name, animated: "image/gif" == &attachment.content_type, nsfw: data.nsfw, @@ -64,5 +67,12 @@ pub async fn create_emoji( // Save emoji emoji.create(db).await?; + + if let v0::EmojiParent::Server { id: server_id } = data.parent { + AuditLogEntryAction::EmojiCreate { emoji: emoji.id.clone(), name: emoji.name.clone() } + .insert(db, server_id, reason, user.id, None) + .await; + } + Ok(Json(emoji.into())) } diff --git a/crates/delta/src/routes/customisation/emoji_delete.rs b/crates/delta/src/routes/customisation/emoji_delete.rs index 155d730d..86f4c887 100644 --- a/crates/delta/src/routes/customisation/emoji_delete.rs +++ b/crates/delta/src/routes/customisation/emoji_delete.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, EmojiParent, User, + AuditLogEntryAction, Database, EmojiParent, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::Result; @@ -8,6 +8,8 @@ use revolt_result::Result; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Delete Emoji /// /// Delete an emoji by its id. @@ -16,6 +18,7 @@ use rocket_empty::EmptyResponse; pub async fn delete_emoji( db: &State, user: User, + reason: AuditLogReason, emoji_id: Reference<'_>, ) -> Result { // Fetch the emoji @@ -39,5 +42,16 @@ pub async fn delete_emoji( } // Delete the emoji - emoji.delete(db).await.map(|_| EmptyResponse) + emoji.delete(db).await?; + + if let EmojiParent::Server { id: server_id } = emoji.parent { + AuditLogEntryAction::EmojiDelete { + emoji: emoji.id, + name: emoji.name, + } + .insert(db, server_id, reason, user.id, Some(emoji.creator_id)) + .await; + }; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/customisation/emoji_edit.rs b/crates/delta/src/routes/customisation/emoji_edit.rs index 72e99e34..19368570 100644 --- a/crates/delta/src/routes/customisation/emoji_edit.rs +++ b/crates/delta/src/routes/customisation/emoji_edit.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, EmojiParent, PartialEmoji, User, + AuditLogEntryAction, Database, EmojiParent, PartialEmoji, User, }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -8,6 +8,8 @@ use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edit Emoji /// /// Edit an emoji by its id. @@ -16,6 +18,7 @@ use validator::Validate; pub async fn edit_emoji( db: &State, user: User, + reason: AuditLogReason, emoji_id: Reference<'_>, data: Json, ) -> Result> { @@ -44,8 +47,24 @@ pub async fn edit_emoji( return Ok(Json(emoji.into())); } - let partial = PartialEmoji { name: data.name }; - emoji.update(db, partial).await?; + let partial = PartialEmoji { + name: data.name, + ..Default::default() + }; + + let before = emoji.generate_diff(&partial); + + emoji.update(db, partial.clone()).await?; + + if let EmojiParent::Server { id: server_id } = emoji.parent.clone() { + AuditLogEntryAction::EmojiUpdate { + emoji: emoji.id.clone(), + before, + after: partial, + } + .insert(db, server_id, reason, user.id, Some(emoji.creator_id.clone())) + .await; + }; Ok(Json(emoji.into())) } diff --git a/crates/delta/src/routes/invites/invite_delete.rs b/crates/delta/src/routes/invites/invite_delete.rs index cbdc5db5..50cc8153 100644 --- a/crates/delta/src/routes/invites/invite_delete.rs +++ b/crates/delta/src/routes/invites/invite_delete.rs @@ -1,35 +1,56 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, Invite, User, + AuditLogEntryAction, Database, Invite, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::Result; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Delete Invite /// /// Delete an invite by its id. #[openapi(tag = "Invites")] #[delete("/")] -pub async fn delete(db: &State, user: User, target: Reference<'_>) -> Result { +pub async fn delete( + db: &State, + user: User, + reason: AuditLogReason, + target: Reference<'_>, +) -> Result { let invite = target.as_invite(db).await?; if user.id == invite.creator() { - db.delete_invite(invite.code()).await + db.delete_invite(invite.code()).await?; } else { match invite { - Invite::Server { code, server, .. } => { + Invite::Server { + code, + server, + channel, + creator, + .. + } => { let server = db.fetch_server(&server).await?; let mut query = DatabasePermissionQuery::new(db, &user).server(&server); calculate_server_permissions(&mut query) .await .throw_if_lacking_channel_permission(ChannelPermission::ManageServer)?; - db.delete_invite(&code).await + db.delete_invite(&code).await?; + + AuditLogEntryAction::InviteDelete { + invite: code, + channel, + } + .insert(db, server.id, reason, user.id, Some(creator)) + .await; } _ => unreachable!(), } } - .map(|_| EmptyResponse) + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/servers/audit_log_query.rs b/crates/delta/src/routes/servers/audit_log_query.rs new file mode 100644 index 00000000..49936398 --- /dev/null +++ b/crates/delta/src/routes/servers/audit_log_query.rs @@ -0,0 +1,227 @@ +use revolt_database::{ + util::{permissions::DatabasePermissionQuery, reference::Reference}, + AuditLogEntry, AuditLogQuery, Database, User, +}; +use revolt_models::v0; +use revolt_permissions::{calculate_server_permissions, ChannelPermission}; +use revolt_result::{create_error, Result}; +use rocket::{serde::json::Json, State}; +use validator::Validate; + +/// # Audit Log Query +/// +/// Queries a server's audit logs. +#[openapi(tag = "Audit Logs")] +#[get("//audit_logs?")] +pub async fn query( + db: &State, + user: User, + target: Reference<'_>, + options: v0::OptionsAuditLogQuery, +) -> Result> { + options.validate().map_err(|error| { + create_error!(FailedValidation { + error: error.to_string() + }) + })?; + + let server = target.as_server(db).await?; + + let mut query = DatabasePermissionQuery::new(db, &user).server(&server); + calculate_server_permissions(&mut query) + .await + .throw_if_lacking_channel_permission(ChannelPermission::ViewAuditLogs)?; + + let v0::OptionsAuditLogQuery { + user: user_filter, + target, + r#type, + before, + after, + limit, + } = options; + + let audit_logs = db + .get_server_audit_logs( + &server.id, + AuditLogQuery { + user: user_filter, + target, + r#type, + before, + after, + limit: limit.unwrap_or(50), + }, + ) + .await?; + + let (users, members) = AuditLogEntry::with_users(db, &server.id, &user, &audit_logs).await?; + + Ok(Json(v0::AuditLogQueryResponse { + audit_logs: audit_logs.into_iter().map(Into::into).collect(), + users, + members, + })) +} + +#[cfg(test)] +mod test { + use revolt_database::{Member, Server}; + use revolt_models::v0; + use rocket::http::{Header, Status}; + + use crate::util::test::TestHarness; + + #[rocket::async_test] + async fn audit_log_query() { + let harness = TestHarness::new().await; + let (_, session, user) = harness.new_user().await; + let (server, channels) = Server::create( + &harness.db, + v0::DataCreateServer { + name: "Test Server".to_string(), + ..Default::default() + }, + &user, + true, + ) + .await + .expect("Failed to create test server."); + Member::create(&harness.db, &server, &user, None).await.unwrap(); + + let channel = &channels[0]; + + let status = harness + .client + .patch(format!("/channels/{}", channel.id())) + .header(Header::new("X-Audit-Log-Reason", "Test Reason 1")) + .header(Header::new("x-session-token", session.token.clone())) + .json(&v0::DataEditChannel { + description: Some("General chat channel.".to_string()), + name: None, + owner: None, + icon: None, + nsfw: None, + archived: None, + voice: None, + slowmode: None, + remove: Vec::new(), + }) + .dispatch() + .await + .status(); + + assert_eq!(status, Status::Ok); + + let status = harness + .client + .patch(format!("/channels/{}", channel.id())) + .header(Header::new("X-Audit-Log-Reason", "Test Reason 2")) + .header(Header::new("x-session-token", session.token.clone())) + .json(&v0::DataEditChannel { + description: Some("New description.".to_string()), + name: None, + owner: None, + icon: None, + nsfw: None, + archived: None, + voice: None, + slowmode: None, + remove: Vec::new(), + }) + .dispatch() + .await + .status(); + + assert_eq!(status, Status::Ok); + + let status = harness + .client + .delete(format!("/channels/{}", channel.id())) + .header(Header::new("X-Audit-Log-Reason", "Test Reason 3")) + .header(Header::new("x-session-token", session.token.clone())) + .dispatch() + .await + .status(); + + assert_eq!(status, Status::NoContent); + + let response = harness + .client + .get(format!( + "/servers/{}/audit_logs?include_users=true", + &server.id + )) + .header(Header::new("x-session-token", session.token.clone())) + .dispatch() + .await + .into_json::() + .await + .expect("Failed to deserialise audit_logs response"); + + let v0::AuditLogQueryResponse { + audit_logs: entries, + users, + members, + } = response; + + assert_eq!(entries.len(), 3); + assert_eq!(users.len(), 1); + assert_eq!(members.len(), 1); + + assert_eq!(&users[0].id, &user.id); + + let entry = &entries[0]; + + assert_eq!(entry.reason.as_deref(), Some("Test Reason 3")); + assert_eq!(&entry.server, &server.id); + assert_eq!(&entry.user, &user.id); + assert_eq!( + &entry.action, + &v0::AuditLogEntryAction::ChannelDelete { + channel: channel.id().to_string(), + name: "General".to_string() + } + ); + + let entry = &entries[1]; + + assert_eq!(entry.reason.as_deref(), Some("Test Reason 2")); + assert_eq!(&entry.server, &server.id); + assert_eq!(&entry.user, &user.id); + assert_eq!( + &entry.action, + &v0::AuditLogEntryAction::ChannelEdit { + channel: channel.id().to_string(), + before: v0::PartialChannel { + description: Some("General chat channel.".to_string()), + ..Default::default() + }, + after: v0::PartialChannel { + description: Some("New description.".to_string()), + ..Default::default() + } + } + ); + + let entry = &entries[2]; + + assert_eq!(entry.reason.as_deref(), Some("Test Reason 1")); + assert_eq!(&entry.server, &server.id); + assert_eq!(&entry.user, &user.id); + assert_eq!( + &entry.action, + &v0::AuditLogEntryAction::ChannelEdit { + channel: channel.id().to_string(), + before: v0::PartialChannel { + description: None, + ..Default::default() + }, + after: v0::PartialChannel { + description: Some("General chat channel.".to_string()), + ..Default::default() + } + } + ); + } +} diff --git a/crates/delta/src/routes/servers/ban_create.rs b/crates/delta/src/routes/servers/ban_create.rs index a29b4300..3af2ae8a 100644 --- a/crates/delta/src/routes/servers/ban_create.rs +++ b/crates/delta/src/routes/servers/ban_create.rs @@ -4,7 +4,7 @@ use revolt_database::{ get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel, VoiceClient, }, - Database, Message, RemovalIntention, ServerBan, User, + AuditLogEntryAction, Database, Message, RemovalIntention, ServerBan, User, }; use revolt_models::v0; use std::time::{Duration, SystemTime}; @@ -16,6 +16,8 @@ use rocket::{serde::json::Json, State}; use ulid::Ulid; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Ban User /// /// Ban a user by their id. @@ -25,6 +27,7 @@ pub async fn ban( db: &State, voice_client: &State, user: User, + audit_log_reason: AuditLogReason, server: Reference<'_>, target: Reference<'_>, data: Json, @@ -85,8 +88,20 @@ pub async fn ban( .await?; } } - ServerBan::create(db, &server, target.id, data.reason) - .await - .map(Into::into) - .map(Json) + + let ban = ServerBan::create(db, &server, target.id, data.reason.clone()).await?; + + AuditLogEntryAction::BanCreate { + user: target.id.to_string(), + } + .insert( + db, + server.id, + audit_log_reason.0.or(data.reason), + user.id, + Some(target.id.to_string()), + ) + .await; + + Ok(Json(ban.into())) } diff --git a/crates/delta/src/routes/servers/ban_remove.rs b/crates/delta/src/routes/servers/ban_remove.rs index f01bc8b0..dbce9ccf 100644 --- a/crates/delta/src/routes/servers/ban_remove.rs +++ b/crates/delta/src/routes/servers/ban_remove.rs @@ -1,12 +1,14 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, User, + AuditLogEntryAction, Database, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::Result; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Unban user /// /// Remove a user's ban. @@ -15,6 +17,7 @@ use rocket_empty::EmptyResponse; pub async fn unban( db: &State, user: User, + reason: AuditLogReason, server: Reference<'_>, target: Reference<'_>, ) -> Result { @@ -25,5 +28,13 @@ pub async fn unban( .throw_if_lacking_channel_permission(ChannelPermission::BanMembers)?; let ban = target.as_ban(db, &server.id).await?; - db.delete_ban(&ban.id).await.map(|_| EmptyResponse) + db.delete_ban(&ban.id).await?; + + AuditLogEntryAction::BanDelete { + user: target.id.to_string(), + } + .insert(db, server.id, reason, user.id, Some(target.id.to_string())) + .await; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/servers/channel_create.rs b/crates/delta/src/routes/servers/channel_create.rs index 53aa616d..f09b8989 100644 --- a/crates/delta/src/routes/servers/channel_create.rs +++ b/crates/delta/src/routes/servers/channel_create.rs @@ -1,4 +1,5 @@ use revolt_database::util::permissions::DatabasePermissionQuery; +use revolt_database::AuditLogEntryAction; use revolt_database::{util::reference::Reference, Channel, Database, User}; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -8,6 +9,8 @@ use rocket::serde::json::Json; use rocket::State; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Create Channel /// /// Create a new Text or Voice channel. @@ -16,6 +19,7 @@ use validator::Validate; pub async fn create_server_channel( db: &State, user: User, + reason: AuditLogReason, server: Reference<'_>, data: Json, ) -> Result> { @@ -32,8 +36,16 @@ pub async fn create_server_channel( .await .throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; - Channel::create_server_channel(db, &mut server, data, true) - .await - .map(|channel| channel.into()) - .map(Json) + let channel_name = data.name.clone(); + + let channel = Channel::create_server_channel(db, &mut server, data, true).await?; + + AuditLogEntryAction::ChannelCreate { + channel: channel.id().to_string(), + name: channel_name, + } + .insert(db, server.id, reason, user.id, None) + .await; + + Ok(Json(channel.into())) } diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index 848e776f..35a2f971 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -11,15 +11,19 @@ use revolt_database::{ set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions, UserVoiceChannel, VoiceClient, }, - Database, File, PartialMember, User, + AuditLogEntryAction, Database, FieldsMember, File, PartialMember, User, }; -use revolt_models::v0::{self, FieldsMember}; +use revolt_models::v0; -use revolt_permissions::{calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission}; +use revolt_permissions::{ + calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission, +}; use revolt_result::{create_error, Result}; use rocket::{form::validate::Contains, serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edit Member /// /// Edit a member by their id. @@ -29,6 +33,7 @@ pub async fn edit( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, server_id: Reference<'_>, member_id: Reference<'_>, data: Json, @@ -76,7 +81,7 @@ pub async fn edit( } else if data.remove.contains(&v0::FieldsMember::Avatar) { permissions.throw_if_lacking_channel_permission(ChannelPermission::RemoveAvatars)?; } else { - return Err(create_error!(InvalidOperation)) + return Err(create_error!(InvalidOperation)); } } @@ -106,11 +111,11 @@ pub async fn edit( permissions.throw_if_lacking_channel_permission(ChannelPermission::DeafenMembers)?; } - if data.voice_channel.is_some() && data.remove.contains(&FieldsMember::VoiceChannel) { + if data.voice_channel.is_some() && data.remove.contains(&v0::FieldsMember::VoiceChannel) { return Err(create_error!(InvalidOperation)); } - if data.voice_channel.is_some() || data.remove.contains(&FieldsMember::VoiceChannel) { + if data.voice_channel.is_some() || data.remove.contains(&v0::FieldsMember::VoiceChannel) { if !voice_client.is_enabled() { return Err(create_error!(LiveKitUnavailable)); }; @@ -132,7 +137,8 @@ pub async fn edit( Err(create_error!(UnknownChannel))? } - let channel_permissions = calculate_channel_permissions(&mut query.clone().channel(&channel)).await; + let channel_permissions = + calculate_channel_permissions(&mut query.clone().channel(&channel)).await; channel_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?; if get_user_voice_channel_in_server(&target_user.id, &server.id) @@ -210,9 +216,28 @@ pub async fn edit( partial.avatar = Some(File::use_user_avatar(db, &avatar, &user.id, &user.id).await?); } - member - .update(db, partial, remove.clone().into_iter().map(Into::into).collect()) - .await?; + let remove = remove + .into_iter() + .map(Into::into) + .collect::>(); + + let before = member.generate_diff(&partial, &remove); + + member.update(db, partial.clone(), remove.clone()).await?; + + AuditLogEntryAction::MemberEdit { + user: member.id.user.clone(), + before, + after: partial, + } + .insert( + db, + server.id.clone(), + reason, + user.id.clone(), + Some(member.id.user.clone()), + ) + .await; if let Some(new_voice_channel) = new_voice_channel { if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await? @@ -264,7 +289,11 @@ pub async fn edit( .private(target_user.id.clone()) .await; }; - } else if can_publish.is_some() || can_receive.is_some() || remove.contains(FieldsMember::CanPublish) || remove.contains(FieldsMember::CanReceive) { + } else if can_publish.is_some() + || can_receive.is_some() + || remove.contains(FieldsMember::CanPublish) + || remove.contains(FieldsMember::CanReceive) + { if let Some(channel) = get_user_voice_channel_in_server(&target_user.id, &server.id).await? { let node = get_channel_node(&channel).await?.unwrap(); diff --git a/crates/delta/src/routes/servers/member_remove.rs b/crates/delta/src/routes/servers/member_remove.rs index 1885c5ba..679a2487 100644 --- a/crates/delta/src/routes/servers/member_remove.rs +++ b/crates/delta/src/routes/servers/member_remove.rs @@ -4,13 +4,15 @@ use revolt_database::{ get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel, VoiceClient, }, - Database, RemovalIntention, User, + AuditLogEntryAction, Database, RemovalIntention, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Kick Member /// /// Removes a member from the server. @@ -20,6 +22,7 @@ pub async fn kick( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, server_id: Reference<'_>, member_id: Reference<'_>, ) -> Result { @@ -49,6 +52,18 @@ pub async fn kick( .remove(db, &server, RemovalIntention::Kick, false) .await?; + AuditLogEntryAction::MemberKick { + user: member.id.user.clone(), + } + .insert( + db, + server.id.clone(), + reason, + user.id, + Some(member.id.user.clone()), + ) + .await; + if let Some(channel_id) = get_user_voice_channel_in_server(member_id.id, &server.id).await? { remove_user_from_voice_channel( voice_client, diff --git a/crates/delta/src/routes/servers/mod.rs b/crates/delta/src/routes/servers/mod.rs index 78b96dbd..55e6cae6 100644 --- a/crates/delta/src/routes/servers/mod.rs +++ b/crates/delta/src/routes/servers/mod.rs @@ -1,6 +1,7 @@ use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi; use rocket::Route; +mod audit_log_query; mod ban_create; mod ban_list; mod ban_remove; @@ -49,6 +50,7 @@ pub fn routes() -> (Vec, OpenApi) { permissions_set::set_role_permission, permissions_set_default::set_default_server_permissions, emoji_list::list_emoji, - roles_edit_positions::edit_role_ranks + roles_edit_positions::edit_role_ranks, + audit_log_query::query, ] } diff --git a/crates/delta/src/routes/servers/permissions_set.rs b/crates/delta/src/routes/servers/permissions_set.rs index 41979a61..ea74f8c6 100644 --- a/crates/delta/src/routes/servers/permissions_set.rs +++ b/crates/delta/src/routes/servers/permissions_set.rs @@ -1,13 +1,17 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, - Database, User, + AuditLogEntryAction, Database, PartialRole, User, }; use revolt_models::v0; -use revolt_permissions::{calculate_server_permissions, ChannelPermission, Override}; +use revolt_permissions::{ + calculate_server_permissions, ChannelPermission, Override, OverrideField, +}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Set Role Permission /// /// Sets permissions for the specified role in the server. @@ -17,6 +21,7 @@ pub async fn set_role_permission( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, role_id: String, data: Json, @@ -42,20 +47,36 @@ pub async fn set_role_permission( } // Ensure we have access to grant these permissions forwards - let current_value: Override = current_value.into(); + let current_override: Override = current_value.into(); permissions - .throw_permission_override(current_value, &data.permissions) + .throw_permission_override(current_override, &data.permissions) .await?; + let override_field: OverrideField = data.permissions.into(); + server - .set_role_permission(db, &role_id, data.permissions.into()) + .set_role_permission(db, &role_id, override_field) .await?; + AuditLogEntryAction::RoleEdit { + role: role_id.clone(), + before: PartialRole { + permissions: Some(current_value), + ..Default::default() + }, + after: PartialRole { + permissions: Some(override_field), + ..Default::default() + }, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; + for channel_id in &server.channels { let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?; - }; + } Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/permissions_set_default.rs b/crates/delta/src/routes/servers/permissions_set_default.rs index bf9dd304..93727b05 100644 --- a/crates/delta/src/routes/servers/permissions_set_default.rs +++ b/crates/delta/src/routes/servers/permissions_set_default.rs @@ -1,5 +1,7 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, PartialServer, User + util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, + AuditLogEntryAction, Database, PartialServer, User, }; use revolt_models::v0; use revolt_permissions::{ @@ -8,6 +10,8 @@ use revolt_permissions::{ use revolt_result::Result; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Set Default Permission /// /// Sets permissions for the default role in this server. @@ -17,6 +21,7 @@ pub async fn set_default_server_permissions( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, ) -> Result> { @@ -39,22 +44,27 @@ pub async fn set_default_server_permissions( ) .await?; - server - .update( - db, - PartialServer { - default_permissions: Some(data.permissions as i64), - ..Default::default() - }, - vec![], - ) - .await?; + let partial = PartialServer { + default_permissions: Some(data.permissions as i64), + ..Default::default() + }; + + let before = server.generate_diff(&partial, &[]); + + server.update(db, partial.clone(), vec![]).await?; + + AuditLogEntryAction::ServerEdit { + before, + after: partial, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; for channel_id in &server.channels { let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?; - }; + } Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/roles_create.rs b/crates/delta/src/routes/servers/roles_create.rs index 20a3c94b..ed55e714 100644 --- a/crates/delta/src/routes/servers/roles_create.rs +++ b/crates/delta/src/routes/servers/roles_create.rs @@ -1,7 +1,7 @@ use revolt_config::config; use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, Role, User, + AuditLogEntryAction, Database, Role, User, }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -9,6 +9,8 @@ use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Create Role /// /// Creates a new server role. @@ -17,6 +19,7 @@ use validator::Validate; pub async fn create( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, ) -> Result> { @@ -42,6 +45,13 @@ pub async fn create( let role = Role::create(db, &server, data.name).await?; + AuditLogEntryAction::RoleCreate { + role: role.id.clone(), + name: role.name.clone(), + } + .insert(db, server.id, reason, user.id, None) + .await; + Ok(Json(v0::NewRoleResponse { id: role.id.clone(), role: role.into(), diff --git a/crates/delta/src/routes/servers/roles_delete.rs b/crates/delta/src/routes/servers/roles_delete.rs index 232ae92e..ad8885b2 100644 --- a/crates/delta/src/routes/servers/roles_delete.rs +++ b/crates/delta/src/routes/servers/roles_delete.rs @@ -1,13 +1,15 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, - Database, User, + AuditLogEntryAction, Database, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Delete Role /// /// Delete a server role by its id. @@ -16,6 +18,7 @@ use rocket_empty::EmptyResponse; pub async fn delete( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, role_id: String, voice_client: &State, @@ -39,6 +42,13 @@ pub async fn delete( role.delete(db, &server.id).await?; + AuditLogEntryAction::RoleDelete { + role: role_id.clone(), + name: role.name, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; + for channel_id in &server.channels { let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; diff --git a/crates/delta/src/routes/servers/roles_edit.rs b/crates/delta/src/routes/servers/roles_edit.rs index e46932ef..3b7c7d13 100644 --- a/crates/delta/src/routes/servers/roles_edit.rs +++ b/crates/delta/src/routes/servers/roles_edit.rs @@ -1,7 +1,7 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, - Database, File, PartialRole, User, + AuditLogEntryAction, Database, FieldsRole, PartialRole, User, File }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -9,6 +9,8 @@ use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edit Role /// /// Edit a role by its id. @@ -18,6 +20,7 @@ pub async fn edit( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, role_id: String, data: Json, @@ -71,13 +74,23 @@ pub async fn edit( ..Default::default() }; - role.update( - db, - &server.id, - partial, - remove.into_iter().map(Into::into).collect(), - ) - .await?; + let remove = remove + .into_iter() + .map(Into::into) + .collect::>(); + + let before = role.generate_diff(&partial, &remove); + + role.update(db, &server.id, partial.clone(), remove) + .await?; + + AuditLogEntryAction::RoleEdit { + role: role_id.clone(), + before, + after: partial, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; for channel_id in &server.channels { let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; diff --git a/crates/delta/src/routes/servers/roles_edit_positions.rs b/crates/delta/src/routes/servers/roles_edit_positions.rs index 4b55e95a..53a3a9be 100644 --- a/crates/delta/src/routes/servers/roles_edit_positions.rs +++ b/crates/delta/src/routes/servers/roles_edit_positions.rs @@ -1,11 +1,15 @@ use revolt_database::{ - util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{sync_voice_permissions, VoiceClient}, Database, User + util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{sync_voice_permissions, VoiceClient}, + AuditLogEntryAction, Database, User, }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edits server roles ranks /// /// Edit's server role's ranks. @@ -15,6 +19,7 @@ pub async fn edit_role_ranks( db: &State, voice_client: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, ) -> Result> { @@ -68,13 +73,20 @@ pub async fn edit_role_ranks( } } - server.set_role_ordering(db, new_order).await?; + server.set_role_ordering(db, new_order.clone()).await?; + + AuditLogEntryAction::RolesReorder { + before: existing_order, + after: new_order, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; for channel_id in &server.channels { let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; sync_voice_permissions(db, voice_client, &channel, Some(&server), None).await?; - }; + } Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/servers/server_edit.rs b/crates/delta/src/routes/servers/server_edit.rs index c27bf72e..4b3bba24 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, ValidatedTicket, + AuditLogEntryAction, Database, FieldsServer, File, PartialServer, User, ValidatedTicket }; use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -10,6 +10,8 @@ use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; use validator::Validate; +use crate::util::audit_log_reason::AuditLogReason; + /// # Edit Server /// /// Edit a server by its id. @@ -18,6 +20,7 @@ use validator::Validate; pub async fn edit( db: &State, user: User, + reason: AuditLogReason, target: Reference<'_>, data: Json, validated_ticket: Option, @@ -176,9 +179,21 @@ pub async fn edit( partial.owner = Some(server.owner.clone()); } - server - .update(db, partial, remove.into_iter().map(Into::into).collect()) - .await?; + let remove = remove + .into_iter() + .map(Into::into) + .collect::>(); + + let before = server.generate_diff(&partial, &remove); + + server.update(db, partial.clone(), remove).await?; + + AuditLogEntryAction::ServerEdit { + before, + after: partial, + } + .insert(db, server.id.clone(), reason, user.id, None) + .await; Ok(Json(server.into())) } diff --git a/crates/delta/src/routes/webhooks/webhook_delete.rs b/crates/delta/src/routes/webhooks/webhook_delete.rs index fcd428d3..91a6b810 100644 --- a/crates/delta/src/routes/webhooks/webhook_delete.rs +++ b/crates/delta/src/routes/webhooks/webhook_delete.rs @@ -1,12 +1,14 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - Database, User, + AuditLogEntryAction, Database, User, }; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::Result; use rocket::State; use rocket_empty::EmptyResponse; +use crate::util::audit_log_reason::AuditLogReason; + /// # Deletes a webhook /// /// Deletes a webhook @@ -15,6 +17,7 @@ use rocket_empty::EmptyResponse; pub async fn webhook_delete( db: &State, user: User, + reason: AuditLogReason, webhook_id: Reference<'_>, ) -> Result { let webhook = webhook_id.as_webhook(db).await?; @@ -25,5 +28,24 @@ pub async fn webhook_delete( .await .throw_if_lacking_channel_permission(ChannelPermission::ManageWebhooks)?; - webhook.delete(db).await.map(|_| EmptyResponse) + webhook.delete(db).await?; + + AuditLogEntryAction::WebhookDelete { + webhook: webhook.id, + name: webhook.name, + channel: webhook.channel_id, + } + .insert( + db, + channel + .server() + .expect("Webhook created on non server channel") + .to_string(), + reason, + user.id, + Some(webhook.creator_id), + ) + .await; + + Ok(EmptyResponse) } diff --git a/crates/delta/src/util/audit_log_reason.rs b/crates/delta/src/util/audit_log_reason.rs new file mode 100644 index 00000000..cd21b96d --- /dev/null +++ b/crates/delta/src/util/audit_log_reason.rs @@ -0,0 +1,62 @@ +use revolt_result::{create_error, Error}; +use revolt_rocket_okapi::{OpenApiError, gen::OpenApiGenerator, request::{OpenApiFromRequest, RequestHeaderInput}, revolt_okapi::openapi3::{Parameter, ParameterValue}}; +use rocket::{ + http::Status, + request::{FromRequest, Outcome, Request}, +}; +use schemars::schema::{InstanceType, SchemaObject, SingleOrVec}; + +/// Newtype for an audit log reason. +/// +/// Extracts the reason from the `X-Audit-Log-Reason` header if provided. +pub struct AuditLogReason(pub Option); + +#[async_trait] +impl<'r> FromRequest<'r> for AuditLogReason { + type Error = Error; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + let reason = req.headers().get_one("x-audit-log-reason"); + + if reason.is_some_and(|str| str.len() > 512) { + return Outcome::Error((Status::BadRequest, create_error!(HeaderTooLarge))); + }; + + Outcome::Success(Self(reason.map(|str| str.to_string()))) + } +} + +impl OpenApiFromRequest<'_> for AuditLogReason { + fn from_request_input( + _gen: &mut OpenApiGenerator, + _name: String, + _required: bool, + ) -> Result { + Ok(RequestHeaderInput::Parameter(Parameter { + name: "X-Audit-Log-Reason".to_string(), + description: Some("Reason for action which is stored in the audit log.".to_string()), + allow_empty_value: false, + required: false, + deprecated: false, + extensions: schemars::Map::new(), + location: "header".to_string(), + value: ParameterValue::Schema { + allow_reserved: false, + example: None, + examples: None, + explode: None, + style: None, + schema: SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + ..Default::default() + }, + }, + })) + } +} + +impl From for Option { + fn from(value: AuditLogReason) -> Self { + value.0 + } +} \ No newline at end of file diff --git a/crates/delta/src/util/mod.rs b/crates/delta/src/util/mod.rs index 1c32be84..c6dfd3e3 100644 --- a/crates/delta/src/util/mod.rs +++ b/crates/delta/src/util/mod.rs @@ -1,3 +1,4 @@ +pub mod audit_log_reason; pub mod ratelimits; #[cfg(test)]