diff --git a/Cargo.lock b/Cargo.lock index a487c73..98ef2c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,6 +413,15 @@ dependencies = [ "syn", ] +[[package]] +name = "addr2line" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b" +dependencies = [ + "gimli", +] + [[package]] name = "adler" version = "1.0.2" @@ -573,6 +582,21 @@ dependencies = [ "rand", ] +[[package]] +name = "backtrace" +version = "0.3.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "base-x" version = "0.2.10" @@ -607,7 +631,6 @@ dependencies = [ "actix-web", "actix-web-httpauth", "actix-web-opentelemetry", - "argon2", "async-trait", "cart_manager", "chrono", @@ -620,6 +643,7 @@ dependencies = [ "futures", "futures-util", "gumdrop", + "human-panic", "jemallocator", "log", "messagebus", @@ -627,10 +651,8 @@ dependencies = [ "oauth2", "order_manager", "parking_lot 0.12.0", - "password-hash", "payment_manager", "pretty_env_logger", - "rand_core", "search_manager", "serde", "serde_json", @@ -1116,10 +1138,15 @@ dependencies = [ "database_manager", "dotenv", "fake", + "fs_manager", + "human-panic", "log", "model", + "password-hash", "pretty_env_logger", "rand", + "thiserror", + "tokio", ] [[package]] @@ -1519,6 +1546,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "gimli" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4" + [[package]] name = "git2" version = "0.13.25" @@ -1751,6 +1784,21 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "human-panic" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f357a500abcbd7c5f967c1d45c8838585b36743823b9d43488f24850534e36" +dependencies = [ + "backtrace", + "os_type", + "serde", + "serde_derive", + "termcolor", + "toml", + "uuid", +] + [[package]] name = "humansize" version = "1.1.1" @@ -2184,10 +2232,14 @@ dependencies = [ name = "model" version = "0.1.0" dependencies = [ + "argon2", "chrono", "derive_more", "fake", + "log", + "password-hash", "rand", + "rand_core", "serde", "sqlx", "sqlx-core", @@ -2315,6 +2367,15 @@ dependencies = [ "url", ] +[[package]] +name = "object" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40bec70ba014595f99f7aa110b84331ffe1ee9aece7fe6f387cc7e3ecda4d456" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.10.0" @@ -2412,6 +2473,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "os_type" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3df761f6470298359f84fcfb60d86db02acc22c251c37265c07a3d1057d2389" +dependencies = [ + "regex", +] + [[package]] name = "parking_lot" version = "0.11.2" @@ -2887,6 +2957,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-demangle" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342" + [[package]] name = "rustc_version" version = "0.2.3" diff --git a/actors/database_manager/src/accounts.rs b/actors/database_manager/src/accounts.rs index c3377bd..df4ef64 100644 --- a/actors/database_manager/src/accounts.rs +++ b/actors/database_manager/src/accounts.rs @@ -3,8 +3,7 @@ use fake::Fake; use model::{AccountId, AccountState, Email, FullAccount, Login, PassHash, Role}; use sqlx::PgPool; -use super::Result; -use crate::db_async_handler; +use crate::{db_async_handler, Result}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -40,12 +39,10 @@ FROM accounts } #[cfg_attr(feature = "dummy", derive(fake::Dummy))] -#[derive(actix::Message)] +#[derive(actix::Message, Debug)] #[rtype(result = "Result")] pub struct CreateAccount { - #[cfg_attr(feature = "dummy", dummy("fake::faker::internet::en::FreeEmail"))] pub email: Email, - #[cfg_attr(feature = "dummy", dummy("fake::faker::internet::en::Username"))] pub login: Login, pub pass_hash: PassHash, pub role: Role, diff --git a/actors/database_manager/src/photos.rs b/actors/database_manager/src/photos.rs index 6d11c21..ed21d82 100644 --- a/actors/database_manager/src/photos.rs +++ b/actors/database_manager/src/photos.rs @@ -4,12 +4,40 @@ use crate::Result; pub enum Error { #[error("Failed to create photo")] Create, + #[error("Failed to fetch all photo")] + All, +} + +#[derive(actix::Message)] +#[rtype(result = "Result>")] +pub struct AllPhotos; + +crate::db_async_handler!(AllPhotos, all_photos, Vec, inner_all_photos); + +pub(crate) async fn all_photos<'e, E>(_msg: AllPhotos, pool: E) -> Result> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + sqlx::query_as( + r#" +SELECT id, local_path, file_name +FROM photos +"#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + crate::Error::Photo(Error::All) + }) } #[derive(actix::Message)] #[rtype(result = "Result")] pub struct CreatePhoto { + /// Local FILE path pub local_path: model::LocalPath, + /// Only file name, this part should be also included in `local_path` pub file_name: model::FileName, } diff --git a/actors/database_manager/src/product_photos.rs b/actors/database_manager/src/product_photos.rs index 0afaa5e..dcb6f96 100644 --- a/actors/database_manager/src/product_photos.rs +++ b/actors/database_manager/src/product_photos.rs @@ -1,2 +1,79 @@ +use crate::{db_async_handler, Result}; + #[derive(Debug, thiserror::Error)] -pub enum Error {} +pub enum Error { + #[error("Failed to attach photo to product")] + Create, + #[error("Failed to load all product photos")] + All, +} + +#[derive(actix::Message)] +#[rtype(result = "Result>")] +pub struct AllProductPhotos; + +crate::db_async_handler!( + AllProductPhotos, + all_product_photos, + Vec, + inner_all_product_photos +); + +pub(crate) async fn all_product_photos<'e, E>( + _msg: AllProductPhotos, + pool: E, +) -> Result> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + sqlx::query_as( + r#" +SELECT id, product_id, photo_id +FROM product_photos +"#, + ) + .fetch_all(pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + crate::Error::ProductPhoto(Error::All) + }) +} + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct CreateProductPhoto { + pub product_id: model::ProductId, + pub photo_id: model::PhotoId, +} + +db_async_handler!( + CreateProductPhoto, + create_product_photo, + model::ProductPhoto, + inner_create_product_photo +); + +pub(crate) async fn create_product_photo<'e, E>( + msg: CreateProductPhoto, + pool: E, +) -> Result +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + sqlx::query_as( + r#" +INSERT INTO product_photos(product_id, photo_id) +VALUES ($1, $2) +RETURNING id, product_id, photo_id + "#, + ) + .bind(msg.product_id) + .bind(msg.photo_id) + .fetch_one(pool) + .await + .map_err(|e| { + log::error!("{:?}", e); + crate::Error::ProductPhoto(Error::Create) + }) +} diff --git a/actors/database_manager/src/products.rs b/actors/database_manager/src/products.rs index 4738cb3..5920cb1 100644 --- a/actors/database_manager/src/products.rs +++ b/actors/database_manager/src/products.rs @@ -53,7 +53,7 @@ FROM products } #[cfg_attr(feature = "dummy", derive(fake::Dummy))] -#[derive(Message)] +#[derive(Message, Debug)] #[rtype(result = "Result")] pub struct CreateProduct { pub name: ProductName, diff --git a/actors/fs_manager/src/lib.rs b/actors/fs_manager/src/lib.rs index bdf3b8f..c44f66a 100644 --- a/actors/fs_manager/src/lib.rs +++ b/actors/fs_manager/src/lib.rs @@ -21,6 +21,54 @@ macro_rules! fs_async_handler { }; } +#[macro_export] +macro_rules! query_fs { + ($fs: expr, $msg: expr, default $fail: expr) => { + match $fs.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + $fail + } + Err(e) => { + log::error!("{e:?}"); + $fail + } + } + }; + ($fs: expr, $msg: expr, panic) => { + match $fs.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + panic!("{:?}", e); + } + Err(e) => { + log::error!("{e:?}"); + panic!("{:?}", e); + } + } + }; + + ($fs: expr, $msg: expr, $fail: expr) => { + $crate::query_db!($fs, $msg, $fail, $fail) + }; + + ($fs: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => { + match $fs.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + return Err($db_fail); + } + Err(e) => { + log::error!("{e:?}"); + return Err($act_fail); + } + } + }; +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Can't access file system. Please check privileges")] @@ -98,7 +146,9 @@ pub(crate) async fn remove_file(msg: RemoveFile, config: SharedAppConfig) -> Res } pub struct WriteResult { - pub file_name: FileName, + /// Unique file name created with UUID and original file extension + pub unique_name: FileName, + /// Path to file in local storage pub local_path: LocalPath, } @@ -117,6 +167,8 @@ pub(crate) async fn write_file(msg: WriteFile, config: SharedAppConfig) -> Resul mut stream, } = msg; + log::debug!("Writing file {:?}", file_name); + let p = std::path::Path::new(&file_name); let ext = p .file_name() @@ -126,7 +178,7 @@ pub(crate) async fn write_file(msg: WriteFile, config: SharedAppConfig) -> Resul .and_then(OsStr::to_str) .map(String::from); - let file_name = format!( + let unique_name = format!( "{}{}", uuid::Uuid::new_v4(), ext.map(|s| format!(".{s}")).unwrap_or_default() @@ -139,21 +191,34 @@ pub(crate) async fn write_file(msg: WriteFile, config: SharedAppConfig) -> Resul let path = std::path::PathBuf::new() .join(&output_path) - .join(&file_name); + .join(&unique_name); + log::debug!( + "File {:?} will be written as {:?} at {:?}", + file_name, + unique_name, + path + ); let mut file = match std::fs::File::create(&path) { Ok(f) => f, Err(e) => return Err(Error::CantWrite(e)), }; + let mut counter = 0; while let Some(b) = stream.recv().await { + counter += 1; + if counter % 100_000 == 0 { + log::debug!("Wrote {} for {:?}", counter, file_name); + } match file.write(&[b]) { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::StorageFull => return Err(Error::NoSpace), Err(e) => return Err(Error::CantWrite(e)), } } + log::debug!("File {:?} successfully written", unique_name); + Ok(WriteResult { - file_name: FileName::from(file_name), + unique_name: FileName::from(unique_name), local_path: LocalPath::from(path.to_str().unwrap_or_default().to_string()), }) } diff --git a/api/Cargo.toml b/api/Cargo.toml index a02cd93..799f4af 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -15,6 +15,8 @@ search_manager = { path = "../actors/search_manager" } token_manager = { path = "../actors/token_manager" } fs_manager = { path = "../actors/fs_manager" } +human-panic = { version = "1.0.3" } + actix = { version = "0.13", features = [] } actix-rt = { version = "2.7", features = [] } actix-web = { version = "4.0", features = [] } @@ -56,10 +58,6 @@ dotenv = { version = "0.15", features = [] } derive_more = { version = "0.99", features = [] } parking_lot = { version = "0.12", features = [] } -password-hash = { version = "0.4", features = ["alloc"] } -argon2 = { version = "0.4", features = ["parallel", "password-hash"] } -rand_core = { version = "0.6", features = ["std"] } - tokio = { version = "1.17", features = ["full"] } futures = { version = "0.3", features = [] } futures-util = { version = "0.3", features = [] } diff --git a/api/src/logic/mod.rs b/api/src/logic/mod.rs index a66f398..7d93bff 100644 --- a/api/src/logic/mod.rs +++ b/api/src/logic/mod.rs @@ -1,25 +1 @@ -use argon2::{Algorithm, Argon2, Params, Version}; -use model::Password; -use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; - -use crate::PassHash; - -mod order_state; - -pub fn encrypt_password(pass: &Password, salt: &SaltString) -> password_hash::Result { - log::debug!("Hashing password {:?}", pass); - Ok( - Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) - .hash_password(pass.as_bytes(), &salt)? - .to_string(), - ) -} - -pub fn validate_password(pass: &Password, pass_hash: &PassHash) -> password_hash::Result<()> { - log::debug!("Validating password {:?} {:?}", pass, pass_hash); - - Argon2::default().verify_password( - pass.as_bytes(), - &PasswordHash::new(pass_hash.as_str()).expect("Invalid hashed password"), - ) -} +pub mod order_state; diff --git a/api/src/main.rs b/api/src/main.rs index 57625cb..b3ab0e3 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -11,16 +11,13 @@ use actix_web::{App, HttpServer}; use config::UpdateConfig; use email_manager::TestMail; use jemallocator::Jemalloc; -use model::{Email, Login, PassHash, Password, Role}; +use model::{Email, Encrypt, Login, PassHash, Password, Role}; use opts::{ Command, CreateAccountCmd, CreateAccountOpts, GenerateHashOpts, MigrateOpts, Opts, ServerOpts, TestMailerOpts, }; -use password_hash::SaltString; use validator::{validate_email, validate_length}; -use crate::logic::encrypt_password; - pub mod logic; mod opts; pub mod routes; @@ -119,9 +116,7 @@ async fn migrate(opts: MigrateOpts) -> Result<()> { } async fn generate_hash(_opts: GenerateHashOpts) -> Result<()> { - use argon2::password_hash::rand_core::OsRng; - let salt = SaltString::generate(&mut OsRng); - println!("{salt}"); + model::print_hash(); Ok(()) } @@ -164,7 +159,9 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> { if pass.trim().is_empty() { panic!("Password cannot be empty!"); } - let hash = encrypt_password(&Password::from(pass), &config.lock().web().pass_salt()).unwrap(); + let hash = Password::from(pass) + .encrypt(&config.lock().web().pass_salt()) + .unwrap(); db.send(database_manager::CreateAccount { email: Email::from(opts.email), @@ -204,6 +201,8 @@ async fn test_mailer(opts: TestMailerOpts) -> Result<()> { #[actix_web::main] async fn main() -> Result<()> { + human_panic::setup_panic!(); + dotenv::dotenv().ok(); pretty_env_logger::init(); diff --git a/api/src/routes/admin.rs b/api/src/routes/admin.rs index 9760460..4d7edc8 100644 --- a/api/src/routes/admin.rs +++ b/api/src/routes/admin.rs @@ -6,10 +6,9 @@ use actix_web::web::{scope, Data, Json, ServiceConfig}; use actix_web::{delete, get, post, HttpResponse}; use config::SharedAppConfig; use database_manager::{query_db, Database}; -use model::{Account, Email, Login, PassHash, Password, PasswordConfirmation, Role}; +use model::{Account, Email, Encrypt, Login, PassHash, Password, PasswordConfirmation, Role}; use serde::{Deserialize, Serialize}; -use crate::logic::encrypt_password; use crate::routes; use crate::routes::{RequireLogin, Result}; @@ -79,7 +78,7 @@ async fn sign_in( }, routes::Error::Unauthorized ); - if let Err(e) = crate::logic::validate_password(&payload.password, &user.pass_hash) { + if let Err(e) = payload.password.validate(&user.pass_hash) { log::error!("Password validation failed. {}", e); Err(routes::Error::Unauthorized) } else { @@ -126,7 +125,7 @@ async fn register( response.errors.push(RegisterError::PasswordDiffer); } - let hash = match encrypt_password(&input.password, &config.lock().web().pass_salt()) { + let hash = match input.password.encrypt(&config.lock().web().pass_salt()) { Ok(s) => s, Err(e) => { log::error!("{e:?}"); diff --git a/api/src/routes/admin/api_v1/accounts.rs b/api/src/routes/admin/api_v1/accounts.rs index 4696633..22d60d9 100644 --- a/api/src/routes/admin/api_v1/accounts.rs +++ b/api/src/routes/admin/api_v1/accounts.rs @@ -4,11 +4,11 @@ use actix_web::web::{Data, Json, ServiceConfig}; use actix_web::{get, patch, post, HttpResponse}; use config::SharedAppConfig; use database_manager::Database; -use model::{AccountId, AccountState, PasswordConfirmation}; +use model::{AccountId, AccountState, Encrypt, PasswordConfirmation}; use crate::routes::admin::Error; use crate::routes::RequireLogin; -use crate::{admin_send_db, encrypt_password, routes, Email, Login, PassHash, Password, Role}; +use crate::{admin_send_db, routes, Email, Login, PassHash, Password, Role}; #[get("/accounts")] pub async fn accounts(session: Session, db: Data>) -> routes::Result { @@ -45,7 +45,7 @@ pub async fn update_account( routes::admin::Error::DifferentPasswords, )); } - let hash = match encrypt_password(&p1, &config.lock().web().pass_salt()) { + let hash = match p1.encrypt(&config.lock().web().pass_salt()) { Ok(hash) => hash, Err(e) => { log::error!("{e:?}"); @@ -97,7 +97,7 @@ pub async fn create_account( routes::admin::Error::DifferentPasswords, )); } - let hash = match encrypt_password(&payload.password, &config.lock().web().pass_salt()) { + let hash = match payload.password.encrypt(&config.lock().web().pass_salt()) { Ok(hash) => hash, Err(e) => { log::error!("{e:?}"); diff --git a/api/src/routes/admin/api_v1/uploads.rs b/api/src/routes/admin/api_v1/uploads.rs index 769f522..a405f2c 100644 --- a/api/src/routes/admin/api_v1/uploads.rs +++ b/api/src/routes/admin/api_v1/uploads.rs @@ -64,7 +64,7 @@ async fn upload_product_image( let write = async { let fs_manager::WriteResult { local_path, - file_name, + unique_name: file_name, } = match fs.send(msg).await { Ok(Ok(file_name)) => file_name, Ok(Err(e)) => { diff --git a/api/src/routes/public/api_v1/unrestricted.rs b/api/src/routes/public/api_v1/unrestricted.rs index 5c7d92d..8416b78 100644 --- a/api/src/routes/public/api_v1/unrestricted.rs +++ b/api/src/routes/public/api_v1/unrestricted.rs @@ -1,12 +1,12 @@ use actix::Addr; use actix_web::web::{Data, Json, ServiceConfig}; use actix_web::{get, post, HttpResponse}; +use config::SharedAppConfig; use database_manager::{query_db, Database}; -use model::{Audience, FullAccount, Token, TokenString}; +use model::{Audience, Encrypt, FullAccount, Token, TokenString}; use payment_manager::{PaymentManager, PaymentNotification}; use token_manager::TokenManager; -use crate::logic::validate_password; use crate::routes::public::Error as PublicError; use crate::routes::{self, Result}; use crate::{public_send_db, Login, Password}; @@ -21,6 +21,44 @@ async fn stocks(db: Data>) -> Result { public_send_db!(db.into_inner(), database_manager::AllStocks) } +#[derive(serde::Deserialize)] +pub struct CreateAccountInput { + pub email: model::Email, + pub login: Login, + pub password: Password, + pub password_confirmation: model::PasswordConfirmation, +} + +#[post("/register")] +pub async fn create_account( + db: Data>, + Json(payload): Json, + config: Data, +) -> routes::Result { + if payload.password != payload.password_confirmation { + return Err(routes::Error::Admin( + routes::admin::Error::DifferentPasswords, + )); + } + let hash = match payload.password.encrypt(&config.lock().web().pass_salt()) { + Ok(hash) => hash, + Err(e) => { + log::error!("{e:?}"); + return Err(routes::Error::Admin(routes::admin::Error::HashPass)); + } + }; + + public_send_db!( + db, + database_manager::CreateAccount { + email: payload.email, + login: payload.login, + pass_hash: model::PassHash::from(hash), + role: model::Role::User, + } + ); +} + #[derive(serde::Deserialize)] pub struct SignInInput { pub login: String, @@ -50,7 +88,10 @@ async fn sign_in( routes::Error::Public(PublicError::DatabaseConnection), routes::Error::Public(PublicError::DatabaseConnection) ); - if validate_password(&Password::from(payload.password), &account.pass_hash).is_err() { + if Password::from(payload.password) + .validate(&account.pass_hash) + .is_err() + { return Err(routes::Error::Unauthorized); } diff --git a/assets/examples/images/pexels-Venus-HD-Make-up-and-perfume-2587370.webp b/assets/examples/images/pexels-Venus-HD-Make-up-and-perfume-2587370.webp new file mode 100644 index 0000000..b02cc94 Binary files /dev/null and b/assets/examples/images/pexels-Venus-HD-Make-up-and-perfume-2587370.webp differ diff --git a/assets/examples/images/pexels-alex-azabache-3907507.webp b/assets/examples/images/pexels-alex-azabache-3907507.webp new file mode 100644 index 0000000..b534d57 Binary files /dev/null and b/assets/examples/images/pexels-alex-azabache-3907507.webp differ diff --git a/assets/examples/images/pexels-binoid-cbd-3612182.webp b/assets/examples/images/pexels-binoid-cbd-3612182.webp new file mode 100644 index 0000000..a218d9f Binary files /dev/null and b/assets/examples/images/pexels-binoid-cbd-3612182.webp differ diff --git a/assets/examples/images/pexels-caio-1279107.webp b/assets/examples/images/pexels-caio-1279107.webp new file mode 100644 index 0000000..058e02f Binary files /dev/null and b/assets/examples/images/pexels-caio-1279107.webp differ diff --git a/assets/examples/images/pexels-eprism-studio-335257.webp b/assets/examples/images/pexels-eprism-studio-335257.webp new file mode 100644 index 0000000..d925c69 Binary files /dev/null and b/assets/examples/images/pexels-eprism-studio-335257.webp differ diff --git a/assets/examples/images/pexels-gabriel-freytez-341523.webp b/assets/examples/images/pexels-gabriel-freytez-341523.webp new file mode 100644 index 0000000..962795f Binary files /dev/null and b/assets/examples/images/pexels-gabriel-freytez-341523.webp differ diff --git a/assets/examples/images/pexels-jess-bailey-designs-913135.webp b/assets/examples/images/pexels-jess-bailey-designs-913135.webp new file mode 100644 index 0000000..7ce0dce Binary files /dev/null and b/assets/examples/images/pexels-jess-bailey-designs-913135.webp differ diff --git a/assets/examples/images/pexels-luis-quintero-1738641.webp b/assets/examples/images/pexels-luis-quintero-1738641.webp new file mode 100644 index 0000000..59f5e8b Binary files /dev/null and b/assets/examples/images/pexels-luis-quintero-1738641.webp differ diff --git a/assets/examples/images/pexels-math-90946.webp b/assets/examples/images/pexels-math-90946.webp new file mode 100644 index 0000000..e59e44b Binary files /dev/null and b/assets/examples/images/pexels-math-90946.webp differ diff --git a/assets/examples/images/pexels-mike-380954.webp b/assets/examples/images/pexels-mike-380954.webp new file mode 100644 index 0000000..d71c5de Binary files /dev/null and b/assets/examples/images/pexels-mike-380954.webp differ diff --git a/assets/examples/images/pexels-pixabay-279906.webp b/assets/examples/images/pexels-pixabay-279906.webp new file mode 100644 index 0000000..e230265 Binary files /dev/null and b/assets/examples/images/pexels-pixabay-279906.webp differ diff --git a/db-seed/Cargo.toml b/db-seed/Cargo.toml index 3cf27a7..7224e32 100644 --- a/db-seed/Cargo.toml +++ b/db-seed/Cargo.toml @@ -7,11 +7,14 @@ edition = "2021" model = { path = "../shared/model", version = "0.1", features = ["db", "dummy"] } config = { path = "../shared/config" } database_manager = { path = "../actors/database_manager", features = ["dummy"] } +fs_manager = { path = "../actors/fs_manager", features = [] } actix = { version = "0.13", features = [] } actix-rt = { version = "2.7", features = [] } actix-web = { version = "4.0", features = [] } +tokio = { version = "1.18.1", features = ["full"] } + fake = { version = "2.4.3", features = ["derive", "chrono", "http"] } rand = { version = "0.8.5" } @@ -19,3 +22,9 @@ dotenv = { version = "0.15", features = [] } log = { version = "0.4", features = [] } pretty_env_logger = { version = "0.4", features = [] } + +password-hash = { version = "0.4", features = ["alloc"] } + +thiserror = { version = "1.0.31" } + +human-panic = { version = "1.0.3" } diff --git a/db-seed/src/accounts.rs b/db-seed/src/accounts.rs new file mode 100644 index 0000000..f3c3358 --- /dev/null +++ b/db-seed/src/accounts.rs @@ -0,0 +1,37 @@ +use actix::Addr; +use config::SharedAppConfig; +use database_manager::{query_db, Database}; +use fake::{Fake, Faker}; + +use crate::{Result, SharedState}; + +pub(crate) async fn create_accounts( + db: Addr, + seed: SharedState, + _config: SharedAppConfig, +) -> Result<()> { + let accounts: Vec = + query_db!(db, database_manager::AllAccounts, default vec![]); + if accounts.len() >= 10 { + seed.lock().unwrap().accounts = accounts; + return Ok(()); + } + + let mut accounts = Vec::with_capacity(10); + for _ in 0..10 { + let msg: database_manager::CreateAccount = Faker.fake::(); + + match db.send(msg).await { + Ok(Ok(account)) => accounts.push(account), + Ok(Err(e)) => { + log::error!("{e}") + } + Err(e) => { + log::error!("{e}") + } + } + } + + seed.lock().unwrap().accounts = accounts; + Ok(()) +} diff --git a/db-seed/src/main.rs b/db-seed/src/main.rs index 6cf4a64..000d9d2 100644 --- a/db-seed/src/main.rs +++ b/db-seed/src/main.rs @@ -1,37 +1,63 @@ +mod accounts; +mod photos; +mod product_photos; +mod products; + +use std::sync::{Arc, Mutex}; + use actix::Actor; use config::{AppConfig, UpdateConfig}; -use fake::{Fake, Faker}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Photo with name {0:?} was not found")] + PhotoNotFound(String), + #[error("Failed to create product record in database")] + DbProduct, + #[error("Failed to attach photo {1:?} to product {0:?} in database")] + DbProductPhoto(model::ProductId, model::PhotoId), + #[error("Product with name {0:?} does not exists")] + NoProduct(String), + #[error("Failed to create photo record for file {0:?}")] + WritePhoto(String), +} + +pub type Result = std::result::Result; pub struct Opts; impl UpdateConfig for Opts { fn update_config(&self, _config: &mut AppConfig) {} } +#[derive(Default)] +pub(crate) struct DbSeed { + pub accounts: Vec, + pub products: Vec, + pub photos: Vec, +} + +pub(crate) type SharedState = Arc>; + #[actix_web::main] async fn main() { + human_panic::setup_panic!(); + dotenv::dotenv().ok(); std::env::set_var("RUST_LOG", "DEBUG"); pretty_env_logger::init(); + let db_seed = Arc::new(Mutex::new(DbSeed::default())); let config = config::default_load(&Opts); - let db = database_manager::Database::build(config) + + let db = database_manager::Database::build(config.clone()) .await .unwrap() .start(); - let mut users = Vec::with_capacity(10); - for _ in 0..10 { - match db - .send(Faker.fake::()) - .await - { - Ok(Ok(user)) => users.push(user), - Ok(Err(e)) => { - log::error!("{e}") - } - Err(e) => { - log::error!("{e}") - } - } - } + let res = tokio::join!( + accounts::create_accounts(db.clone(), db_seed.clone(), config.clone()), + products::create_products(db.clone(), db_seed.clone(), config.clone()) + ); + res.0.unwrap(); + res.1.unwrap(); } diff --git a/db-seed/src/photos.rs b/db-seed/src/photos.rs new file mode 100644 index 0000000..70dae09 --- /dev/null +++ b/db-seed/src/photos.rs @@ -0,0 +1,152 @@ +use actix::{Actor, Addr}; +use config::SharedAppConfig; +use database_manager::{query_db, Database}; +use fs_manager::query_fs; +use tokio::io::AsyncReadExt; + +use crate::{Result, SharedState}; + +async fn create_photo( + db: Addr, + seed: SharedState, + fs: Addr, + file: &'static str, +) -> crate::Result<()> { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let read = async move { + let mut file = + tokio::fs::File::open(std::path::Path::new("./assets/examples/images").join(file)) + .await + .unwrap(); + while let Ok(b) = file.read_u8().await { + tx.send(b).unwrap(); + } + }; + + let write = async { + let fs_manager::WriteResult { + unique_name: _, + local_path, + } = query_fs!( + fs, + fs_manager::WriteFile { + file_name: file.to_string(), + stream: rx, + }, + panic + ); + let photo: model::Photo = query_db!( + db, + database_manager::CreatePhoto { + local_path, + file_name: model::FileName::new(file) + }, + crate::Error::WritePhoto(file.into()) + ); + seed.lock().unwrap().photos.push(photo); + Ok(()) + }; + + let (_, res) = tokio::join!(read, write); + log::debug!("Photo {:?} done", file); + res +} + +pub(crate) async fn create_photos( + db: Addr, + seed: SharedState, + config: SharedAppConfig, +) -> Result<()> { + log::debug!("Creating photos"); + let photos: Vec = query_db!(db, database_manager::AllPhotos, default vec![]); + if photos.len() >= 10 { + seed.lock().unwrap().photos = photos; + return Ok(()); + } + + let fs = fs_manager::FsManager::build(config.clone()) + .await + .unwrap() + .start(); + + let results = tokio::join!( + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-alex-azabache-3907507.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-binoid-cbd-3612182.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-caio-1279107.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-eprism-studio-335257.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-gabriel-freytez-341523.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-jess-bailey-designs-913135.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-luis-quintero-1738641.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-math-90946.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-mike-380954.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-pixabay-279906.webp" + ), + create_photo( + db.clone(), + seed.clone(), + fs.clone(), + "pexels-Venus-HD-Make-up-and-perfume-2587370.webp" + ) + ); + results.0.unwrap(); + results.1.unwrap(); + results.2.unwrap(); + results.3.unwrap(); + results.4.unwrap(); + results.5.unwrap(); + results.6.unwrap(); + results.7.unwrap(); + results.8.unwrap(); + results.9.unwrap(); + results.10.unwrap(); + Ok(()) +} diff --git a/db-seed/src/product_photos.rs b/db-seed/src/product_photos.rs new file mode 100644 index 0000000..b380c62 --- /dev/null +++ b/db-seed/src/product_photos.rs @@ -0,0 +1,139 @@ +use actix::Addr; +use config::SharedAppConfig; +use database_manager::{query_db, Database}; + +use crate::{Result, SharedState}; + +async fn create_product_photo( + db: Addr, + seed: SharedState, + name: &'static str, + file_name: &'static str, +) -> crate::Result<()> { + let product_id = { + seed.lock() + .unwrap() + .products + .iter() + .find(|p| { + let _x = 1; + p.name.as_str() == name + }) + .map(|p| p.id) + .ok_or_else(|| { + let _x = 1; + crate::Error::NoProduct(name.into()) + })? + }; + let photo_id = { + seed.lock() + .unwrap() + .photos + .iter() + .find(|p| p.file_name.as_str() == file_name) + .map(|p| p.id) + .ok_or_else(|| crate::Error::PhotoNotFound(file_name.into()))? + }; + + query_db!( + db, + database_manager::CreateProductPhoto { + product_id, + photo_id, + }, + crate::Error::DbProductPhoto(product_id, photo_id) + ); + Ok(()) +} + +pub(crate) async fn create_product_photos( + db: Addr, + seed: SharedState, + _config: SharedAppConfig, +) -> Result<()> { + let product_photos = query_db!(db, database_manager::AllProductPhotos, default vec![]); + if product_photos.len() >= 10 { + return Ok(()); + } + + let results = tokio::join!( + create_product_photo( + db.clone(), + seed.clone(), + "Nikon", + "pexels-alex-azabache-3907507.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Bonoid CBD", + "pexels-binoid-cbd-3612182.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Casio Speaker", + "pexels-caio-1279107.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Eprism Studio", + "pexels-eprism-studio-335257.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Best Phones 2022", + "pexels-gabriel-freytez-341523.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Sweet cake", + "pexels-jess-bailey-designs-913135.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Lexal 128G", + "pexels-luis-quintero-1738641.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Fujifilm X-T10", + "pexels-math-90946.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Sweet Tower", + "pexels-mike-380954.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Nikon Lenses", + "pexels-pixabay-279906.webp" + ), + create_product_photo( + db.clone(), + seed.clone(), + "Venus HD Professional", + "pexels-Venus-HD-Make-up-and-perfume-2587370.webp" + ) + ); + results.0.unwrap(); + results.1.unwrap(); + results.2.unwrap(); + results.3.unwrap(); + results.4.unwrap(); + results.5.unwrap(); + results.6.unwrap(); + results.7.unwrap(); + results.8.unwrap(); + results.9.unwrap(); + results.10.unwrap(); + Ok(()) +} diff --git a/db-seed/src/products.rs b/db-seed/src/products.rs new file mode 100644 index 0000000..c33e5a9 --- /dev/null +++ b/db-seed/src/products.rs @@ -0,0 +1,87 @@ +use actix::Addr; +use config::SharedAppConfig; +use database_manager::{query_db, Database}; +use fake::{Fake, Faker}; +use model::{ProductCategory, ProductName}; + +use crate::product_photos::create_product_photos; +use crate::{Result, SharedState}; + +async fn create_product( + db: Addr, + seed: SharedState, + name: &'static str, + category: &'static str, +) -> crate::Result<()> { + let seed = seed.clone(); + let db = db.clone(); + let product = query_db!( + db, + database_manager::CreateProduct { + name: ProductName::from(String::from(name)), + short_description: Faker.fake(), + long_description: Faker.fake(), + category: Some(ProductCategory::new(category)), + price: Faker.fake(), + deliver_days_flag: Faker.fake(), + }, + crate::Error::DbProduct + ); + + seed.lock().unwrap().products.push(product); + + Ok(()) +} + +pub(crate) async fn create_products( + db: Addr, + seed: SharedState, + config: SharedAppConfig, +) -> Result<()> { + crate::photos::create_photos(db.clone(), seed.clone(), config.clone()) + .await + .unwrap(); + + let products = query_db!(db, database_manager::AllProducts, default vec![]); + if products.len() >= 10 { + { + seed.lock().unwrap().products = products; + } + if let Err(e) = create_product_photos(db.clone(), seed.clone(), config.clone()).await { + log::error!("{e:?}"); + } + return Ok(()); + } + + let results = tokio::join!( + create_product(db.clone(), seed.clone(), "Nikon", "Cameras",), + create_product(db.clone(), seed.clone(), "Bonoid CBD", "Drugstore",), + create_product(db.clone(), seed.clone(), "Casio Speaker", "Speakers",), + create_product(db.clone(), seed.clone(), "Eprism Studio", "Drugstore",), + create_product(db.clone(), seed.clone(), "Best Phones 2022", "Phones",), + create_product(db.clone(), seed.clone(), "Sweet cake", "Sweets",), + create_product(db.clone(), seed.clone(), "Lexal 128G", "Memory",), + create_product(db.clone(), seed.clone(), "Fujifilm X-T10", "Cameras",), + create_product(db.clone(), seed.clone(), "Sweet Tower", "Sweets",), + create_product(db.clone(), seed.clone(), "Nikon Lenses", "Cameras",), + create_product( + db.clone(), + seed.clone(), + "Venus HD Professional", + "Drugstore", + ) + ); + results.0.unwrap(); + results.1.unwrap(); + results.2.unwrap(); + results.3.unwrap(); + results.4.unwrap(); + results.5.unwrap(); + results.6.unwrap(); + results.7.unwrap(); + results.8.unwrap(); + results.9.unwrap(); + results.10.unwrap(); + + create_product_photos(db.clone(), seed.clone(), config.clone()).await +} diff --git a/shared/model/Cargo.toml b/shared/model/Cargo.toml index 41bc782..adfa89c 100644 --- a/shared/model/Cargo.toml +++ b/shared/model/Cargo.toml @@ -22,5 +22,11 @@ thiserror = { version = "1.0.31" } validator = { version = "0.15.0" } -fake = { version = "2.4.3", features = ["derive", "chrono", "http", "uuid"], optional = true } +fake = { version = "2.4.3", features = ["derive", "chrono", "http", "uuid", "dummy"], optional = true } rand = { version = "0.8.5", optional = true } + +password-hash = { version = "0.4", features = ["alloc"] } +argon2 = { version = "0.4", features = ["parallel", "password-hash"] } +rand_core = { version = "0.6", features = ["std"] } + +log = { version = "0.4.17" } diff --git a/shared/model/src/dummy.rs b/shared/model/src/dummy.rs new file mode 100644 index 0000000..3342eb2 --- /dev/null +++ b/shared/model/src/dummy.rs @@ -0,0 +1,78 @@ +use fake::faker::internet::en::{FreeEmail, Password as FakePass, Username}; +use fake::Fake; + +use crate::*; + +impl fake::Dummy for Login { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + Self(Username().fake()) + } +} + +impl fake::Dummy for Email { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + Self(FreeEmail().fake()) + } +} + +impl fake::Dummy for ProductShortDesc { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + use fake::faker::lorem::en::Words; + let words: Vec = Words(5..20).fake(); + Self(words.join(" ")) + } +} + +impl fake::Dummy for ProductLongDesc { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + use fake::faker::lorem::en::Paragraphs; + let words: Vec = Paragraphs(10..20).fake(); + Self(words.join("\n")) + } +} + +impl fake::Dummy for Password { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + let pass: String = FakePass(6..20).fake(); + Self(pass) + } +} + +impl fake::Dummy for PassHash { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + let pass: Password = fake::Faker.fake(); + Self( + pass.encrypt( + &password_hash::SaltString::new("a7sydd98asd98ahsda9shdahd98ahsd9aysd9aysd9y") + .unwrap(), + ) + .unwrap(), + ) + } +} + +impl fake::Dummy for ProductName { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + use fake::faker::lorem::en::Words; + let name: Vec = Words(5..8).fake(); + Self(name.join(" ")) + } +} + +impl fake::Dummy for Price { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + let price = rand::random::() as i32 * 100i32 + rand::random::() as i32; + + let price: i32 = price as i32; + Self(NonNegative(price)) + } +} + +impl fake::Dummy for NonNegative { + fn dummy_with_rng(_config: &T, _rng: &mut R) -> Self { + let price = rand::random::() as i32 * 100i32 + rand::random::() as i32; + + let price: i32 = price as i32; + Self(price) + } +} diff --git a/shared/model/src/encrypt.rs b/shared/model/src/encrypt.rs new file mode 100644 index 0000000..54dff44 --- /dev/null +++ b/shared/model/src/encrypt.rs @@ -0,0 +1,34 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; + +pub trait Encrypt { + fn encrypt(&self, salt: &SaltString) -> password_hash::Result; + + fn validate(&self, pass_hash: &crate::PassHash) -> password_hash::Result<()>; +} + +impl Encrypt for crate::Password { + fn encrypt(&self, salt: &SaltString) -> password_hash::Result { + log::debug!("Hashing password {:?}", self); + Ok( + Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()) + .hash_password(self.as_bytes(), &salt)? + .to_string(), + ) + } + + fn validate(&self, pass_hash: &crate::PassHash) -> password_hash::Result<()> { + log::debug!("Validating password {:?} {:?}", self, pass_hash); + + Argon2::default().verify_password( + self.as_bytes(), + &PasswordHash::new(pass_hash.as_str()).expect("Invalid hashed password"), + ) + } +} + +pub fn print_hash() { + use argon2::password_hash::rand_core::OsRng; + let salt = SaltString::generate(&mut OsRng); + println!("{salt}"); +} diff --git a/shared/model/src/lib.rs b/shared/model/src/lib.rs index 768b5ee..e312a29 100644 --- a/shared/model/src/lib.rs +++ b/shared/model/src/lib.rs @@ -2,15 +2,22 @@ pub mod api; +#[cfg(feature = "dummy")] +mod dummy; +pub mod encrypt; + use std::fmt::Formatter; use std::str::FromStr; use derive_more::{Deref, Display, From}; #[cfg(feature = "dummy")] use fake::Fake; +use rand::Rng; use serde::de::{Error, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; +pub use crate::encrypt::*; + #[derive(Debug, thiserror::Error)] pub enum TransformError { #[error("Given value is below minimal value")] @@ -151,7 +158,6 @@ impl Default for Audience { } } -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Default, Debug, Copy, Clone, Deref, From)] @@ -173,16 +179,12 @@ impl TryFrom for Quantity { } } -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] -#[cfg_attr(feature = "dummy", dummy("fake::faker::internet::en::Username"))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Deserialize, Serialize, Debug, Deref, From, Display)] #[serde(transparent)] pub struct Login(String); -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] -#[cfg_attr(feature = "dummy", dummy("fake::faker::internet::en::FreeEmail"))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Debug, Deref, From, Display)] @@ -230,7 +232,6 @@ impl<'de> serde::Deserialize<'de> for Email { } } -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Default, Debug, Copy, Clone, Deref, Display)] @@ -421,13 +422,18 @@ where } } -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Deref, From, Display)] #[serde(transparent)] pub struct Password(String); +impl Password { + pub fn new>(pass: S) -> Self { + Self(pass.into()) + } +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] @@ -435,7 +441,6 @@ pub struct Password(String); #[serde(transparent)] pub struct PasswordConfirmation(String); -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Deref, From, Display)] @@ -457,7 +462,7 @@ pub struct AccountId(RecordId); #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::FromRow))] -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct FullAccount { pub id: AccountId, pub email: Email, @@ -510,21 +515,18 @@ impl From for Account { #[serde(transparent)] pub struct ProductId(RecordId); -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)] #[serde(transparent)] pub struct ProductName(String); -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)] #[serde(transparent)] pub struct ProductShortDesc(String); -#[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] #[derive(Serialize, Deserialize, Debug, Clone, Deref, Display, From)] @@ -538,9 +540,15 @@ pub struct ProductLongDesc(String); #[serde(transparent)] pub struct ProductCategory(String); +impl ProductCategory { + pub fn new>(s: S) -> Self { + Self(s.into()) + } +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::FromRow))] -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct Product { pub id: ProductId, pub name: ProductName, @@ -732,10 +740,16 @@ pub struct LocalPath(String); #[derive(Serialize, Deserialize, Debug, Deref, Display, From)] pub struct FileName(String); +impl FileName { + pub fn new>(s: S) -> Self { + Self(s.into()) + } +} + #[cfg_attr(feature = "dummy", derive(fake::Dummy))] #[cfg_attr(feature = "db", derive(sqlx::Type))] #[cfg_attr(feature = "db", sqlx(transparent))] -#[derive(Serialize, Deserialize, Debug, Deref, Display, From)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, Deref, Display, From)] pub struct PhotoId(RecordId); #[cfg_attr(feature = "dummy", derive(fake::Dummy))]