feat: add discriminator and display name fields

This commit is contained in:
Paul Makles
2023-06-09 16:34:18 +01:00
parent aba5c7d8af
commit 31c7dc0577
16 changed files with 229 additions and 64 deletions

View File

@@ -44,6 +44,10 @@ pub async fn create_database(db: &MongoDb) {
.await .await
.expect("Failed to create channel_unreads collection."); .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) db.create_collection("migrations", None)
.await .await
.expect("Failed to create migrations collection."); .expect("Failed to create migrations collection.");
@@ -91,6 +95,18 @@ pub async fn create_database(db: &MongoDb) {
"username": 1_i32 "username": 1_i32
}, },
"name": "username", "name": "username",
"unique": false,
"collation": {
"locale": "en",
"strength": 2_i32
}
},
{
"key": {
"username": 1_i32,
"discriminator": 1_i32
},
"name": "username_discriminator",
"unique": true, "unique": true,
"collation": { "collation": {
"locale": "en", "locale": "en",

View File

@@ -16,7 +16,7 @@ struct MigrationInfo {
revision: i32, revision: i32,
} }
pub const LATEST_REVISION: i32 = 23; pub const LATEST_REVISION: i32 = 24;
pub async fn migrate_database(db: &MongoDb) { pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations"); let migrations = db.col::<Document>("migrations");
@@ -768,6 +768,61 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("Failed to update server members."); .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`. // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
// Reminder to update LATEST_REVISION when adding new migrations. // Reminder to update LATEST_REVISION when adding new migrations.

View File

@@ -10,6 +10,10 @@ auto_derived_partial!(
pub id: String, pub id: String,
/// Username /// Username
pub username: String, pub username: String,
/// Discriminator
pub discriminator: String,
/// Display name
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
/// Avatar attachment /// Avatar attachment
pub avatar: Option<File>, pub avatar: Option<File>,

View File

@@ -257,6 +257,8 @@ impl crate::User {
User { User {
username: self.username, username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: vec![], relations: vec![],
badges: self.badges.unwrap_or_default() as u32, badges: self.badges.unwrap_or_default() as u32,

View File

@@ -8,6 +8,10 @@ auto_derived!(
pub id: String, pub id: String,
/// Username /// Username
pub username: String, pub username: String,
/// Discriminator
pub discriminator: String,
/// Display name
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
/// Avatar attachment /// Avatar attachment
pub avatar: Option<File>, pub avatar: Option<File>,

View File

@@ -38,14 +38,12 @@ pub async fn create_bot(db: &Db, user: User, info: Json<DataCreateBot>) -> Resul
return Err(Error::ReachedMaximumBots); return Err(Error::ReachedMaximumBots);
} }
if db.is_username_taken(&info.name).await? {
return Err(Error::UsernameTaken);
}
let id = Ulid::new().to_string(); let id = Ulid::new().to_string();
let username = User::validate_username(info.name)?;
let bot_user = User { let bot_user = User {
id: id.clone(), id: id.clone(),
username: info.name.trim().to_string(), discriminator: User::find_discriminator(db, &username, None).await?,
username,
bot: Some(BotInformation { bot: Some(BotInformation {
owner: user.id.clone(), owner: user.id.clone(),
}), }),

View File

@@ -58,10 +58,6 @@ pub async fn edit_bot(
} }
if let Some(name) = data.name { 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?; let mut user = db.fetch_user(&bot.id).await?;
user.update_username(db, name).await?; user.update_username(db, name).await?;
} }

View File

@@ -34,9 +34,10 @@ pub async fn req(
data.validate() data.validate()
.map_err(|error| Error::FailedValidation { error })?; .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 { let user = User {
id: session.user_id, id: session.user_id,
discriminator: User::find_discriminator(db, &username, None).await?,
username, username,
..Default::default() ..Default::default()
}; };

View File

@@ -8,6 +8,8 @@ use rocket::State;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use validator::Validate; use validator::Validate;
use crate::util::regex::RE_DISPLAY_NAME;
/// # Profile Data /// # Profile Data
#[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)] #[derive(Validate, Serialize, Deserialize, Debug, JsonSchema)]
pub struct UserProfileData { pub struct UserProfileData {
@@ -24,6 +26,10 @@ pub struct UserProfileData {
/// # User Data /// # User Data
#[derive(Validate, Serialize, Deserialize, JsonSchema)] #[derive(Validate, Serialize, Deserialize, JsonSchema)]
pub struct DataEditUser { pub struct DataEditUser {
/// New display name
#[serde(rename = "displayName")]
#[validate(length(min = 2, max = 32), regex = "RE_DISPLAY_NAME")]
display_name: Option<String>,
/// Attachment Id for avatar /// Attachment Id for avatar
#[validate(length(min = 1, max = 128))] #[validate(length(min = 1, max = 128))]
avatar: Option<String>, avatar: Option<String>,
@@ -84,7 +90,8 @@ pub async fn req(
} }
// Exit out early if nothing is changed // 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.profile.is_none()
&& data.avatar.is_none() && data.avatar.is_none()
&& data.badges.is_none() && data.badges.is_none()

View File

@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
/// # User Lookup Information /// # User Lookup Information
#[derive(Serialize, Deserialize, JsonSchema)] #[derive(Serialize, Deserialize, JsonSchema)]
pub struct DataSendFriendRequest { pub struct DataSendFriendRequest {
/// Username and discriminator combo separated by #
username: String, username: String,
} }
@@ -21,12 +22,16 @@ pub async fn req(
user: User, user: User,
data: Json<DataSendFriendRequest>, data: Json<DataSendFriendRequest>,
) -> Result<Json<User>> { ) -> Result<Json<User>> {
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() { if user.bot.is_some() || target.bot.is_some() {
return Err(Error::IsBot); 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))
} }

View File

@@ -1,12 +1,17 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
/// Regex for valid display names
///
/// Block zero width space
/// Block newline and carriage return
pub static RE_DISPLAY_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[^\u200B\n\r]+$").unwrap());
/// Regex for valid usernames /// Regex for valid usernames
/// ///
/// Block zero width space /// Block zero width space
/// Block lookalike characters /// Block lookalike characters
pub static RE_USERNAME: Lazy<Regex> = pub static RE_USERNAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\p{L}|[\d_.-])+$").unwrap());
Lazy::new(|| Regex::new(r"^[^\u200BА-Яа-яΑ-Ωα-ω@#:\n\r\[\]]+$").unwrap());
/// Regex for valid emoji names /// Regex for valid emoji names
/// ///

View File

@@ -9,11 +9,12 @@ impl AbstractUser for DummyDb {
Ok(User { Ok(User {
id: id.into(), id: id.into(),
username: "username".into(), username: "username".into(),
discriminator: "0000".into(),
..Default::default() ..Default::default()
}) })
} }
async fn fetch_user_by_username(&self, username: &str) -> Result<User> { async fn fetch_user_by_username(&self, username: &str, _discriminator: &str) -> Result<User> {
self.fetch_user(username).await self.fetch_user(username).await
} }
@@ -45,8 +46,8 @@ impl AbstractUser for DummyDb {
Ok(vec![self.fetch_user("id").await.unwrap()]) Ok(vec![self.fetch_user("id").await.unwrap()])
} }
async fn is_username_taken(&self, _username: &str) -> Result<bool> { async fn fetch_discriminators_in_use(&self, _username: &str) -> Result<Vec<String>> {
Ok(false) Ok(vec![])
} }
async fn fetch_mutual_user_ids(&self, _user_a: &str, _user_b: &str) -> Result<Vec<String>> { async fn fetch_mutual_user_ids(&self, _user_a: &str, _user_b: &str) -> Result<Vec<String>> {

View File

@@ -8,7 +8,9 @@ use crate::{perms, Database, Error, Result};
use futures::try_join; use futures::try_join;
use impl_ops::impl_op_ex_commutative; use impl_ops::impl_op_ex_commutative;
use rand::seq::SliceRandom;
use revolt_presence::filter_online; use revolt_presence::filter_online;
use std::collections::HashSet;
use std::ops; use std::ops;
impl_op_ex_commutative!(+ |a: &i32, b: &Badges| -> i32 { *a | *b as i32 }); 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 /// Sanitise and validate a username can be used
pub async fn validate_username(db: &Database, username: String) -> Result<String> { pub fn validate_username(username: String) -> Result<String> {
// 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);
}
// Copy the username for validation // Copy the username for validation
let username_lowercase = username.to_lowercase(); let username_lowercase = username.to_lowercase();
@@ -199,25 +193,74 @@ impl User {
} }
} }
// Make sure the username isn't taken Ok(username)
if db.is_username_taken(&username).await? { }
// Find a free discriminator for a given username
pub async fn find_discriminator(
db: &Database,
username: &str,
preferred: Option<String>,
) -> Result<String> {
let search_space: HashSet<String> = (0..9999).map(|v| format!("{:0>4}", v)).collect();
let used_discriminators: HashSet<String> = 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); 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 /// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
self.update( let username = User::validate_username(username)?;
db, if self.username.to_lowercase() == username.to_lowercase() {
PartialUser { self.update(
username: Some(User::validate_username(db, username).await?), db,
..Default::default() PartialUser {
}, username: Some(username),
vec![], ..Default::default()
) },
.await 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 /// Apply a certain relationship between two users

View File

@@ -9,14 +9,16 @@ use crate::{AbstractUser, Error, Result};
use super::super::MongoDb; use super::super::MongoDb;
static FIND_USERNAME_OPTIONS: Lazy<FindOneOptions> = Lazy::new(|| FindOneOptions::builder() static FIND_USERNAME_OPTIONS: Lazy<FindOneOptions> = Lazy::new(|| {
.collation( FindOneOptions::builder()
Collation::builder() .collation(
.locale("en") Collation::builder()
.strength(CollationStrength::Secondary) .locale("en")
.build() .strength(CollationStrength::Secondary)
) .build(),
.build()); )
.build()
});
static COL: &str = "users"; static COL: &str = "users";
@@ -26,11 +28,12 @@ impl AbstractUser for MongoDb {
self.find_one_by_id(COL, id).await self.find_one_by_id(COL, id).await
} }
async fn fetch_user_by_username(&self, username: &str) -> Result<User> { async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User> {
self.find_one_with_options( self.find_one_with_options(
COL, COL,
doc! { doc! {
"username": username "username": username,
"discriminator": discriminator
}, },
FIND_USERNAME_OPTIONS.clone(), FIND_USERNAME_OPTIONS.clone(),
) )
@@ -106,13 +109,34 @@ impl AbstractUser for MongoDb {
Ok(users) Ok(users)
} }
async fn is_username_taken(&self, username: &str) -> Result<bool> { async fn fetch_discriminators_in_use(&self, username: &str) -> Result<Vec<String>> {
// ! FIXME: move this up to generic Ok(self
match self.fetch_user_by_username(username).await { .col::<Document>(COL)
Ok(_) => Ok(true), .find(
Err(Error::NotFound) => Ok(false), doc! {
Err(error) => Err(error), "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::<Vec<Document>>()
.await
.into_iter()
.filter_map(|x| x.get_str("discriminator").ok().map(|x| x.to_string()))
.collect::<Vec<String>>())
} }
async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>> { async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>> {

View File

@@ -128,6 +128,10 @@ pub struct User {
pub id: String, pub id: String,
/// Username /// Username
pub username: String, pub username: String,
/// Discriminator
pub discriminator: String,
/// Display name
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
/// Avatar attachment /// Avatar attachment
pub avatar: Option<File>, pub avatar: Option<File>,

View File

@@ -7,7 +7,7 @@ pub trait AbstractUser: Sync + Send {
async fn fetch_user(&self, id: &str) -> Result<User>; async fn fetch_user(&self, id: &str) -> Result<User>;
/// Fetch a user from the database by their username /// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str) -> Result<User>; async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>;
/// Fetch a user from the database by their session token /// Fetch a user from the database by their session token
async fn fetch_user_by_token(&self, token: &str) -> Result<User>; async fn fetch_user_by_token(&self, token: &str) -> Result<User>;
@@ -29,8 +29,8 @@ pub trait AbstractUser: Sync + Send {
/// Fetch multiple users by their ids /// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>; async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>;
/// Check whether a username is already in use by another user /// Fetch all discriminators in use for a username
async fn is_username_taken(&self, username: &str) -> Result<bool>; async fn fetch_discriminators_in_use(&self, username: &str) -> Result<Vec<String>>;
/// Fetch ids of users that both users are friends with /// Fetch ids of users that both users are friends with
async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>>; async fn fetch_mutual_user_ids(&self, user_a: &str, user_b: &str) -> Result<Vec<String>>;