diff --git a/Cargo.lock b/Cargo.lock index 7322d38f..ce648f79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2113,9 +2113,13 @@ dependencies = [ [[package]] name = "decancer" -version = "1.6.5" +version = "3.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080b09f6adad25c23d8c47c54e52e59b0dc09d079c4b23e0f147dac440359d0d" +checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" +dependencies = [ + "lazy_static", + "regex", +] [[package]] name = "der" diff --git a/Cargo.toml b/Cargo.toml index 23b4ceb0..f10bfb26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,7 +171,7 @@ config = "0.13.3" cached = "0.44.0" rand = "0.8.5" base64 = "0.21.3" -decancer = "1.6.2" +decancer = "3.3.3" linkify = "0.8.1" url-escape = "0.1.1" revolt_optional_struct = "0.2.0" @@ -198,4 +198,4 @@ revolt-parser = { version = "0.12.0", path = "crates/core/parser" } revolt-permissions = { version = "0.12.0", path = "crates/core/permissions" } revolt-presence = { version = "0.12.0", path = "crates/core/presence" } revolt-ratelimits = { version = "0.12.0", path = "crates/core/ratelimits" } -revolt-result = { version = "0.12.0", path = "crates/core/result" } \ No newline at end of file +revolt-result = { version = "0.12.0", path = "crates/core/result" } diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index f701622d..59dd217b 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -81,6 +81,8 @@ call_ring_duration = 30 [api.livekit.nodes] [api.users] +# Minimum allowed length of usernames +min_username_length = 2 [pushd] # this changes the names of the queues to not overlap diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 41dc94d4..ceb8152e 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -232,6 +232,7 @@ pub struct LiveKitNode { #[derive(Deserialize, Debug, Clone)] pub struct ApiUsers { pub early_adopter_cutoff: Option, + pub min_username_length: usize, } #[derive(Deserialize, Debug, Clone)] diff --git a/crates/core/database/src/models/servers/ops/mongodb.rs b/crates/core/database/src/models/servers/ops/mongodb.rs index ecdff53b..d8ef26f2 100644 --- a/crates/core/database/src/models/servers/ops/mongodb.rs +++ b/crates/core/database/src/models/servers/ops/mongodb.rs @@ -77,7 +77,7 @@ impl AbstractServers for MongoDb { }, doc! { "$set": { - "roles.".to_owned() + &role.id: to_document(role) + "roles.".to_owned() + role.id.as_str(): to_document(role) .map_err(|_| create_database_error!("to_document", "role"))? } }, diff --git a/crates/core/database/src/models/users/model.rs b/crates/core/database/src/models/users/model.rs index cce0366c..ef1b0d83 100644 --- a/crates/core/database/src/models/users/model.rs +++ b/crates/core/database/src/models/users/model.rs @@ -7,6 +7,7 @@ use futures::future::join_all; use iso8601_timestamp::Timestamp; use once_cell::sync::Lazy; use rand::seq::SliceRandom; +use regex::{Regex, RegexBuilder}; use revolt_config::{config, FeaturesLimits}; use revolt_models::v0::{self, UserBadges, UserFlags}; use revolt_presence::filter_online; @@ -163,6 +164,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy> = Lazy::new(|| { set.into_iter().collect() }); +static BLOCKED_USERNAME_PATTERNS: Lazy = Lazy::new(|| { + RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/") + .case_insensitive(true) + .build() + .unwrap() +}); + #[allow(clippy::derivable_impls)] impl Default for User { fn default() -> Self { @@ -198,11 +206,13 @@ impl User { I: Into>, D: Into>, { - let username = User::validate_username(username)?; + let new_username = User::sanitise_username(&username).await?; + User::validate_username(&new_username)?; + let mut user = User { id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()), - discriminator: User::find_discriminator(db, &username, None).await?, - username, + discriminator: User::find_discriminator(db, &new_username, None).await?, + username: new_username.clone(), last_acknowledged_policy_change: Timestamp::now_utc(), ..Default::default() }; @@ -278,39 +288,40 @@ impl User { } } - /// Sanitise and validate a username can be used - pub fn validate_username(username: String) -> Result { - // Copy the username for validation + /// Validate a username + /// + /// This will check if the username is a blocked name or contains a blocked pattern. + fn validate_username(username: &str) -> Result<()> { let username_lowercase = username.to_lowercase(); - // Block homoglyphs - if decancer::cure(&username_lowercase).into_str() != username_lowercase { + const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"]; + + if BLOCKED_USERNAMES.contains(&username_lowercase.as_str()) + || BLOCKED_USERNAME_PATTERNS.is_match(username) + { return Err(create_error!(InvalidUsername)); } - // Ensure the username itself isn't blocked - const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"]; + Ok(()) + } - for username in BLOCKED_USERNAMES { - if username_lowercase == *username { - return Err(create_error!(InvalidUsername)); - } - } + /// Sanitise a username + /// + /// This will clean up Unicode homoglyphs and pad to the min username length with underscores. + async fn sanitise_username(username: &str) -> Result { + let options = decancer::Options::default().retain_capitalization(); + let mut username = decancer::cure(username, options) + .map_err(|_| create_error!(InvalidUsername))? + .to_string(); - // Ensure none of the following substrings show up in the username - const BLOCKED_SUBSTRINGS: &[&str] = &[ - "```", - "discord.gg", - "rvlt.gg", - "guilded.gg", - "https://", - "http://", - ]; - - for substr in BLOCKED_SUBSTRINGS { - if username_lowercase.contains(substr) { - return Err(create_error!(InvalidUsername)); - } + let config = revolt_config::config().await; + let username_length_diff = config + .api + .users + .min_username_length + .saturating_sub(username.len()); + if username_length_diff > 0 { + username.push_str(&"_".repeat(username_length_diff)) } Ok(username) @@ -416,12 +427,14 @@ impl User { /// Update a user's username pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { - let username = User::validate_username(username)?; - if self.username.to_lowercase() == username.to_lowercase() { + let new_username = User::sanitise_username(&username).await?; + User::validate_username(&new_username)?; + + if self.username.to_lowercase() == new_username.to_lowercase() { self.update( db, PartialUser { - username: Some(username), + username: Some(new_username), ..Default::default() }, vec![], @@ -434,12 +447,12 @@ impl User { discriminator: Some( User::find_discriminator( db, - &username, + &new_username, Some((self.discriminator.to_string(), self.id.clone())), ) .await?, ), - username: Some(username), + username: Some(new_username), ..Default::default() }, vec![], @@ -849,3 +862,109 @@ impl UserFlagsValue { self } } + +#[cfg(test)] +mod tests { + use crate::User; + + #[test] + fn username_validation_blocked_names() { + let username_admin = "Admin"; + let username_revolt = "Revolt"; + let username_stoat = "Stoat"; + let username_allowed = "Allowed"; + + assert!(User::validate_username(username_admin).is_err()); + assert!(User::validate_username(username_revolt).is_err()); + assert!(User::validate_username(username_stoat).is_err()); + assert!(User::validate_username(username_allowed).is_ok()); + } + + #[test] + fn username_validation_blocked_patterns() { + let username_grave = "```_test"; + let username_discord = "discord.gg_test"; + let username_rvlt = "rvlt.gg_test"; + let username_guilded = "guilded.gg_test"; + let username_stt = "stt.gg_test"; + let username_revolt = "revolt.chat_test"; + let username_stoat = "stoat.chat_test"; + let username_http = "http://_test"; + let username_https = "https://_test"; + + assert!(User::validate_username(username_grave).is_err()); + assert!(User::validate_username(username_discord).is_err()); + assert!(User::validate_username(username_rvlt).is_err()); + assert!(User::validate_username(username_guilded).is_err()); + assert!(User::validate_username(username_stt).is_err()); + assert!(User::validate_username(username_revolt).is_err()); + assert!(User::validate_username(username_stoat).is_err()); + assert!(User::validate_username(username_http).is_err()); + assert!(User::validate_username(username_https).is_err()); + } + + #[async_std::test] + async fn username_sanitisation_clean() { + let username_clean = "Test"; + + let username_clean_sanitised = User::sanitise_username(username_clean).await; + + assert!(username_clean_sanitised.is_ok()); + assert_eq!(username_clean, username_clean_sanitised.unwrap()); + } + + #[async_std::test] + async fn username_sanitisation_homoglyphs() { + let username_homoglyphs = "𝔽𝕌Ňℕy"; + + let username_homoglyphs_sanitised = + User::sanitise_username(username_homoglyphs).await.unwrap(); + + assert_ne!(username_homoglyphs, username_homoglyphs_sanitised); + assert_eq!("funny", username_homoglyphs_sanitised); + } + + #[async_std::test] + async fn username_sanitisation_padding() { + let username_padding = "a"; + + let username = User::sanitise_username(username_padding).await.unwrap(); + + assert_eq!("a_", username); + } + + #[async_std::test] + async fn create_user() { + use revolt_result::Result; + + database_test!(|db| async move { + let mut created_clean = User::create(&db, "Test".to_string(), None, None) + .await + .unwrap(); + + assert_eq!("Test", created_clean.username); + + created_clean + .update_username(&db, "Test2".to_string()) + .await + .unwrap(); + + assert_eq!("Test2", created_clean.username); + + let created_invalid_result: Result<_> = + User::create(&db, "stoat.chat".to_string(), None, None).await; + + assert!(created_invalid_result.is_err()); + + let mut updated_invalid = User::create(&db, "Test".to_string(), None, None) + .await + .unwrap(); + + let updated_invalid_update_result = updated_invalid + .update_username(&db, "http://test".to_string()) + .await; + + assert!(updated_invalid_update_result.is_err()); + }); + } +} diff --git a/crates/core/models/src/v0/messages.rs b/crates/core/models/src/v0/messages.rs index 2b03d211..cb8f3f24 100644 --- a/crates/core/models/src/v0/messages.rs +++ b/crates/core/models/src/v0/messages.rs @@ -261,7 +261,7 @@ auto_derived!( pub nonce: Option, /// Message content to send - #[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))] + #[cfg_attr(feature = "validator", validate(length(min = 0)))] pub content: Option, /// Attachments to include in message pub attachments: Option>, @@ -345,7 +345,7 @@ auto_derived!( #[cfg_attr(feature = "validator", derive(Validate))] pub struct DataEditMessage { /// New message content - #[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))] + #[cfg_attr(feature = "validator", validate(length(min = 1)))] pub content: Option, /// Embeds to include in the message #[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))] diff --git a/crates/daemons/pushd/src/consumers/outbound/fcm.rs b/crates/daemons/pushd/src/consumers/outbound/fcm.rs index 196a9eea..9ff70fc8 100644 --- a/crates/daemons/pushd/src/consumers/outbound/fcm.rs +++ b/crates/daemons/pushd/src/consumers/outbound/fcm.rs @@ -5,9 +5,8 @@ use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProp use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use fcm_v1::{ - android::{AndroidConfig, AndroidMessagePriority}, auth::{Authenticator, ServiceAccountKey}, - message::{Message, Notification}, + message::Message, Client, Error as FcmError, }; use revolt_config::config; @@ -15,6 +14,102 @@ use revolt_database::{events::rabbit::*, Database}; use revolt_models::v0::{Channel, PushNotification}; use serde_json::Value; +/// Custom notification data +#[derive(Debug, Clone, PartialEq)] +pub enum NotificationData { + FRReceived { + id: String, + username: String, + }, + FRAccepted { + id: String, + username: String, + }, + Generic { + title: String, + body: String, + image: Option, + }, + Message { + title: String, + body: String, + image: String, + tag: String, + }, + DmCallStartEnd { + initiator_id: String, + channel_id: String, + started_at: String, + ended: bool, + duration: usize, + }, +} + +impl NotificationData { + pub fn get_type(&self) -> &str { + match self { + NotificationData::FRReceived { .. } => "push.fr.receive", + NotificationData::FRAccepted { .. } => "push.fr.accept", + NotificationData::Generic { .. } => "push.generic", + NotificationData::Message { .. } => "push.message", + NotificationData::DmCallStartEnd { .. } => "push.dm.call", + } + } + + pub fn into_payload(self) -> HashMap { + let mut data = HashMap::new(); + data.insert( + "type".to_string(), + Value::String(self.get_type().to_string()), + ); + + match self { + NotificationData::FRReceived { id, username } => { + data.insert("id".to_string(), Value::String(id)); + data.insert("username".to_string(), Value::String(username)); + } + NotificationData::FRAccepted { id, username } => { + data.insert("id".to_string(), Value::String(id)); + data.insert("username".to_string(), Value::String(username)); + } + NotificationData::Generic { title, body, image } => { + data.insert("title".to_string(), Value::String(title)); + data.insert("body".to_string(), Value::String(body)); + + if let Some(image) = image { + data.insert("image".to_string(), Value::String(image)); + } + } + NotificationData::Message { + title, + body, + image, + tag, + } => { + data.insert("title".to_string(), Value::String(title)); + data.insert("body".to_string(), Value::String(body)); + data.insert("image".to_string(), Value::String(image)); + data.insert("tag".to_string(), Value::String(tag)); + } + NotificationData::DmCallStartEnd { + initiator_id, + channel_id, + started_at, + ended, + duration, + } => { + data.insert("initiator_id".to_string(), Value::String(initiator_id)); + data.insert("channel_id".to_string(), Value::String(channel_id)); + data.insert("started_at".to_string(), Value::String(started_at)); + data.insert("ended".to_string(), Value::Bool(ended)); + data.insert("duration".to_string(), Value::Number(duration.into())); + } + } + + data + } +} + pub struct FcmOutboundConsumer { db: Database, client: Client, @@ -93,17 +188,14 @@ impl FcmOutboundConsumer { .clone() .ok_or_else(|| anyhow!("missing name"))?; - let mut data = HashMap::new(); - data.insert( - "type".to_string(), - Value::String("push.fr.receive".to_string()), - ); - data.insert("id".to_string(), Value::String(alert.from_user.id)); - data.insert("username".to_string(), Value::String(name)); + let data = NotificationData::FRReceived { + id: alert.from_user.id, + username: name, + }; let msg = Message { token: Some(payload.token), - data: Some(data), + data: Some(data.into_payload()), ..Default::default() }; @@ -121,30 +213,29 @@ impl FcmOutboundConsumer { .clone() .ok_or_else(|| anyhow!("missing name"))?; - let mut data: HashMap = HashMap::new(); - data.insert( - "type".to_string(), - Value::String("push.fr.accept".to_string()), - ); - data.insert("id".to_string(), Value::String(alert.accepted_user.id)); - data.insert("username".to_string(), Value::String(name)); + let data = NotificationData::FRAccepted { + id: alert.accepted_user.id, + username: name, + }; let msg = Message { token: Some(payload.token), - data: Some(data), + data: Some(data.into_payload()), ..Default::default() }; resp = self.client.send(&msg).await; } PayloadKind::Generic(alert) => { + let data = NotificationData::Generic { + title: alert.title, + body: alert.body, + image: alert.icon, + }; + let msg = Message { token: Some(payload.token), - notification: Some(Notification { - title: Some(alert.title), - body: Some(alert.body), - image: alert.icon, - }), + data: Some(data.into_payload()), ..Default::default() }; @@ -152,19 +243,16 @@ impl FcmOutboundConsumer { } PayloadKind::MessageNotification(alert) => { - let title = self.format_title(&alert); + let data = NotificationData::Message { + title: self.format_title(&alert), + body: alert.body, + image: alert.icon, + tag: alert.tag, + }; let msg = Message { token: Some(payload.token), - notification: Some(Notification { - title: Some(title), - body: Some(alert.body), - image: Some(alert.icon), - }), - android: Some(AndroidConfig { - collapse_key: Some(alert.tag), - ..Default::default() - }), + data: Some(data.into_payload()), ..Default::default() }; @@ -172,30 +260,17 @@ impl FcmOutboundConsumer { } PayloadKind::DmCallStartEnd(alert) => { - let mut data: HashMap = HashMap::new(); - data.insert( - "initiator_id".to_string(), - Value::String(alert.initiator_id), - ); - data.insert("channel_id".to_string(), Value::String(alert.channel_id)); - data.insert( - "started_at".to_string(), - Value::String(alert.started_at.unwrap_or_else(|| "".to_string())), - ); - data.insert("ended".to_string(), Value::Bool(alert.ended)); + let data = NotificationData::DmCallStartEnd { + initiator_id: alert.initiator_id, + channel_id: alert.channel_id, + started_at: alert.started_at.unwrap_or_else(|| "".to_string()), + ended: alert.ended, + duration: config().await.api.livekit.call_ring_duration, + }; let msg = Message { token: Some(payload.token), - notification: None, - data: Some(data), - android: Some(AndroidConfig { - priority: Some(AndroidMessagePriority::High), - ttl: Some(format!( - "{}s", - config().await.api.livekit.call_ring_duration - )), - ..Default::default() - }), + data: Some(data.into_payload()), ..Default::default() }; diff --git a/crates/delta/src/routes/root.rs b/crates/delta/src/routes/root.rs index 6fd37a19..14ead75c 100644 --- a/crates/delta/src/routes/root.rs +++ b/crates/delta/src/routes/root.rs @@ -92,6 +92,8 @@ pub struct GlobalLimits { /// restrict server creation to these users. /// if blank, all users can create servers pub restrict_server_creation: Vec, + /// New user hours + new_user_hours: i64, } /// # User Limits @@ -231,6 +233,7 @@ pub async fn root() -> Result> { .limits .global .restrict_server_creation, + new_user_hours: config.features.limits.global.new_user_hours as i64, }, new_user: UserLimits::from_feature_limits(config.features.limits.new_user), default: UserLimits::from_feature_limits(config.features.limits.default),