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 352d04ad..729a8b31 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 @@ -44,6 +44,10 @@ pub async fn create_database(db: &MongoDb) { .await .expect("Failed to create channel_unreads collection."); + db.create_collection("channel_webhooks", None) + .await + .expect("Failed to create channel_webhooks collection."); + db.create_collection("migrations", None) .await .expect("Failed to create migrations collection."); @@ -91,6 +95,18 @@ pub async fn create_database(db: &MongoDb) { "username": 1_i32 }, "name": "username", + "unique": false, + "collation": { + "locale": "en", + "strength": 2_i32 + } + }, + { + "key": { + "username": 1_i32, + "discriminator": 1_i32 + }, + "name": "username_discriminator", "unique": true, "collation": { "locale": "en", 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 7a9a8d38..9fea8f9f 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 @@ -16,7 +16,7 @@ struct MigrationInfo { revision: i32, } -pub const LATEST_REVISION: i32 = 23; +pub const LATEST_REVISION: i32 = 24; pub async fn migrate_database(db: &MongoDb) { let migrations = db.col::("migrations"); @@ -768,6 +768,61 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 { .expect("Failed to update server members."); } + if revision <= 23 { + info!("Running migration [revision 23 / 09-06-2023]: Add collection `channel_webhooks` if not exists, update users index."); + + db.db() + .create_collection("channel_webhooks", None) + .await + .ok(); + + db.db() + .run_command( + doc! { + "dropIndexes": "users", + "indexes": "username" + }, + None, + ) + .await + .expect("Failed to drop existing username index."); + + db.db() + .run_command( + doc! { + "createIndexes": "users", + "indexes": [ + { + "key": { + "username": 1_i32 + }, + "name": "username", + "unique": false, + "collation": { + "locale": "en", + "strength": 2_i32 + } + }, + { + "key": { + "username": 1_i32, + "discriminator": 1_i32 + }, + "name": "username_discriminator", + "unique": true, + "collation": { + "locale": "en", + "strength": 2_i32 + } + } + ] + }, + None, + ) + .await + .expect("Failed to create username index."); + } + // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`. // Reminder to update LATEST_REVISION when adding new migrations. diff --git a/crates/core/database/src/models/users/model.rs b/crates/core/database/src/models/users/model.rs index c70e0839..589589eb 100644 --- a/crates/core/database/src/models/users/model.rs +++ b/crates/core/database/src/models/users/model.rs @@ -10,6 +10,10 @@ auto_derived_partial!( pub id: String, /// Username pub username: String, + /// Discriminator + pub discriminator: String, + /// Display name + pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] /// Avatar attachment pub avatar: Option, diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 4d6c13c9..45fad903 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -257,6 +257,8 @@ impl crate::User { User { username: self.username, + discriminator: self.discriminator, + display_name: self.display_name, avatar: self.avatar.map(|file| file.into()), relations: vec![], badges: self.badges.unwrap_or_default() as u32, diff --git a/crates/core/models/src/v0/users.rs b/crates/core/models/src/v0/users.rs index 8224639f..4e34a3ec 100644 --- a/crates/core/models/src/v0/users.rs +++ b/crates/core/models/src/v0/users.rs @@ -8,6 +8,10 @@ auto_derived!( pub id: String, /// Username pub username: String, + /// Discriminator + pub discriminator: String, + /// Display name + pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] /// Avatar attachment pub avatar: Option, diff --git a/crates/delta/src/routes/bots/create.rs b/crates/delta/src/routes/bots/create.rs index 3af72e87..06f70089 100644 --- a/crates/delta/src/routes/bots/create.rs +++ b/crates/delta/src/routes/bots/create.rs @@ -38,14 +38,12 @@ pub async fn create_bot(db: &Db, user: User, info: Json) -> Resul return Err(Error::ReachedMaximumBots); } - if db.is_username_taken(&info.name).await? { - return Err(Error::UsernameTaken); - } - let id = Ulid::new().to_string(); + let username = User::validate_username(info.name)?; let bot_user = User { id: id.clone(), - username: info.name.trim().to_string(), + discriminator: User::find_discriminator(db, &username, None).await?, + username, bot: Some(BotInformation { owner: user.id.clone(), }), diff --git a/crates/delta/src/routes/bots/edit.rs b/crates/delta/src/routes/bots/edit.rs index 0395744f..7e22adb2 100644 --- a/crates/delta/src/routes/bots/edit.rs +++ b/crates/delta/src/routes/bots/edit.rs @@ -58,10 +58,6 @@ pub async fn edit_bot( } if let Some(name) = data.name { - if db.is_username_taken(&name).await? { - return Err(Error::UsernameTaken); - } - let mut user = db.fetch_user(&bot.id).await?; user.update_username(db, name).await?; } diff --git a/crates/delta/src/routes/onboard/complete.rs b/crates/delta/src/routes/onboard/complete.rs index a96444f7..90f7136f 100644 --- a/crates/delta/src/routes/onboard/complete.rs +++ b/crates/delta/src/routes/onboard/complete.rs @@ -34,9 +34,10 @@ pub async fn req( data.validate() .map_err(|error| Error::FailedValidation { error })?; - let username = User::validate_username(db, data.username).await?; + let username = User::validate_username(data.username)?; let user = User { id: session.user_id, + discriminator: User::find_discriminator(db, &username, None).await?, username, ..Default::default() }; diff --git a/crates/delta/src/routes/users/edit_user.rs b/crates/delta/src/routes/users/edit_user.rs index e91d8919..76e04a21 100644 --- a/crates/delta/src/routes/users/edit_user.rs +++ b/crates/delta/src/routes/users/edit_user.rs @@ -8,6 +8,8 @@ use rocket::State; use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::util::regex::RE_DISPLAY_NAME; + /// # Profile Data #[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)] pub struct UserProfileData { @@ -24,6 +26,10 @@ pub struct UserProfileData { /// # User Data #[derive(Validate, Serialize, Deserialize, JsonSchema)] pub struct DataEditUser { + /// New display name + #[serde(rename = "displayName")] + #[validate(length(min = 2, max = 32), regex = "RE_DISPLAY_NAME")] + display_name: Option, /// Attachment Id for avatar #[validate(length(min = 1, max = 128))] avatar: Option, @@ -84,7 +90,8 @@ pub async fn req( } // Exit out early if nothing is changed - if data.status.is_none() + if data.display_name.is_none() + && data.status.is_none() && data.profile.is_none() && data.avatar.is_none() && data.badges.is_none() diff --git a/crates/delta/src/routes/users/send_friend_request.rs b/crates/delta/src/routes/users/send_friend_request.rs index 1c9a6d1e..156532aa 100644 --- a/crates/delta/src/routes/users/send_friend_request.rs +++ b/crates/delta/src/routes/users/send_friend_request.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; /// # User Lookup Information #[derive(Serialize, Deserialize, JsonSchema)] pub struct DataSendFriendRequest { + /// Username and discriminator combo separated by # username: String, } @@ -21,12 +22,16 @@ pub async fn req( user: User, data: Json, ) -> Result> { - let mut target = db.fetch_user_by_username(&data.username).await?; + if let Some((username, discriminator)) = data.username.split_once('#') { + let mut target = db.fetch_user_by_username(username, discriminator).await?; - if user.bot.is_some() || target.bot.is_some() { - return Err(Error::IsBot); + if user.bot.is_some() || target.bot.is_some() { + return Err(Error::IsBot); + } + + user.add_friend(db, &mut target).await?; + Ok(Json(target.with_auto_perspective(db, &user).await)) + } else { + Err(Error::InvalidProperty) } - - user.add_friend(db, &mut target).await?; - Ok(Json(target.with_auto_perspective(db, &user).await)) } diff --git a/crates/delta/src/util/regex.rs b/crates/delta/src/util/regex.rs index 5ac6a710..7c71e4b1 100644 --- a/crates/delta/src/util/regex.rs +++ b/crates/delta/src/util/regex.rs @@ -1,12 +1,17 @@ use once_cell::sync::Lazy; use regex::Regex; +/// Regex for valid display names +/// +/// Block zero width space +/// Block newline and carriage return +pub static RE_DISPLAY_NAME: Lazy = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap()); + /// Regex for valid usernames /// /// Block zero width space /// Block lookalike characters -pub static RE_USERNAME: Lazy = - Lazy::new(|| Regex::new(r"^[^\u200BА-Яа-яΑ-Ωα-ω@#:\n\r\[\]]+$").unwrap()); +pub static RE_USERNAME: Lazy = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap()); /// Regex for valid emoji names /// diff --git a/crates/quark/src/impl/dummy/users/user.rs b/crates/quark/src/impl/dummy/users/user.rs index 9b3bfd2a..8a0c7f6a 100644 --- a/crates/quark/src/impl/dummy/users/user.rs +++ b/crates/quark/src/impl/dummy/users/user.rs @@ -9,11 +9,12 @@ impl AbstractUser for DummyDb { Ok(User { id: id.into(), username: "username".into(), + discriminator: "0000".into(), ..Default::default() }) } - async fn fetch_user_by_username(&self, username: &str) -> Result { + async fn fetch_user_by_username(&self, username: &str, _discriminator: &str) -> Result { self.fetch_user(username).await } @@ -45,8 +46,8 @@ impl AbstractUser for DummyDb { Ok(vec![self.fetch_user("id").await.unwrap()]) } - async fn is_username_taken(&self, _username: &str) -> Result { - Ok(false) + async fn fetch_discriminators_in_use(&self, _username: &str) -> Result> { + Ok(vec![]) } async fn fetch_mutual_user_ids(&self, _user_a: &str, _user_b: &str) -> Result> { diff --git a/crates/quark/src/impl/generic/users/user.rs b/crates/quark/src/impl/generic/users/user.rs index 3355a5c7..f73a7adf 100644 --- a/crates/quark/src/impl/generic/users/user.rs +++ b/crates/quark/src/impl/generic/users/user.rs @@ -8,7 +8,9 @@ use crate::{perms, Database, Error, Result}; use futures::try_join; use impl_ops::impl_op_ex_commutative; +use rand::seq::SliceRandom; use revolt_presence::filter_online; +use std::collections::HashSet; use std::ops; impl_op_ex_commutative!(+ |a: &i32, b: &Badges| -> i32 { *a | *b as i32 }); @@ -169,15 +171,7 @@ impl User { } /// Sanitise and validate a username can be used - pub async fn validate_username(db: &Database, username: String) -> Result { - // Trim surrounding spaces - let username = username.trim().to_string(); - - // Make sure username is still at least 3 characters - if username.len() < 2 { - return Err(Error::InvalidUsername); - } - + pub fn validate_username(username: String) -> Result { // Copy the username for validation let username_lowercase = username.to_lowercase(); @@ -199,25 +193,74 @@ impl User { } } - // Make sure the username isn't taken - if db.is_username_taken(&username).await? { + Ok(username) + } + + // Find a free discriminator for a given username + pub async fn find_discriminator( + db: &Database, + username: &str, + preferred: Option, + ) -> Result { + let search_space: HashSet = (0..9999).map(|v| format!("{:0>4}", v)).collect(); + let used_discriminators: HashSet = db + .fetch_discriminators_in_use(username) + .await? + .into_iter() + .collect(); + + let available_discriminators: Vec<&String> = + search_space.difference(&used_discriminators).collect(); + + if available_discriminators.is_empty() { return Err(Error::UsernameTaken); } - Ok(username) + if let Some(preferred) = preferred { + if available_discriminators.contains(&&preferred) { + return Ok(preferred.to_string()); + } + } + + let mut rng = rand::thread_rng(); + Ok(available_discriminators + .choose(&mut rng) + .expect("we can assert this has an element") + .to_string()) } /// Update a user's username pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { - self.update( - db, - PartialUser { - username: Some(User::validate_username(db, username).await?), - ..Default::default() - }, - vec![], - ) - .await + let username = User::validate_username(username)?; + if self.username.to_lowercase() == username.to_lowercase() { + self.update( + db, + PartialUser { + username: Some(username), + ..Default::default() + }, + vec![], + ) + .await + } else { + self.update( + db, + PartialUser { + discriminator: Some( + User::find_discriminator( + db, + &username, + Some(self.discriminator.to_string()), + ) + .await?, + ), + username: Some(username), + ..Default::default() + }, + vec![], + ) + .await + } } /// Apply a certain relationship between two users diff --git a/crates/quark/src/impl/mongo/users/user.rs b/crates/quark/src/impl/mongo/users/user.rs index bbfe84f1..ee7ba192 100644 --- a/crates/quark/src/impl/mongo/users/user.rs +++ b/crates/quark/src/impl/mongo/users/user.rs @@ -9,14 +9,16 @@ use crate::{AbstractUser, Error, Result}; use super::super::MongoDb; -static FIND_USERNAME_OPTIONS: Lazy = Lazy::new(|| FindOneOptions::builder() - .collation( - Collation::builder() - .locale("en") - .strength(CollationStrength::Secondary) - .build() - ) - .build()); +static FIND_USERNAME_OPTIONS: Lazy = Lazy::new(|| { + FindOneOptions::builder() + .collation( + Collation::builder() + .locale("en") + .strength(CollationStrength::Secondary) + .build(), + ) + .build() +}); static COL: &str = "users"; @@ -26,11 +28,12 @@ impl AbstractUser for MongoDb { self.find_one_by_id(COL, id).await } - async fn fetch_user_by_username(&self, username: &str) -> Result { + async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result { self.find_one_with_options( COL, doc! { - "username": username + "username": username, + "discriminator": discriminator }, FIND_USERNAME_OPTIONS.clone(), ) @@ -106,13 +109,34 @@ impl AbstractUser for MongoDb { Ok(users) } - async fn is_username_taken(&self, username: &str) -> Result { - // ! FIXME: move this up to generic - match self.fetch_user_by_username(username).await { - Ok(_) => Ok(true), - Err(Error::NotFound) => Ok(false), - Err(error) => Err(error), - } + async fn fetch_discriminators_in_use(&self, username: &str) -> Result> { + Ok(self + .col::(COL) + .find( + doc! { + "username": username + }, + FindOptions::builder() + .collation( + Collation::builder() + .locale("en") + .strength(CollationStrength::Secondary) + .build(), + ) + .projection(doc! { "_id": 0, "discriminator": 1 }) + .build(), + ) + .await + .map_err(|_| Error::DatabaseError { + operation: "find", + with: "users", + })? + .filter_map(|s| async { s.ok() }) + .collect::>() + .await + .into_iter() + .filter_map(|x| x.get_str("discriminator").ok().map(|x| x.to_string())) + .collect::>()) } async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result> { diff --git a/crates/quark/src/models/users/user.rs b/crates/quark/src/models/users/user.rs index 1ea03fd9..7875e037 100644 --- a/crates/quark/src/models/users/user.rs +++ b/crates/quark/src/models/users/user.rs @@ -128,6 +128,10 @@ pub struct User { pub id: String, /// Username pub username: String, + /// Discriminator + pub discriminator: String, + /// Display name + pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] /// Avatar attachment pub avatar: Option, diff --git a/crates/quark/src/traits/users/user.rs b/crates/quark/src/traits/users/user.rs index 820b1941..b0b5f8c8 100644 --- a/crates/quark/src/traits/users/user.rs +++ b/crates/quark/src/traits/users/user.rs @@ -7,7 +7,7 @@ pub trait AbstractUser: Sync + Send { async fn fetch_user(&self, id: &str) -> Result; /// Fetch a user from the database by their username - async fn fetch_user_by_username(&self, username: &str) -> Result; + async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result; /// Fetch a user from the database by their session token async fn fetch_user_by_token(&self, token: &str) -> Result; @@ -29,8 +29,8 @@ pub trait AbstractUser: Sync + Send { /// Fetch multiple users by their ids async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result>; - /// Check whether a username is already in use by another user - async fn is_username_taken(&self, username: &str) -> Result; + /// Fetch all discriminators in use for a username + async fn fetch_discriminators_in_use(&self, username: &str) -> Result>; /// Fetch ids of users that both users are friends with async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result>;