From 9217c6e49b6629cd077e74d0d2bdb5318cc94b68 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Thu, 25 Jun 2026 13:38:39 +0100 Subject: [PATCH 01/24] ci: fix concurrency group (#837) Signed-off-by: Zomatree --- .github/workflows/docker.yaml | 2 +- .github/workflows/rust.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 75aefeb6..69df9836 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -12,7 +12,7 @@ permissions: packages: write concurrency: - group: ${{ github.head_ref || github.ref }} + group: docker-test-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml index 3b7a78d2..99f218ee 100644 --- a/.github/workflows/rust.yaml +++ b/.github/workflows/rust.yaml @@ -6,7 +6,7 @@ on: pull_request: concurrency: - group: ${{ github.head_ref || github.ref }} + group: rust-build-test-${{ github.head_ref || github.ref }} cancel-in-progress: true env: From 164be6a2da05c8b31909e227d9e410656e884cb2 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Thu, 25 Jun 2026 18:57:12 +0100 Subject: [PATCH 02/24] Revert "chore(deps): update dependency gh to v2.95.0" (#840) Signed-off-by: Zomatree --- .mise/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise/config.toml b/.mise/config.toml index 7cee11fc..331ba797 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -2,7 +2,7 @@ node = "25.4.0" pnpm = "10.28.1" -gh = "2.95.0" +gh = "2.25.0" rust = "1.92.0" "cargo:cargo-nextest" = "0.9.122" From a22378c35c2c6c84f8897ce897b9c4df420871d9 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Sat, 27 Jun 2026 04:24:00 +0100 Subject: [PATCH 03/24] fix(docs): update react version (#842) Signed-off-by: Zomatree --- docs/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index 1c9f5215..f1c29cc9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,8 +22,8 @@ "@scalar/docusaurus": "^0.7.21", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.2.7", + "react-dom": "^19.2.7" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.9.2", From 59f6e012f827ab08a8e326354bfe6c9026e2cb2d Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 2 Jul 2026 19:20:18 -0700 Subject: [PATCH 04/24] feat: replace tenor with gifbox (#844) Signed-off-by: IAmTomahawkx --- crates/services/gifbox/src/tenor/mod.rs | 87 ++++++++++++++----------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/crates/services/gifbox/src/tenor/mod.rs b/crates/services/gifbox/src/tenor/mod.rs index a9971b17..090b9fd0 100644 --- a/crates/services/gifbox/src/tenor/mod.rs +++ b/crates/services/gifbox/src/tenor/mod.rs @@ -10,7 +10,7 @@ use tokio::sync::RwLock; pub mod types; -const TENOR_API_BASE_URL: &str = "https://tenor.googleapis.com/v2"; +const TENOR_API_BASE_URL: &str = "https://compat.gifbox.me/v2"; #[derive(Clone, Debug, PartialEq, Eq)] pub enum TenorError { @@ -59,7 +59,11 @@ impl Tenor { } } - pub async fn request(&self, path: &str, query: &[Option<(&str, &str)>]) -> Result, TenorError> { + pub async fn request( + &self, + path: &str, + query: &[Option<(&str, &str)>], + ) -> Result, TenorError> { let response = self .client .get(format!("{TENOR_API_BASE_URL}{path}")) @@ -95,24 +99,27 @@ impl Tenor { } } - let res = self.coalescion.execute(unique_key.clone(), || async move { - self.request::( - "/search", - &[ - Some(("key", &self.key)), - Some(("q", query)), - Some(("client_key", "Gifbox")), - Some(("media_filter", "webm,tinywebm")), - Some(("locale", locale)), - Some(("contentfilter", "high")), - Some(("limit", &limit.to_string())), - position.is_empty().then_some(("pos", position)), - is_category.then_some(("component", "categories")) - ] - ).await - }) - .await - .unwrap(); + let res = self + .coalescion + .execute(unique_key.clone(), || async move { + self.request::( + "/search", + &[ + Some(("key", &self.key)), + Some(("q", query)), + Some(("client_key", "Gifbox")), + Some(("media_filter", "webm,tinywebm")), + Some(("locale", locale)), + Some(("contentfilter", "high")), + Some(("limit", &limit.to_string())), + position.is_empty().then_some(("pos", position)), + is_category.then_some(("component", "categories")), + ], + ) + .await + }) + .await + .unwrap(); if let Ok(resp) = &*res { self.cache.write().await.insert(unique_key, resp.clone()); @@ -143,8 +150,9 @@ impl Tenor { Some(("client_key", "Gifbox")), Some(("locale", locale)), Some(("contentfilter", "high")), - ] - ).await + ], + ) + .await }) .await .unwrap(); @@ -173,22 +181,25 @@ impl Tenor { } } - let res = self.coalescion.execute(unique_key.clone(), || async move { - self.request::( - "/featured", - &[ - Some(("key", &self.key)), - Some(("client_key", "Gifbox")), - Some(("media_filter", "webm,tinywebm")), - Some(("locale", locale)), - Some(("contentfilter", "high")), - Some(("limit", &limit.to_string())), - position.is_empty().then_some(("pos", position)), - ] - ).await - }) - .await - .unwrap(); + let res = self + .coalescion + .execute(unique_key.clone(), || async move { + self.request::( + "/featured", + &[ + Some(("key", &self.key)), + Some(("client_key", "Gifbox")), + Some(("media_filter", "webm,tinywebm")), + Some(("locale", locale)), + Some(("contentfilter", "high")), + Some(("limit", &limit.to_string())), + position.is_empty().then_some(("pos", position)), + ], + ) + .await + }) + .await + .unwrap(); if let Ok(resp) = &*res { self.featured.write().await.insert(unique_key, resp.clone()); From 21daf3aec693beae55bff51235e5e4b7d90f2362 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Fri, 3 Jul 2026 03:23:52 +0100 Subject: [PATCH 05/24] fix: allow removing channel slowmode (#836) Signed-off-by: Zomatree --- crates/core/database/src/models/channels/model.rs | 8 ++++++++ crates/core/database/src/util/bridge/v0.rs | 2 ++ crates/core/models/src/v0/channels.rs | 1 + crates/delta/src/routes/channels/channel_edit.rs | 3 +++ 4 files changed, 14 insertions(+) diff --git a/crates/core/database/src/models/channels/model.rs b/crates/core/database/src/models/channels/model.rs index 9712f5aa..2d275395 100644 --- a/crates/core/database/src/models/channels/model.rs +++ b/crates/core/database/src/models/channels/model.rs @@ -161,6 +161,7 @@ auto_derived!( Icon, DefaultPermissions, Voice, + Slowmode, } ); @@ -554,6 +555,12 @@ impl Channel { } _ => {} }, + FieldsChannel::Slowmode => match self { + Self::TextChannel { slowmode, .. } => { + slowmode.take(); + } + _ => {} + } } } @@ -777,6 +784,7 @@ impl IntoDocumentPath for FieldsChannel { FieldsChannel::Icon => "icon", FieldsChannel::DefaultPermissions => "default_permissions", FieldsChannel::Voice => "voice", + FieldsChannel::Slowmode => "slowmode", }) } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 2cce6799..102bdf3b 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -319,6 +319,7 @@ impl From for crate::FieldsChannel { FieldsChannel::Icon => crate::FieldsChannel::Icon, FieldsChannel::DefaultPermissions => crate::FieldsChannel::DefaultPermissions, FieldsChannel::Voice => crate::FieldsChannel::Voice, + FieldsChannel::Slowmode => crate::FieldsChannel::Slowmode, } } } @@ -330,6 +331,7 @@ impl From for FieldsChannel { crate::FieldsChannel::Icon => FieldsChannel::Icon, crate::FieldsChannel::DefaultPermissions => FieldsChannel::DefaultPermissions, crate::FieldsChannel::Voice => FieldsChannel::Voice, + crate::FieldsChannel::Slowmode => FieldsChannel::Slowmode, } } } diff --git a/crates/core/models/src/v0/channels.rs b/crates/core/models/src/v0/channels.rs index faddee74..8e32a8aa 100644 --- a/crates/core/models/src/v0/channels.rs +++ b/crates/core/models/src/v0/channels.rs @@ -164,6 +164,7 @@ auto_derived!( Icon, DefaultPermissions, Voice, + Slowmode, } /// New webhook information diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index 25ead30b..8ef439fa 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -221,6 +221,9 @@ pub async fn edit( v0::FieldsChannel::Voice => { voice.take(); } + v0::FieldsChannel::Slowmode => { + slowmode.take(); + } _ => {} } } From 0b53db9921f5ee5992d57a6316cd4e75d241726a Mon Sep 17 00:00:00 2001 From: Zomatree Date: Tue, 7 Jul 2026 06:37:27 +0100 Subject: [PATCH 06/24] fix: voice system messages and call notifs by fetching participant list (#846) Signed-off-by: Zomatree --- .../core/database/src/voice/voice_client.rs | 13 ++ crates/daemons/voice-ingress/src/api.rs | 183 +++++++++--------- 2 files changed, 104 insertions(+), 92 deletions(-) diff --git a/crates/core/database/src/voice/voice_client.rs b/crates/core/database/src/voice/voice_client.rs index 94d1f22b..a82b6037 100644 --- a/crates/core/database/src/voice/voice_client.rs +++ b/crates/core/database/src/voice/voice_client.rs @@ -159,4 +159,17 @@ impl VoiceClient { .await .to_internal_error() } + + pub async fn get_room_participants( + &self, + node: &str, + channel_id: &str, + ) -> Result> { + let room = self.get_node(node)?; + + room.client + .list_participants(channel_id) + .await + .to_internal_error() + } } diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs index dbe8934e..70bd9f67 100644 --- a/crates/daemons/voice-ingress/src/api.rs +++ b/crates/daemons/voice-ingress/src/api.rs @@ -1,19 +1,15 @@ use livekit_api::{access_token::TokenVerifier, webhooks::WebhookReceiver}; use livekit_protocol::TrackType; use revolt_database::{ - events::client::EventV1, - iso8601_timestamp::{Duration, Timestamp}, - util::reference::Reference, - voice::{ - create_voice_state, delete_channel_voice_state, delete_voice_state, - get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks, - RoomMetadata, UserVoiceChannel, VoiceClient, - }, - Database, AMQP, + AMQP, Database, PartialMessage, SystemMessage, events::client::EventV1, iso8601_timestamp::{Duration, Timestamp}, util::reference::Reference, voice::{ + RoomMetadata, UserVoiceChannel, VoiceClient, create_voice_state, delete_channel_voice_state, delete_voice_state, get_call_notification_recipients, get_user_moved_from_voice, get_user_moved_to_voice, get_voice_channel_members, set_channel_call_started_system_message, take_channel_call_started_system_message, update_voice_state_tracks + } }; +use revolt_models::v0; use revolt_result::{Result, ToRevoltError}; use rocket::{post, State}; use rocket_empty::EmptyResponse; +use ulid::Ulid; use crate::guard::AuthHeader; @@ -21,12 +17,12 @@ use crate::guard::AuthHeader; pub async fn ingress( db: &State, voice_client: &State, - _amqp: &State, + amqp: &State, node: &str, auth_header: AuthHeader<'_>, body: &str, ) -> Result { - log::debug!("received event: {body:?}"); + log::debug!("received event: {body}"); let config = revolt_config::config().await; @@ -63,16 +59,18 @@ pub async fn ingress( let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; let server_id = room_metadata.to_internal_error()?.server; - let channel = UserVoiceChannel { + let voice_channel = UserVoiceChannel { id: channel_id.clone(), server_id: server_id.clone(), }; + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + let joined_at = Timestamp::UNIX_EPOCH .checked_add(Duration::seconds(event.created_at)) .unwrap(); - let voice_state = create_voice_state(&channel, user_id, joined_at).await?; + let voice_state = create_voice_state(&voice_channel, user_id, joined_at).await?; // Only publish one event when a user is moved from one channel to another. if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? { @@ -93,63 +91,66 @@ pub async fn ingress( .await; }; - // TODO: fix `num_participants` being incorrect sometimes see (#457) - // First user who joined - send call started system message. - // if event.room.as_ref().unwrap().num_participants == 1 { - // let user = Reference::from_unchecked(user_id).as_user(db).await?; + let participants = voice_client.get_room_participants(node, channel_id).await?; - // let message_id = - // Ulid::from_datetime(DateTime::from_timestamp_secs(event.created_at).unwrap()) - // .to_string(); + if participants.len() == 1 { + let user = Reference::from_unchecked(user_id).as_user(db).await?; + let message_id = Ulid::from_datetime( + Timestamp::UNIX_EPOCH + .checked_add(Duration::seconds(event.created_at)) + .unwrap() + .into(), + ) + .to_string(); - // let mut call_started_message = SystemMessage::CallStarted { - // by: user_id.to_string(), - // finished_at: None, - // } - // .into_message(channel.id().to_string()); + let mut call_started_message = SystemMessage::CallStarted { + by: user_id.to_string(), + finished_at: None, + } + .into_message(channel_id.clone()); - // call_started_message.id = message_id; + call_started_message.id = message_id; - // set_channel_call_started_system_message(channel.id(), &call_started_message.id) - // .await?; + set_channel_call_started_system_message(channel_id, &call_started_message.id) + .await?; - // call_started_message - // .send( - // db, - // Some(amqp), - // v0::MessageAuthor::System { - // username: &user.username, - // avatar: user.avatar.as_ref().map(|file| file.id.as_ref()), - // }, - // None, - // None, - // &channel, - // false, - // ) - // .await?; + call_started_message + .send( + db, + Some(amqp), + v0::MessageAuthor::System { + username: &user.username, + avatar: user.avatar.as_ref().map(|file| file.id.as_ref()), + }, + None, + None, + &channel, + false, + ) + .await?; - // let recipients = get_call_notification_recipients(&channel_id, &user_id).await?; - // let now = joined_at.format_short().to_string(); + let recipients = get_call_notification_recipients(channel_id, user_id).await?; + let now = joined_at.format_short().to_string(); - // if let Err(e) = amqp - // .dm_call_updated(&user.id, channel.id(), Some(&now), false, recipients) - // .await - // { - // revolt_config::capture_error(&e); - // } - // } + if let Err(e) = amqp + .dm_call_updated(&user.id, channel_id, Some(&now), false, recipients) + .await + { + revolt_config::capture_error(&e); + } + } } // User left a channel "participant_left" => { let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; let server_id = room_metadata.to_internal_error()?.server; - let channel = UserVoiceChannel { + let voice_channel = UserVoiceChannel { id: channel_id.clone(), server_id: server_id.clone(), }; - delete_voice_state(&channel, user_id).await?; + delete_voice_state(&voice_channel, user_id).await?; // Dont send leave event when a user is moved if get_user_moved_from_voice(channel_id, user_id) @@ -164,49 +165,47 @@ pub async fn ingress( .await; }; - // See above for why this is commented out - // // Update CallStarted system message if everyone has left with the end time - // let members = get_voice_channel_members(channel_id).await?; + let members = get_voice_channel_members(&voice_channel).await?; - // if members.is_none_or(|m| m.is_empty()) { - // // The channel is empty so send out an "end" message for ringing - // if let Err(e) = amqp - // .dm_call_updated(user_id, channel_id, None, true, None) - // .await - // { - // revolt_config::capture_internal_error!(&e); - // } + if members.is_none_or(|m| m.is_empty()) { + // The channel is empty so send out an "end" message for ringing + if let Err(e) = amqp + .dm_call_updated(user_id, channel_id, None, true, None) + .await + { + revolt_config::capture_internal_error!(&e); + } - // if let Some(system_message_id) = - // take_channel_call_started_system_message(channel_id).await? - // { - // // Could have been deleted - // if let Ok(mut message) = Reference::from_unchecked(&system_message_id) - // .as_message(db) - // .await - // { - // if let Some(SystemMessage::CallStarted { finished_at, .. }) = - // &mut message.system - // { - // *finished_at = Some(Timestamp::now_utc()); + if let Some(system_message_id) = + take_channel_call_started_system_message(channel_id).await? + { + // Could have been deleted + if let Ok(mut message) = Reference::from_unchecked(&system_message_id) + .as_message(db) + .await + { + if let Some(SystemMessage::CallStarted { finished_at, .. }) = + &mut message.system + { + *finished_at = Some(Timestamp::now_utc()); - // message - // .update( - // db, - // PartialMessage { - // system: message.system.clone(), - // ..Default::default() - // }, - // Vec::new(), - // ) - // .await?; - // } else { - // log::error!("Broken State: Call started message ID ({}) does not contain a CallStarted system message.", &message.id) - // } - // }; - // }; - // } + message + .update( + db, + PartialMessage { + system: message.system.clone(), + ..Default::default() + }, + Vec::new(), + ) + .await?; + } else { + log::error!("Broken State: Call started message ID ({}) does not contain a CallStarted system message.", &message.id) + } + }; + }; + } } // Audio/video track was started/stopped/unmuted/muted "track_published" | "track_unpublished" | "track_unmuted" | "track_muted" => { From f60c8340f4b9d2519704d134451ad5415f35217e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 13 Jul 2026 13:34:12 -0700 Subject: [PATCH 07/24] Merge commit from fork Signed-off-by: IAmTomahawkx --- crates/services/january/src/requests.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/services/january/src/requests.rs b/crates/services/january/src/requests.rs index c818619b..5566e326 100644 --- a/crates/services/january/src/requests.rs +++ b/crates/services/january/src/requests.rs @@ -419,21 +419,22 @@ impl Request { } pub async fn url_is_blacklisted(url: &Url) -> Result { - let resolved_address: IpAddr; + let mut resolved_address: Option = None; if let Some(host) = url.host() { match host { Host::Ipv4(ipv4) => { - resolved_address = ipv4.into(); if !IP_BLOCKLIST.is_allowed(&ipv4.to_string()) { return Err(create_error!(InvalidOperation)); } + resolved_address = Some(ipv4.into()); } Host::Ipv6(ipv6) => { - resolved_address = ipv6.into(); - if !IP_BLOCKLIST.is_allowed(&ipv6.to_string()) { + let string = ipv6.to_string(); + if string.contains("::ffff:") || !IP_BLOCKLIST.is_allowed(&string) { return Err(create_error!(InvalidOperation)); } + resolved_address = Some(ipv6.into()); } Host::Domain(domain) => { let domain = domain.to_string(); @@ -449,7 +450,7 @@ impl Request { // Second step: resolve the IP and check the blocklist let resolver = CachedDnsResolver {}; - if let Ok(mut resolved_ip) = resolver + if let Ok(resolved_ips) = resolver .resolve( Name::from_str(&domain) .map_err(|_| create_error!(ProxyError)) @@ -457,16 +458,14 @@ impl Request { ) .await { - if let Some(resolved_ip) = resolved_ip.next() { - resolved_address = resolved_ip.ip(); - let resolved_string = resolved_address.to_string(); + for resolved in resolved_ips { + resolved_address = Some(resolved.ip()); // last resolved ip will be the one we hit as a consequence of this for loop. + let resolved_string = resolved_address.unwrap().to_string(); if !IP_BLOCKLIST.is_allowed(&resolved_string) || resolved_string.contains("::ffff:") { return Err(create_error!(InvalidOperation)); } - } else { - return Err(create_error!(InvalidOperation)); } } else { return Err(create_error!(ProxyError)); @@ -477,9 +476,13 @@ impl Request { return Err(create_error!(ProxyError)); }; + if resolved_address.is_none() { + return Err(create_error!(InvalidOperation)); + } + Ok(IPRequest { url: url.clone(), - ip: resolved_address, + ip: resolved_address.unwrap(), blocked: false, }) } From 502203d37c63e486c32e33078e0021bf6390fe97 Mon Sep 17 00:00:00 2001 From: Zomatree Date: Mon, 13 Jul 2026 21:39:28 +0100 Subject: [PATCH 08/24] feat: Audit Logs (#466) * fix: squash audit log branch changes Signed-off-by: Zomatree * feat: emoji edit + pronoun Signed-off-by: Zomatree * fix: use tokio instead of async-std Signed-off-by: Zomatree * feat: add slowmode to audit log Signed-off-by: Zomatree * chore: update comments on permissions Signed-off-by: Zomatree --------- Signed-off-by: Zomatree --- crates/core/config/Revolt.toml | 4 + crates/core/config/src/lib.rs | 7 + crates/core/database/src/drivers/reference.rs | 7 +- crates/core/database/src/lib.rs | 73 ++++- .../admin_migrations/ops/mongodb/init.rs | 55 ++++ .../admin_migrations/ops/mongodb/scripts.rs | 35 ++- .../database/src/models/audit_logs/mod.rs | 5 + .../database/src/models/audit_logs/model.rs | 275 ++++++++++++++++++ .../database/src/models/audit_logs/ops.rs | 20 ++ .../src/models/audit_logs/ops/mongodb.rs | 66 +++++ .../src/models/audit_logs/ops/reference.rs | 81 ++++++ .../database/src/models/channels/model.rs | 116 ++++++++ .../core/database/src/models/emojis/model.rs | 31 +- .../database/src/models/messages/model.rs | 14 +- crates/core/database/src/models/mod.rs | 3 + .../src/models/server_members/model.rs | 28 +- .../core/database/src/models/servers/model.rs | 46 ++- .../src/models/servers/ops/mongodb.rs | 7 + crates/core/database/src/util/bridge/v0.rs | 163 ++++++++++- crates/core/models/src/v0/audit_logs.rs | 163 +++++++++++ crates/core/models/src/v0/mod.rs | 2 + crates/core/permissions/src/models/channel.rs | 5 +- crates/core/result/src/axum.rs | 1 + crates/core/result/src/lib.rs | 1 + crates/core/result/src/rocket.rs | 1 + .../src/routes/channels/channel_delete.rs | 14 +- .../delta/src/routes/channels/channel_edit.rs | 38 ++- .../routes/channels/group_remove_member.rs | 4 +- .../src/routes/channels/invite_create.rs | 21 +- .../routes/channels/message_bulk_delete.rs | 20 +- .../src/routes/channels/message_delete.rs | 32 +- .../delta/src/routes/channels/message_pin.rs | 21 +- .../src/routes/channels/message_unpin.rs | 22 +- .../src/routes/channels/permissions_set.rs | 25 +- .../channels/permissions_set_default.rs | 40 ++- .../src/routes/channels/webhook_create.rs | 17 +- .../src/routes/customisation/emoji_create.rs | 16 +- .../src/routes/customisation/emoji_delete.rs | 18 +- .../src/routes/customisation/emoji_edit.rs | 25 +- .../delta/src/routes/invites/invite_delete.rs | 33 ++- .../src/routes/servers/audit_log_query.rs | 227 +++++++++++++++ crates/delta/src/routes/servers/ban_create.rs | 25 +- crates/delta/src/routes/servers/ban_remove.rs | 15 +- .../src/routes/servers/channel_create.rs | 20 +- .../delta/src/routes/servers/member_edit.rs | 51 +++- .../delta/src/routes/servers/member_remove.rs | 17 +- crates/delta/src/routes/servers/mod.rs | 4 +- .../src/routes/servers/permissions_set.rs | 33 ++- .../routes/servers/permissions_set_default.rs | 34 ++- .../delta/src/routes/servers/roles_create.rs | 12 +- .../delta/src/routes/servers/roles_delete.rs | 12 +- crates/delta/src/routes/servers/roles_edit.rs | 29 +- .../routes/servers/roles_edit_positions.rs | 18 +- .../delta/src/routes/servers/server_edit.rs | 23 +- .../src/routes/webhooks/webhook_delete.rs | 26 +- crates/delta/src/util/audit_log_reason.rs | 62 ++++ crates/delta/src/util/mod.rs | 1 + 57 files changed, 2014 insertions(+), 150 deletions(-) create mode 100644 crates/core/database/src/models/audit_logs/mod.rs create mode 100644 crates/core/database/src/models/audit_logs/model.rs create mode 100644 crates/core/database/src/models/audit_logs/ops.rs create mode 100644 crates/core/database/src/models/audit_logs/ops/mongodb.rs create mode 100644 crates/core/database/src/models/audit_logs/ops/reference.rs create mode 100644 crates/core/models/src/v0/audit_logs.rs create mode 100644 crates/delta/src/routes/servers/audit_log_query.rs create mode 100644 crates/delta/src/util/audit_log_reason.rs 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)] From 784f35ebfa8568593812683b5fa399ca87af2d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0spik?= Date: Mon, 13 Jul 2026 23:40:15 +0300 Subject: [PATCH 09/24] fix: migration script would panic on fresh installations since it couldn't find invites collection (#820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: İspik --- .../admin_migrations/ops/mongodb/scripts.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 e7c4ea89..2da6e9e6 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 @@ -1478,16 +1478,22 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { if revision <= 50 { info!("Running migration [revision 50 / 13-04-2026]: Rename invites collection to account_invites"); - db.db() + let result = db.db() .client() .database("admin") .run_command(doc! { - "renameCollection": "revolt.invites", - "to": "revolt.account_invites", - "dropTarget": true - }) - .await - .unwrap(); + "renameCollection": "revolt.invites", + "to": "revolt.account_invites", + "dropTarget": true + }) + .await; + + if let Err(e) = result { + // NamespaceNotFound (26) = source collection doesn't exist, safe to ignore + if !matches!(e.kind.as_ref(), mongodb::error::ErrorKind::Command(ce) if ce.code == 26) { + panic!("Failed to rename invites collection: {e}"); + } + } } if revision >= 51 { From b9740a760453c9d5318039713f7914791d02c23a Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 15 Jul 2026 00:21:25 -0700 Subject: [PATCH 10/24] chore: rename session keys in redis (#812) Signed-off-by: IAmTomahawkx --- crates/core/presence/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/core/presence/src/lib.rs b/crates/core/presence/src/lib.rs index 124eacc3..ce7ce46f 100644 --- a/crates/core/presence/src/lib.rs +++ b/crates/core/presence/src/lib.rs @@ -30,7 +30,7 @@ pub async fn create_session(user_id: &str, flags: u8) -> (bool, u32) { if let Ok(mut conn) = get_connection().await { // Check whether this is the first session - let was_empty = __get_set_size(&mut conn, user_id).await == 0; + let was_empty = __get_set_size(&mut conn, &format!("sessions:{user_id}")).await == 0; // A session ID is comprised of random data and any flags ORed to the end let session_id = { @@ -39,7 +39,7 @@ pub async fn create_session(user_id: &str, flags: u8) -> (bool, u32) { }; // Add session to user's sessions and to the region - __add_to_set_u32(&mut conn, user_id, session_id).await; + __add_to_set_u32(&mut conn, &format!("sessions:{user_id}"), session_id).await; __add_to_set_string(&mut conn, ONLINE_SET, user_id).await; __add_to_set_string(&mut conn, ®ION_KEY, &format!("{user_id}:{session_id}")).await; info!("Created session for {user_id}, assigned them a session ID of {session_id}."); @@ -62,7 +62,7 @@ async fn delete_session_internal(user_id: &str, session_id: u32, skip_region: bo if let Ok(mut conn) = get_connection().await { // Remove the session - __remove_from_set_u32(&mut conn, user_id, session_id).await; + __remove_from_set_u32(&mut conn, &format!("sessions:{user_id}"), session_id).await; // Remove from the region if !skip_region { From 07217dfac43cacf02e4ed805ccb3330d4b37e1a9 Mon Sep 17 00:00:00 2001 From: "stoat-release[bot]" <245062572+stoat-release[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:22:18 -0700 Subject: [PATCH 11/24] chore(main): release 0.14.0 (#798) Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 23 ++++++++++++++++ Cargo.lock | 36 ++++++++++++------------- Cargo.toml | 20 +++++++------- crates/bonfire/Cargo.toml | 2 +- crates/core/coalesced/Cargo.toml | 2 +- crates/core/config/Cargo.toml | 2 +- crates/core/database/Cargo.toml | 2 +- crates/core/files/Cargo.toml | 2 +- crates/core/models/Cargo.toml | 2 +- crates/core/parser/Cargo.toml | 2 +- crates/core/permissions/Cargo.toml | 2 +- crates/core/presence/Cargo.toml | 2 +- crates/core/ratelimits/Cargo.toml | 2 +- crates/core/result/Cargo.toml | 2 +- crates/daemons/crond/Cargo.toml | 2 +- crates/daemons/pushd/Cargo.toml | 2 +- crates/daemons/voice-ingress/Cargo.toml | 2 +- crates/delta/Cargo.toml | 2 +- crates/services/autumn/Cargo.toml | 2 +- crates/services/gifbox/Cargo.toml | 2 +- crates/services/january/Cargo.toml | 2 +- version.txt | 2 +- 23 files changed, 71 insertions(+), 48 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 29781928..4694aaf8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.13.7" + ".": "0.14.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9912f5b6..f8c195cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.14.0](https://github.com/stoatchat/stoatchat/compare/v0.13.7...v0.14.0) (2026-07-15) + + +### Features + +* add pronouns to user and server members field ([#811](https://github.com/stoatchat/stoatchat/issues/811)) ([ffab236](https://github.com/stoatchat/stoatchat/commit/ffab2369ab5c9b88e007dcd74b91bb48e1988d26)) +* Audit Logs ([#466](https://github.com/stoatchat/stoatchat/issues/466)) ([502203d](https://github.com/stoatchat/stoatchat/commit/502203d37c63e486c32e33078e0021bf6390fe97)) +* replace tenor with gifbox ([#844](https://github.com/stoatchat/stoatchat/issues/844)) ([59f6e01](https://github.com/stoatchat/stoatchat/commit/59f6e012f827ab08a8e326354bfe6c9026e2cb2d)) + + +### Bug Fixes + +* allow removing channel slowmode ([#836](https://github.com/stoatchat/stoatchat/issues/836)) ([21daf3a](https://github.com/stoatchat/stoatchat/commit/21daf3aec693beae55bff51235e5e4b7d90f2362)) +* allow true server owner to bypass rank check on channel role-permission overrides ([0af376c](https://github.com/stoatchat/stoatchat/commit/0af376c26b149a5a0286608ebe3869587780a949)) +* channel role permissions fail with 400 InvalidOperation for server owners/admins ([#802](https://github.com/stoatchat/stoatchat/issues/802)) ([a7af24b](https://github.com/stoatchat/stoatchat/commit/a7af24b38d0a38d6f04187464a89e67d459d1708)) +* channel role permissions fail with InvalidOperation for owners/admins ([a7af24b](https://github.com/stoatchat/stoatchat/commit/a7af24b38d0a38d6f04187464a89e67d459d1708)) +* **docs:** update react version ([#842](https://github.com/stoatchat/stoatchat/issues/842)) ([a22378c](https://github.com/stoatchat/stoatchat/commit/a22378c35c2c6c84f8897ce897b9c4df420871d9)) +* migration script would panic on fresh installations since it couldn't find invites collection ([#820](https://github.com/stoatchat/stoatchat/issues/820)) ([784f35e](https://github.com/stoatchat/stoatchat/commit/784f35ebfa8568593812683b5fa399ca87af2d6b)) +* openapi using old naming ([#777](https://github.com/stoatchat/stoatchat/issues/777)) ([c70459b](https://github.com/stoatchat/stoatchat/commit/c70459b10ce107611b9d478add26db372361baf2)) +* point docs favicon to correct location ([#789](https://github.com/stoatchat/stoatchat/issues/789)) ([bebfe34](https://github.com/stoatchat/stoatchat/commit/bebfe349227d8cc555e1b488eb343f2c28b28b88)) +* server owner should bypass rank check on channel role-permission overrides ([#805](https://github.com/stoatchat/stoatchat/issues/805)) ([0af376c](https://github.com/stoatchat/stoatchat/commit/0af376c26b149a5a0286608ebe3869587780a949)) +* voice system messages and call notifs by fetching participant list ([#846](https://github.com/stoatchat/stoatchat/issues/846)) ([0b53db9](https://github.com/stoatchat/stoatchat/commit/0b53db9921f5ee5992d57a6316cd4e75d241726a)) + ## [0.13.7](https://github.com/stoatchat/stoatchat/compare/v0.13.6...v0.13.7) (2026-05-21) diff --git a/Cargo.lock b/Cargo.lock index 872ee2a0..9752e954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7164,7 +7164,7 @@ dependencies = [ [[package]] name = "revolt-autumn" -version = "0.13.7" +version = "0.14.0" dependencies = [ "axum", "axum-macros", @@ -7205,7 +7205,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-channel", "async-tungstenite", @@ -7236,7 +7236,7 @@ dependencies = [ [[package]] name = "revolt-coalesced" -version = "0.13.7" +version = "0.14.0" dependencies = [ "indexmap 2.14.0", "lru", @@ -7245,7 +7245,7 @@ dependencies = [ [[package]] name = "revolt-config" -version = "0.13.7" +version = "0.14.0" dependencies = [ "cached", "config", @@ -7261,7 +7261,7 @@ dependencies = [ [[package]] name = "revolt-crond" -version = "0.13.7" +version = "0.14.0" dependencies = [ "futures", "futures-lite", @@ -7282,7 +7282,7 @@ dependencies = [ [[package]] name = "revolt-database" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-lock 2.8.0", "async-recursion", @@ -7340,7 +7340,7 @@ dependencies = [ [[package]] name = "revolt-delta" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-channel", "bitfield", @@ -7387,7 +7387,7 @@ dependencies = [ [[package]] name = "revolt-files" -version = "0.13.7" +version = "0.14.0" dependencies = [ "aes-gcm", "anyhow", @@ -7415,7 +7415,7 @@ dependencies = [ [[package]] name = "revolt-gifbox" -version = "0.13.7" +version = "0.14.0" dependencies = [ "axum", "axum-extra", @@ -7438,7 +7438,7 @@ dependencies = [ [[package]] name = "revolt-january" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-recursion", "axum", @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "revolt-models" -version = "0.13.7" +version = "0.14.0" dependencies = [ "indexmap 2.14.0", "iso8601-timestamp", @@ -7488,14 +7488,14 @@ dependencies = [ [[package]] name = "revolt-parser" -version = "0.13.7" +version = "0.14.0" dependencies = [ "logos", ] [[package]] name = "revolt-permissions" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-trait", "auto_ops", @@ -7510,7 +7510,7 @@ dependencies = [ [[package]] name = "revolt-presence" -version = "0.13.7" +version = "0.14.0" dependencies = [ "log", "once_cell", @@ -7522,7 +7522,7 @@ dependencies = [ [[package]] name = "revolt-pushd" -version = "0.13.7" +version = "0.14.0" dependencies = [ "anyhow", "async-trait", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "revolt-ratelimits" -version = "0.13.7" +version = "0.14.0" dependencies = [ "async-trait", "axum", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "revolt-result" -version = "0.13.7" +version = "0.14.0" dependencies = [ "axum", "log", @@ -7584,7 +7584,7 @@ dependencies = [ [[package]] name = "revolt-voice-ingress" -version = "0.13.7" +version = "0.14.0" dependencies = [ "chrono", "futures", diff --git a/Cargo.toml b/Cargo.toml index dcbdb01d..59d32c50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,13 +193,13 @@ futures-lite = "2.6.1" vergen = "7.5.0" # Local packages -revolt-coalesced = { version = "0.13.7", path = "crates/core/coalesced" } -revolt-config = { version = "0.13.7", path = "crates/core/config" } -revolt-database = { version = "0.13.7", path = "crates/core/database" } -revolt-files = { version = "0.13.7", path = "crates/core/files" } -revolt-models = { version = "0.13.7", path = "crates/core/models" } -revolt-parser = { version = "0.13.7", path = "crates/core/parser" } -revolt-permissions = { version = "0.13.7", path = "crates/core/permissions" } -revolt-presence = { version = "0.13.7", path = "crates/core/presence" } -revolt-ratelimits = { version = "0.13.7", path = "crates/core/ratelimits" } -revolt-result = { version = "0.13.7", path = "crates/core/result" } +revolt-coalesced = { version = "0.14.0", path = "crates/core/coalesced" } +revolt-config = { version = "0.14.0", path = "crates/core/config" } +revolt-database = { version = "0.14.0", path = "crates/core/database" } +revolt-files = { version = "0.14.0", path = "crates/core/files" } +revolt-models = { version = "0.14.0", path = "crates/core/models" } +revolt-parser = { version = "0.14.0", path = "crates/core/parser" } +revolt-permissions = { version = "0.14.0", path = "crates/core/permissions" } +revolt-presence = { version = "0.14.0", path = "crates/core/presence" } +revolt-ratelimits = { version = "0.14.0", path = "crates/core/ratelimits" } +revolt-result = { version = "0.14.0", path = "crates/core/result" } diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index 91dc90c2..59286b71 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.13.7" +version = "0.14.0" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/core/coalesced/Cargo.toml b/crates/core/coalesced/Cargo.toml index 1c8102ad..cb4c2ddf 100644 --- a/crates/core/coalesced/Cargo.toml +++ b/crates/core/coalesced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-coalesced" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Paul Makles ", "Zomatree "] diff --git a/crates/core/config/Cargo.toml b/crates/core/config/Cargo.toml index 59d2f6af..0deabe1b 100644 --- a/crates/core/config/Cargo.toml +++ b/crates/core/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-config" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 38d83174..03a5d4cd 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-database" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/files/Cargo.toml b/crates/core/files/Cargo.toml index c60bf678..68171f06 100644 --- a/crates/core/files/Cargo.toml +++ b/crates/core/files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-files" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index cf453ece..eabe031a 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-models" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/parser/Cargo.toml b/crates/core/parser/Cargo.toml index 5bb079ac..b673f65f 100644 --- a/crates/core/parser/Cargo.toml +++ b/crates/core/parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-parser" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/permissions/Cargo.toml b/crates/core/permissions/Cargo.toml index b9e4eb40..f12275ba 100644 --- a/crates/core/permissions/Cargo.toml +++ b/crates/core/permissions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-permissions" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/presence/Cargo.toml b/crates/core/presence/Cargo.toml index b0f7f173..50a81652 100644 --- a/crates/core/presence/Cargo.toml +++ b/crates/core/presence/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-presence" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/ratelimits/Cargo.toml b/crates/core/ratelimits/Cargo.toml index 440c3b8a..dc4e05f9 100644 --- a/crates/core/ratelimits/Cargo.toml +++ b/crates/core/ratelimits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-ratelimits" -version = "0.13.7" +version = "0.14.0" edition = "2024" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index 9cc5db3f..f7932463 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-result" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/daemons/crond/Cargo.toml b/crates/daemons/crond/Cargo.toml index 75dc2c4e..a24ae5d3 100644 --- a/crates/daemons/crond/Cargo.toml +++ b/crates/daemons/crond/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-crond" -version = "0.13.7" +version = "0.14.0" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2021" diff --git a/crates/daemons/pushd/Cargo.toml b/crates/daemons/pushd/Cargo.toml index 1942bfb9..fc0e2b9d 100644 --- a/crates/daemons/pushd/Cargo.toml +++ b/crates/daemons/pushd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-pushd" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml index 2c554b1f..977dafca 100644 --- a/crates/daemons/voice-ingress/Cargo.toml +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-voice-ingress" -version = "0.13.7" +version = "0.14.0" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index d9adb983..e7230301 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.13.7" +version = "0.14.0" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index 7c134170..4efda1e3 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-autumn" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/gifbox/Cargo.toml b/crates/services/gifbox/Cargo.toml index a569b2cd..3c66c07a 100644 --- a/crates/services/gifbox/Cargo.toml +++ b/crates/services/gifbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-gifbox" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/january/Cargo.toml b/crates/services/january/Cargo.toml index f72f6572..77be89ed 100644 --- a/crates/services/january/Cargo.toml +++ b/crates/services/january/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-january" -version = "0.13.7" +version = "0.14.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/version.txt b/version.txt index 5daaa7ba..a803cc22 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.13.7 +0.14.0 From 81648dbf9511b32a6e023d556061bb8b74fe41ed Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 17 Jul 2026 23:07:29 -0700 Subject: [PATCH 12/24] fix: presence key was incorrect after other related update (#861) * fix: presence key was incorrect after other related update Signed-off-by: IAmTomahawkx * rust analyzer try not to be annoying challenge Signed-off-by: IAmTomahawkx --------- Signed-off-by: IAmTomahawkx --- crates/core/presence/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/core/presence/src/lib.rs b/crates/core/presence/src/lib.rs index ce7ce46f..e80cf9fd 100644 --- a/crates/core/presence/src/lib.rs +++ b/crates/core/presence/src/lib.rs @@ -87,7 +87,9 @@ async fn delete_session_internal(user_id: &str, session_id: u32, skip_region: bo /// Check whether a given user ID is online pub async fn is_online(user_id: &str) -> bool { if let Ok(mut conn) = get_connection().await { - conn.exists(user_id).await.unwrap_or(false) + conn.exists(format!("sessions:{user_id}")) + .await + .unwrap_or(false) } else { false } From 7784be21ccb80cbb1bc69adb7aeca9fff3e63b21 Mon Sep 17 00:00:00 2001 From: "stoat-release[bot]" <245062572+stoat-release[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:09:15 -0700 Subject: [PATCH 13/24] chore(main): release 0.14.1 (#862) * chore(main): release 0.14.1 * chore: update Cargo.lock Signed-off-by: github-actions[bot] --------- Signed-off-by: github-actions[bot] Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++ Cargo.lock | 36 ++++++++++++------------- Cargo.toml | 20 +++++++------- crates/bonfire/Cargo.toml | 2 +- crates/core/coalesced/Cargo.toml | 2 +- crates/core/config/Cargo.toml | 2 +- crates/core/database/Cargo.toml | 2 +- crates/core/files/Cargo.toml | 2 +- crates/core/models/Cargo.toml | 2 +- crates/core/parser/Cargo.toml | 2 +- crates/core/permissions/Cargo.toml | 2 +- crates/core/presence/Cargo.toml | 2 +- crates/core/ratelimits/Cargo.toml | 2 +- crates/core/result/Cargo.toml | 2 +- crates/daemons/crond/Cargo.toml | 2 +- crates/daemons/pushd/Cargo.toml | 2 +- crates/daemons/voice-ingress/Cargo.toml | 2 +- crates/delta/Cargo.toml | 2 +- crates/services/autumn/Cargo.toml | 2 +- crates/services/gifbox/Cargo.toml | 2 +- crates/services/january/Cargo.toml | 2 +- version.txt | 2 +- 23 files changed, 55 insertions(+), 48 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4694aaf8..d9a79f43 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.14.0" + ".": "0.14.1" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c195cc..910ed0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.14.1](https://github.com/stoatchat/stoatchat/compare/v0.14.0...v0.14.1) (2026-07-18) + + +### Bug Fixes + +* presence key was incorrect after other related update ([#861](https://github.com/stoatchat/stoatchat/issues/861)) ([81648db](https://github.com/stoatchat/stoatchat/commit/81648dbf9511b32a6e023d556061bb8b74fe41ed)) + ## [0.14.0](https://github.com/stoatchat/stoatchat/compare/v0.13.7...v0.14.0) (2026-07-15) diff --git a/Cargo.lock b/Cargo.lock index 9752e954..f4cd70bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7164,7 +7164,7 @@ dependencies = [ [[package]] name = "revolt-autumn" -version = "0.14.0" +version = "0.14.1" dependencies = [ "axum", "axum-macros", @@ -7205,7 +7205,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-channel", "async-tungstenite", @@ -7236,7 +7236,7 @@ dependencies = [ [[package]] name = "revolt-coalesced" -version = "0.14.0" +version = "0.14.1" dependencies = [ "indexmap 2.14.0", "lru", @@ -7245,7 +7245,7 @@ dependencies = [ [[package]] name = "revolt-config" -version = "0.14.0" +version = "0.14.1" dependencies = [ "cached", "config", @@ -7261,7 +7261,7 @@ dependencies = [ [[package]] name = "revolt-crond" -version = "0.14.0" +version = "0.14.1" dependencies = [ "futures", "futures-lite", @@ -7282,7 +7282,7 @@ dependencies = [ [[package]] name = "revolt-database" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-lock 2.8.0", "async-recursion", @@ -7340,7 +7340,7 @@ dependencies = [ [[package]] name = "revolt-delta" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-channel", "bitfield", @@ -7387,7 +7387,7 @@ dependencies = [ [[package]] name = "revolt-files" -version = "0.14.0" +version = "0.14.1" dependencies = [ "aes-gcm", "anyhow", @@ -7415,7 +7415,7 @@ dependencies = [ [[package]] name = "revolt-gifbox" -version = "0.14.0" +version = "0.14.1" dependencies = [ "axum", "axum-extra", @@ -7438,7 +7438,7 @@ dependencies = [ [[package]] name = "revolt-january" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-recursion", "axum", @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "revolt-models" -version = "0.14.0" +version = "0.14.1" dependencies = [ "indexmap 2.14.0", "iso8601-timestamp", @@ -7488,14 +7488,14 @@ dependencies = [ [[package]] name = "revolt-parser" -version = "0.14.0" +version = "0.14.1" dependencies = [ "logos", ] [[package]] name = "revolt-permissions" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-trait", "auto_ops", @@ -7510,7 +7510,7 @@ dependencies = [ [[package]] name = "revolt-presence" -version = "0.14.0" +version = "0.14.1" dependencies = [ "log", "once_cell", @@ -7522,7 +7522,7 @@ dependencies = [ [[package]] name = "revolt-pushd" -version = "0.14.0" +version = "0.14.1" dependencies = [ "anyhow", "async-trait", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "revolt-ratelimits" -version = "0.14.0" +version = "0.14.1" dependencies = [ "async-trait", "axum", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "revolt-result" -version = "0.14.0" +version = "0.14.1" dependencies = [ "axum", "log", @@ -7584,7 +7584,7 @@ dependencies = [ [[package]] name = "revolt-voice-ingress" -version = "0.14.0" +version = "0.14.1" dependencies = [ "chrono", "futures", diff --git a/Cargo.toml b/Cargo.toml index 59d32c50..185a6826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,13 +193,13 @@ futures-lite = "2.6.1" vergen = "7.5.0" # Local packages -revolt-coalesced = { version = "0.14.0", path = "crates/core/coalesced" } -revolt-config = { version = "0.14.0", path = "crates/core/config" } -revolt-database = { version = "0.14.0", path = "crates/core/database" } -revolt-files = { version = "0.14.0", path = "crates/core/files" } -revolt-models = { version = "0.14.0", path = "crates/core/models" } -revolt-parser = { version = "0.14.0", path = "crates/core/parser" } -revolt-permissions = { version = "0.14.0", path = "crates/core/permissions" } -revolt-presence = { version = "0.14.0", path = "crates/core/presence" } -revolt-ratelimits = { version = "0.14.0", path = "crates/core/ratelimits" } -revolt-result = { version = "0.14.0", path = "crates/core/result" } +revolt-coalesced = { version = "0.14.1", path = "crates/core/coalesced" } +revolt-config = { version = "0.14.1", path = "crates/core/config" } +revolt-database = { version = "0.14.1", path = "crates/core/database" } +revolt-files = { version = "0.14.1", path = "crates/core/files" } +revolt-models = { version = "0.14.1", path = "crates/core/models" } +revolt-parser = { version = "0.14.1", path = "crates/core/parser" } +revolt-permissions = { version = "0.14.1", path = "crates/core/permissions" } +revolt-presence = { version = "0.14.1", path = "crates/core/presence" } +revolt-ratelimits = { version = "0.14.1", path = "crates/core/ratelimits" } +revolt-result = { version = "0.14.1", path = "crates/core/result" } diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index 59286b71..fef1aa54 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.14.0" +version = "0.14.1" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/core/coalesced/Cargo.toml b/crates/core/coalesced/Cargo.toml index cb4c2ddf..ebe8ccff 100644 --- a/crates/core/coalesced/Cargo.toml +++ b/crates/core/coalesced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-coalesced" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Paul Makles ", "Zomatree "] diff --git a/crates/core/config/Cargo.toml b/crates/core/config/Cargo.toml index 0deabe1b..11ef0e2f 100644 --- a/crates/core/config/Cargo.toml +++ b/crates/core/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-config" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 03a5d4cd..0d2e92cd 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-database" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/files/Cargo.toml b/crates/core/files/Cargo.toml index 68171f06..34c250dd 100644 --- a/crates/core/files/Cargo.toml +++ b/crates/core/files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-files" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index eabe031a..24fee5b0 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-models" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/parser/Cargo.toml b/crates/core/parser/Cargo.toml index b673f65f..2eec2b18 100644 --- a/crates/core/parser/Cargo.toml +++ b/crates/core/parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-parser" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/permissions/Cargo.toml b/crates/core/permissions/Cargo.toml index f12275ba..12ee9739 100644 --- a/crates/core/permissions/Cargo.toml +++ b/crates/core/permissions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-permissions" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/presence/Cargo.toml b/crates/core/presence/Cargo.toml index 50a81652..d23025c0 100644 --- a/crates/core/presence/Cargo.toml +++ b/crates/core/presence/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-presence" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/ratelimits/Cargo.toml b/crates/core/ratelimits/Cargo.toml index dc4e05f9..1c877f15 100644 --- a/crates/core/ratelimits/Cargo.toml +++ b/crates/core/ratelimits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-ratelimits" -version = "0.14.0" +version = "0.14.1" edition = "2024" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index f7932463..8b861469 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-result" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/daemons/crond/Cargo.toml b/crates/daemons/crond/Cargo.toml index a24ae5d3..5946b55d 100644 --- a/crates/daemons/crond/Cargo.toml +++ b/crates/daemons/crond/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-crond" -version = "0.14.0" +version = "0.14.1" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2021" diff --git a/crates/daemons/pushd/Cargo.toml b/crates/daemons/pushd/Cargo.toml index fc0e2b9d..908959b0 100644 --- a/crates/daemons/pushd/Cargo.toml +++ b/crates/daemons/pushd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-pushd" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml index 977dafca..deece75c 100644 --- a/crates/daemons/voice-ingress/Cargo.toml +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-voice-ingress" -version = "0.14.0" +version = "0.14.1" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index e7230301..8fba4cb7 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.14.0" +version = "0.14.1" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index 4efda1e3..41e28efa 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-autumn" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/gifbox/Cargo.toml b/crates/services/gifbox/Cargo.toml index 3c66c07a..ef92fd5e 100644 --- a/crates/services/gifbox/Cargo.toml +++ b/crates/services/gifbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-gifbox" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/january/Cargo.toml b/crates/services/january/Cargo.toml index 77be89ed..da11a1c4 100644 --- a/crates/services/january/Cargo.toml +++ b/crates/services/january/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-january" -version = "0.14.0" +version = "0.14.1" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/version.txt b/version.txt index a803cc22..930e3000 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.14.0 +0.14.1 From 764a4dc81c8eab22dfea6a1723f8ffaef1de40bc Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 18 Jul 2026 15:15:59 -0700 Subject: [PATCH 14/24] fix: bonfire session deletion redis key is incorrect (#863) * fix: presence key was incorrect after other related update Signed-off-by: IAmTomahawkx * rust analyzer try not to be annoying challenge Signed-off-by: IAmTomahawkx * fix: delete session redis key is wrong Signed-off-by: IAmTomahawkx --------- Signed-off-by: IAmTomahawkx --- crates/core/presence/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/presence/src/lib.rs b/crates/core/presence/src/lib.rs index e80cf9fd..f84b6e4f 100644 --- a/crates/core/presence/src/lib.rs +++ b/crates/core/presence/src/lib.rs @@ -71,7 +71,7 @@ async fn delete_session_internal(user_id: &str, session_id: u32, skip_region: bo } // Return whether this was the last session - let is_empty = __get_set_size(&mut conn, user_id).await == 0; + let is_empty = __get_set_size(&mut conn, &format!("sessions:{user_id}")).await == 0; if is_empty { __remove_from_set_string(&mut conn, ONLINE_SET, user_id).await; info!("User ID {} just went offline.", &user_id); From 3f393ea24b9973294715256eba85045ba5947bd2 Mon Sep 17 00:00:00 2001 From: "stoat-release[bot]" <245062572+stoat-release[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:17:18 -0700 Subject: [PATCH 15/24] chore(main): release 0.14.2 (#864) * chore(main): release 0.14.2 * chore: update Cargo.lock Signed-off-by: github-actions[bot] --------- Signed-off-by: github-actions[bot] Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++ Cargo.lock | 36 ++++++++++++------------- Cargo.toml | 20 +++++++------- crates/bonfire/Cargo.toml | 2 +- crates/core/coalesced/Cargo.toml | 2 +- crates/core/config/Cargo.toml | 2 +- crates/core/database/Cargo.toml | 2 +- crates/core/files/Cargo.toml | 2 +- crates/core/models/Cargo.toml | 2 +- crates/core/parser/Cargo.toml | 2 +- crates/core/permissions/Cargo.toml | 2 +- crates/core/presence/Cargo.toml | 2 +- crates/core/ratelimits/Cargo.toml | 2 +- crates/core/result/Cargo.toml | 2 +- crates/daemons/crond/Cargo.toml | 2 +- crates/daemons/pushd/Cargo.toml | 2 +- crates/daemons/voice-ingress/Cargo.toml | 2 +- crates/delta/Cargo.toml | 2 +- crates/services/autumn/Cargo.toml | 2 +- crates/services/gifbox/Cargo.toml | 2 +- crates/services/january/Cargo.toml | 2 +- version.txt | 2 +- 23 files changed, 55 insertions(+), 48 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d9a79f43..b7c8c925 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.14.1" + ".": "0.14.2" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 910ed0ba..5e030205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.14.2](https://github.com/stoatchat/stoatchat/compare/v0.14.1...v0.14.2) (2026-07-18) + + +### Bug Fixes + +* bonfire session deletion redis key is incorrect ([#863](https://github.com/stoatchat/stoatchat/issues/863)) ([764a4dc](https://github.com/stoatchat/stoatchat/commit/764a4dc81c8eab22dfea6a1723f8ffaef1de40bc)) + ## [0.14.1](https://github.com/stoatchat/stoatchat/compare/v0.14.0...v0.14.1) (2026-07-18) diff --git a/Cargo.lock b/Cargo.lock index f4cd70bd..43026306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7164,7 +7164,7 @@ dependencies = [ [[package]] name = "revolt-autumn" -version = "0.14.1" +version = "0.14.2" dependencies = [ "axum", "axum-macros", @@ -7205,7 +7205,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-channel", "async-tungstenite", @@ -7236,7 +7236,7 @@ dependencies = [ [[package]] name = "revolt-coalesced" -version = "0.14.1" +version = "0.14.2" dependencies = [ "indexmap 2.14.0", "lru", @@ -7245,7 +7245,7 @@ dependencies = [ [[package]] name = "revolt-config" -version = "0.14.1" +version = "0.14.2" dependencies = [ "cached", "config", @@ -7261,7 +7261,7 @@ dependencies = [ [[package]] name = "revolt-crond" -version = "0.14.1" +version = "0.14.2" dependencies = [ "futures", "futures-lite", @@ -7282,7 +7282,7 @@ dependencies = [ [[package]] name = "revolt-database" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-lock 2.8.0", "async-recursion", @@ -7340,7 +7340,7 @@ dependencies = [ [[package]] name = "revolt-delta" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-channel", "bitfield", @@ -7387,7 +7387,7 @@ dependencies = [ [[package]] name = "revolt-files" -version = "0.14.1" +version = "0.14.2" dependencies = [ "aes-gcm", "anyhow", @@ -7415,7 +7415,7 @@ dependencies = [ [[package]] name = "revolt-gifbox" -version = "0.14.1" +version = "0.14.2" dependencies = [ "axum", "axum-extra", @@ -7438,7 +7438,7 @@ dependencies = [ [[package]] name = "revolt-january" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-recursion", "axum", @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "revolt-models" -version = "0.14.1" +version = "0.14.2" dependencies = [ "indexmap 2.14.0", "iso8601-timestamp", @@ -7488,14 +7488,14 @@ dependencies = [ [[package]] name = "revolt-parser" -version = "0.14.1" +version = "0.14.2" dependencies = [ "logos", ] [[package]] name = "revolt-permissions" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-trait", "auto_ops", @@ -7510,7 +7510,7 @@ dependencies = [ [[package]] name = "revolt-presence" -version = "0.14.1" +version = "0.14.2" dependencies = [ "log", "once_cell", @@ -7522,7 +7522,7 @@ dependencies = [ [[package]] name = "revolt-pushd" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "async-trait", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "revolt-ratelimits" -version = "0.14.1" +version = "0.14.2" dependencies = [ "async-trait", "axum", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "revolt-result" -version = "0.14.1" +version = "0.14.2" dependencies = [ "axum", "log", @@ -7584,7 +7584,7 @@ dependencies = [ [[package]] name = "revolt-voice-ingress" -version = "0.14.1" +version = "0.14.2" dependencies = [ "chrono", "futures", diff --git a/Cargo.toml b/Cargo.toml index 185a6826..0c636dcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,13 +193,13 @@ futures-lite = "2.6.1" vergen = "7.5.0" # Local packages -revolt-coalesced = { version = "0.14.1", path = "crates/core/coalesced" } -revolt-config = { version = "0.14.1", path = "crates/core/config" } -revolt-database = { version = "0.14.1", path = "crates/core/database" } -revolt-files = { version = "0.14.1", path = "crates/core/files" } -revolt-models = { version = "0.14.1", path = "crates/core/models" } -revolt-parser = { version = "0.14.1", path = "crates/core/parser" } -revolt-permissions = { version = "0.14.1", path = "crates/core/permissions" } -revolt-presence = { version = "0.14.1", path = "crates/core/presence" } -revolt-ratelimits = { version = "0.14.1", path = "crates/core/ratelimits" } -revolt-result = { version = "0.14.1", path = "crates/core/result" } +revolt-coalesced = { version = "0.14.2", path = "crates/core/coalesced" } +revolt-config = { version = "0.14.2", path = "crates/core/config" } +revolt-database = { version = "0.14.2", path = "crates/core/database" } +revolt-files = { version = "0.14.2", path = "crates/core/files" } +revolt-models = { version = "0.14.2", path = "crates/core/models" } +revolt-parser = { version = "0.14.2", path = "crates/core/parser" } +revolt-permissions = { version = "0.14.2", path = "crates/core/permissions" } +revolt-presence = { version = "0.14.2", path = "crates/core/presence" } +revolt-ratelimits = { version = "0.14.2", path = "crates/core/ratelimits" } +revolt-result = { version = "0.14.2", path = "crates/core/result" } diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index fef1aa54..2446a96e 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.14.1" +version = "0.14.2" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/core/coalesced/Cargo.toml b/crates/core/coalesced/Cargo.toml index ebe8ccff..415f861d 100644 --- a/crates/core/coalesced/Cargo.toml +++ b/crates/core/coalesced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-coalesced" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Paul Makles ", "Zomatree "] diff --git a/crates/core/config/Cargo.toml b/crates/core/config/Cargo.toml index 11ef0e2f..93c4c3f0 100644 --- a/crates/core/config/Cargo.toml +++ b/crates/core/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-config" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 0d2e92cd..ceb0c469 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-database" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/files/Cargo.toml b/crates/core/files/Cargo.toml index 34c250dd..ef3587b2 100644 --- a/crates/core/files/Cargo.toml +++ b/crates/core/files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-files" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index 24fee5b0..365a39c6 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-models" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/parser/Cargo.toml b/crates/core/parser/Cargo.toml index 2eec2b18..a8e2f6f2 100644 --- a/crates/core/parser/Cargo.toml +++ b/crates/core/parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-parser" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/permissions/Cargo.toml b/crates/core/permissions/Cargo.toml index 12ee9739..d6d0d664 100644 --- a/crates/core/permissions/Cargo.toml +++ b/crates/core/permissions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-permissions" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/presence/Cargo.toml b/crates/core/presence/Cargo.toml index d23025c0..52f948fe 100644 --- a/crates/core/presence/Cargo.toml +++ b/crates/core/presence/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-presence" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/ratelimits/Cargo.toml b/crates/core/ratelimits/Cargo.toml index 1c877f15..7e48be65 100644 --- a/crates/core/ratelimits/Cargo.toml +++ b/crates/core/ratelimits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-ratelimits" -version = "0.14.1" +version = "0.14.2" edition = "2024" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index 8b861469..8003c0e7 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-result" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/daemons/crond/Cargo.toml b/crates/daemons/crond/Cargo.toml index 5946b55d..956ca97b 100644 --- a/crates/daemons/crond/Cargo.toml +++ b/crates/daemons/crond/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-crond" -version = "0.14.1" +version = "0.14.2" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2021" diff --git a/crates/daemons/pushd/Cargo.toml b/crates/daemons/pushd/Cargo.toml index 908959b0..891c99b7 100644 --- a/crates/daemons/pushd/Cargo.toml +++ b/crates/daemons/pushd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-pushd" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml index deece75c..244c2268 100644 --- a/crates/daemons/voice-ingress/Cargo.toml +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-voice-ingress" -version = "0.14.1" +version = "0.14.2" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index 8fba4cb7..ab67ff90 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.14.1" +version = "0.14.2" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index 41e28efa..c2f25068 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-autumn" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/gifbox/Cargo.toml b/crates/services/gifbox/Cargo.toml index ef92fd5e..64928fe7 100644 --- a/crates/services/gifbox/Cargo.toml +++ b/crates/services/gifbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-gifbox" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/january/Cargo.toml b/crates/services/january/Cargo.toml index da11a1c4..9327cf50 100644 --- a/crates/services/january/Cargo.toml +++ b/crates/services/january/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-january" -version = "0.14.1" +version = "0.14.2" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/version.txt b/version.txt index 930e3000..e867cc2a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.14.1 +0.14.2 From 44c35bfb519143f1726f7eba9636a87dbef04800 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 21 Jul 2026 18:28:16 -0700 Subject: [PATCH 16/24] fix: increase cache size to hopefully deduplicate events (#865) * fix: presence key was incorrect after other related update Signed-off-by: IAmTomahawkx * rust analyzer try not to be annoying challenge Signed-off-by: IAmTomahawkx * fix: increase seen events cache and reenable typing events when disabling overload events Signed-off-by: IAmTomahawkx * add canary publisher and ability to lower job count to not OOM Signed-off-by: IAmTomahawkx --------- Signed-off-by: IAmTomahawkx --- .mise/tasks/docker/publish-canary | 59 ++++++++++++++++++++++++++++++ Dockerfile | 3 ++ Dockerfile.useCurrentArch | 3 ++ crates/bonfire/src/events/state.rs | 26 +++++++++---- crates/bonfire/src/websocket.rs | 30 +++++++-------- crates/core/config/Revolt.toml | 3 ++ crates/core/config/src/lib.rs | 3 ++ scripts/build-image-layer.sh | 10 +++-- 8 files changed, 109 insertions(+), 28 deletions(-) create mode 100755 .mise/tasks/docker/publish-canary diff --git a/.mise/tasks/docker/publish-canary b/.mise/tasks/docker/publish-canary new file mode 100755 index 00000000..26cb9065 --- /dev/null +++ b/.mise/tasks/docker/publish-canary @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +#MISE description="Build the API image and push it to the canary registry. Use CARGO_BUILD_JOBS= to lower the job count if this OOMs." +set -e + +IMAGE="registry.stoatinternal.com/stoat/backend-canary" +TAG=latest +PLATFORM=linux/amd64 + +# echo "Building base image for ${PLATFORM}..." +# docker buildx build --platform "${PLATFORM}" --load \ +# -t ghcr.io/stoatchat/base:latest -f Dockerfile \ +# --build-arg CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-10}" . + +echo "Building API image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-delta" - < crates/delta/Dockerfile + +echo "Building Bonfire image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-bonfire" - < crates/bonfire/Dockerfile + +echo "Building Autumn image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-autumn" - < crates/services/autumn/Dockerfile + +echo "Building Gifbox image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-gifbox" - < crates/services/gifbox/Dockerfile + +echo "Building January image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-january" - < crates/services/january/Dockerfile + +echo "Building Crond image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-crond" - < crates/daemons/crond/Dockerfile + +echo "Building Pushd image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-pushd" - < crates/daemons/pushd/Dockerfile + +echo "Building Voice-ingress image for ${PLATFORM}..." +docker buildx build --platform "${PLATFORM}" --load \ + -t "${IMAGE}:${TAG}-voice-ingress" - < crates/daemons/voice-ingress/Dockerfile + +echo "Logging in to registry.stoatinternal.com..." +docker login registry.stoatinternal.com \ + --username "$(op read "op://Employee/canary-robot/username")" \ + --password-stdin < <(op read "op://Employee/canary-robot/credential") + +echo "Pushing ${IMAGE}:${TAG}..." +docker push "${IMAGE}:${TAG}-delta" +docker push "${IMAGE}:${TAG}-bonfire" +docker push "${IMAGE}:${TAG}-autumn" +docker push "${IMAGE}:${TAG}-gifbox" +docker push "${IMAGE}:${TAG}-january" +docker push "${IMAGE}:${TAG}-crond" +docker push "${IMAGE}:${TAG}-pushd" +docker push "${IMAGE}:${TAG}-voice-ingress" diff --git a/Dockerfile b/Dockerfile index aca233ec..ec61859b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ WORKDIR /home/rust/src ARG TARGETARCH +ARG CARGO_BUILD_JOBS=10 +ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} + # Install build requirements RUN dpkg --add-architecture "${TARGETARCH}" RUN apt-get update && \ diff --git a/Dockerfile.useCurrentArch b/Dockerfile.useCurrentArch index 9bcce8ac..37ab8248 100644 --- a/Dockerfile.useCurrentArch +++ b/Dockerfile.useCurrentArch @@ -3,6 +3,9 @@ FROM rust:1.92.0-slim-bookworm USER 0:0 WORKDIR /home/rust/src +ARG CARGO_BUILD_JOBS=10 +ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS} + # Install build requirements RUN apt-get update && \ apt-get install -y \ diff --git a/crates/bonfire/src/events/state.rs b/crates/bonfire/src/events/state.rs index 09bef49e..bebc0c3a 100644 --- a/crates/bonfire/src/events/state.rs +++ b/crates/bonfire/src/events/state.rs @@ -1,11 +1,14 @@ use std::{ - collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration + collections::{HashMap, HashSet}, + num::NonZeroUsize, + sync::Arc, + time::Duration, }; -use tokio::sync::{Mutex, RwLock}; use lru::LruCache; use lru_time_cache::{LruCache as LruTimeCache, TimedEntry}; use revolt_database::{Channel, Member, Server, User}; +use tokio::sync::{Mutex, RwLock}; /// Enumeration representing some change in subscriptions pub enum SubscriptionStateChange { @@ -44,6 +47,16 @@ pub struct Cache { pub seen_events: LruCache, } +impl Cache { + fn with_events_size(user_id: Option, seen_events_size: NonZeroUsize) -> Self { + Self { + user_id: user_id.unwrap_or(String::default()), + seen_events: LruCache::new(seen_events_size), + ..Default::default() + } + } +} + impl Default for Cache { fn default() -> Self { Cache { @@ -55,7 +68,7 @@ impl Default for Cache { members: Default::default(), servers: Default::default(), - seen_events: LruCache::new(NonZeroUsize::new(20).unwrap()), + seen_events: LruCache::new(NonZeroUsize::new(2048).unwrap()), } } } @@ -74,16 +87,13 @@ pub struct State { impl State { /// Create state from User - pub fn from(user: User, session_id: String) -> State { + pub fn from(user: User, session_id: String, cache_size: NonZeroUsize) -> State { let mut subscribed = HashSet::new(); let private_topic = format!("{}!", user.id); subscribed.insert(private_topic.clone()); subscribed.insert(user.id.clone()); - let mut cache: Cache = Cache { - user_id: user.id.clone(), - ..Default::default() - }; + let mut cache: Cache = Cache::with_events_size(Some(user.id.clone()), cache_size); cache.users.insert(user.id.clone(), user); diff --git a/crates/bonfire/src/websocket.rs b/crates/bonfire/src/websocket.rs index 172cf74a..426f9f27 100644 --- a/crates/bonfire/src/websocket.rs +++ b/crates/bonfire/src/websocket.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, net::SocketAddr, sync::Arc}; +use std::{collections::HashSet, net::SocketAddr, num::NonZeroUsize, sync::Arc}; use async_tungstenite::WebSocketStream; use fred::{ @@ -21,21 +21,22 @@ use revolt_database::{ }; use revolt_presence::{create_session, delete_session}; +use revolt_result::create_error; +use sentry::Level; use tokio::{ net::TcpStream, sync::{Mutex, RwLock}, task::spawn, }; -use tokio_util::compat::{TokioAsyncReadCompatExt, Compat}; -use revolt_result::create_error; -use sentry::Level; +use tokio_util::compat::{Compat, TokioAsyncReadCompatExt}; use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback}; use crate::events::state::{State, SubscriptionStateChange}; use revolt_models::v0; type WsReader = SplitStream>>; -type WsWriter = SplitSink>, async_tungstenite::tungstenite::Message>; +type WsWriter = + SplitSink>, async_tungstenite::tungstenite::Message>; /// Start a new WebSocket client worker given access to the database, /// the relevant TCP stream and the remote address of the client. @@ -106,8 +107,15 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr) .await .ok(); + let backend_config = revolt_config::config().await; + // Create local state. - let mut state = State::from(user, session_id); + let mut state = State::from( + user, + session_id, + NonZeroUsize::new(backend_config.features.advanced.seen_events_cache_size as usize) + .expect("config.features.advanced.seen_events_cache_size cannot be 0!"), + ); let user_id = state.cache.user_id.clone(); // Notify socket we have authenticated. @@ -440,8 +448,6 @@ async fn worker( mut read: WsReader, write: &Mutex, ) { - let revolt_config = revolt_config::config().await; - loop { let t1 = read.try_next().fuse(); let t2 = kill_signal_r.recv().fuse(); @@ -478,10 +484,6 @@ async fn worker( match payload { ClientMessage::BeginTyping { channel } => { - if revolt_config.disable_events_dont_use { - continue; - } - if !subscribed.read().await.contains(&channel) { continue; } @@ -494,10 +496,6 @@ async fn worker( .await; } ClientMessage::EndTyping { channel } => { - if revolt_config.disable_events_dont_use { - continue; - } - if !subscribed.read().await.contains(&channel) { continue; } diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index 0861fd3e..15bc483c 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -333,6 +333,9 @@ emojis = 500_000 # The max amount of messages the rabbitmq provider/db mention adder job will delay for before forcing handling of a channel. # default: 5 process_message_delay_limit = 5 +# How many event ids to cache per bonfire connections. +# Higher numbers result in more deduplication but more ram usage. +seen_events_cache_size = 2048 [features.legal_links] # URLs for legal documents diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 41b5e3dd..6eccbf59 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -429,12 +429,15 @@ pub struct LegalLinks { pub struct FeaturesAdvanced { #[serde(default)] pub process_message_delay_limit: u16, + #[serde(default)] + pub seen_events_cache_size: u32, } impl Default for FeaturesAdvanced { fn default() -> Self { Self { process_message_delay_limit: 5, + seen_events_cache_size: 20, } } } diff --git a/scripts/build-image-layer.sh b/scripts/build-image-layer.sh index 52170fdd..26f8d54d 100644 --- a/scripts/build-image-layer.sh +++ b/scripts/build-image-layer.sh @@ -1,4 +1,6 @@ #!/bin/sh +# If you're having trouble building this locally or on your CI, try lowering +# the job count via CARGO_BUILD_JOBS. It defaults to 10. if [ -z "$TARGETARCH" ]; then : @@ -62,9 +64,9 @@ deps() { tee crates/core/ratelimits/src/lib.rs if [ -z "$TARGETARCH" ]; then - cargo build -j 10 --locked --release + cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release else - cargo build -j 10 --locked --release --target "${BUILD_TARGET}" + cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release --target "${BUILD_TARGET}" fi } @@ -86,9 +88,9 @@ apps() { crates/core/ratelimits/src/lib.rs if [ -z "$TARGETARCH" ]; then - cargo build -j 10 --locked --release + cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release else - cargo build -j 10 --locked --release --target "${BUILD_TARGET}" + cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release --target "${BUILD_TARGET}" mv target _target && mv _target/"${BUILD_TARGET}" target fi } From d5ca4a0fd6e6bdce0126e790a010fe0032d9b556 Mon Sep 17 00:00:00 2001 From: "stoat-release[bot]" <245062572+stoat-release[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:10:35 -0700 Subject: [PATCH 17/24] chore(main): release 0.14.3 (#867) * chore(main): release 0.14.3 * chore: update Cargo.lock Signed-off-by: github-actions[bot] --------- Signed-off-by: github-actions[bot] Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++ Cargo.lock | 36 ++++++++++++------------- Cargo.toml | 20 +++++++------- crates/bonfire/Cargo.toml | 2 +- crates/core/coalesced/Cargo.toml | 2 +- crates/core/config/Cargo.toml | 2 +- crates/core/database/Cargo.toml | 2 +- crates/core/files/Cargo.toml | 2 +- crates/core/models/Cargo.toml | 2 +- crates/core/parser/Cargo.toml | 2 +- crates/core/permissions/Cargo.toml | 2 +- crates/core/presence/Cargo.toml | 2 +- crates/core/ratelimits/Cargo.toml | 2 +- crates/core/result/Cargo.toml | 2 +- crates/daemons/crond/Cargo.toml | 2 +- crates/daemons/pushd/Cargo.toml | 2 +- crates/daemons/voice-ingress/Cargo.toml | 2 +- crates/delta/Cargo.toml | 2 +- crates/services/autumn/Cargo.toml | 2 +- crates/services/gifbox/Cargo.toml | 2 +- crates/services/january/Cargo.toml | 2 +- version.txt | 2 +- 23 files changed, 55 insertions(+), 48 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b7c8c925..9991f1fd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.14.2" + ".": "0.14.3" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e030205..425202cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.14.3](https://github.com/stoatchat/stoatchat/compare/v0.14.2...v0.14.3) (2026-07-22) + + +### Bug Fixes + +* increase cache size to hopefully deduplicate events ([#865](https://github.com/stoatchat/stoatchat/issues/865)) ([44c35bf](https://github.com/stoatchat/stoatchat/commit/44c35bfb519143f1726f7eba9636a87dbef04800)) + ## [0.14.2](https://github.com/stoatchat/stoatchat/compare/v0.14.1...v0.14.2) (2026-07-18) diff --git a/Cargo.lock b/Cargo.lock index 43026306..2ee99478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7164,7 +7164,7 @@ dependencies = [ [[package]] name = "revolt-autumn" -version = "0.14.2" +version = "0.14.3" dependencies = [ "axum", "axum-macros", @@ -7205,7 +7205,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-channel", "async-tungstenite", @@ -7236,7 +7236,7 @@ dependencies = [ [[package]] name = "revolt-coalesced" -version = "0.14.2" +version = "0.14.3" dependencies = [ "indexmap 2.14.0", "lru", @@ -7245,7 +7245,7 @@ dependencies = [ [[package]] name = "revolt-config" -version = "0.14.2" +version = "0.14.3" dependencies = [ "cached", "config", @@ -7261,7 +7261,7 @@ dependencies = [ [[package]] name = "revolt-crond" -version = "0.14.2" +version = "0.14.3" dependencies = [ "futures", "futures-lite", @@ -7282,7 +7282,7 @@ dependencies = [ [[package]] name = "revolt-database" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-lock 2.8.0", "async-recursion", @@ -7340,7 +7340,7 @@ dependencies = [ [[package]] name = "revolt-delta" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-channel", "bitfield", @@ -7387,7 +7387,7 @@ dependencies = [ [[package]] name = "revolt-files" -version = "0.14.2" +version = "0.14.3" dependencies = [ "aes-gcm", "anyhow", @@ -7415,7 +7415,7 @@ dependencies = [ [[package]] name = "revolt-gifbox" -version = "0.14.2" +version = "0.14.3" dependencies = [ "axum", "axum-extra", @@ -7438,7 +7438,7 @@ dependencies = [ [[package]] name = "revolt-january" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-recursion", "axum", @@ -7468,7 +7468,7 @@ dependencies = [ [[package]] name = "revolt-models" -version = "0.14.2" +version = "0.14.3" dependencies = [ "indexmap 2.14.0", "iso8601-timestamp", @@ -7488,14 +7488,14 @@ dependencies = [ [[package]] name = "revolt-parser" -version = "0.14.2" +version = "0.14.3" dependencies = [ "logos", ] [[package]] name = "revolt-permissions" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-trait", "auto_ops", @@ -7510,7 +7510,7 @@ dependencies = [ [[package]] name = "revolt-presence" -version = "0.14.2" +version = "0.14.3" dependencies = [ "log", "once_cell", @@ -7522,7 +7522,7 @@ dependencies = [ [[package]] name = "revolt-pushd" -version = "0.14.2" +version = "0.14.3" dependencies = [ "anyhow", "async-trait", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "revolt-ratelimits" -version = "0.14.2" +version = "0.14.3" dependencies = [ "async-trait", "axum", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "revolt-result" -version = "0.14.2" +version = "0.14.3" dependencies = [ "axum", "log", @@ -7584,7 +7584,7 @@ dependencies = [ [[package]] name = "revolt-voice-ingress" -version = "0.14.2" +version = "0.14.3" dependencies = [ "chrono", "futures", diff --git a/Cargo.toml b/Cargo.toml index 0c636dcc..7547c893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -193,13 +193,13 @@ futures-lite = "2.6.1" vergen = "7.5.0" # Local packages -revolt-coalesced = { version = "0.14.2", path = "crates/core/coalesced" } -revolt-config = { version = "0.14.2", path = "crates/core/config" } -revolt-database = { version = "0.14.2", path = "crates/core/database" } -revolt-files = { version = "0.14.2", path = "crates/core/files" } -revolt-models = { version = "0.14.2", path = "crates/core/models" } -revolt-parser = { version = "0.14.2", path = "crates/core/parser" } -revolt-permissions = { version = "0.14.2", path = "crates/core/permissions" } -revolt-presence = { version = "0.14.2", path = "crates/core/presence" } -revolt-ratelimits = { version = "0.14.2", path = "crates/core/ratelimits" } -revolt-result = { version = "0.14.2", path = "crates/core/result" } +revolt-coalesced = { version = "0.14.3", path = "crates/core/coalesced" } +revolt-config = { version = "0.14.3", path = "crates/core/config" } +revolt-database = { version = "0.14.3", path = "crates/core/database" } +revolt-files = { version = "0.14.3", path = "crates/core/files" } +revolt-models = { version = "0.14.3", path = "crates/core/models" } +revolt-parser = { version = "0.14.3", path = "crates/core/parser" } +revolt-permissions = { version = "0.14.3", path = "crates/core/permissions" } +revolt-presence = { version = "0.14.3", path = "crates/core/presence" } +revolt-ratelimits = { version = "0.14.3", path = "crates/core/ratelimits" } +revolt-result = { version = "0.14.3", path = "crates/core/result" } diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index 2446a96e..f2aed928 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.14.2" +version = "0.14.3" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/core/coalesced/Cargo.toml b/crates/core/coalesced/Cargo.toml index 415f861d..3ac531b7 100644 --- a/crates/core/coalesced/Cargo.toml +++ b/crates/core/coalesced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-coalesced" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Paul Makles ", "Zomatree "] diff --git a/crates/core/config/Cargo.toml b/crates/core/config/Cargo.toml index 93c4c3f0..1388467b 100644 --- a/crates/core/config/Cargo.toml +++ b/crates/core/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-config" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index ceb0c469..55368338 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-database" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/files/Cargo.toml b/crates/core/files/Cargo.toml index ef3587b2..bb86d942 100644 --- a/crates/core/files/Cargo.toml +++ b/crates/core/files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-files" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index 365a39c6..2742e90b 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-models" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/parser/Cargo.toml b/crates/core/parser/Cargo.toml index a8e2f6f2..3891cb39 100644 --- a/crates/core/parser/Cargo.toml +++ b/crates/core/parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-parser" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/permissions/Cargo.toml b/crates/core/permissions/Cargo.toml index d6d0d664..dd8e9bc1 100644 --- a/crates/core/permissions/Cargo.toml +++ b/crates/core/permissions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-permissions" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/presence/Cargo.toml b/crates/core/presence/Cargo.toml index 52f948fe..ec73dcd1 100644 --- a/crates/core/presence/Cargo.toml +++ b/crates/core/presence/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-presence" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] diff --git a/crates/core/ratelimits/Cargo.toml b/crates/core/ratelimits/Cargo.toml index 7e48be65..4703eba9 100644 --- a/crates/core/ratelimits/Cargo.toml +++ b/crates/core/ratelimits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-ratelimits" -version = "0.14.2" +version = "0.14.3" edition = "2024" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index 8003c0e7..e05b8e6e 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-result" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/daemons/crond/Cargo.toml b/crates/daemons/crond/Cargo.toml index 956ca97b..dfaf67c8 100644 --- a/crates/daemons/crond/Cargo.toml +++ b/crates/daemons/crond/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-crond" -version = "0.14.2" +version = "0.14.3" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2021" diff --git a/crates/daemons/pushd/Cargo.toml b/crates/daemons/pushd/Cargo.toml index 891c99b7..f00b72b1 100644 --- a/crates/daemons/pushd/Cargo.toml +++ b/crates/daemons/pushd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-pushd" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml index 244c2268..3fec8abc 100644 --- a/crates/daemons/voice-ingress/Cargo.toml +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-voice-ingress" -version = "0.14.2" +version = "0.14.3" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index ab67ff90..a2b085cf 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.14.2" +version = "0.14.3" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index c2f25068..a1ad24ba 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-autumn" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/gifbox/Cargo.toml b/crates/services/gifbox/Cargo.toml index 64928fe7..f2e1f94a 100644 --- a/crates/services/gifbox/Cargo.toml +++ b/crates/services/gifbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-gifbox" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/crates/services/january/Cargo.toml b/crates/services/january/Cargo.toml index 9327cf50..2cc6897b 100644 --- a/crates/services/january/Cargo.toml +++ b/crates/services/january/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-january" -version = "0.14.2" +version = "0.14.3" edition = "2021" license = "AGPL-3.0-or-later" publish = false diff --git a/version.txt b/version.txt index e867cc2a..ac4a7962 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.14.2 +0.14.3 From 457c7709c75060cd8519cc45df3badcfc1b629ea Mon Sep 17 00:00:00 2001 From: Zomatree Date: Thu, 30 Jul 2026 23:02:04 +0100 Subject: [PATCH 18/24] feat: add call event (#873) Signed-off-by: Zomatree --- crates/core/database/src/events/client.rs | 7 ++ crates/daemons/voice-ingress/src/api.rs | 96 +++++++++++++++++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/crates/core/database/src/events/client.rs b/crates/core/database/src/events/client.rs index a55e7171..3e295221 100644 --- a/crates/core/database/src/events/client.rs +++ b/crates/core/database/src/events/client.rs @@ -1,3 +1,4 @@ +use iso8601_timestamp::Timestamp; use revolt_result::Error; use serde::{Deserialize, Serialize}; @@ -373,6 +374,12 @@ pub enum EventV1 { UserSlowmodes { slowmodes: Vec, }, + VoiceCallUpdate { + initiator_id: String, + channel_id: String, + started_at: Option, + ended: bool, + } } impl EventV1 { diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs index 70bd9f67..7010c58e 100644 --- a/crates/daemons/voice-ingress/src/api.rs +++ b/crates/daemons/voice-ingress/src/api.rs @@ -1,9 +1,17 @@ use livekit_api::{access_token::TokenVerifier, webhooks::WebhookReceiver}; use livekit_protocol::TrackType; use revolt_database::{ - AMQP, Database, PartialMessage, SystemMessage, events::client::EventV1, iso8601_timestamp::{Duration, Timestamp}, util::reference::Reference, voice::{ - RoomMetadata, UserVoiceChannel, VoiceClient, create_voice_state, delete_channel_voice_state, delete_voice_state, get_call_notification_recipients, get_user_moved_from_voice, get_user_moved_to_voice, get_voice_channel_members, set_channel_call_started_system_message, take_channel_call_started_system_message, update_voice_state_tracks - } + events::client::EventV1, + iso8601_timestamp::{Duration, Timestamp}, + util::reference::Reference, + voice::{ + create_voice_state, delete_channel_voice_state, delete_voice_state, + get_call_notification_recipients, get_user_moved_from_voice, get_user_moved_to_voice, + get_voice_channel_members, set_channel_call_started_system_message, + take_channel_call_started_system_message, update_voice_state_tracks, RoomMetadata, + UserVoiceChannel, VoiceClient, + }, + Channel, Database, PartialMessage, SystemMessage, AMQP, }; use revolt_models::v0; use revolt_result::{Result, ToRevoltError}; @@ -48,7 +56,7 @@ pub async fn ingress( let channel_id = event.room.as_ref().map(|r| &r.name); let user_id = event.participant.as_ref().map(|r| &r.identity); let room_metadata = if let Some(room) = event.room.as_ref() { - Some(serde_json::from_str::(&room.metadata).to_internal_error()?) + serde_json::from_str::(&room.metadata).ok() } else { None }; @@ -129,14 +137,52 @@ pub async fn ingress( ) .await?; - let recipients = get_call_notification_recipients(channel_id, user_id).await?; - let now = joined_at.format_short().to_string(); - - if let Err(e) = amqp - .dm_call_updated(&user.id, channel_id, Some(&now), false, recipients) - .await + if let Channel::DirectMessage { recipients, .. } + | Channel::Group { recipients, .. } = channel { - revolt_config::capture_error(&e); + let call_recipients = + get_call_notification_recipients(channel_id, user_id).await?; + + { + let call_recipients = if let Some(user_recipients) = call_recipients.clone() + { + user_recipients + .into_iter() + .filter(|user_id| { + recipients.contains(user_id) && user_id != &user.id + }) + .collect::>() + } else { + recipients + .into_iter() + .filter(|user_id| user_id != &user.id) + .collect() + }; + + for recipient in call_recipients { + EventV1::VoiceCallUpdate { + initiator_id: user.id.clone(), + channel_id: channel_id.clone(), + started_at: Some(joined_at), + ended: false, + } + .private(recipient) + .await + } + } + + if let Err(e) = amqp + .dm_call_updated( + &user.id, + channel_id, + Some(&joined_at.format_short()), + false, + call_recipients, + ) + .await + { + revolt_config::capture_error(&e); + } } } } @@ -169,12 +215,28 @@ pub async fn ingress( let members = get_voice_channel_members(&voice_channel).await?; if members.is_none_or(|m| m.is_empty()) { - // The channel is empty so send out an "end" message for ringing - if let Err(e) = amqp - .dm_call_updated(user_id, channel_id, None, true, None) - .await - { - revolt_config::capture_internal_error!(&e); + let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + + // The channel is empty so send out an "end" notification for ringing + if matches!( + channel, + Channel::DirectMessage { .. } | Channel::Group { .. } + ) { + EventV1::VoiceCallUpdate { + initiator_id: user_id.clone(), + channel_id: channel_id.clone(), + started_at: None, + ended: true, + } + .p(channel_id.clone()) + .await; + + if let Err(e) = amqp + .dm_call_updated(user_id, channel_id, None, true, None) + .await + { + revolt_config::capture_internal_error!(&e); + } } if let Some(system_message_id) = From 03694512b90be90367299c3ebfb072ebbc8a681d Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 1 Aug 2026 18:30:42 -0700 Subject: [PATCH 19/24] fix: rewrite youtube embedder to use youtube oembed (#878) * fix: rewrite youtube embedder to use youtube oembed Signed-off-by: IAmTomahawkx * chore: remove extra fields from oembed Signed-off-by: IAmTomahawkx --------- Signed-off-by: IAmTomahawkx --- crates/core/models/src/v0/embeds.rs | 2 + crates/services/january/src/main.rs | 1 + crates/services/january/src/requests.rs | 24 +++++++- crates/services/january/src/specialty.rs | 62 ++++++++++++++++++++ crates/services/january/src/website_embed.rs | 39 ------------ 5 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 crates/services/january/src/specialty.rs diff --git a/crates/core/models/src/v0/embeds.rs b/crates/core/models/src/v0/embeds.rs index 4944afae..d1ec9eb8 100644 --- a/crates/core/models/src/v0/embeds.rs +++ b/crates/core/models/src/v0/embeds.rs @@ -61,6 +61,8 @@ auto_derived!( /// YouTube video YouTube { id: String, + creator_name: Option, + creator_url: Option, #[serde(skip_serializing_if = "Option::is_none")] timestamp: Option, diff --git a/crates/services/january/src/main.rs b/crates/services/january/src/main.rs index e203ce28..1d287a94 100644 --- a/crates/services/january/src/main.rs +++ b/crates/services/january/src/main.rs @@ -11,6 +11,7 @@ use utoipa_scalar::{Scalar, Servable as ScalarServable}; mod api; pub mod requests; +pub mod specialty; pub mod website_embed; #[tokio::main] diff --git a/crates/services/january/src/requests.rs b/crates/services/january/src/requests.rs index 5566e326..7ee44cc6 100644 --- a/crates/services/january/src/requests.rs +++ b/crates/services/january/src/requests.rs @@ -20,6 +20,8 @@ use std::{ }; use url::{Host, Url}; +use crate::specialty; + lazy_static! { /// Request client static ref CLIENT: Client = reqwest::Client::builder() @@ -37,7 +39,13 @@ lazy_static! { static ref RE_URL_NEW_REDDIT: Regex = Regex::new("^(?:(?:new\\.|www\\.)?reddit).com").expect("valid regex"); /// Regex for matching YouTube Shorts URLs - static ref RE_URL_YOUTUBE_SHORTS: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www\\.)?youtube\\.com)/shorts/([a-zA-Z0-9_-]+)").expect("valid regex"); + pub static ref RE_URL_YOUTUBE_SHORTS: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www\\.)?youtube\\.com)/shorts/([a-zA-Z0-9_-]+)").expect("valid regex"); + + /// Regex for matching YouTube URLs + pub static ref RE_URL_YOUTUBE: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www|m)\\.)?(?:(?:youtube\\.com|youtu\\.be))(?:/(?:[\\w\\-]+\\?v=|embed/|v/|shorts/)?)([\\w\\-]+)(?:(?:&t|&start)=([\\d]+))?(?:\\S+)?$").unwrap(); + + /// Url for YouTube oembed + pub static ref OEMBED_URL: Url = Url::parse("https://www.youtube.com/oembed").unwrap(); /// Cache for proxy results static ref PROXY_CACHE: moka::future::Cache)>> = moka::future::Cache::builder() @@ -130,8 +138,8 @@ impl reqwest::dns::Resolve for CachedDnsResolver { /// Information about a successful request pub struct Request { - response: Response, - mime: Mime, + pub response: Response, + pub mime: Mime, } impl Request { @@ -285,6 +293,16 @@ impl Request { // Generate the actual embed if let Some(hit) = EMBED_CACHE.get(&url).await { Ok(hit) + } else if RE_URL_YOUTUBE.is_match(&url) { + let mut yt_url = OEMBED_URL.clone(); + yt_url.set_query(Some(&format!("url={url}"))); + + let request = Request::new(yt_url).await?; + let embed = specialty::SpecialtySitesGenerator::youtube(&url, request).await?; + + EMBED_CACHE.insert(url.to_owned(), embed.clone()).await; + + Ok(embed) } else { let request = Request::new_from_str(&url).await?; let embed = match (request.mime.type_(), request.mime.subtype()) { diff --git a/crates/services/january/src/specialty.rs b/crates/services/january/src/specialty.rs new file mode 100644 index 00000000..6f830301 --- /dev/null +++ b/crates/services/january/src/specialty.rs @@ -0,0 +1,62 @@ +use revolt_models::v0::{Embed, Special, WebsiteMetadata}; +use revolt_result::{create_error, Result}; +use serde::Deserialize; + +use crate::requests::{Request, RE_URL_YOUTUBE}; +pub struct SpecialtySitesGenerator {} + +#[allow(unused)] +#[derive(Debug, Deserialize)] +struct YoutubeOEmbed { + pub title: String, + pub author_name: String, + pub author_url: String, + // #[serde(rename = "type")] + // pub kind: String, + // pub height: u32, + // pub width: u32, + // pub version: String, + pub provider_name: String, + // pub provider_url: String, + // pub thumbnail_height: u32, + // pub thumbnail_width: u32, + pub thumbnail_url: String, + // pub html: String, +} + +impl SpecialtySitesGenerator { + pub async fn youtube(url: &str, request: Request) -> Result { + let json: YoutubeOEmbed = request + .response + .json() + .await + .map_err(|_| create_error!(ProxyError))?; + + let captures = RE_URL_YOUTUBE + .captures(url) + .ok_or_else(|| create_error!(ProxyError))?; + let id = captures[1].to_string(); + let timestamp = captures + .get(2) + .map(|e| Some(e.as_str().to_string())) + .unwrap_or(None); + + Ok(Embed::Website(WebsiteMetadata { + url: Some(url.to_string()), + original_url: None, + special: Some(Special::YouTube { + id, + timestamp, + creator_name: Some(json.author_name), + creator_url: Some(json.author_url), + }), + title: Some(json.title), + description: None, + image: None, + video: None, + site_name: Some(json.provider_name), + icon_url: Some(json.thumbnail_url), + colour: None, + })) + } +} diff --git a/crates/services/january/src/website_embed.rs b/crates/services/january/src/website_embed.rs index 0e025b73..b4c51694 100644 --- a/crates/services/january/src/website_embed.rs +++ b/crates/services/january/src/website_embed.rs @@ -219,45 +219,6 @@ pub async fn populate_special(original_url: String, metadata: &mut WebsiteMetada Some(Special::Streamable { id: captures[1].to_string(), }) - } else if let Some(captures) = RE_YOUTUBE.captures_iter(url).next() { - let id = captures[1].to_string(); - - lazy_static! { - static ref RE_TIMESTAMP: Regex = Regex::new("(?:\\?|&)(?:t|start)=([\\w]+)").unwrap(); - } - - // YouTube now blocks datacentre IPs from fetching information - // This is a fallback to prevent the embed from looking weird - if metadata.video.is_none() { - metadata.title.replace("YouTube".to_owned()); - metadata.description.take(); - metadata.colour.take(); - metadata.icon_url.take(); - metadata.site_name.take(); - - // Verify the video exists - if !crate::requests::Request::exists_from_str(&format!( - "http://img.youtube.com/vi/{}/sddefault.jpg", - id - )) - .await - .unwrap_or(false) - { - return; - } - } - - if let Some(timestamp_captures) = RE_TIMESTAMP.captures_iter(url).next() { - Some(Special::YouTube { - id, - timestamp: Some(timestamp_captures[1].to_string()), - }) - } else { - Some(Special::YouTube { - id, - timestamp: None, - }) - } } else if let Some(captures) = RE_LIGHTSPEED.captures_iter(url).next() { Some(Special::Lightspeed { id: captures[1].to_string(), From 8ecfd2c2754f2efd0d52a0164b71ffb7dc5d4963 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 1 Aug 2026 18:31:10 -0700 Subject: [PATCH 20/24] chore: release the bees (#879) Signed-off-by: IAmTomahawkx --- crates/core/database/src/events/client.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/core/database/src/events/client.rs b/crates/core/database/src/events/client.rs index 3e295221..2e388b54 100644 --- a/crates/core/database/src/events/client.rs +++ b/crates/core/database/src/events/client.rs @@ -96,6 +96,10 @@ pub enum EventV1 { Pong { data: Ping, }, + + /// Release the bees. + Bees, + /// New message Message(Message), @@ -379,7 +383,7 @@ pub enum EventV1 { channel_id: String, started_at: Option, ended: bool, - } + }, } impl EventV1 { From 76e07a85e0d314b7899c026f04775cea3c5e5552 Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 1 Aug 2026 18:49:22 -0700 Subject: [PATCH 21/24] Merge commit from fork Signed-off-by: Zomatree Co-authored-by: Zomatree --- .../core/files/src/implementation/media_impl.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/core/files/src/implementation/media_impl.rs b/crates/core/files/src/implementation/media_impl.rs index 7a7fad84..a316ec21 100644 --- a/crates/core/files/src/implementation/media_impl.rs +++ b/crates/core/files/src/implementation/media_impl.rs @@ -2,6 +2,7 @@ use anyhow::Result; use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader}; use jxl_oxide::integration::JxlDecoder; use revolt_config::report_internal_error; +use usvg::Transform; use std::io::{BufRead, Read, Seek}; use tempfile::NamedTempFile; use tiny_skia::Pixmap; @@ -104,18 +105,25 @@ impl MediaRepository for MediaImpl { .map_err(|e| MediaError::from(anyhow::anyhow!(e)))?; let size = tree.size(); - let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32) + + // scale down svg to a max of 500x500 + let scale = (500. / size.width()).min(500. / size.height()).min(1.); + + let width = size.width() * scale; + let height = size.height() * scale; + + let mut pixmap = Pixmap::new(width as u32, height as u32) .ok_or_else(|| MediaError::ImageProcessingFailed { cause: "failed to create Pixmap, likely zero sized".to_string(), })?; let mut pixmap_mut = pixmap.as_mut(); - resvg::render(&tree, Default::default(), &mut pixmap_mut); + resvg::render(&tree, Transform::from_scale(scale, scale), &mut pixmap_mut); Ok(DynamicImage::ImageRgba8( ImageBuffer::from_vec( - size.width() as u32, - size.height() as u32, + width as u32, + height as u32, pixmap.data().to_vec(), ) .ok_or_else(|| MediaError::ImageProcessingFailed { From b0698a6b78def409e0f96738baa0c630f496357b Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 1 Aug 2026 19:38:22 -0700 Subject: [PATCH 22/24] Merge commit from fork Signed-off-by: IAmTomahawkx --- crates/delta/src/routes/channels/message_fetch.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/delta/src/routes/channels/message_fetch.rs b/crates/delta/src/routes/channels/message_fetch.rs index 8f09d47f..432af4b6 100644 --- a/crates/delta/src/routes/channels/message_fetch.rs +++ b/crates/delta/src/routes/channels/message_fetch.rs @@ -20,9 +20,10 @@ pub async fn fetch( ) -> Result> { 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::ViewChannel)?; + let perms = calculate_channel_permissions(&mut query).await; + + perms.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?; + perms.throw_if_lacking_channel_permission(ChannelPermission::ReadMessageHistory)?; let message = msg.as_message(db).await?; if message.channel != channel.id() { From 4283c0b33b5a9fa8ed8661e966102db70311fdee Mon Sep 17 00:00:00 2001 From: Tom Date: Sat, 1 Aug 2026 19:46:56 -0700 Subject: [PATCH 23/24] Merge commit from fork Signed-off-by: IAmTomahawkx --- crates/services/january/src/requests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/services/january/src/requests.rs b/crates/services/january/src/requests.rs index 7ee44cc6..e5694433 100644 --- a/crates/services/january/src/requests.rs +++ b/crates/services/january/src/requests.rs @@ -82,7 +82,9 @@ lazy_static! { "172.16.0.0/12", "169.254.0.0/16", "::1", + "::", "fc00::/7", + "fc00::/10" ] ).unwrap(); } From fa74826eeacc23d92a0ab963fecc96324793c910 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 2 Aug 2026 10:52:25 -0700 Subject: [PATCH 24/24] Merge commit from fork * fix: check membership before subscribing to servers Signed-off-by: IAmTomahawkx * remove active_server when leaving a server. Signed-off-by: IAmTomahawkx --------- Signed-off-by: IAmTomahawkx --- crates/bonfire/src/events/impl.rs | 1 + crates/bonfire/src/events/state.rs | 15 +++++++++++++++ crates/bonfire/src/websocket.rs | 18 ++++++++++++------ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/bonfire/src/events/impl.rs b/crates/bonfire/src/events/impl.rs index 96a0f2ab..84a4cdc4 100644 --- a/crates/bonfire/src/events/impl.rs +++ b/crates/bonfire/src/events/impl.rs @@ -567,6 +567,7 @@ impl State { EventV1::ServerMemberLeave { id, user, .. } => { if user == &self.cache.user_id { self.remove_subscription(id).await; + self.remove_active_server(id).await; if let Some(server) = self.cache.servers.remove(id) { for channel in &server.channels { diff --git a/crates/bonfire/src/events/state.rs b/crates/bonfire/src/events/state.rs index bebc0c3a..c65e8ff0 100644 --- a/crates/bonfire/src/events/state.rs +++ b/crates/bonfire/src/events/state.rs @@ -216,4 +216,19 @@ impl State { subscribed.remove(subscription); } + + // Remove a server from the active server state. + pub async fn remove_active_server(&mut self, server_id: &str) -> Option<()> { + let removed = { + let mut lock = self.active_servers.lock().await; + lock.remove(server_id).is_some() + }; + + if removed { + self.remove_subscription(&format!("{server_id}u")).await; + Some(()) + } else { + None + } + } } diff --git a/crates/bonfire/src/websocket.rs b/crates/bonfire/src/websocket.rs index 426f9f27..381ad906 100644 --- a/crates/bonfire/src/websocket.rs +++ b/crates/bonfire/src/websocket.rs @@ -189,6 +189,7 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr) read, &write, kill_signal_1_s, + db, ); join!(listener, worker); @@ -420,6 +421,7 @@ async fn worker_with_kill_signal( read: WsReader, write: &Mutex, kill_signal_s: async_channel::Sender<()>, + db: &Database, ) { worker( addr, @@ -431,6 +433,7 @@ async fn worker_with_kill_signal( kill_signal_r, read, write, + db, ) .await; kill_signal_s.send(()).await.ok(); @@ -447,6 +450,7 @@ async fn worker( kill_signal_r: async_channel::Receiver<()>, mut read: WsReader, write: &Mutex, + db: &Database, ) { loop { let t1 = read.try_next().fuse(); @@ -508,13 +512,15 @@ async fn worker( .await; } ClientMessage::Subscribe { server_id } => { - let mut servers = active_servers.lock().await; - let has_item = servers.contains_key(&server_id); - servers.insert(server_id, ()); + if db.fetch_member(&server_id, &user_id).await.is_ok() { + let mut servers = active_servers.lock().await; + let has_item = servers.contains_key(&server_id); + servers.insert(server_id, ()); - if !has_item { - // Poke the listener to adjust subscriptions - topic_signal_s.send(()).await.ok(); + if !has_item { + // Poke the listener to adjust subscriptions + topic_signal_s.send(()).await.ok(); + } } } ClientMessage::Ping { data, responded } => {