diff --git a/Cargo.lock b/Cargo.lock index ae9c11c6c2..7159928999 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,6 +1798,22 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "dev-database-seed" +version = "0.0.1" +dependencies = [ + "config", + "easyenv", + "enums", + "errors", + "hashing", + "log", + "mysql_queries", + "sqlx", + "tokens", + "tokio 1.25.0", +] + [[package]] name = "diesel" version = "1.4.6" @@ -2538,8 +2554,10 @@ dependencies = [ name = "hashing" version = "0.0.1" dependencies = [ + "bcrypt", "data-encoding", "errors", + "md-5", "ring 0.17.0-alpha.11", ] diff --git a/Cargo.toml b/Cargo.toml index 69d64b6bc4..f1f6318592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ # CLI tools for operations, etc. "crates/cli/database_backup", "crates/cli/database_cleanup", + "crates/cli/dev_database_seed", "crates/cli/scrape_test", # Shared libraries diff --git a/crates/cli/dev_database_seed/Cargo.toml b/crates/cli/dev_database_seed/Cargo.toml new file mode 100644 index 0000000000..b96392f8f9 --- /dev/null +++ b/crates/cli/dev_database_seed/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "dev-database-seed" +edition = "2018" +version = "0.0.1" +authors = [ + "Brandon Thomas ", + "Brandon Thomas ", +] +publish = false + +[[bin]] +name = "dev-database-seed" +path = "src/main.rs" + +[dependencies] +# Internal +config = { path = "../../lib/deprecated/config" } +easyenv = { path = "../../lib/easyenv" } +enums = { path = "../../_types/enums" } +errors = { path = "../../lib/errors" } +hashing = { path = "../../lib/files/hashing" } +mysql_queries = { path = "../../lib/mysql_queries" } +tokens = { path = "../../_types/tokens" } + +# External +log = "0.4.14" + +# Runtime +# NB: We switched to Tokio from `async-std` for broader ecosystem support. +tokio = { version = "1.6.0", features = ["macros"] } + +# MySql Database +# "offline" supports typesafe builds without a live database +# "rustls" doesn't appear to work with IP addresses, per: +# https://github.com/launchbadge/sqlx/issues/1095#issuecomment-798050828 +sqlx = { version = "0.6.2", features = [ "mysql", "runtime-actix-native-tls", "offline", "chrono" ] } + +[dev-dependencies] +# None yet diff --git a/crates/cli/dev_database_seed/src/main.rs b/crates/cli/dev_database_seed/src/main.rs new file mode 100644 index 0000000000..802e20af62 --- /dev/null +++ b/crates/cli/dev_database_seed/src/main.rs @@ -0,0 +1,33 @@ +use log::{info, log}; +use sqlx::mysql::MySqlPoolOptions; + +use config::shared_constants::{DEFAULT_MYSQL_CONNECTION_STRING, DEFAULT_RUST_LOG}; +use errors::AnyhowResult; + +use crate::seeding::users::seed_user_accounts; +use crate::seeding::zero_shot_tts::seed_zero_shot_tts; + +pub mod seeding; + +#[tokio::main] +pub async fn main() -> AnyhowResult<()> { + easyenv::init_all_with_default_logging(Some(DEFAULT_RUST_LOG)); + + info!("Database seed CLI script."); + + let db_connection_string = + easyenv::get_env_string_or_default( + "MYSQL_URL", + DEFAULT_MYSQL_CONNECTION_STRING); + + let pool = MySqlPoolOptions::new() + .max_connections(easyenv::get_env_num("MYSQL_MAX_CONNECTIONS", 3)?) + .connect(&db_connection_string) + .await?; + + seed_user_accounts(&pool).await?; + seed_zero_shot_tts(&pool).await?; + + info!("Done!"); + Ok(()) +} diff --git a/crates/cli/dev_database_seed/src/seeding/mod.rs b/crates/cli/dev_database_seed/src/seeding/mod.rs new file mode 100644 index 0000000000..bbcc01cf08 --- /dev/null +++ b/crates/cli/dev_database_seed/src/seeding/mod.rs @@ -0,0 +1,2 @@ +pub mod users; +pub mod zero_shot_tts; \ No newline at end of file diff --git a/crates/cli/dev_database_seed/src/seeding/users.rs b/crates/cli/dev_database_seed/src/seeding/users.rs new file mode 100644 index 0000000000..1a1be65162 --- /dev/null +++ b/crates/cli/dev_database_seed/src/seeding/users.rs @@ -0,0 +1,52 @@ +use log::{info, warn}; +use sqlx::{MySql, Pool}; + +use errors::{anyhow, AnyhowResult}; +use hashing::bcrypt::bcrypt_password_hash::bcrypt_password_hash; +use hashing::md5::email_to_gravatar_hash::email_to_gravatar_hash; +use mysql_queries::queries::users::user::create_account::{create_account, CreateAccountArgs}; + +pub async fn seed_user_accounts(mysql_pool: &Pool) -> AnyhowResult<()> { + info!("Seeding user accounts..."); + + let users = [ + ("admin", "admin@storyteller.ai", "password"), + ("hanashi", "admin@storyteller.ai", "password"), + ("test", "admin@storyteller.ai", "password"), + ]; + + for (username, email, password) in users { + let result = seed_user(username, email, password, &mysql_pool).await; + match result { + Ok(_) => info!("Seeded {}", username), + Err(err) => warn!("Could not seed user {} : {:?}", username, err), + } + } + + Ok(()) +} + +async fn seed_user( + username: &str, + email_address: &str, + password: &str, + mysql_pool: &Pool, +) -> AnyhowResult<()> { + info!("Seeding user {} ...", username); + + let display_name = username.clone(); + let username = username.to_lowercase(); + let email_gravatar_hash = email_to_gravatar_hash(&email_address); + let password_hash = bcrypt_password_hash(password)?; + + create_account(mysql_pool, CreateAccountArgs { + username: &username, + display_name, + email_address, + email_gravatar_hash: &email_gravatar_hash, + password_hash: &password_hash, + ip_address: "127.0.0.1", + }).await.map_err(|err| anyhow!("err: {:?}", err))?; + + Ok(()) +} diff --git a/crates/cli/dev_database_seed/src/seeding/zero_shot_tts.rs b/crates/cli/dev_database_seed/src/seeding/zero_shot_tts.rs new file mode 100644 index 0000000000..0e3f10468b --- /dev/null +++ b/crates/cli/dev_database_seed/src/seeding/zero_shot_tts.rs @@ -0,0 +1,52 @@ +use log::{info, warn}; +use sqlx::{MySql, Pool}; + +use errors::{anyhow, AnyhowResult}; +use hashing::bcrypt::bcrypt_password_hash::bcrypt_password_hash; +use hashing::md5::email_to_gravatar_hash::email_to_gravatar_hash; +use mysql_queries::queries::users::user::create_account::{create_account, CreateAccountArgs}; + +pub async fn seed_zero_shot_tts(mysql_pool: &Pool) -> AnyhowResult<()> { + info!("Seeding zero shot TTS..."); + + let users = [ + ("admin", "admin@storyteller.ai", "password"), + ("hanashi", "admin@storyteller.ai", "password"), + ("test", "admin@storyteller.ai", "password"), + ]; + + for (username, email, password) in users { + let result = seed_user(username, email, password, &mysql_pool).await; + match result { + Ok(_) => info!("Seeded {}", username), + Err(err) => warn!("Could not seed user {} : {:?}", username, err), + } + } + + Ok(()) +} + +async fn seed_user( + username: &str, + email_address: &str, + password: &str, + mysql_pool: &Pool, +) -> AnyhowResult<()> { + info!("Seeding user {} ...", username); + + let display_name = username.clone(); + let username = username.to_lowercase(); + let email_gravatar_hash = email_to_gravatar_hash(&email_address); + let password_hash = bcrypt_password_hash(password)?; + + create_account(mysql_pool, CreateAccountArgs { + username: &username, + display_name, + email_address, + email_gravatar_hash: &email_gravatar_hash, + password_hash: &password_hash, + ip_address: "127.0.0.1", + }).await.map_err(|err| anyhow!("err: {:?}", err))?; + + Ok(()) +} diff --git a/crates/lib/files/hashing/Cargo.toml b/crates/lib/files/hashing/Cargo.toml index 4b687faa42..c45c9502aa 100644 --- a/crates/lib/files/hashing/Cargo.toml +++ b/crates/lib/files/hashing/Cargo.toml @@ -18,7 +18,9 @@ path = "src/lib.rs" errors = { path = "../../errors" } # External +bcrypt = "0.8" # password hashes data-encoding = "2.3.2" +md-5 = "0.9.1" # DO NOT USE FOR SERIOUS CRYPTO (we use this for gravatar) ring = "0.17.0-alpha.10" [dev-dependencies] diff --git a/crates/lib/files/hashing/src/bcrypt/bcrypt_password_hash.rs b/crates/lib/files/hashing/src/bcrypt/bcrypt_password_hash.rs new file mode 100644 index 0000000000..2429b4f9d8 --- /dev/null +++ b/crates/lib/files/hashing/src/bcrypt/bcrypt_password_hash.rs @@ -0,0 +1,25 @@ +use errors::AnyhowResult; + +/// Create a bcrypt password hash. +/// TODO(bt,2023-10-05): Investigate stronger hashing (Argon2??) +/// TODO(bt,2023-10-05): Return something better than an AnyhowResult<()> ! +pub fn bcrypt_password_hash(password: &str) -> AnyhowResult { + let password_hash = bcrypt_lib::hash(password, bcrypt_lib::DEFAULT_COST)?; + Ok(password_hash) +} + +#[cfg(test)] +mod tests { + use crate::bcrypt::bcrypt_password_hash::bcrypt_password_hash; + + #[test] + fn test_bcrypt_password_hashing() { + let password = "password"; + for _ in 0..3 { + // NB(1): Each bcrypt hash has an os-random salt + // NB(2): Testing these takes time (as bcrypt is meant to), so we only test 3. + let password_hash = bcrypt_password_hash(&password).unwrap(); + assert!(bcrypt_lib::verify(password, &password_hash).unwrap()); + } + } +} diff --git a/crates/lib/files/hashing/src/bcrypt/mod.rs b/crates/lib/files/hashing/src/bcrypt/mod.rs new file mode 100644 index 0000000000..49fd0e37d9 --- /dev/null +++ b/crates/lib/files/hashing/src/bcrypt/mod.rs @@ -0,0 +1 @@ +pub mod bcrypt_password_hash; \ No newline at end of file diff --git a/crates/lib/files/hashing/src/lib.rs b/crates/lib/files/hashing/src/lib.rs index 0c563202de..7cadb4203a 100644 --- a/crates/lib/files/hashing/src/lib.rs +++ b/crates/lib/files/hashing/src/lib.rs @@ -18,4 +18,18 @@ #![allow(dead_code)] #![allow(non_snake_case)] +// TODO(bt,2023-10-05): Should this library contain any "user libraries" +// (like password hashing and gravatar), or focus on purely hashing functions? + +// NB: Because we have our own bcrypt module +extern crate bcrypt as bcrypt_lib; + +pub mod bcrypt; + +// NB(bt,2023-10-05): md5 is broken and should not be used for anything seriously. +// We're using it in this case to support email gravatar hashing, which honestly, +// should be carefully reconsidered since all of these hashes are broken and exposed +// to the public. +pub mod md5; + pub mod sha256; diff --git a/crates/lib/files/hashing/src/md5/email_to_gravatar_hash.rs b/crates/lib/files/hashing/src/md5/email_to_gravatar_hash.rs new file mode 100644 index 0000000000..6fbe0e3d29 --- /dev/null +++ b/crates/lib/files/hashing/src/md5/email_to_gravatar_hash.rs @@ -0,0 +1,25 @@ +use md5::{Digest, Md5}; + +/// See https://gravatar.com/ +pub fn email_to_gravatar_hash(email_address: &str) -> String { + let email = email_address.trim().to_lowercase(); + + let mut hasher = Md5::new(); + hasher.update(email); + let hash = hasher.finalize(); + let gravatar_hash = format!("{:x}", hash); + + gravatar_hash +} + +#[cfg(test)] +mod tests { + use crate::md5::email_to_gravatar_hash::email_to_gravatar_hash; + + #[test] + fn test_email_hashing() { + assert_eq!(email_to_gravatar_hash("email@email.com"), "4f64c9f81bb0d4ee969aaf7b4a5a6f40".to_string()); + assert_eq!(email_to_gravatar_hash("foo@bar.com"), "f3ada405ce890b6f8204094deb12d8a8".to_string()); + assert_eq!(email_to_gravatar_hash("brandon@storyteller.ai"), "54bd9d9491cd63f733f0970ae4059438".to_string()); + } +} diff --git a/crates/lib/files/hashing/src/md5/mod.rs b/crates/lib/files/hashing/src/md5/mod.rs new file mode 100644 index 0000000000..cf64006f29 --- /dev/null +++ b/crates/lib/files/hashing/src/md5/mod.rs @@ -0,0 +1 @@ +pub mod email_to_gravatar_hash; \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/users/user/create_account.rs b/crates/lib/mysql_queries/src/queries/users/user/create_account.rs index 7f310f5df5..55053de0eb 100644 --- a/crates/lib/mysql_queries/src/queries/users/user/create_account.rs +++ b/crates/lib/mysql_queries/src/queries/users/user/create_account.rs @@ -23,6 +23,7 @@ pub struct CreateAccountSuccessResult { pub user_id: u64, } +#[derive(Debug, Copy, Clone)] pub enum CreateAccountError { EmailIsTaken, UsernameIsTaken,