From fce4eade7319c0cf2d0975ecf47536f4ee4d6f36 Mon Sep 17 00:00:00 2001 From: Greg Burri Date: Wed, 18 Dec 2024 23:10:19 +0100 Subject: [PATCH] Split db::Connection implementation in submodules (db::user and db::recipe). --- Cargo.lock | 12 +- backend/sql/data_test.sql | 4 +- backend/sql/version_1.sql | 42 +- backend/src/data/db.rs | 1065 +------------------------ backend/src/data/db/recipe.rs | 125 +++ backend/src/data/db/user.rs | 945 ++++++++++++++++++++++ backend/src/data/mod.rs | 1 + backend/src/{ => data}/model.rs | 159 ++-- backend/src/data/utils.rs | 2 +- backend/src/html_templates.rs | 35 +- backend/src/main.rs | 11 +- backend/src/services.rs | 90 ++- backend/templates/base_with_list.html | 8 +- backend/templates/recipe_edit.html | 4 +- backend/templates/recipe_view.html | 18 + backend/templates/sign_up_form.html | 4 +- backend/templates/view_recipe.html | 18 - 17 files changed, 1308 insertions(+), 1235 deletions(-) create mode 100644 backend/src/data/db/recipe.rs create mode 100644 backend/src/data/db/user.rs rename backend/src/{ => data}/model.rs (78%) create mode 100644 backend/templates/recipe_view.html delete mode 100644 backend/templates/view_recipe.html diff --git a/Cargo.lock b/Cargo.lock index 5ab7bfd..8598d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2006,7 +2006,7 @@ dependencies = [ "ron", "serde", "sqlx", - "thiserror 2.0.7", + "thiserror 2.0.8", "tokio", "tower", "tower-http", @@ -2672,11 +2672,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" +checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" dependencies = [ - "thiserror-impl 2.0.7", + "thiserror-impl 2.0.8", ] [[package]] @@ -2692,9 +2692,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.7" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" +checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" dependencies = [ "proc-macro2", "quote", diff --git a/backend/sql/data_test.sql b/backend/sql/data_test.sql index 72ccb3d..af9cc03 100644 --- a/backend/sql/data_test.sql +++ b/backend/sql/data_test.sql @@ -1,4 +1,4 @@ -INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) +INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) VALUES ( 1, 'paul@atreides.com', @@ -8,7 +8,7 @@ VALUES ( NULL ); -INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) +INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) VALUES ( 2, 'alia@atreides.com', diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index 915e321..2462b2b 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -47,7 +47,9 @@ CREATE TABLE [Recipe] ( [id] INTEGER PRIMARY KEY, [user_id] INTEGER, -- Can be null if a user is deleted. [title] TEXT NOT NULL, - [estimate_time] INTEGER, + -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes + [lang] TEXT NOT NULL DEFAULT 'en', + [estimate_time] INTEGER, -- in [s]. [description] TEXT NOT NULL DEFAULT '', [difficulty] INTEGER NOT NULL DEFAULT 0, [servings] INTEGER DEFAULT 4, @@ -61,7 +63,7 @@ CREATE TABLE [Image] ( [recipe_id] INTEGER NOT NULL, [name] TEXT NOT NULL DEFAULT '', [description] TEXT NOT NULL DEFAULT '', - [image] BLOB, + [image] BLOB NOT NULL, FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE ) STRICT; @@ -80,26 +82,30 @@ CREATE TABLE [RecipeTag] ( CREATE TABLE [Tag] ( [id] INTEGER PRIMARY KEY, - [name] TEXT NOT NULL + [name] TEXT NOT NULL, + -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes + [lang] TEXT NOT NULL DEFAULT 'en' ) STRICT; -CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]); +CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]); CREATE TABLE [Ingredient] ( [id] INTEGER PRIMARY KEY, [name] TEXT NOT NULL, + [comment] TEXT NOT NULL DEFAULT '', [quantity_value] REAL, [quantity_unit] TEXT NOT NULL DEFAULT '', - [input_group_id] INTEGER NOT NULL, + [input_step_id] INTEGER NOT NULL, - FOREIGN KEY([input_group_id]) REFERENCES [Group]([id]) ON DELETE CASCADE + FOREIGN KEY([input_step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE ) STRICT; CREATE TABLE [Group] ( [id] INTEGER PRIMARY KEY, [order] INTEGER NOT NULL DEFAULT 0, - [recipe_id] INTEGER, + [recipe_id] INTEGER NOT NULL, [name] TEXT NOT NULL DEFAULT '', + [comment] TEXT NOT NULL DEFAULT '', FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE ) STRICT; @@ -117,14 +123,14 @@ CREATE TABLE [Step] ( CREATE INDEX [Step_order_index] ON [Group]([order]); -CREATE TABLE [IntermediateSubstance] ( - [id] INTEGER PRIMARY KEY, - [name] TEXT NOT NULL DEFAULT '', - [quantity_value] REAL, - [quantity_unit] TEXT NOT NULL DEFAULT '', - [output_group_id] INTEGER NOT NULL, - [input_group_id] INTEGER NOT NULL, - - FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE, - FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE -) STRICT; +-- CREATE TABLE [IntermediateSubstance] ( +-- [id] INTEGER PRIMARY KEY, +-- [name] TEXT NOT NULL DEFAULT '', +-- [quantity_value] REAL, +-- [quantity_unit] TEXT NOT NULL DEFAULT '', +-- [output_group_id] INTEGER NOT NULL, +-- [input_group_id] INTEGER NOT NULL, + +-- FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE, +-- FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE +-- ) STRICT; diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs index b1b7829..3124db9 100644 --- a/backend/src/data/db.rs +++ b/backend/src/data/db.rs @@ -6,8 +6,6 @@ use std::{ str::FromStr, }; -use chrono::{prelude::*, Duration}; -use rand::distributions::{Alphanumeric, DistString}; use sqlx::{ sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, Pool, Sqlite, Transaction, @@ -15,11 +13,11 @@ use sqlx::{ use thiserror::Error; use tracing::{event, Level}; -use crate::{ - consts, - hash::{hash, verify_password}, - model, -}; +use super::model; +use crate::consts; + +pub mod recipe; +pub mod user; const CURRENT_DB_VERSION: u32 = 1; @@ -46,53 +44,6 @@ impl DBError { type Result = std::result::Result; -#[derive(Debug)] -pub enum SignUpResult { - UserAlreadyExists, - UserCreatedWaitingForValidation(String), // Validation token. -} - -#[derive(Debug)] -pub enum UpdateUserResult { - EmailAlreadyTaken, - UserUpdatedWaitingForRevalidation(String), // Validation token. - Ok, -} - -#[derive(Debug)] -pub enum ValidationResult { - UnknownUser, - ValidationExpired, - Ok(String, i64), // Returns token and user id. -} - -#[derive(Debug)] -pub enum SignInResult { - UserNotFound, - WrongPassword, - AccountNotValidated, - Ok(String, i64), // Returns token and user id. -} - -#[derive(Debug)] -pub enum AuthenticationResult { - NotValidToken, - Ok(i64), // Returns user id. -} - -#[derive(Debug)] -pub enum GetTokenResetPasswordResult { - PasswordAlreadyReset, - EmailUnknown, - Ok(String), -} - -#[derive(Debug)] -pub enum ResetPasswordResult { - ResetTokenExpired, - Ok, -} - #[derive(Clone)] pub struct Connection { pool: Pool, @@ -220,472 +171,6 @@ WHERE [type] = 'table' AND [name] = 'Version' } } - pub async fn get_all_recipe_titles(&self) -> Result> { - sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]") - .fetch_all(&self.pool) - .await - .map_err(DBError::from) - } - - pub async fn get_recipe(&self, id: i64) -> Result> { - sqlx::query_as( - r#" -SELECT [id], [user_id], [title], [description] -FROM [Recipe] WHERE [id] = $1 - "#, - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(DBError::from) - } - - #[cfg(test)] - pub async fn get_user_login_info(&self, token: &str) -> Result { - sqlx::query_as( - r#" -SELECT [last_login_datetime], [ip], [user_agent] -FROM [UserLoginToken] WHERE [token] = $1 - "#, - ) - .bind(token) - .fetch_one(&self.pool) - .await - .map_err(DBError::from) - } - - pub async fn load_user(&self, user_id: i64) -> Result> { - sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1") - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(DBError::from) - } - - /// If a new email is given and it doesn't match the current one then it has to be - /// Revalidated. - pub async fn update_user( - &self, - user_id: i64, - new_email: Option<&str>, - new_name: Option<&str>, - new_password: Option<&str>, - ) -> Result { - let mut tx = self.tx().await?; - let hashed_new_password = new_password.map(|p| hash(p).unwrap()); - - let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>( - "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1", - ) - .bind(user_id) - .fetch_one(&mut *tx) - .await?; - - let email_changed = new_email.is_some_and(|new_email| new_email != email); - - // Check if email not already taken. - let validation_token = if email_changed { - if sqlx::query_scalar::<_, i64>( - r#" -SELECT COUNT(*) -FROM [User] -WHERE [email] = $1 - "#, - ) - .bind(new_email.unwrap()) - .fetch_one(&mut *tx) - .await? - > 0 - { - return Ok(UpdateUserResult::EmailAlreadyTaken); - } - - let token = Some(generate_token()); - sqlx::query( - r#" -UPDATE [User] -SET [validation_token] = $2, [validation_token_datetime] = $3 -WHERE [id] = $1 - "#, - ) - .bind(user_id) - .bind(&token) - .bind(Utc::now()) - .execute(&mut *tx) - .await?; - token - } else { - None - }; - - sqlx::query( - r#" -UPDATE [User] -SET [email] = $2, [name] = $3, [password] = $4 -WHERE [id] = $1 - "#, - ) - .bind(user_id) - .bind(new_email.unwrap_or(&email)) - .bind(new_name.unwrap_or(&name)) - .bind(hashed_new_password.unwrap_or(hashed_password)) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(if let Some(validation_token) = validation_token { - UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token) - } else { - UpdateUserResult::Ok - }) - } - - pub async fn sign_up(&self, email: &str, password: &str) -> Result { - self.sign_up_with_given_time(email, password, Utc::now()) - .await - } - - async fn sign_up_with_given_time( - &self, - email: &str, - password: &str, - datetime: DateTime, - ) -> Result { - let mut tx = self.tx().await?; - - let token = match sqlx::query_as::<_, (i64, Option)>( - r#" -SELECT [id], [validation_token] -FROM [User] WHERE [email] = $1 - "#, - ) - .bind(email) - .fetch_optional(&mut *tx) - .await? - { - Some((id, validation_token)) => { - if validation_token.is_none() { - return Ok(SignUpResult::UserAlreadyExists); - } - let token = generate_token(); - let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; - sqlx::query( - r#" -UPDATE [User] -SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4 -WHERE [id] = $1 - "#, - ) - .bind(id) - .bind(&token) - .bind(datetime) - .bind(hashed_password) - .execute(&mut *tx) - .await?; - token - } - None => { - let token = generate_token(); - let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; - sqlx::query( - r#" -INSERT INTO [User] -([email], [validation_token], [validation_token_datetime], [password]) -VALUES ($1, $2, $3, $4) - "#, - ) - .bind(email) - .bind(&token) - .bind(datetime) - .bind(hashed_password) - .execute(&mut *tx) - .await?; - token - } - }; - - tx.commit().await?; - - Ok(SignUpResult::UserCreatedWaitingForValidation(token)) - } - - pub async fn validation( - &self, - token: &str, - validation_time: Duration, - ip: &str, - user_agent: &str, - ) -> Result { - let mut tx = self.tx().await?; - - // There is no index on [validation_token]. Is it useful? - let user_id = match sqlx::query_as::<_, (i64, DateTime)>( - "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1", - ) - .bind(token) - .fetch_optional(&mut *tx) - .await? - { - Some((id, validation_token_datetime)) => { - if Utc::now() - validation_token_datetime > validation_time { - return Ok(ValidationResult::ValidationExpired); - } - sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1") - .bind(id) - .execute(&mut *tx) - .await?; - id - } - None => return Ok(ValidationResult::UnknownUser), - }; - - let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?; - - tx.commit().await?; - Ok(ValidationResult::Ok(token, user_id)) - } - - pub async fn sign_in( - &self, - email: &str, - password: &str, - ip: &str, - user_agent: &str, - ) -> Result { - let mut tx = self.tx().await?; - match sqlx::query_as::<_, (i64, String, Option)>( - "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1", - ) - .bind(email) - .fetch_optional(&mut *tx) - .await? - { - Some((id, stored_password, validation_token)) => { - if validation_token.is_some() { - Ok(SignInResult::AccountNotValidated) - } else if verify_password(password, &stored_password) - .map_err(DBError::from_dyn_error)? - { - let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?; - tx.commit().await?; - Ok(SignInResult::Ok(token, id)) - } else { - Ok(SignInResult::WrongPassword) - } - } - None => Ok(SignInResult::UserNotFound), - } - } - - pub async fn authentication( - &self, - token: &str, - ip: &str, - user_agent: &str, - ) -> Result { - let mut tx = self.tx().await?; - match sqlx::query_as::<_, (i64, i64)>( - "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1", - ) - .bind(token) - .fetch_optional(&mut *tx) - .await? - { - Some((login_id, user_id)) => { - sqlx::query( - r#" -UPDATE [UserLoginToken] -SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4 -WHERE [id] = $1 - "#, - ) - .bind(login_id) - .bind(Utc::now()) - .bind(ip) - .bind(user_agent) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(AuthenticationResult::Ok(user_id)) - } - None => Ok(AuthenticationResult::NotValidToken), - } - } - - pub async fn sign_out(&self, token: &str) -> Result<()> { - let mut tx = self.tx().await?; - match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1") - .bind(token) - .fetch_optional(&mut *tx) - .await? - { - Some(login_id) => { - sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1") - .bind(login_id) - .execute(&mut *tx) - .await?; - tx.commit().await?; - } - None => (), - } - Ok(()) - } - - pub async fn get_token_reset_password( - &self, - email: &str, - validation_time: Duration, - ) -> Result { - let mut tx = self.tx().await?; - - if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option>>( - r#" -SELECT [password_reset_datetime] -FROM [User] -WHERE [email] = $1 - "#, - ) - .bind(email) - .fetch_optional(&mut *tx) - .await? - { - if let Some(db_datetime) = db_datetime_nullable { - if Utc::now() - db_datetime <= validation_time { - return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset); - } - } - } else { - return Ok(GetTokenResetPasswordResult::EmailUnknown); - } - - let token = generate_token(); - - sqlx::query( - r#" -UPDATE [User] -SET [password_reset_token] = $2, [password_reset_datetime] = $3 -WHERE [email] = $1 - "#, - ) - .bind(email) - .bind(&token) - .bind(Utc::now()) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(GetTokenResetPasswordResult::Ok(token)) - } - - pub async fn reset_password( - &self, - new_password: &str, - token: &str, - validation_time: Duration, - ) -> Result { - let mut tx = self.tx().await?; - // There is no index on [password_reset_token]. Is it useful? - if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option>)>( - r#" -SELECT [id], [password_reset_datetime] -FROM [User] -WHERE [password_reset_token] = $1 - "#, - ) - .bind(token) - .fetch_one(&mut *tx) - .await? - { - if Utc::now() - db_datetime > validation_time { - return Ok(ResetPasswordResult::ResetTokenExpired); - } - - // Remove all login tokens (for security reasons). - sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1") - .bind(user_id) - .execute(&mut *tx) - .await?; - - let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?; - - sqlx::query( - r#" -UPDATE [User] -SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL -WHERE [id] = $1 - "#, - ) - .bind(user_id) - .bind(hashed_new_password) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(ResetPasswordResult::Ok) - } else { - Err(DBError::Other( - "Can't reset password: stored token or datetime not set (NULL)".to_string(), - )) - } - } - - pub async fn create_recipe(&self, user_id: i64) -> Result { - let mut tx = self.tx().await?; - - match sqlx::query_scalar::<_, i64>( - r#" -SELECT [Recipe].[id] FROM [Recipe] -LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id] -LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] -WHERE [Recipe].[user_id] = $1 - AND [Recipe].[title] = '' - AND [Recipe].[estimate_time] IS NULL - AND [Recipe].[description] = '' - AND [Image].[id] IS NULL - AND [Group].[id] IS NULL - "#, - ) - .bind(user_id) - .fetch_optional(&mut *tx) - .await? - { - Some(recipe_id) => Ok(recipe_id), - None => { - let db_result = - sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')") - .bind(user_id) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(db_result.last_insert_rowid()) - } - } - } - - pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> { - sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1") - .bind(recipe_id) - .bind(title) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(DBError::from) - } - - pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> { - sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1") - .bind(recipe_id) - .bind(description) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(DBError::from) - } - /// Execute a given SQL file. pub async fn execute_file + fmt::Display>(&self, file: P) -> Result<()> { let sql = load_sql_file(file)?; @@ -706,31 +191,6 @@ WHERE [Recipe].[user_id] = $1 .map(|db_result| db_result.rows_affected()) .map_err(DBError::from) } - - // Return the token. - async fn create_login_token( - tx: &mut sqlx::Transaction<'_, Sqlite>, - user_id: i64, - ip: &str, - user_agent: &str, - ) -> Result { - let token = generate_token(); - sqlx::query( - r#" -INSERT INTO [UserLoginToken] -([user_id], [last_login_datetime], [token], [ip], [user_agent]) -VALUES ($1, $2, $3, $4, $5) - "#, - ) - .bind(user_id) - .bind(Utc::now()) - .bind(&token) - .bind(ip) - .bind(user_agent) - .execute(&mut **tx) - .await?; - Ok(token) - } } fn load_sql_file + fmt::Display>(sql_file: P) -> Result { @@ -752,515 +212,8 @@ fn load_sql_file + fmt::Display>(sql_file: P) -> Result { Ok(sql) } -fn generate_token() -> String { - Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn sign_up() -> Result<()> { - let connection = Connection::new_in_memory().await?; - match connection.sign_up("paul@atreides.com", "12345").await? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_to_an_already_existing_user() -> Result<()> { - let connection = Connection::new_in_memory().await?; - connection.execute_sql( - sqlx::query( - r#" -INSERT INTO - [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) - VALUES ( - 1, - 'paul@atreides.com', - 'paul', - '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', - 0, - NULL - ); - "#)).await?; - match connection.sign_up("paul@atreides.com", "12345").await? { - SignUpResult::UserAlreadyExists => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_and_sign_in_without_validation() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - let password = "12345"; - - match connection.sign_up(email, password).await? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - - match connection - .sign_in(email, password, "127.0.0.1", "Mozilla/5.0") - .await? - { - SignInResult::AccountNotValidated => (), // Nominal case. - other => panic!("{:?}", other), - } - - Ok(()) - } - - #[tokio::test] - async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> { - let connection = Connection::new_in_memory().await?; - let token = generate_token(); - connection.execute_sql( - sqlx::query( - r#" -INSERT INTO [User] - ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) -VALUES ( - 1, - 'paul@atreides.com', - 'paul', - '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', - 0, - $1 -) - "# - ).bind(token)).await?; - match connection.sign_up("paul@atreides.com", "12345").await? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_at_time() -> Result<()> { - let connection = Connection::new_in_memory().await?; - let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla/5.0", - ) - .await? - { - ValidationResult::Ok(_, _) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_too_late() -> Result<()> { - let connection = Connection::new_in_memory().await?; - let validation_token = match connection - .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1)) - .await? - { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla/5.0", - ) - .await? - { - ValidationResult::ValidationExpired => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_with_bad_token() -> Result<()> { - let connection = Connection::new_in_memory().await?; - let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - let random_token = generate_token(); - match connection - .validation( - &random_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla/5.0", - ) - .await? - { - ValidationResult::UnknownUser => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_then_sign_in() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - let password = "12345"; - - // Sign up. - let validation_token = match connection.sign_up(email, password).await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - - // Validation. - match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla/5.0", - ) - .await? - { - ValidationResult::Ok(_, _) => (), - other => panic!("{:?}", other), - }; - - // Sign in. - match connection - .sign_in(email, password, "127.0.0.1", "Mozilla/5.0") - .await? - { - SignInResult::Ok(_, _) => (), // Nominal case. - other => panic!("{:?}", other), - } - - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_then_authentication() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - let password = "12345"; - - // Sign up. - let validation_token = match connection.sign_up(email, password).await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - - // Validation. - let (authentication_token, _user_id) = match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla", - ) - .await? - { - ValidationResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - - // Check user login information. - let user_login_info_1 = connection - .get_user_login_info(&authentication_token) - .await?; - assert_eq!(user_login_info_1.ip, "127.0.0.1"); - assert_eq!(user_login_info_1.user_agent, "Mozilla"); - - // Authentication. - let _user_id = match connection - .authentication(&authentication_token, "192.168.1.1", "Chrome") - .await? - { - AuthenticationResult::Ok(user_id) => user_id, // Nominal case. - other => panic!("{:?}", other), - }; - - // Check user login information. - let user_login_info_2 = connection - .get_user_login_info(&authentication_token) - .await?; - - assert_eq!(user_login_info_2.ip, "192.168.1.1"); - assert_eq!(user_login_info_2.user_agent, "Chrome"); - - Ok(()) - } - - #[tokio::test] - async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - let password = "12345"; - - // Sign up. - let validation_token = match connection.sign_up(email, password).await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - - // Validation. - let (authentication_token_1, user_id_1) = match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla", - ) - .await? - { - ValidationResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - - // Check user login information. - let user_login_info_1 = connection - .get_user_login_info(&authentication_token_1) - .await?; - assert_eq!(user_login_info_1.ip, "127.0.0.1"); - assert_eq!(user_login_info_1.user_agent, "Mozilla"); - - // Sign out. - connection.sign_out(&authentication_token_1).await?; - - // Sign in. - let (authentication_token_2, user_id_2) = match connection - .sign_in(email, password, "192.168.1.1", "Chrome") - .await? - { - SignInResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - - assert_eq!(user_id_1, user_id_2); - assert_ne!(authentication_token_1, authentication_token_2); - - // Check user login information. - let user_login_info_2 = connection - .get_user_login_info(&authentication_token_2) - .await?; - - assert_eq!(user_login_info_2.ip, "192.168.1.1"); - assert_eq!(user_login_info_2.user_agent, "Chrome"); - - Ok(()) - } - - #[tokio::test] - async fn ask_to_reset_password_for_unknown_email() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - - // Ask for password reset. - match connection - .get_token_reset_password(email, Duration::hours(1)) - .await? - { - GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case. - other => panic!("{:?}", other), - } - } - - #[tokio::test] - async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - let email = "paul@atreides.com"; - let password = "12345"; - let new_password = "54321"; - - // Sign up. - let validation_token = match connection.sign_up(email, password).await? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - - // Validation. - let (authentication_token_1, user_id_1) = match connection - .validation( - &validation_token, - Duration::hours(1), - "127.0.0.1", - "Mozilla", - ) - .await? - { - ValidationResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - - // Check user login information. - let user_login_info_1 = connection - .get_user_login_info(&authentication_token_1) - .await?; - assert_eq!(user_login_info_1.ip, "127.0.0.1"); - assert_eq!(user_login_info_1.user_agent, "Mozilla"); - - // Sign out. - connection.sign_out(&authentication_token_1).await?; - - // Ask for password reset. - let token = match connection - .get_token_reset_password(email, Duration::hours(1)) - .await? - { - GetTokenResetPasswordResult::Ok(token) => token, - other => panic!("{:?}", other), - }; - - connection - .reset_password(&new_password, &token, Duration::hours(1)) - .await?; +// #[cfg(test)] +// mod tests { +// use super::*; - // Sign in. - let (authentication_token_2, user_id_2) = match connection - .sign_in(email, new_password, "192.168.1.1", "Chrome") - .await? - { - SignInResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - - assert_eq!(user_id_1, user_id_2); - assert_ne!(authentication_token_1, authentication_token_2); - - // Check user login information. - let user_login_info_2 = connection - .get_user_login_info(&authentication_token_2) - .await?; - - assert_eq!(user_login_info_2.ip, "192.168.1.1"); - assert_eq!(user_login_info_2.user_agent, "Chrome"); - - Ok(()) - } - - #[tokio::test] - async fn update_user() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - connection.execute_sql( - sqlx::query( - r#" -INSERT INTO [User] - ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) -VALUES - ($1, $2, $3, $4, $5, $6) - "# - ) - .bind(1) - .bind("paul@atreides.com") - .bind("paul") - .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") - .bind("2022-11-29 22:05:04.121407300+00:00") - .bind(None::<&str>) // 'null'. - ).await?; - - let user = connection.load_user(1).await?.unwrap(); - - assert_eq!(user.name, "paul"); - assert_eq!(user.email, "paul@atreides.com"); - - if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection - .update_user( - 1, - Some("muaddib@fremen.com"), - Some("muaddib"), - Some("Chani"), - ) - .await? - { - let (_authentication_token_1, user_id_1) = match connection - .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0") - .await? - { - ValidationResult::Ok(token, user_id) => (token, user_id), - other => panic!("{:?}", other), - }; - assert_eq!(user_id_1, 1); - } else { - panic!("A revalidation token must be created when changin e-mail"); - } - - let user = connection.load_user(1).await?.unwrap(); - - assert_eq!(user.name, "muaddib"); - assert_eq!(user.email, "muaddib@fremen.com"); - - // Tests if password has been updated correctly. - if let SignInResult::Ok(_token, id) = connection - .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0") - .await? - { - assert_eq!(id, 1); - } else { - panic!("Can't sign in"); - } - - Ok(()) - } - - #[tokio::test] - async fn create_a_new_recipe_then_update_its_title() -> Result<()> { - let connection = Connection::new_in_memory().await?; - - connection.execute_sql( - sqlx::query( - r#" -INSERT INTO [User] - ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) -VALUES - ($1, $2, $3, $4, $5, $6) - "# - ) - .bind(1) - .bind("paul@atreides.com") - .bind("paul") - .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") - .bind("2022-11-29 22:05:04.121407300+00:00") - .bind(None::<&str>) // 'null'. - ).await?; - - match connection.create_recipe(2).await { - Err(DBError::Sqlx(sqlx::Error::Database(err))) => { - // SQLITE_CONSTRAINT_FOREIGNKEY - // https://www.sqlite.org/rescode.html#constraint_foreignkey - assert_eq!(err.code(), Some(std::borrow::Cow::from("787"))); - } // Nominal case. TODO: check 'err' value. - other => panic!( - "Creating a recipe with an inexistant user must fail: {:?}", - other - ), - } - - let recipe_id = connection.create_recipe(1).await?; - assert_eq!(recipe_id, 1); - - connection.set_recipe_title(recipe_id, "Crêpe").await?; - let recipe = connection.get_recipe(recipe_id).await?.unwrap(); - assert_eq!(recipe.title, "Crêpe".to_string()); - - Ok(()) - } -} +// } diff --git a/backend/src/data/db/recipe.rs b/backend/src/data/db/recipe.rs new file mode 100644 index 0000000..a942e7e --- /dev/null +++ b/backend/src/data/db/recipe.rs @@ -0,0 +1,125 @@ +use super::{model, Connection, DBError, Result}; + +impl Connection { + pub async fn get_all_recipe_titles(&self) -> Result> { + sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]") + .fetch_all(&self.pool) + .await + .map_err(DBError::from) + } + + pub async fn get_recipe(&self, id: i64) -> Result> { + sqlx::query_as( + r#" +SELECT [id], [user_id], [title], [description] +FROM [Recipe] WHERE [id] = $1 + "#, + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(DBError::from) + } + + pub async fn create_recipe(&self, user_id: i64) -> Result { + let mut tx = self.tx().await?; + + match sqlx::query_scalar::<_, i64>( + r#" +SELECT [Recipe].[id] FROM [Recipe] +LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id] +LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id] +WHERE [Recipe].[user_id] = $1 + AND [Recipe].[title] = '' + AND [Recipe].[estimate_time] IS NULL + AND [Recipe].[description] = '' + AND [Image].[id] IS NULL + AND [Group].[id] IS NULL + "#, + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await? + { + Some(recipe_id) => Ok(recipe_id), + None => { + let db_result = + sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')") + .bind(user_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(db_result.last_insert_rowid()) + } + } + } + + pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(title) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } + + pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> { + sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1") + .bind(recipe_id) + .bind(description) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(DBError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn create_a_new_recipe_then_update_its_title() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + connection.execute_sql( + sqlx::query( + r#" +INSERT INTO [User] + ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) +VALUES + ($1, $2, $3, $4, $5, $6) + "# + ) + .bind(1) + .bind("paul@atreides.com") + .bind("paul") + .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") + .bind("2022-11-29 22:05:04.121407300+00:00") + .bind(None::<&str>) // 'null'. + ).await?; + + match connection.create_recipe(2).await { + Err(DBError::Sqlx(sqlx::Error::Database(err))) => { + // SQLITE_CONSTRAINT_FOREIGNKEY + // https://www.sqlite.org/rescode.html#constraint_foreignkey + assert_eq!(err.code(), Some(std::borrow::Cow::from("787"))); + } // Nominal case. TODO: check 'err' value. + other => panic!( + "Creating a recipe with an inexistant user must fail: {:?}", + other + ), + } + + let recipe_id = connection.create_recipe(1).await?; + assert_eq!(recipe_id, 1); + + connection.set_recipe_title(recipe_id, "Crêpe").await?; + let recipe = connection.get_recipe(recipe_id).await?.unwrap(); + assert_eq!(recipe.title, "Crêpe".to_string()); + + Ok(()) + } +} diff --git a/backend/src/data/db/user.rs b/backend/src/data/db/user.rs new file mode 100644 index 0000000..06d6353 --- /dev/null +++ b/backend/src/data/db/user.rs @@ -0,0 +1,945 @@ +use chrono::{prelude::*, Duration}; +use rand::distributions::{Alphanumeric, DistString}; +use sqlx::Sqlite; + +use super::{model, Connection, DBError, Result}; +use crate::{ + consts, + hash::{hash, verify_password}, +}; + +#[derive(Debug)] +pub enum SignUpResult { + UserAlreadyExists, + UserCreatedWaitingForValidation(String), // Validation token. +} + +#[derive(Debug)] +pub enum UpdateUserResult { + EmailAlreadyTaken, + UserUpdatedWaitingForRevalidation(String), // Validation token. + Ok, +} + +#[derive(Debug)] +pub enum ValidationResult { + UnknownUser, + ValidationExpired, + Ok(String, i64), // Returns token and user id. +} + +#[derive(Debug)] +pub enum SignInResult { + UserNotFound, + WrongPassword, + AccountNotValidated, + Ok(String, i64), // Returns token and user id. +} + +#[derive(Debug)] +pub enum AuthenticationResult { + NotValidToken, + Ok(i64), // Returns user id. +} + +#[derive(Debug)] +pub enum GetTokenResetPasswordResult { + PasswordAlreadyReset, + EmailUnknown, + Ok(String), +} + +#[derive(Debug)] +pub enum ResetPasswordResult { + ResetTokenExpired, + Ok, +} + +fn generate_token() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE) +} + +impl Connection { + #[cfg(test)] + pub async fn get_user_login_info(&self, token: &str) -> Result { + sqlx::query_as( + r#" +SELECT [last_login_datetime], [ip], [user_agent] +FROM [UserLoginToken] WHERE [token] = $1 + "#, + ) + .bind(token) + .fetch_one(&self.pool) + .await + .map_err(DBError::from) + } + + pub async fn load_user(&self, user_id: i64) -> Result> { + sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1") + .bind(user_id) + .fetch_optional(&self.pool) + .await + .map_err(DBError::from) + } + + /// If a new email is given and it doesn't match the current one then it has to be + /// Revalidated. + pub async fn update_user( + &self, + user_id: i64, + new_email: Option<&str>, + new_name: Option<&str>, + new_password: Option<&str>, + ) -> Result { + let mut tx = self.tx().await?; + let hashed_new_password = new_password.map(|p| hash(p).unwrap()); + + let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>( + "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + let email_changed = new_email.is_some_and(|new_email| new_email != email); + + // Check if email not already taken. + let validation_token = if email_changed { + if sqlx::query_scalar::<_, i64>( + r#" +SELECT COUNT(*) +FROM [User] +WHERE [email] = $1 + "#, + ) + .bind(new_email.unwrap()) + .fetch_one(&mut *tx) + .await? + > 0 + { + return Ok(UpdateUserResult::EmailAlreadyTaken); + } + + let token = Some(generate_token()); + sqlx::query( + r#" +UPDATE [User] +SET [validation_token] = $2, [validation_token_datetime] = $3 +WHERE [id] = $1 + "#, + ) + .bind(user_id) + .bind(&token) + .bind(Utc::now()) + .execute(&mut *tx) + .await?; + token + } else { + None + }; + + sqlx::query( + r#" +UPDATE [User] +SET [email] = $2, [name] = $3, [password] = $4 +WHERE [id] = $1 + "#, + ) + .bind(user_id) + .bind(new_email.unwrap_or(&email)) + .bind(new_name.unwrap_or(&name)) + .bind(hashed_new_password.unwrap_or(hashed_password)) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(if let Some(validation_token) = validation_token { + UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token) + } else { + UpdateUserResult::Ok + }) + } + + pub async fn sign_up(&self, email: &str, password: &str) -> Result { + self.sign_up_with_given_time(email, password, Utc::now()) + .await + } + + async fn sign_up_with_given_time( + &self, + email: &str, + password: &str, + datetime: DateTime, + ) -> Result { + let mut tx = self.tx().await?; + + let token = match sqlx::query_as::<_, (i64, Option)>( + r#" +SELECT [id], [validation_token] +FROM [User] WHERE [email] = $1 + "#, + ) + .bind(email) + .fetch_optional(&mut *tx) + .await? + { + Some((id, validation_token)) => { + if validation_token.is_none() { + return Ok(SignUpResult::UserAlreadyExists); + } + let token = generate_token(); + let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; + sqlx::query( + r#" +UPDATE [User] +SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4 +WHERE [id] = $1 + "#, + ) + .bind(id) + .bind(&token) + .bind(datetime) + .bind(hashed_password) + .execute(&mut *tx) + .await?; + token + } + None => { + let token = generate_token(); + let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; + sqlx::query( + r#" +INSERT INTO [User] +([email], [validation_token], [validation_token_datetime], [password]) +VALUES ($1, $2, $3, $4) + "#, + ) + .bind(email) + .bind(&token) + .bind(datetime) + .bind(hashed_password) + .execute(&mut *tx) + .await?; + token + } + }; + + tx.commit().await?; + + Ok(SignUpResult::UserCreatedWaitingForValidation(token)) + } + + pub async fn validation( + &self, + token: &str, + validation_time: Duration, + ip: &str, + user_agent: &str, + ) -> Result { + let mut tx = self.tx().await?; + + // There is no index on [validation_token]. Is it useful? + let user_id = match sqlx::query_as::<_, (i64, DateTime)>( + "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1", + ) + .bind(token) + .fetch_optional(&mut *tx) + .await? + { + Some((id, validation_token_datetime)) => { + if Utc::now() - validation_token_datetime > validation_time { + return Ok(ValidationResult::ValidationExpired); + } + sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1") + .bind(id) + .execute(&mut *tx) + .await?; + id + } + None => return Ok(ValidationResult::UnknownUser), + }; + + let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?; + + tx.commit().await?; + Ok(ValidationResult::Ok(token, user_id)) + } + + pub async fn sign_in( + &self, + email: &str, + password: &str, + ip: &str, + user_agent: &str, + ) -> Result { + let mut tx = self.tx().await?; + match sqlx::query_as::<_, (i64, String, Option)>( + "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1", + ) + .bind(email) + .fetch_optional(&mut *tx) + .await? + { + Some((id, stored_password, validation_token)) => { + if validation_token.is_some() { + Ok(SignInResult::AccountNotValidated) + } else if verify_password(password, &stored_password) + .map_err(DBError::from_dyn_error)? + { + let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?; + tx.commit().await?; + Ok(SignInResult::Ok(token, id)) + } else { + Ok(SignInResult::WrongPassword) + } + } + None => Ok(SignInResult::UserNotFound), + } + } + + pub async fn authentication( + &self, + token: &str, + ip: &str, + user_agent: &str, + ) -> Result { + let mut tx = self.tx().await?; + match sqlx::query_as::<_, (i64, i64)>( + "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1", + ) + .bind(token) + .fetch_optional(&mut *tx) + .await? + { + Some((login_id, user_id)) => { + sqlx::query( + r#" +UPDATE [UserLoginToken] +SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4 +WHERE [id] = $1 + "#, + ) + .bind(login_id) + .bind(Utc::now()) + .bind(ip) + .bind(user_agent) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(AuthenticationResult::Ok(user_id)) + } + None => Ok(AuthenticationResult::NotValidToken), + } + } + + pub async fn sign_out(&self, token: &str) -> Result<()> { + let mut tx = self.tx().await?; + match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1") + .bind(token) + .fetch_optional(&mut *tx) + .await? + { + Some(login_id) => { + sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1") + .bind(login_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + } + None => (), + } + Ok(()) + } + + pub async fn get_token_reset_password( + &self, + email: &str, + validation_time: Duration, + ) -> Result { + let mut tx = self.tx().await?; + + if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option>>( + r#" +SELECT [password_reset_datetime] +FROM [User] +WHERE [email] = $1 + "#, + ) + .bind(email) + .fetch_optional(&mut *tx) + .await? + { + if let Some(db_datetime) = db_datetime_nullable { + if Utc::now() - db_datetime <= validation_time { + return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset); + } + } + } else { + return Ok(GetTokenResetPasswordResult::EmailUnknown); + } + + let token = generate_token(); + + sqlx::query( + r#" +UPDATE [User] +SET [password_reset_token] = $2, [password_reset_datetime] = $3 +WHERE [email] = $1 + "#, + ) + .bind(email) + .bind(&token) + .bind(Utc::now()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(GetTokenResetPasswordResult::Ok(token)) + } + + pub async fn reset_password( + &self, + new_password: &str, + token: &str, + validation_time: Duration, + ) -> Result { + let mut tx = self.tx().await?; + // There is no index on [password_reset_token]. Is it useful? + if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option>)>( + r#" +SELECT [id], [password_reset_datetime] +FROM [User] +WHERE [password_reset_token] = $1 + "#, + ) + .bind(token) + .fetch_one(&mut *tx) + .await? + { + if Utc::now() - db_datetime > validation_time { + return Ok(ResetPasswordResult::ResetTokenExpired); + } + + // Remove all login tokens (for security reasons). + sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + + let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?; + + sqlx::query( + r#" +UPDATE [User] +SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL +WHERE [id] = $1 + "#, + ) + .bind(user_id) + .bind(hashed_new_password) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(ResetPasswordResult::Ok) + } else { + Err(DBError::Other( + "Can't reset password: stored token or datetime not set (NULL)".to_string(), + )) + } + } + + // Return the token. + async fn create_login_token( + tx: &mut sqlx::Transaction<'_, Sqlite>, + user_id: i64, + ip: &str, + user_agent: &str, + ) -> Result { + let token = generate_token(); + sqlx::query( + r#" +INSERT INTO [UserLoginToken] +([user_id], [last_login_datetime], [token], [ip], [user_agent]) +VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(user_id) + .bind(Utc::now()) + .bind(&token) + .bind(ip) + .bind(user_agent) + .execute(&mut **tx) + .await?; + Ok(token) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sign_up() -> Result<()> { + let connection = Connection::new_in_memory().await?; + match connection.sign_up("paul@atreides.com", "12345").await? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_to_an_already_existing_user() -> Result<()> { + let connection = Connection::new_in_memory().await?; + connection.execute_sql( + sqlx::query( + r#" +INSERT INTO + [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) + VALUES ( + 1, + 'paul@atreides.com', + 'paul', + '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', + 0, + NULL + ); + "#)).await?; + match connection.sign_up("paul@atreides.com", "12345").await? { + SignUpResult::UserAlreadyExists => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_and_sign_in_without_validation() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + let password = "12345"; + + match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + + match connection + .sign_in(email, password, "127.0.0.1", "Mozilla/5.0") + .await? + { + SignInResult::AccountNotValidated => (), // Nominal case. + other => panic!("{:?}", other), + } + + Ok(()) + } + + #[tokio::test] + async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> { + let connection = Connection::new_in_memory().await?; + let token = generate_token(); + connection.execute_sql( + sqlx::query( + r#" +INSERT INTO [User] + ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) +VALUES ( + 1, + 'paul@atreides.com', + 'paul', + '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', + 0, + $1 +) + "# + ).bind(token)).await?; + match connection.sign_up("paul@atreides.com", "12345").await? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_at_time() -> Result<()> { + let connection = Connection::new_in_memory().await?; + let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla/5.0", + ) + .await? + { + ValidationResult::Ok(_, _) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_too_late() -> Result<()> { + let connection = Connection::new_in_memory().await?; + let validation_token = match connection + .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1)) + .await? + { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla/5.0", + ) + .await? + { + ValidationResult::ValidationExpired => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_with_bad_token() -> Result<()> { + let connection = Connection::new_in_memory().await?; + let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + let random_token = generate_token(); + match connection + .validation( + &random_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla/5.0", + ) + .await? + { + ValidationResult::UnknownUser => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_then_sign_in() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + let password = "12345"; + + // Sign up. + let validation_token = match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + + // Validation. + match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla/5.0", + ) + .await? + { + ValidationResult::Ok(_, _) => (), + other => panic!("{:?}", other), + }; + + // Sign in. + match connection + .sign_in(email, password, "127.0.0.1", "Mozilla/5.0") + .await? + { + SignInResult::Ok(_, _) => (), // Nominal case. + other => panic!("{:?}", other), + } + + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_then_authentication() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + let password = "12345"; + + // Sign up. + let validation_token = match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + + // Validation. + let (authentication_token, _user_id) = match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla", + ) + .await? + { + ValidationResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + // Check user login information. + let user_login_info_1 = connection + .get_user_login_info(&authentication_token) + .await?; + assert_eq!(user_login_info_1.ip, "127.0.0.1"); + assert_eq!(user_login_info_1.user_agent, "Mozilla"); + + // Authentication. + let _user_id = match connection + .authentication(&authentication_token, "192.168.1.1", "Chrome") + .await? + { + AuthenticationResult::Ok(user_id) => user_id, // Nominal case. + other => panic!("{:?}", other), + }; + + // Check user login information. + let user_login_info_2 = connection + .get_user_login_info(&authentication_token) + .await?; + + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } + + #[tokio::test] + async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + let password = "12345"; + + // Sign up. + let validation_token = match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + + // Validation. + let (authentication_token_1, user_id_1) = match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla", + ) + .await? + { + ValidationResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + // Check user login information. + let user_login_info_1 = connection + .get_user_login_info(&authentication_token_1) + .await?; + assert_eq!(user_login_info_1.ip, "127.0.0.1"); + assert_eq!(user_login_info_1.user_agent, "Mozilla"); + + // Sign out. + connection.sign_out(&authentication_token_1).await?; + + // Sign in. + let (authentication_token_2, user_id_2) = match connection + .sign_in(email, password, "192.168.1.1", "Chrome") + .await? + { + SignInResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + assert_eq!(user_id_1, user_id_2); + assert_ne!(authentication_token_1, authentication_token_2); + + // Check user login information. + let user_login_info_2 = connection + .get_user_login_info(&authentication_token_2) + .await?; + + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } + + #[tokio::test] + async fn ask_to_reset_password_for_unknown_email() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + + // Ask for password reset. + match connection + .get_token_reset_password(email, Duration::hours(1)) + .await? + { + GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case. + other => panic!("{:?}", other), + } + } + + #[tokio::test] + async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + let email = "paul@atreides.com"; + let password = "12345"; + let new_password = "54321"; + + // Sign up. + let validation_token = match connection.sign_up(email, password).await? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + + // Validation. + let (authentication_token_1, user_id_1) = match connection + .validation( + &validation_token, + Duration::hours(1), + "127.0.0.1", + "Mozilla", + ) + .await? + { + ValidationResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + // Check user login information. + let user_login_info_1 = connection + .get_user_login_info(&authentication_token_1) + .await?; + assert_eq!(user_login_info_1.ip, "127.0.0.1"); + assert_eq!(user_login_info_1.user_agent, "Mozilla"); + + // Sign out. + connection.sign_out(&authentication_token_1).await?; + + // Ask for password reset. + let token = match connection + .get_token_reset_password(email, Duration::hours(1)) + .await? + { + GetTokenResetPasswordResult::Ok(token) => token, + other => panic!("{:?}", other), + }; + + connection + .reset_password(&new_password, &token, Duration::hours(1)) + .await?; + + // Sign in. + let (authentication_token_2, user_id_2) = match connection + .sign_in(email, new_password, "192.168.1.1", "Chrome") + .await? + { + SignInResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + + assert_eq!(user_id_1, user_id_2); + assert_ne!(authentication_token_1, authentication_token_2); + + // Check user login information. + let user_login_info_2 = connection + .get_user_login_info(&authentication_token_2) + .await?; + + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } + + #[tokio::test] + async fn update_user() -> Result<()> { + let connection = Connection::new_in_memory().await?; + + connection.execute_sql( + sqlx::query( + r#" +INSERT INTO [User] + ([id], [email], [name], [password], [validation_token_datetime], [validation_token]) +VALUES + ($1, $2, $3, $4, $5, $6) + "# + ) + .bind(1) + .bind("paul@atreides.com") + .bind("paul") + .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc") + .bind("2022-11-29 22:05:04.121407300+00:00") + .bind(None::<&str>) // 'null'. + ).await?; + + let user = connection.load_user(1).await?.unwrap(); + + assert_eq!(user.name, "paul"); + assert_eq!(user.email, "paul@atreides.com"); + + if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection + .update_user( + 1, + Some("muaddib@fremen.com"), + Some("muaddib"), + Some("Chani"), + ) + .await? + { + let (_authentication_token_1, user_id_1) = match connection + .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0") + .await? + { + ValidationResult::Ok(token, user_id) => (token, user_id), + other => panic!("{:?}", other), + }; + assert_eq!(user_id_1, 1); + } else { + panic!("A revalidation token must be created when changin e-mail"); + } + + let user = connection.load_user(1).await?.unwrap(); + + assert_eq!(user.name, "muaddib"); + assert_eq!(user.email, "muaddib@fremen.com"); + + // Tests if password has been updated correctly. + if let SignInResult::Ok(_token, id) = connection + .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0") + .await? + { + assert_eq!(id, 1); + } else { + panic!("Can't sign in"); + } + + Ok(()) + } +} diff --git a/backend/src/data/mod.rs b/backend/src/data/mod.rs index 98b6ecd..c91ea29 100644 --- a/backend/src/data/mod.rs +++ b/backend/src/data/mod.rs @@ -1,2 +1,3 @@ pub mod db; +pub mod model; mod utils; diff --git a/backend/src/model.rs b/backend/src/data/model.rs similarity index 78% rename from backend/src/model.rs rename to backend/src/data/model.rs index 2cf53e8..f52704c 100644 --- a/backend/src/model.rs +++ b/backend/src/data/model.rs @@ -1,82 +1,77 @@ -use chrono::prelude::*; - -#[derive(Debug, Clone)] -pub struct User { - pub id: i64, - pub name: String, - pub email: String, -} - -pub struct UserLoginInfo { - pub last_login_datetime: DateTime, - pub ip: String, - pub user_agent: String, -} - -pub struct Recipe { - pub id: i64, - pub user_id: i64, - pub title: String, - pub description: String, - pub estimate_time: Option, // [min]. - pub difficulty: Difficulty, - - //ingredients: Vec, // For four people. - pub process: Vec, -} - -impl Recipe { - pub fn empty(id: i64, user_id: i64) -> Recipe { - Self::new(id, user_id, String::new(), String::new()) - } - - pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe { - Recipe { - id, - user_id, - title, - description, - estimate_time: None, - difficulty: Difficulty::Unknown, - process: Vec::new(), - } - } -} - -pub struct Ingredient { - pub quantity: Option, - pub name: String, -} - -pub struct Quantity { - pub value: f32, - pub unit: String, -} - -pub struct Group { - pub name: Option, - pub input: Vec, - pub output: Vec, - pub steps: Vec, -} - -pub struct Step { - pub action: String, -} - -pub struct IntermediateSubstance { - pub name: String, - pub quantity: Option, -} - -pub enum StepInput { - Ingredient(Ingredient), - IntermediateSubstance(IntermediateSubstance), -} - -pub enum Difficulty { - Unknown, - Easy, - Medium, - Hard, -} +use chrono::prelude::*; + +#[derive(Debug, Clone)] +pub struct User { + pub id: i64, + pub name: String, + pub email: String, +} + +pub struct UserLoginInfo { + pub last_login_datetime: DateTime, + pub ip: String, + pub user_agent: String, +} + +pub struct Recipe { + pub id: i64, + pub user_id: i64, + pub title: String, + pub description: String, + pub estimate_time: Option, // [s]. + pub difficulty: Difficulty, + pub lang: String, + + //ingredients: Vec, // For four people. + pub process: Vec, +} + +impl Recipe { + pub fn empty(id: i64, user_id: i64) -> Recipe { + Self::new(id, user_id, String::new(), String::new()) + } + + pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe { + Recipe { + id, + user_id, + title, + description, + estimate_time: None, + difficulty: Difficulty::Unknown, + lang: "en".to_string(), + process: Vec::new(), + } + } +} + +pub struct Ingredient { + pub quantity: Option, + pub name: String, +} + +pub struct Quantity { + pub value: f32, + pub unit: String, +} + +pub struct Group { + pub name: Option, + pub input: Vec, + pub steps: Vec, +} + +pub struct Step { + pub action: String, +} + +pub enum StepInput { + Ingredient(Ingredient), +} + +pub enum Difficulty { + Unknown = 0, + Easy = 1, + Medium = 2, + Hard = 3, +} diff --git a/backend/src/data/utils.rs b/backend/src/data/utils.rs index a692892..73b6c1c 100644 --- a/backend/src/data/utils.rs +++ b/backend/src/data/utils.rs @@ -1,6 +1,6 @@ use sqlx::{sqlite::SqliteRow, FromRow, Row}; -use crate::model; +use super::model; impl FromRow<'_, SqliteRow> for model::Recipe { fn from_row(row: &SqliteRow) -> sqlx::Result { diff --git a/backend/src/html_templates.rs b/backend/src/html_templates.rs index 416f8c7..3f3fd4c 100644 --- a/backend/src/html_templates.rs +++ b/backend/src/html_templates.rs @@ -1,22 +1,17 @@ use askama::Template; -use crate::model; +use crate::data::model; -#[derive(Template)] -#[template(path = "home.html")] -pub struct HomeTemplate { - pub user: Option, - pub recipes: Vec<(i64, String)>, - pub current_recipe_id: Option, +pub struct Recipes { + pub list: Vec<(i64, String)>, + pub current_id: Option, } #[derive(Template)] -#[template(path = "view_recipe.html")] -pub struct ViewRecipeTemplate { +#[template(path = "home.html")] +pub struct HomeTemplate { pub user: Option, - pub recipes: Vec<(i64, String)>, - pub current_recipe_id: Option, - pub current_recipe: model::Recipe, + pub recipes: Recipes, } #[derive(Template)] @@ -91,3 +86,19 @@ pub struct ProfileTemplate { pub message_email: String, pub message_password: String, } + +#[derive(Template)] +#[template(path = "recipe_view.html")] +pub struct RecipeViewTemplate { + pub user: Option, + pub recipes: Recipes, + pub recipe: model::Recipe, +} + +#[derive(Template)] +#[template(path = "recipe_edit.html")] +pub struct RecipeEditTemplate { + pub user: Option, + pub recipes: Recipes, + pub recipe: model::Recipe, +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 594d8d2..2c00ddc 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,7 +15,7 @@ use config::Config; use tower_http::{services::ServeDir, trace::TraceLayer}; use tracing::{event, Level}; -use data::db; +use data::{db, model}; mod config; mod consts; @@ -23,7 +23,6 @@ mod data; mod email; mod hash; mod html_templates; -mod model; mod ron_extractor; mod ron_utils; mod services; @@ -111,6 +110,8 @@ async fn main() { get(services::reset_password_get).post(services::reset_password_post), ) // Recipes. + .route("/recipe/new", get(services::create_recipe)) + // .route("/recipe/edit/:id", get(services::edit_recipe)) .route("/recipe/view/:id", get(services::view_recipe)) // User. .route( @@ -163,8 +164,8 @@ async fn get_current_user( .authentication(token_cookie.value(), &client_ip, &client_user_agent) .await { - Ok(db::AuthenticationResult::NotValidToken) => None, - Ok(db::AuthenticationResult::Ok(user_id)) => { + Ok(db::user::AuthenticationResult::NotValidToken) => None, + Ok(db::user::AuthenticationResult::Ok(user_id)) => { match connection.load_user(user_id).await { Ok(user) => user, Err(error) => { @@ -227,7 +228,7 @@ async fn process_args() -> bool { // Set the creation datetime to 'now'. con.execute_sql( sqlx::query( - "UPDATE [User] SET [creation_datetime] = ?1 WHERE [email] = 'paul@test.org'") + "UPDATE [User] SET [validation_token_datetime] = $1 WHERE [email] = 'paul@test.org'") .bind(Utc::now()) ) .await diff --git a/backend/src/services.rs b/backend/src/services.rs index 2991d86..a14eda4 100644 --- a/backend/src/services.rs +++ b/backend/src/services.rs @@ -15,7 +15,12 @@ use serde::Deserialize; use tracing::{event, Level}; use crate::{ - config::Config, consts, data::db, email, html_templates::*, model, ron_utils, utils, AppState, + config::Config, + consts, + data::{db, model}, + email, + html_templates::*, + ron_utils, utils, AppState, }; pub mod ron; @@ -53,12 +58,41 @@ pub async fn home_page( Ok(HomeTemplate { user, - current_recipe_id: None, - recipes, + recipes: Recipes { + list: recipes, + current_id: None, + }, // current_recipe_id: None, + // recipes, }) } -///// VIEW RECIPE ///// +///// RECIPE ///// + +#[debug_handler] +pub async fn create_recipe( + State(connection): State, + Extension(user): Extension>, +) -> Result { + if let Some(user) = user { + let recipe_id = connection.create_recipe(user.id).await?; + Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response()) + } else { + Ok(MessageTemplate::new("Not logged in").into_response()) + } +} + +// #[debug_handler] +// pub async fn edit_recipe( +// State(connection): State, +// Extension(user): Extension>, +// Path(recipe_id): Path, +// ) -> Result { +// if let Some(user) = user { +// Ok(RecipeEditTemplate { user }.into_response()) +// } else { +// Ok(MessageTemplate::new("Not logged in").into_response()) +// } +// } #[debug_handler] pub async fn view_recipe( @@ -68,11 +102,13 @@ pub async fn view_recipe( ) -> Result { let recipes = connection.get_all_recipe_titles().await?; match connection.get_recipe(recipe_id).await? { - Some(recipe) => Ok(ViewRecipeTemplate { + Some(recipe) => Ok(RecipeViewTemplate { user, - current_recipe_id: Some(recipe.id), - recipes, - current_recipe: recipe, + recipes: Recipes { + list: recipes, + current_id: Some(recipe.id), + }, + recipe, } .into_response()), None => Ok(MessageTemplate::new_with_user( @@ -173,10 +209,10 @@ pub async fn sign_up_post( .sign_up(&form_data.email, &form_data.password_1) .await { - Ok(db::SignUpResult::UserAlreadyExists) => { + Ok(db::user::SignUpResult::UserAlreadyExists) => { error_response(SignUpError::UserAlreadyExists, &form_data, user) } - Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => { + Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => { let url = utils::get_url_from_host(&host); let email = form_data.email.clone(); match email::send_email( @@ -236,7 +272,7 @@ pub async fn sign_up_validation( ) .await? { - db::ValidationResult::Ok(token, user_id) => { + db::user::ValidationResult::Ok(token, user_id) => { let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); jar = jar.add(cookie); let user = connection.load_user(user_id).await?; @@ -248,14 +284,14 @@ pub async fn sign_up_validation( ), )) } - db::ValidationResult::ValidationExpired => Ok(( + db::user::ValidationResult::ValidationExpired => Ok(( jar, MessageTemplate::new_with_user( "The validation has expired. Try to sign up again", user, ), )), - db::ValidationResult::UnknownUser => Ok(( + db::user::ValidationResult::UnknownUser => Ok(( jar, MessageTemplate::new_with_user("Validation error. Try to sign up again", user), )), @@ -307,7 +343,7 @@ pub async fn sign_in_post( ) .await? { - db::SignInResult::AccountNotValidated => Ok(( + db::user::SignInResult::AccountNotValidated => Ok(( jar, SignInFormTemplate { user, @@ -316,7 +352,7 @@ pub async fn sign_in_post( } .into_response(), )), - db::SignInResult::UserNotFound | db::SignInResult::WrongPassword => Ok(( + db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok(( jar, SignInFormTemplate { user, @@ -325,7 +361,7 @@ pub async fn sign_in_post( } .into_response(), )), - db::SignInResult::Ok(token, _user_id) => { + db::user::SignInResult::Ok(token, _user_id) => { let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); Ok((jar.add(cookie), Redirect::to("/").into_response())) } @@ -433,15 +469,15 @@ pub async fn ask_reset_password_post( ) .await { - Ok(db::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response( + Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response( AskResetPasswordError::EmailAlreadyReset, &form_data.email, user, ), - Ok(db::GetTokenResetPasswordResult::EmailUnknown) => { + Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => { error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user) } - Ok(db::GetTokenResetPasswordResult::Ok(token)) => { + Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => { let url = utils::get_url_from_host(&host); match email::send_email( &form_data.email, @@ -559,12 +595,12 @@ pub async fn reset_password_post( ) .await { - Ok(db::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user( + Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user( "Your password has been reset", user, ) .into_response()), - Ok(db::ResetPasswordResult::ResetTokenExpired) => { + Ok(db::user::ResetPasswordResult::ResetTokenExpired) => { error_response(ResetPasswordError::TokenExpired, &form_data, user) } Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user), @@ -681,10 +717,10 @@ pub async fn edit_user_post( ) .await { - Ok(db::UpdateUserResult::EmailAlreadyTaken) => { + Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => { return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user); } - Ok(db::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => { + Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => { let url = utils::get_url_from_host(&host); let email = form_data.email.clone(); match email::send_email( @@ -709,7 +745,7 @@ pub async fn edit_user_post( } } } - Ok(db::UpdateUserResult::Ok) => { + Ok(db::user::UpdateUserResult::Ok) => { message = "Profile saved"; } Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user), @@ -760,7 +796,7 @@ pub async fn email_revalidation( ) .await? { - db::ValidationResult::Ok(token, user_id) => { + db::user::ValidationResult::Ok(token, user_id) => { let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); jar = jar.add(cookie); let user = connection.load_user(user_id).await?; @@ -769,14 +805,14 @@ pub async fn email_revalidation( MessageTemplate::new_with_user("Email validation successful", user), )) } - db::ValidationResult::ValidationExpired => Ok(( + db::user::ValidationResult::ValidationExpired => Ok(( jar, MessageTemplate::new_with_user( "The validation has expired. Try to sign up again with the same email", user, ), )), - db::ValidationResult::UnknownUser => Ok(( + db::user::ValidationResult::UnknownUser => Ok(( jar, MessageTemplate::new_with_user( "Validation error. Try to sign up again with the same email", diff --git a/backend/templates/base_with_list.html b/backend/templates/base_with_list.html index 7a4e937..76d84ac 100644 --- a/backend/templates/base_with_list.html +++ b/backend/templates/base_with_list.html @@ -7,14 +7,14 @@ {% block main_container %}