From: Greg Burri Date: Tue, 29 Nov 2022 14:58:06 +0000 (+0100) Subject: Add asynchronous call to database. X-Git-Url: http://git.euphorik.ch/?p=recipes.git;a=commitdiff_plain;h=d28e765e39e70ad2ab9a42885c786d5d8ba9ba40 Add asynchronous call to database. See file 'asynchronous.ts'. --- diff --git a/Cargo.lock b/Cargo.lock index 96baf91..5184cf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,9 +452,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.0.27" +version = "4.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0acbd8d28a0a60d7108d7ae850af6ba34cf2d1257fc646980e5f97ce14275966" +checksum = "4d63b9e9c07271b9957ad22c173bae2a4d9a81127680962039296abcd2f8251d" dependencies = [ "bitflags", "clap_derive", @@ -1068,9 +1068,9 @@ dependencies = [ [[package]] name = "io-lifetimes" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e394faa0efb47f9f227f1cd89978f854542b318a6f64fa695489c9c993056656" +checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c" dependencies = [ "libc", "windows-sys", @@ -1078,9 +1078,9 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae5bc6e2eb41c9def29a3e0f1306382807764b9b53112030eff57435667352d" +checksum = "927609f78c2913a6f6ac3c27a4fe87f43e2a35367c0c4b0f8265e8f49a104330" dependencies = [ "hermit-abi 0.2.6", "io-lifetimes", @@ -1350,9 +1350,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" +checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba" dependencies = [ "cfg-if", "libc", @@ -1563,6 +1563,7 @@ dependencies = [ "chrono", "clap", "common", + "derive_more", "env_logger", "futures", "itertools", @@ -1666,9 +1667,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.36.3" +version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b1fbb4dfc4eb1d390c02df47760bb19a84bb80b301ecc947ab5406394d8223e" +checksum = "cb93e85278e08bb5788653183213d3a60fc242b10cb9be96586f5a73dcb67c23" dependencies = [ "bitflags", "errno", @@ -1744,18 +1745,18 @@ checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4" [[package]] name = "serde" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +checksum = "e53f64bb4ba0191d6d0676e1b141ca55047d83b74f5607e6d8eb88126c52c2dc" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +checksum = "a55492425aa53521babf6137309e7d34c20bbfbbfcfe2c7f3a047fd1f6b92c0c" dependencies = [ "proc-macro2", "quote", @@ -1856,9 +1857,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" +checksum = "4ae548ec36cf198c0ef7710d3c230987c2d6d7bd98ad6edc0274462724c585ce" dependencies = [ "proc-macro2", "quote", @@ -2327,9 +2328,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.3+zstd.1.5.2" +version = "2.0.4+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44ccf97612ac95f3ccb89b2d7346b345e52f1c3019be4984f0455fb4ba991f8a" +checksum = "4fa202f2ef00074143e219d15b62ffc317d17cc33909feac471c044087cad7b0" dependencies = [ "cc", "libc", diff --git a/TODO.md b/TODO.md index 8e6de0a..0bb5b28 100644 --- a/TODO.md +++ b/TODO.md @@ -1,10 +1,14 @@ -* Asynchonous email sending and database requests * Try using WASM for all the client logic (test on signup page) * Describe the use cases. * Define the UI (mockups). * Two CSS: one for desktop and one for mobile * Define the logic behind each page and action. +[ok] Check cookie lifetime -> Session by default +[ok] Asynchonous email sending and database requests + [ok] Try to return Result for async routes (and watch what is printed in log) + [ok] Then try to make async database calls + [ok] Set email sending as async and show a waiter when sending email. Handle (and test) a timeout (~10s). -> (timeout put to 60s) [ok] How to log error to journalctl? [ok] Sign out [ok] Read all the askama doc and see if the current approach is good diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dfff361..cc98cf5 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -35,3 +35,5 @@ rand_core = {version = "0.6", features = ["std"]} rand = "0.8" lettre = {version = "0.10", default-features = false, features = ["smtp-transport", "pool", "hostname", "builder", "rustls-tls"]} + +derive_more = "0.99" \ No newline at end of file diff --git a/backend/sql/version_1.sql b/backend/sql/version_1.sql index 1685cd1..93f567b 100644 --- a/backend/sql/version_1.sql +++ b/backend/sql/version_1.sql @@ -46,6 +46,26 @@ CREATE TABLE [Recipe] ( FOREIGN KEY([user_id]) REFERENCES [User]([id]) ON DELETE SET NULL ); +CREATE TABLE [RecipeTag] ( + [id] INTEGER PRIMARY KEY, + + [recipe_id] INTEGER NOT NULL, + [tag_id] INTEGER NO NULL, + + FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE, + FOREIGN KEY([tag_id]) REFERENCES [Tag]([id]) ON DELETE CASCADE +); + +CREATE TABLE [Tag] ( + [id] INTEGER PRIMARY KEY, + [recipe_tag_id] INTEGER, + [name] TEXT NOT NULL, + + FOREIGN KEY([recipe_tag_id]) REFERENCES [RecipeTag]([id]) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]); + CREATE TABLE [Quantity] ( [id] INTEGER PRIMARY KEY, [value] REAL, diff --git a/backend/src/consts.rs b/backend/src/consts.rs index 02b844d..754b964 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -1,7 +1,12 @@ +use std::time::Duration; + pub const FILE_CONF: &str = "conf.ron"; pub const DB_DIRECTORY: &str = "data"; pub const DB_FILENAME: &str = "recipes.sqlite"; pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql"; pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // 1 hour. [s]. -pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; \ No newline at end of file +pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; +pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token"; +pub const AUTHENTICATION_TOKEN_SIZE: usize = 32; // Number of alphanumeric characters for cookie authentication token. +pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60); diff --git a/backend/src/data/asynchronous.rs b/backend/src/data/asynchronous.rs new file mode 100644 index 0000000..4dda803 --- /dev/null +++ b/backend/src/data/asynchronous.rs @@ -0,0 +1,104 @@ +//! Functions to be called from actix code. They are asynchonrous and won't block worker thread caller. + +use std::fmt; + +use chrono::{prelude::*, Duration}; +use actix_web::{web, error::BlockingError}; + +use super::db::*; +use crate::model; +use crate::user::User; + +#[derive(Debug)] +pub enum DBAsyncError { + DBError(DBError), + ActixError(BlockingError), + Other(String), +} + +impl fmt::Display for DBAsyncError { + fn fmt(&self, f: &mut fmt::Formatter) -> std::result::Result<(), fmt::Error> { + write!(f, "{:?}", self) + } +} + +impl std::error::Error for DBAsyncError { } + +impl From for DBAsyncError { + fn from(error: DBError) -> Self { + DBAsyncError::DBError(error) + } +} + +impl From for DBAsyncError { + fn from(error: BlockingError) -> Self { + DBAsyncError::ActixError(error) + } +} + +impl DBAsyncError { + fn from_dyn_error(error: Box) -> Self { + DBAsyncError::Other(error.to_string()) + } +} + +fn combine_errors(error: std::result::Result, BlockingError>) -> Result { + error? +} + +type Result = std::result::Result; + +impl Connection { + pub async fn get_all_recipe_titles_async(&self) -> Result> { + let self_copy = self.clone(); + web::block(move || { self_copy.get_all_recipe_titles().unwrap_or_default() }).await.map_err(DBAsyncError::from) + } + + pub async fn get_recipe_async(&self, id: i32) -> Result { + let self_copy = self.clone(); + combine_errors(web::block(move || { self_copy.get_recipe(id).map_err(DBAsyncError::from) }).await) + } + + pub async fn load_user_async(&self, user_id: i32) -> Result { + let self_copy = self.clone(); + combine_errors(web::block(move || { self_copy.load_user(user_id).map_err(DBAsyncError::from) }).await) + } + + pub async fn sign_up_async(&self, email: &str, password: &str) -> Result { + let self_copy = self.clone(); + let email_copy = email.to_string(); + let password_copy = password.to_string(); + combine_errors(web::block(move || { self_copy.sign_up(&email_copy, &password_copy).map_err(DBAsyncError::from) }).await) + } + + pub async fn validation_async(&self, token: &str, validation_time: Duration, ip: &str, user_agent: &str) -> Result { + let self_copy = self.clone(); + let token_copy = token.to_string(); + let ip_copy = ip.to_string(); + let user_agent_copy = user_agent.to_string(); + combine_errors(web::block(move || { self_copy.validation(&token_copy, validation_time, &ip_copy, &user_agent_copy).map_err(DBAsyncError::from) }).await) + } + + pub async fn sign_in_async(&self, email: &str, password: &str, ip: &str, user_agent: &str) -> Result { + let self_copy = self.clone(); + let email_copy = email.to_string(); + let password_copy = password.to_string(); + let ip_copy = ip.to_string(); + let user_agent_copy = user_agent.to_string(); + combine_errors(web::block(move || { self_copy.sign_in(&email_copy, &password_copy, &ip_copy, &user_agent_copy).map_err(DBAsyncError::from) }).await) + } + + pub async fn authentication_async(&self, token: &str, ip: &str, user_agent: &str) -> Result { + let self_copy = self.clone(); + let token_copy = token.to_string(); + let ip_copy = ip.to_string(); + let user_agent_copy = user_agent.to_string(); + combine_errors(web::block(move || { self_copy.authentication(&token_copy, &ip_copy, &user_agent_copy).map_err(DBAsyncError::from) }).await) + } + + pub async fn sign_out_async(&self, token: &str) -> Result<()> { + let self_copy = self.clone(); + let token_copy = token.to_string(); + combine_errors(web::block(move || { self_copy.sign_out(&token_copy).map_err(DBAsyncError::from) }).await) + } +} \ No newline at end of file diff --git a/backend/src/data/db.rs b/backend/src/data/db.rs new file mode 100644 index 0000000..0014f7f --- /dev/null +++ b/backend/src/data/db.rs @@ -0,0 +1,606 @@ +use std::{fmt, fs::{self, File}, path::Path, io::Read}; + +use itertools::Itertools; +use chrono::{prelude::*, Duration}; +use rusqlite::{named_params, OptionalExtension, params, Params}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rand::distributions::{Alphanumeric, DistString}; + +use crate::{consts, user}; +use crate::hash::{hash, verify_password}; +use crate::model; +use crate::user::*; + +const CURRENT_DB_VERSION: u32 = 1; + +#[derive(Debug)] +pub enum DBError { + SqliteError(rusqlite::Error), + R2d2Error(r2d2::Error), + UnsupportedVersion(u32), + Other(String), +} + +impl fmt::Display for DBError { + fn fmt(&self, f: &mut fmt::Formatter) -> std::result::Result<(), fmt::Error> { + write!(f, "{:?}", self) + } +} + +impl std::error::Error for DBError { } + +impl From for DBError { + fn from(error: rusqlite::Error) -> Self { + DBError::SqliteError(error) + } +} + +impl From for DBError { + fn from(error: r2d2::Error) -> Self { + DBError::R2d2Error(error) + } +} + +impl DBError { + fn from_dyn_error(error: Box) -> Self { + DBError::Other(error.to_string()) + } +} + +type Result = std::result::Result; + +#[derive(Debug)] +pub enum SignUpResult { + UserAlreadyExists, + UserCreatedWaitingForValidation(String), // Validation token. +} + +#[derive(Debug)] +pub enum ValidationResult { + UnknownUser, + ValidationExpired, + Ok(String, i32), // Returns token and user id. +} + +#[derive(Debug)] +pub enum SignInResult { + UserNotFound, + WrongPassword, + AccountNotValidated, + Ok(String, i32), // Returns token and user id. +} + +#[derive(Debug)] +pub enum AuthenticationResult { + NotValidToken, + Ok(i32), // Returns user id. +} + +#[derive(Clone)] +pub struct Connection { + //con: rusqlite::Connection + pool: Pool +} + +impl Connection { + pub fn new() -> Result { + let path = Path::new(consts::DB_DIRECTORY).join(consts::DB_FILENAME); + Self::new_from_file(path) + } + + pub fn new_in_memory() -> Result { + Self::create_connection(SqliteConnectionManager::memory()) + } + + pub fn new_from_file>(file: P) -> Result { + if let Some(data_dir) = file.as_ref().parent() { + if !data_dir.exists() { + fs::DirBuilder::new().create(data_dir).unwrap(); + } + } + + Self::create_connection(SqliteConnectionManager::file(file)) + } + + fn create_connection(manager: SqliteConnectionManager) -> Result { + let pool = r2d2::Pool::new(manager).unwrap(); + let connection = Connection { pool }; + connection.create_or_update()?; + Ok(connection) + } + + /// Called after the connection has been established for creating or updating the database. + /// The 'Version' table tracks the current state of the database. + fn create_or_update(&self) -> Result<()> { + // Check the Database version. + let mut con = self.pool.get()?; + let tx = con.transaction()?; + + // Version 0 corresponds to an empty database. + let mut version = { + match tx.query_row( + "SELECT [name] FROM [sqlite_master] WHERE [type] = 'table' AND [name] = 'Version'", + [], + |row| row.get::(0) + ) { + Ok(_) => tx.query_row("SELECT [version] FROM [Version] ORDER BY [id] DESC", [], |row| row.get(0)).unwrap_or_default(), + Err(_) => 0 + } + }; + + while Self::update_to_next_version(version, &tx)? { + version += 1; + } + + tx.commit()?; + + Ok(()) + } + + fn update_to_next_version(current_version: u32, tx: &rusqlite::Transaction) -> Result { + let next_version = current_version + 1; + + if next_version <= CURRENT_DB_VERSION { + println!("Update to version {}...", next_version); + } + + fn update_version(to_version: u32, tx: &rusqlite::Transaction) -> Result<()> { + tx.execute("INSERT INTO [Version] ([version], [datetime]) VALUES (?1, datetime('now'))", [to_version]).map(|_| ()).map_err(DBError::from) + } + + fn ok(updated: bool) -> Result { + if updated { + println!("Version updated"); + } + Ok(updated) + } + + match next_version { + 1 => { + let sql_file = consts::SQL_FILENAME.replace("{VERSION}", &next_version.to_string()); + tx.execute_batch(&load_sql_file(&sql_file)?)?; + update_version(next_version, tx)?; + + ok(true) + } + + // Version 1 doesn't exist yet. + 2 => + ok(false), + + v => + Err(DBError::UnsupportedVersion(v)), + } + } + + pub fn get_all_recipe_titles(&self) -> Result> { + let con = self.pool.get()?; + + let mut stmt = con.prepare("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")?; + + let titles: std::result::Result, rusqlite::Error> = + stmt.query_map([], |row| { + Ok((row.get("id")?, row.get("title")?)) + })?.collect(); + + titles.map_err(DBError::from) + } + + /* Not used for the moment. + pub fn get_all_recipes(&self) -> Result> { + let con = self.pool.get()?; + let mut stmt = con.prepare("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")?; + let recipes = + stmt.query_map([], |row| { + Ok(model::Recipe::new(row.get(0)?, row.get(1)?)) + })?.map(|r| r.unwrap()).collect_vec(); // TODO: remove unwrap. + Ok(recipes) + } */ + + pub fn get_recipe(&self, id: i32) -> Result { + let con = self.pool.get()?; + con.query_row("SELECT [id], [title], [description] FROM [Recipe] WHERE [id] = ?1", [id], |row| { + Ok(model::Recipe::new(row.get("id")?, row.get("title")?, row.get("description")?)) + }).map_err(DBError::from) + } + + pub fn get_user_login_info(&self, token: &str) -> Result { + let con = self.pool.get()?; + con.query_row("SELECT [last_login_datetime], [ip], [user_agent] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { + Ok(UserLoginInfo { + last_login_datetime: r.get("last_login_datetime")?, + ip: r.get("ip")?, + user_agent: r.get("user_agent")?, + }) + }).map_err(DBError::from) + } + + pub fn load_user(&self, user_id: i32) -> Result { + let con = self.pool.get()?; + con.query_row("SELECT [email] FROM [User] WHERE [id] = ?1", [user_id], |r| { + Ok(User { + email: r.get("email")?, + }) + }).map_err(DBError::from) + } + + pub fn sign_up(&self, email: &str, password: &str) -> Result { + self.sign_up_with_given_time(email, password, Utc::now()) + } + + fn sign_up_with_given_time(&self, email: &str, password: &str, datetime: DateTime) -> Result { + let mut con = self.pool.get()?; + let tx = con.transaction()?; + let token = + match tx.query_row("SELECT [id], [validation_token] FROM [User] WHERE [email] = ?1", [email], |r| { + Ok((r.get::<&str, i32>("id")?, r.get::<&str, Option>("validation_token")?)) + }).optional()? { + 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))?; + tx.execute("UPDATE [User] SET [validation_token] = ?2, [creation_datetime] = ?3, [password] = ?4 WHERE [id] = ?1", params![id, token, datetime, hashed_password])?; + token + }, + None => { + let token = generate_token(); + let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; + tx.execute("INSERT INTO [User] ([email], [validation_token], [creation_datetime], [password]) VALUES (?1, ?2, ?3, ?4)", params![email, token, datetime, hashed_password])?; + token + }, + }; + tx.commit()?; + Ok(SignUpResult::UserCreatedWaitingForValidation(token)) + } + + pub fn validation(&self, token: &str, validation_time: Duration, ip: &str, user_agent: &str) -> Result { + let mut con = self.pool.get()?; + let tx = con.transaction()?; + let user_id = + match tx.query_row("SELECT [id], [creation_datetime] FROM [User] WHERE [validation_token] = ?1", [token], |r| { + Ok((r.get::<&str, i32>("id")?, r.get::<&str, DateTime>("creation_datetime")?)) + }).optional()? { + Some((id, creation_datetime)) => { + if Utc::now() - creation_datetime > validation_time { + return Ok(ValidationResult::ValidationExpired) + } + tx.execute("UPDATE [User] SET [validation_token] = NULL WHERE [id] = ?1", [id])?; + id + }, + None => { + return Ok(ValidationResult::UnknownUser) + }, + }; + let token = Connection::create_login_token(&tx, user_id, ip, user_agent)?; + tx.commit()?; + Ok(ValidationResult::Ok(token, user_id)) + } + + pub fn sign_in(&self, email: &str, password: &str, ip: &str, user_agent: &str) -> Result { + let mut con = self.pool.get()?; + let tx = con.transaction()?; + match tx.query_row("SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = ?1", [email], |r| { + Ok((r.get::<&str, i32>("id")?, r.get::<&str, String>("password")?, r.get::<&str, Option>("validation_token")?)) + }).optional()? { + 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 = Connection::create_login_token(&tx, id, ip, user_agent)?; + tx.commit()?; + Ok(SignInResult::Ok(token, id)) + } else { + Ok(SignInResult::WrongPassword) + } + }, + None => { + Ok(SignInResult::UserNotFound) + }, + } + } + + pub fn authentication(&self, token: &str, ip: &str, user_agent: &str) -> Result { + let mut con = self.pool.get()?; + let tx = con.transaction()?; + match tx.query_row("SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { + Ok((r.get::<&str, i32>("id")?, r.get::<&str, i32>("user_id")?)) + }).optional()? { + Some((login_id, user_id)) => { + tx.execute("UPDATE [UserLoginToken] SET [last_login_datetime] = ?2, [ip] = ?3, [user_agent] = ?4 WHERE [id] = ?1", params![login_id, Utc::now(), ip, user_agent])?; + tx.commit()?; + Ok(AuthenticationResult::Ok(user_id)) + }, + None => + Ok(AuthenticationResult::NotValidToken) + } + } + + pub fn sign_out(&self, token: &str) -> Result<()> { + let mut con = self.pool.get()?; + let tx = con.transaction()?; + match tx.query_row("SELECT [id] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { + Ok(r.get::<&str, i32>("id")?) + }).optional()? { + Some(login_id) => { + tx.execute("DELETE FROM [UserLoginToken] WHERE [id] = ?1", params![login_id])?; + tx.commit()? + }, + None => (), + } + Ok(()) + } + + /// Execute a given SQL file. + pub fn execute_file + fmt::Display>(&self, file: P) -> Result<()> { + let con = self.pool.get()?; + let sql = load_sql_file(file)?; + con.execute_batch(&sql).map_err(DBError::from) + } + + /// Execute any SQL statement. + /// Mainly used for testing. + pub fn execute_sql(&self, sql: &str, params: P) -> Result { + let con = self.pool.get()?; + con.execute(sql, params).map_err(DBError::from) + } + + // Return the token. + fn create_login_token(tx: &rusqlite::Transaction, user_id: i32, ip: &str, user_agent: &str) -> Result { + let token = generate_token(); + tx.execute("INSERT INTO [UserLoginToken] ([user_id], [last_login_datetime], [token], [ip], [user_agent]) VALUES (?1, ?2, ?3, ?4, ?5)", params![user_id, Utc::now(), token, ip, user_agent])?; + Ok(token) + } +} + +fn load_sql_file + fmt::Display>(sql_file: P) -> Result { + let mut file = File::open(&sql_file).map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err.to_string())))?; + let mut sql = String::new(); + file.read_to_string(&mut sql).map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err.to_string())))?; + Ok(sql) +} + +fn generate_token() -> String { + Alphanumeric.sample_string(&mut rand::thread_rng(), consts::AUTHENTICATION_TOKEN_SIZE) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_up() -> Result<()> { + let connection = Connection::new_in_memory()?; + match connection.sign_up("paul@test.org", "12345")? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_to_an_already_existing_user() -> Result<()> { + let connection = Connection::new_in_memory()?; + connection.execute_sql(" + INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) + VALUES ( + 1, + 'paul@test.org', + 'paul', + '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', + 0, + NULL + );", [])?; + match connection.sign_up("paul@test.org", "12345")? { + SignUpResult::UserAlreadyExists => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_and_sign_in_without_validation() -> Result<()> { + let connection = Connection::new_in_memory()?; + + let email = "paul@test.org"; + let password = "12345"; + + match connection.sign_up(email, password)? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + + match connection.sign_in(email, password, "127.0.0.1", "Mozilla/5.0")? { + SignInResult::AccountNotValidated => (), // Nominal case. + other => panic!("{:?}", other), + } + + Ok(()) + } + + #[test] + fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> { + let connection = Connection::new_in_memory()?; + let token = generate_token(); + connection.execute_sql(" + INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) + VALUES ( + 1, + 'paul@test.org', + 'paul', + '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', + 0, + :token + );", named_params! { ":token": token })?; + match connection.sign_up("paul@test.org", "12345")? { + SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_at_time() -> Result<()> { + let connection = Connection::new_in_memory()?; + let validation_token = + match connection.sign_up("paul@test.org", "12345")? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + match connection.validation(&validation_token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")? { + ValidationResult::Ok(_, _) => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_too_late() -> Result<()> { + let connection = Connection::new_in_memory()?; + let validation_token = + match connection.sign_up_with_given_time("paul@test.org", "12345", Utc::now() - Duration::days(1))? { + SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. + other => panic!("{:?}", other), + }; + match connection.validation(&validation_token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")? { + ValidationResult::ValidationExpired => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_with_bad_token() -> Result<()> { + let connection = Connection::new_in_memory()?; + let _validation_token = + match connection.sign_up("paul@test.org", "12345")? { + 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")? { + ValidationResult::UnknownUser => (), // Nominal case. + other => panic!("{:?}", other), + } + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_then_sign_in() -> Result<()> { + let connection = Connection::new_in_memory()?; + + let email = "paul@test.org"; + let password = "12345"; + + // Sign up. + let validation_token = + match connection.sign_up(email, password)? { + 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")? { + ValidationResult::Ok(_, _) => (), + other => panic!("{:?}", other), + }; + + // Sign in. + match connection.sign_in(email, password, "127.0.0.1", "Mozilla/5.0")? { + SignInResult::Ok(_, _) => (), // Nominal case. + other => panic!("{:?}", other), + } + + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_then_authentication() -> Result<()> { + let connection = Connection::new_in_memory()?; + + let email = "paul@test.org"; + let password = "12345"; + + // Sign up. + let validation_token = + match connection.sign_up(email, password)? { + 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")? { + 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)?; + 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")? { + 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)?; + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } + + #[test] + fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> { + let connection = Connection::new_in_memory()?; + + let email = "paul@test.org"; + let password = "12345"; + + // Sign up. + let validation_token = + match connection.sign_up(email, password)? { + 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")? { + 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)?; + 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)?; + + // Sign in. + let (authentication_token_2, user_id_2) = + match connection.sign_in(email, password, "192.168.1.1", "Chrome")? { + 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)?; + + assert_eq!(user_login_info_2.ip, "192.168.1.1"); + assert_eq!(user_login_info_2.user_agent, "Chrome"); + + Ok(()) + } +} diff --git a/backend/src/data/mod.rs b/backend/src/data/mod.rs new file mode 100644 index 0000000..37bc498 --- /dev/null +++ b/backend/src/data/mod.rs @@ -0,0 +1,2 @@ +pub mod asynchronous; +pub mod db; \ No newline at end of file diff --git a/backend/src/db.rs b/backend/src/db.rs deleted file mode 100644 index e38aae7..0000000 --- a/backend/src/db.rs +++ /dev/null @@ -1,605 +0,0 @@ -use std::{fmt, fs::{self, File}, path::Path, io::Read}; - -use itertools::Itertools; -use chrono::{prelude::*, Duration}; -use rusqlite::{named_params, OptionalExtension, params, Params}; -use r2d2::Pool; -use r2d2_sqlite::SqliteConnectionManager; -use rand::distributions::{Alphanumeric, DistString}; - -use crate::{consts, user}; -use crate::hash::{hash, verify_password}; -use crate::model; -use crate::user::*; - -const CURRENT_DB_VERSION: u32 = 1; - -#[derive(Debug)] -pub enum DBError { - SqliteError(rusqlite::Error), - R2d2Error(r2d2::Error), - UnsupportedVersion(u32), - Other(String), -} - -impl fmt::Display for DBError { - fn fmt(&self, f: &mut fmt::Formatter) -> std::result::Result<(), fmt::Error> { - write!(f, "{:?}", self) - } -} - -impl std::error::Error for DBError { } - -impl From for DBError { - fn from(error: rusqlite::Error) -> Self { - DBError::SqliteError(error) - } -} - -impl From for DBError { - fn from(error: r2d2::Error) -> Self { - DBError::R2d2Error(error) - } -} - -// TODO: Is there a better solution? -impl DBError { - fn from_dyn_error(error: Box) -> Self { - DBError::Other(error.to_string()) - } -} - -type Result = std::result::Result; - -#[derive(Debug)] -pub enum SignUpResult { - UserAlreadyExists, - UserCreatedWaitingForValidation(String), // Validation token. -} - -#[derive(Debug)] -pub enum ValidationResult { - UnknownUser, - ValidationExpired, - Ok(String, i32), // Returns token and user id. -} - -#[derive(Debug)] -pub enum SignInResult { - UserNotFound, - WrongPassword, - AccountNotValidated, - Ok(String, i32), // Returns token and user id. -} - -#[derive(Debug)] -pub enum AuthenticationResult { - NotValidToken, - Ok(i32), // Returns user id. -} - -#[derive(Clone)] -pub struct Connection { - //con: rusqlite::Connection - pool: Pool -} - -impl Connection { - pub fn new() -> Result { - let path = Path::new(consts::DB_DIRECTORY).join(consts::DB_FILENAME); - Self::new_from_file(path) - } - - pub fn new_in_memory() -> Result { - Self::create_connection(SqliteConnectionManager::memory()) - } - - pub fn new_from_file>(file: P) -> Result { - if let Some(data_dir) = file.as_ref().parent() { - if !data_dir.exists() { - fs::DirBuilder::new().create(data_dir).unwrap(); - } - } - - Self::create_connection(SqliteConnectionManager::file(file)) - } - - fn create_connection(manager: SqliteConnectionManager) -> Result { - let pool = r2d2::Pool::new(manager).unwrap(); - let connection = Connection { pool }; - connection.create_or_update()?; - Ok(connection) - } - - /// Called after the connection has been established for creating or updating the database. - /// The 'Version' table tracks the current state of the database. - fn create_or_update(&self) -> Result<()> { - // Check the Database version. - let mut con = self.pool.get()?; - let tx = con.transaction()?; - - // Version 0 corresponds to an empty database. - let mut version = { - match tx.query_row( - "SELECT [name] FROM [sqlite_master] WHERE [type] = 'table' AND [name] = 'Version'", - [], - |row| row.get::(0) - ) { - Ok(_) => tx.query_row("SELECT [version] FROM [Version] ORDER BY [id] DESC", [], |row| row.get(0)).unwrap_or_default(), - Err(_) => 0 - } - }; - - while Self::update_to_next_version(version, &tx)? { - version += 1; - } - - tx.commit()?; - - Ok(()) - } - - fn update_to_next_version(current_version: u32, tx: &rusqlite::Transaction) -> Result { - let next_version = current_version + 1; - - if next_version <= CURRENT_DB_VERSION { - println!("Update to version {}...", next_version); - } - - fn update_version(to_version: u32, tx: &rusqlite::Transaction) -> Result<()> { - tx.execute("INSERT INTO [Version] ([version], [datetime]) VALUES (?1, datetime('now'))", [to_version]).map(|_| ()).map_err(DBError::from) - } - - fn ok(updated: bool) -> Result { - if updated { - println!("Version updated"); - } - Ok(updated) - } - - match next_version { - 1 => { - let sql_file = consts::SQL_FILENAME.replace("{VERSION}", &next_version.to_string()); - tx.execute_batch(&load_sql_file(&sql_file)?)?; - update_version(next_version, tx)?; - - ok(true) - } - - // Version 1 doesn't exist yet. - 2 => - ok(false), - - v => - Err(DBError::UnsupportedVersion(v)), - } - } - - pub fn get_all_recipe_titles(&self) -> Result> { - let con = self.pool.get()?; - let mut stmt = con.prepare("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")?; - let titles = - stmt.query_map([], |row| { - Ok((row.get(0)?, row.get(1)?)) - })?.map(|r| r.unwrap()).collect_vec(); // TODO: remove unwrap. - Ok(titles) - } - - /* Not used for the moment. - pub fn get_all_recipes(&self) -> Result> { - let con = self.pool.get()?; - let mut stmt = con.prepare("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")?; - let recipes = - stmt.query_map([], |row| { - Ok(model::Recipe::new(row.get(0)?, row.get(1)?)) - })?.map(|r| r.unwrap()).collect_vec(); // TODO: remove unwrap. - Ok(recipes) - } */ - - pub fn get_recipe(&self, id: i32) -> Result { - let con = self.pool.get()?; - con.query_row("SELECT [id], [title], [description] FROM [Recipe] WHERE [id] = ?1", [id], |row| { - Ok(model::Recipe::new(row.get("id")?, row.get("title")?, row.get("description")?)) - }).map_err(DBError::from) - } - - pub fn get_user_login_info(&self, token: &str) -> Result { - let con = self.pool.get()?; - con.query_row("SELECT [last_login_datetime], [ip], [user_agent] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { - Ok(UserLoginInfo { - last_login_datetime: r.get("last_login_datetime")?, - ip: r.get("ip")?, - user_agent: r.get("user_agent")?, - }) - }).map_err(DBError::from) - } - - pub fn load_user(&self, user_id: i32) -> Result { - let con = self.pool.get()?; - con.query_row("SELECT [email] FROM [User] WHERE [id] = ?1", [user_id], |r| { - Ok(User { - email: r.get("email")?, - }) - }).map_err(DBError::from) - } - - /// - pub fn sign_up(&self, email: &str, password: &str) -> Result { - self.sign_up_with_given_time(email, password, Utc::now()) - } - - fn sign_up_with_given_time(&self, email: &str, password: &str, datetime: DateTime) -> Result { - let mut con = self.pool.get()?; - let tx = con.transaction()?; - let token = - match tx.query_row("SELECT [id], [validation_token] FROM [User] WHERE [email] = ?1", [email], |r| { - Ok((r.get::<&str, i32>("id")?, r.get::<&str, Option>("validation_token")?)) - }).optional()? { - 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))?; - tx.execute("UPDATE [User] SET [validation_token] = ?2, [creation_datetime] = ?3, [password] = ?4 WHERE [id] = ?1", params![id, token, datetime, hashed_password])?; - token - }, - None => { - let token = generate_token(); - let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?; - tx.execute("INSERT INTO [User] ([email], [validation_token], [creation_datetime], [password]) VALUES (?1, ?2, ?3, ?4)", params![email, token, datetime, hashed_password])?; - token - }, - }; - tx.commit()?; - Ok(SignUpResult::UserCreatedWaitingForValidation(token)) - } - - pub fn validation(&self, token: &str, validation_time: Duration, ip: &str, user_agent: &str) -> Result { - let mut con = self.pool.get()?; - let tx = con.transaction()?; - let user_id = - match tx.query_row("SELECT [id], [creation_datetime] FROM [User] WHERE [validation_token] = ?1", [token], |r| { - Ok((r.get::<&str, i32>("id")?, r.get::<&str, DateTime>("creation_datetime")?)) - }).optional()? { - Some((id, creation_datetime)) => { - if Utc::now() - creation_datetime > validation_time { - return Ok(ValidationResult::ValidationExpired) - } - tx.execute("UPDATE [User] SET [validation_token] = NULL WHERE [id] = ?1", [id])?; - id - }, - None => { - return Ok(ValidationResult::UnknownUser) - }, - }; - let token = Connection::create_login_token(&tx, user_id, ip, user_agent)?; - tx.commit()?; - Ok(ValidationResult::Ok(token, user_id)) - } - - pub fn sign_in(&self, email: &str, password: &str, ip: &str, user_agent: &str) -> Result { - let mut con = self.pool.get()?; - let tx = con.transaction()?; - match tx.query_row("SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = ?1", [email], |r| { - Ok((r.get::<&str, i32>("id")?, r.get::<&str, String>("password")?, r.get::<&str, Option>("validation_token")?)) - }).optional()? { - 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 = Connection::create_login_token(&tx, id, ip, user_agent)?; - tx.commit()?; - Ok(SignInResult::Ok(token, id)) - } else { - Ok(SignInResult::WrongPassword) - } - }, - None => { - Ok(SignInResult::UserNotFound) - }, - } - } - - pub fn authentication(&self, token: &str, ip: &str, user_agent: &str) -> Result { - let mut con = self.pool.get()?; - let tx = con.transaction()?; - match tx.query_row("SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { - Ok((r.get::<&str, i32>("id")?, r.get::<&str, i32>("user_id")?)) - }).optional()? { - Some((login_id, user_id)) => { - tx.execute("UPDATE [UserLoginToken] SET [last_login_datetime] = ?2, [ip] = ?3, [user_agent] = ?4 WHERE [id] = ?1", params![login_id, Utc::now(), ip, user_agent])?; - tx.commit()?; - Ok(AuthenticationResult::Ok(user_id)) - }, - None => - Ok(AuthenticationResult::NotValidToken) - } - } - - pub fn sign_out(&self, token: &str) -> Result<()> { - let mut con = self.pool.get()?; - let tx = con.transaction()?; - match tx.query_row("SELECT [id] FROM [UserLoginToken] WHERE [token] = ?1", [token], |r| { - Ok(r.get::<&str, i32>("id")?) - }).optional()? { - Some(login_id) => { - tx.execute("DELETE FROM [UserLoginToken] WHERE [id] = ?1", params![login_id])?; - tx.commit()? - }, - None => (), - } - Ok(()) - } - - /// Execute a given SQL file. - pub fn execute_file + fmt::Display>(&self, file: P) -> Result<()> { - let con = self.pool.get()?; - let sql = load_sql_file(file)?; - con.execute_batch(&sql).map_err(DBError::from) - } - - /// Execute any SQL statement. - /// Mainly used for testing. - pub fn execute_sql(&self, sql: &str, params: P) -> Result { - let con = self.pool.get()?; - con.execute(sql, params).map_err(DBError::from) - } - - // Return the token. - fn create_login_token(tx: &rusqlite::Transaction, user_id: i32, ip: &str, user_agent: &str) -> Result { - let token = generate_token(); - tx.execute("INSERT INTO [UserLoginToken] ([user_id], [last_login_datetime], [token], [ip], [user_agent]) VALUES (?1, ?2, ?3, ?4, ?5)", params![user_id, Utc::now(), token, ip, user_agent])?; - Ok(token) - } -} - -fn load_sql_file + fmt::Display>(sql_file: P) -> Result { - let mut file = File::open(&sql_file).map_err(|err| DBError::Other(format!("Cannot open SQL file ({}): {}", &sql_file, err.to_string())))?; - let mut sql = String::new(); - file.read_to_string(&mut sql).map_err(|err| DBError::Other(format!("Cannot read SQL file ({}) : {}", &sql_file, err.to_string())))?; - Ok(sql) -} - -fn generate_token() -> String { - Alphanumeric.sample_string(&mut rand::thread_rng(), 24) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sign_up() -> Result<()> { - let connection = Connection::new_in_memory()?; - match connection.sign_up("paul@test.org", "12345")? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_to_an_already_existing_user() -> Result<()> { - let connection = Connection::new_in_memory()?; - connection.execute_sql(" - INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) - VALUES ( - 1, - 'paul@test.org', - 'paul', - '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', - 0, - NULL - );", [])?; - match connection.sign_up("paul@test.org", "12345")? { - SignUpResult::UserAlreadyExists => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_and_sign_in_without_validation() -> Result<()> { - let connection = Connection::new_in_memory()?; - - let email = "paul@test.org"; - let password = "12345"; - - match connection.sign_up(email, password)? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - - match connection.sign_in(email, password, "127.0.0.1", "Mozilla/5.0")? { - SignInResult::AccountNotValidated => (), // Nominal case. - other => panic!("{:?}", other), - } - - Ok(()) - } - - #[test] - fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> { - let connection = Connection::new_in_memory()?; - let token = generate_token(); - connection.execute_sql(" - INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token]) - VALUES ( - 1, - 'paul@test.org', - 'paul', - '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY', - 0, - :token - );", named_params! { ":token": token })?; - match connection.sign_up("paul@test.org", "12345")? { - SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_at_time() -> Result<()> { - let connection = Connection::new_in_memory()?; - let validation_token = - match connection.sign_up("paul@test.org", "12345")? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - match connection.validation(&validation_token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")? { - ValidationResult::Ok(_, _) => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_too_late() -> Result<()> { - let connection = Connection::new_in_memory()?; - let validation_token = - match connection.sign_up_with_given_time("paul@test.org", "12345", Utc::now() - Duration::days(1))? { - SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case. - other => panic!("{:?}", other), - }; - match connection.validation(&validation_token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")? { - ValidationResult::ValidationExpired => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_with_bad_token() -> Result<()> { - let connection = Connection::new_in_memory()?; - let _validation_token = - match connection.sign_up("paul@test.org", "12345")? { - 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")? { - ValidationResult::UnknownUser => (), // Nominal case. - other => panic!("{:?}", other), - } - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_then_sign_in() -> Result<()> { - let connection = Connection::new_in_memory()?; - - let email = "paul@test.org"; - let password = "12345"; - - // Sign up. - let validation_token = - match connection.sign_up(email, password)? { - 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")? { - ValidationResult::Ok(_, _) => (), - other => panic!("{:?}", other), - }; - - // Sign in. - match connection.sign_in(email, password, "127.0.0.1", "Mozilla/5.0")? { - SignInResult::Ok(_, _) => (), // Nominal case. - other => panic!("{:?}", other), - } - - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_then_authentication() -> Result<()> { - let connection = Connection::new_in_memory()?; - - let email = "paul@test.org"; - let password = "12345"; - - // Sign up. - let validation_token = - match connection.sign_up(email, password)? { - 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")? { - 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)?; - 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")? { - 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)?; - assert_eq!(user_login_info_2.ip, "192.168.1.1"); - assert_eq!(user_login_info_2.user_agent, "Chrome"); - - Ok(()) - } - - #[test] - fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> { - let connection = Connection::new_in_memory()?; - - let email = "paul@test.org"; - let password = "12345"; - - // Sign up. - let validation_token = - match connection.sign_up(email, password)? { - 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")? { - 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)?; - 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)?; - - // Sign in. - let (authentication_token_2, user_id_2) = - match connection.sign_in(email, password, "192.168.1.1", "Chrome")? { - 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)?; - - assert_eq!(user_login_info_2.ip, "192.168.1.1"); - assert_eq!(user_login_info_2.user_agent, "Chrome"); - - Ok(()) - } -} diff --git a/backend/src/email.rs b/backend/src/email.rs index 9b095d5..b571a29 100644 --- a/backend/src/email.rs +++ b/backend/src/email.rs @@ -1,7 +1,35 @@ +use std::time::Duration; +use derive_more::Display; use lettre::{transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport}; -/// -pub fn send_validation(site_url: &str, email: &str, token: &str, smtp_login: &str, smtp_password: &str) -> Result<(), Box> { +use crate::consts; + +#[derive(Debug, Display)] +pub enum Error { + ParseError(lettre::address::AddressError), + SmtpError(lettre::transport::smtp::Error), + Email(lettre::error::Error), +} + +impl From for Error { + fn from(error: lettre::address::AddressError) -> Self { + Error::ParseError(error) + } +} + +impl From for Error { + fn from(error: lettre::transport::smtp::Error) -> Self { + Error::SmtpError(error) + } +} + +impl From for Error { + fn from(error: lettre::error::Error) -> Self { + Error::Email(error) + } +} + +pub fn send_validation(site_url: &str, email: &str, token: &str, smtp_login: &str, smtp_password: &str) -> Result<(), Error> { let email = Message::builder() .message_id(None) .from("recipes@gburri.org".parse()?) @@ -11,7 +39,7 @@ pub fn send_validation(site_url: &str, email: &str, token: &str, smtp_login: &st let credentials = Credentials::new(smtp_login.to_string(), smtp_password.to_string()); - let mailer = SmtpTransport::relay("mail.gandi.net")?.credentials(credentials).build(); + let mailer = SmtpTransport::relay("mail.gandi.net")?.credentials(credentials).timeout(Some(consts::SEND_EMAIL_TIMEOUT)).build(); if let Err(error) = mailer.send(&email) { eprintln!("Error when sending E-mail:\n{:?}", &error); diff --git a/backend/src/main.rs b/backend/src/main.rs index 4e68de1..03517f8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,403 +1,20 @@ -use std::collections::HashMap; - use actix_files as fs; -use actix_web::{http::header, get, post, web, Responder, middleware, App, HttpServer, HttpRequest, HttpResponse, cookie::Cookie}; -use askama_actix::{Template, TemplateToResponse}; -use chrono::{prelude::*, Duration}; +use actix_web::{web, middleware, App, HttpServer}; +use chrono::prelude::*; use clap::Parser; -use serde::Deserialize; -use log::{debug, error, log_enabled, info, Level}; +use log::error; -use config::Config; -use user::User; +use data::db; mod consts; -mod db; +mod utils; +mod data; mod hash; mod model; mod user; mod email; mod config; - -const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token"; - -///// UTILS ///// - -fn get_ip_and_user_agent(req: &HttpRequest) -> (String, String) { - let ip = - match req.headers().get(consts::REVERSE_PROXY_IP_HTTP_FIELD) { - Some(v) => v.to_str().unwrap_or_default().to_string(), - None => req.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_default() - }; - - let user_agent = req.headers().get(header::USER_AGENT).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default().to_string(); - - (ip, user_agent) -} - -fn get_current_user(req: &HttpRequest, connection: &web::Data) -> Option { - let (client_ip, client_user_agent) = get_ip_and_user_agent(req); - - match req.cookie(COOKIE_AUTH_TOKEN_NAME) { - Some(token_cookie) => - match connection.authentication(token_cookie.value(), &client_ip, &client_user_agent) { - Ok(db::AuthenticationResult::NotValidToken) => - // TODO: remove cookie? - None, - Ok(db::AuthenticationResult::Ok(user_id)) => - match connection.load_user(user_id) { - Ok(user) => - Some(user), - Err(error) => { - error!("Error during authentication: {}", error); - None - } - }, - Err(error) => { - error!("Error during authentication: {}", error); - None - }, - }, - None => None - } -} - -///// HOME ///// - -#[derive(Template)] -#[template(path = "home.html")] -struct HomeTemplate { - user: Option, - recipes: Vec<(i32, String)>, -} - -#[get("/")] -async fn home_page(req: HttpRequest, connection: web::Data) -> impl Responder { - HomeTemplate { user: get_current_user(&req, &connection), recipes: connection.get_all_recipe_titles().unwrap_or_default() } -} - -///// VIEW RECIPE ///// - -#[derive(Template)] -#[template(path = "view_recipe.html")] -struct ViewRecipeTemplate { - user: Option, - recipes: Vec<(i32, String)>, - current_recipe: model::Recipe, -} - -#[get("/recipe/view/{id}")] -async fn view_recipe(req: HttpRequest, path: web::Path<(i32,)>, connection: web::Data) -> impl Responder { - let (id,)= path.into_inner(); - let recipes = connection.get_all_recipe_titles().unwrap_or_default(); - let user = get_current_user(&req, &connection); - - match connection.get_recipe(id) { - Ok(recipe) => - ViewRecipeTemplate { - user, - recipes, - current_recipe: recipe, - }.to_response(), - Err(_error) => - MessageTemplate { - user, - recipes, - message: format!("Unable to get recipe #{}", id), - }.to_response(), - } -} - -///// MESSAGE ///// - -#[derive(Template)] -#[template(path = "message.html")] -struct MessageTemplate { - user: Option, - recipes: Vec<(i32, String)>, - message: String, -} - -//// SIGN UP ///// - -#[derive(Template)] -#[template(path = "sign_up_form.html")] -struct SignUpFormTemplate { - user: Option, - email: String, - message: String, - message_email: String, - message_password: String, -} - -#[get("/signup")] -async fn sign_up_get(req: HttpRequest, query: web::Query>, connection: web::Data) -> impl Responder { - SignUpFormTemplate { user: get_current_user(&req, &connection), email: String::new(), message: String::new(), message_email: String::new(), message_password: String::new() } -} - -#[derive(Deserialize)] -struct SignUpFormData { - email: String, - password_1: String, - password_2: String, -} - -enum SignUpError { - InvalidEmail, - PasswordsNotEqual, - InvalidPassword, - UserAlreadyExists, - DatabaseError, - UnableSendEmail, -} - -#[post("/signup")] -async fn sign_up_post(req: HttpRequest, form: web::Form, connection: web::Data, config: web::Data) -> impl Responder { - fn error_response(error: SignUpError, form: &web::Form, user: Option) -> HttpResponse { - SignUpFormTemplate { - user, - email: form.email.clone(), - message_email: - match error { - SignUpError::InvalidEmail => "Invalid email", - _ => "", - }.to_string(), - message_password: - match error { - SignUpError::PasswordsNotEqual => "Passwords don't match", - SignUpError::InvalidPassword => "Password must have at least eight characters", - _ => "", - }.to_string(), - message: - match error { - SignUpError::UserAlreadyExists => "This email is already taken", - SignUpError::DatabaseError => "Database error", - SignUpError::UnableSendEmail => "Unable to send the validation email", - _ => "", - }.to_string(), - }.to_response() - } - - let user = get_current_user(&req, &connection); - - // Validation of email and password. - if let common::utils::EmailValidation::NotValid = common::utils::validate_email(&form.email) { - return error_response(SignUpError::InvalidEmail, &form, user); - } - - if form.password_1 != form.password_2 { - return error_response(SignUpError::PasswordsNotEqual, &form, user); - } - - if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form.password_1) { - return error_response(SignUpError::InvalidPassword, &form, user); - } - - match connection.sign_up(&form.email, &form.password_1) { - Ok(db::SignUpResult::UserAlreadyExists) => { - error_response(SignUpError::UserAlreadyExists, &form, user) - }, - Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => { - let url = { - let host = req.headers().get(header::HOST).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default(); - let port: Option = 'p: { - let split_port: Vec<&str> = host.split(':').collect(); - if split_port.len() == 2 { - if let Ok(p) = split_port[1].parse::() { - break 'p Some(p) - } - } - None - }; - format!("http{}://{}", if port.is_some() && port.unwrap() != 443 { "" } else { "s" }, host) - }; - match email::send_validation(&url, &form.email, &token, &config.smtp_login, &config.smtp_password) { - Ok(()) => - HttpResponse::Found() - .insert_header((header::LOCATION, "/signup_check_email")) - .finish(), - Err(error) => { - error!("Email validation error: {}", error); - error_response(SignUpError::UnableSendEmail, &form, user) - }, - } - }, - Err(error) => { - error!("Signup database error: {}", error); - error_response(SignUpError::DatabaseError, &form, user) - }, - } -} - -#[get("/signup_check_email")] -async fn sign_up_check_email(req: HttpRequest, connection: web::Data) -> impl Responder { - let recipes = connection.get_all_recipe_titles().unwrap_or_default(); - MessageTemplate { - user: get_current_user(&req, &connection), - recipes, - message: "An email has been sent, follow the link to validate your account.".to_string(), - } -} - -#[get("/validation")] -async fn sign_up_validation(req: HttpRequest, query: web::Query>, connection: web::Data) -> impl Responder { - let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); - let user = get_current_user(&req, &connection); - - let recipes = connection.get_all_recipe_titles().unwrap_or_default(); - match query.get("token") { - Some(token) => { - match connection.validation(token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent).unwrap() { - db::ValidationResult::Ok(token, user_id) => { - let cookie = Cookie::new(COOKIE_AUTH_TOKEN_NAME, token); - let user = - match connection.load_user(user_id) { - Ok(user) => - Some(user), - Err(error) => { - error!("Error retrieving user by id: {}", error); - None - } - }; - - let mut response = - MessageTemplate { - user, - recipes, - message: "Email validation successful, your account has been created".to_string(), - }.to_response(); - - if let Err(error) = response.add_cookie(&cookie) { - error!("Unable to set cookie after validation: {}", error); - }; - - response - }, - db::ValidationResult::ValidationExpired => - MessageTemplate { - user, - recipes, - message: "The validation has expired. Try to sign up again.".to_string(), - }.to_response(), - db::ValidationResult::UnknownUser => - MessageTemplate { - user, - recipes, - message: "Validation error.".to_string(), - }.to_response(), - } - }, - None => { - MessageTemplate { - user, - recipes, - message: format!("No token provided"), - }.to_response() - }, - } -} - -///// SIGN IN ///// - -#[derive(Template)] -#[template(path = "sign_in_form.html")] -struct SignInFormTemplate { - user: Option, - email: String, - message: String, -} - -#[get("/signin")] -async fn sign_in_get(req: HttpRequest, connection: web::Data) -> impl Responder { - SignInFormTemplate { - user: get_current_user(&req, &connection), - email: String::new(), - message: String::new(), - } -} - -#[derive(Deserialize)] -struct SignInFormData { - email: String, - password: String, -} - -enum SignInError { - AccountNotValidated, - AuthenticationFailed, -} - -#[post("/signin")] -async fn sign_in_post(req: HttpRequest, form: web::Form, connection: web::Data) -> impl Responder { - fn error_response(error: SignInError, form: &web::Form, user: Option) -> HttpResponse { - SignInFormTemplate { - user, - email: form.email.clone(), - message: - match error { - SignInError::AccountNotValidated => "This account must be validated first", - SignInError::AuthenticationFailed => "Wrong email or password", - }.to_string(), - }.to_response() - } - - let user = get_current_user(&req, &connection); - let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); - - match connection.sign_in(&form.email, &form.password, &client_ip, &client_user_agent) { - Ok(db::SignInResult::AccountNotValidated) => - error_response(SignInError::AccountNotValidated, &form, user), - Ok(db::SignInResult::UserNotFound) | Ok(db::SignInResult::WrongPassword) => { - error_response(SignInError::AuthenticationFailed, &form, user) - }, - Ok(db::SignInResult::Ok(token, user_id)) => { - let cookie = Cookie::new(COOKIE_AUTH_TOKEN_NAME, token); - let mut response = - HttpResponse::Found() - .insert_header((header::LOCATION, "/")) - .finish(); - if let Err(error) = response.add_cookie(&cookie) { - error!("Unable to set cookie after sign in: {}", error); - }; - response - }, - Err(error) => { - error!("Signin error: {}", error); - error_response(SignInError::AuthenticationFailed, &form, user) - }, - } -} - - -///// SIGN OUT ///// - -#[get("/signout")] -async fn sign_out(req: HttpRequest, connection: web::Data) -> impl Responder { - let mut response = - HttpResponse::Found() - .insert_header((header::LOCATION, "/")) - .finish(); - - if let Some(token_cookie) = req.cookie(COOKIE_AUTH_TOKEN_NAME) { - if let Err(error) = connection.sign_out(token_cookie.value()) { - error!("Unable to sign out: {}", error); - }; - - if let Err(error) = response.add_removal_cookie(&Cookie::new(COOKIE_AUTH_TOKEN_NAME, "")) { - error!("Unable to set a removal cookie after sign out: {}", error); - }; - }; - response -} - -async fn not_found(req: HttpRequest, connection: web::Data) -> impl Responder { - let recipes = connection.get_all_recipe_titles().unwrap_or_default(); - MessageTemplate { - user: get_current_user(&req, &connection), - recipes, - message: "404: Not found".to_string(), - } -} +mod services; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -422,18 +39,19 @@ async fn main() -> std::io::Result<()> { .wrap(middleware::Compress::default()) .app_data(db_connection.clone()) .app_data(config.clone()) - .service(home_page) - .service(sign_up_get) - .service(sign_up_post) - .service(sign_up_check_email) - .service(sign_up_validation) - .service(sign_in_get) - .service(sign_in_post) - .service(sign_out) - .service(view_recipe) + .service(services::home_page) + .service(services::sign_up_get) + .service(services::sign_up_post) + .service(services::sign_up_check_email) + .service(services::sign_up_validation) + .service(services::sign_in_get) + .service(services::sign_in_post) + .service(services::sign_out) + .service(services::view_recipe) .service(fs::Files::new("/static", "static")) - .default_service(web::to(not_found)) + .default_service(web::to(services::not_found)) }); + //.workers(1); server.bind(&format!("0.0.0.0:{}", port))?.run().await } @@ -451,13 +69,13 @@ fn process_args() -> bool { match db::Connection::new() { Ok(con) => { if let Err(error) = con.execute_file("sql/data_test.sql") { - error!("{}", error); + eprintln!("{}", error); } // Set the creation datetime to 'now'. con.execute_sql("UPDATE [User] SET [creation_datetime] = ?1 WHERE [email] = 'paul@test.org'", [Utc::now()]).unwrap(); }, Err(error) => { - error!("Error: {}", error) + eprintln!("{}", error); }, } diff --git a/backend/src/services.rs b/backend/src/services.rs new file mode 100644 index 0000000..df81c43 --- /dev/null +++ b/backend/src/services.rs @@ -0,0 +1,452 @@ +use std::collections::HashMap; + +use actix_web::{http::{header, header::ContentType, StatusCode}, get, post, web, Responder, HttpRequest, HttpResponse, cookie::Cookie}; +use askama_actix::{Template, TemplateToResponse}; +use chrono::Duration; +use serde::Deserialize; +use log::{debug, error, log_enabled, info, Level}; + +use crate::utils; +use crate::email; +use crate::consts; +use crate::config::Config; +use crate::user::User; +use crate::model; +use crate::data::{db, asynchronous}; + +///// UTILS ///// + +fn get_ip_and_user_agent(req: &HttpRequest) -> (String, String) { + let ip = + match req.headers().get(consts::REVERSE_PROXY_IP_HTTP_FIELD) { + Some(v) => v.to_str().unwrap_or_default().to_string(), + None => req.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_default() + }; + + let user_agent = req.headers().get(header::USER_AGENT).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default().to_string(); + + (ip, user_agent) +} + +async fn get_current_user(req: &HttpRequest, connection: web::Data) -> Option { + let (client_ip, client_user_agent) = get_ip_and_user_agent(req); + + match req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) { + Some(token_cookie) => + match connection.authentication_async(token_cookie.value(), &client_ip, &client_user_agent).await { + Ok(db::AuthenticationResult::NotValidToken) => + // TODO: remove cookie? + None, + Ok(db::AuthenticationResult::Ok(user_id)) => + match connection.load_user_async(user_id).await { + Ok(user) => + Some(user), + Err(error) => { + error!("Error during authentication: {}", error); + None + } + }, + Err(error) => { + error!("Error during authentication: {}", error); + None + }, + }, + None => None + } +} + +type Result = std::result::Result; + +///// ERROR ///// + +#[derive(Debug)] +pub struct ServiceError { + status_code: StatusCode, + message: Option, +} + +impl From for ServiceError { + fn from(error: asynchronous::DBAsyncError) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl From for ServiceError { + fn from(error: email::Error) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl From for ServiceError { + fn from(error: actix_web::error::BlockingError) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + if let Some(ref m) = self.message { + write!(f, "**{}**\n\n", m)?; + } + write!(f, "Code: {}", self.status_code) + } +} + +impl actix_web::error::ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + MessageBaseTemplate { + message: &self.to_string(), + }.to_response() + } + + fn status_code(&self) -> StatusCode { + self.status_code + } +} + +///// HOME ///// + +#[derive(Template)] +#[template(path = "home.html")] +struct HomeTemplate { + user: Option, + recipes: Vec<(i32, String)>, + current_recipe_id: Option, +} + +#[get("/")] +pub async fn home_page(req: HttpRequest, connection: web::Data) -> Result { + let user = get_current_user(&req, connection.clone()).await; + let recipes = connection.get_all_recipe_titles_async().await?; + + Ok(HomeTemplate { user, current_recipe_id: None, recipes }.to_response()) +} + +///// VIEW RECIPE ///// + +#[derive(Template)] +#[template(path = "view_recipe.html")] +struct ViewRecipeTemplate { + user: Option, + recipes: Vec<(i32, String)>, + current_recipe_id: Option, + current_recipe: model::Recipe, +} + +#[get("/recipe/view/{id}")] +pub async fn view_recipe(req: HttpRequest, path: web::Path<(i32,)>, connection: web::Data) -> Result { + let (id,)= path.into_inner(); + let user = get_current_user(&req, connection.clone()).await; + let recipes = connection.get_all_recipe_titles_async().await?; + let recipe = connection.get_recipe_async(id).await?; + + Ok(ViewRecipeTemplate { + user, + current_recipe_id: Some(recipe.id), + recipes, + current_recipe: recipe, + }.to_response()) +} + +///// MESSAGE ///// + +#[derive(Template)] +#[template(path = "message_base.html")] +struct MessageBaseTemplate<'a> { + message: &'a str, +} + +#[derive(Template)] +#[template(path = "message.html")] +struct MessageTemplate<'a> { + user: Option, + message: &'a str, +} + +//// SIGN UP ///// + +#[derive(Template)] +#[template(path = "sign_up_form.html")] +struct SignUpFormTemplate { + user: Option, + email: String, + message: String, + message_email: String, + message_password: String, +} + +#[get("/signup")] +pub async fn sign_up_get(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + SignUpFormTemplate { user, email: String::new(), message: String::new(), message_email: String::new(), message_password: String::new() } +} + +#[derive(Deserialize)] +pub struct SignUpFormData { + email: String, + password_1: String, + password_2: String, +} + +enum SignUpError { + InvalidEmail, + PasswordsNotEqual, + InvalidPassword, + UserAlreadyExists, + DatabaseError, + UnableSendEmail, +} + +#[post("/signup")] +pub async fn sign_up_post(req: HttpRequest, form: web::Form, connection: web::Data, config: web::Data) -> Result { + fn error_response(error: SignUpError, form: &web::Form, user: Option) -> Result { + Ok(SignUpFormTemplate { + user, + email: form.email.clone(), + message_email: + match error { + SignUpError::InvalidEmail => "Invalid email", + _ => "", + }.to_string(), + message_password: + match error { + SignUpError::PasswordsNotEqual => "Passwords don't match", + SignUpError::InvalidPassword => "Password must have at least eight characters", + _ => "", + }.to_string(), + message: + match error { + SignUpError::UserAlreadyExists => "This email is already taken", + SignUpError::DatabaseError => "Database error", + SignUpError::UnableSendEmail => "Unable to send the validation email", + _ => "", + }.to_string(), + }.to_response()) + } + + let user = get_current_user(&req, connection.clone()).await; + + // Validation of email and password. + if let common::utils::EmailValidation::NotValid = common::utils::validate_email(&form.email) { + return error_response(SignUpError::InvalidEmail, &form, user); + } + + if form.password_1 != form.password_2 { + return error_response(SignUpError::PasswordsNotEqual, &form, user); + } + + if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form.password_1) { + return error_response(SignUpError::InvalidPassword, &form, user); + } + + match connection.sign_up_async(&form.email, &form.password_1).await { + Ok(db::SignUpResult::UserAlreadyExists) => { + error_response(SignUpError::UserAlreadyExists, &form, user) + }, + Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => { + let url = { + let host = req.headers().get(header::HOST).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default(); + let port: Option = 'p: { + let split_port: Vec<&str> = host.split(':').collect(); + if split_port.len() == 2 { + if let Ok(p) = split_port[1].parse::() { + break 'p Some(p) + } + } + None + }; + format!("http{}://{}", if port.is_some() && port.unwrap() != 443 { "" } else { "s" }, host) + }; + + let email = form.email.clone(); + + match web::block(move || { email::send_validation(&url, &email, &token, &config.smtp_login, &config.smtp_password) }).await? { + Ok(()) => + Ok(HttpResponse::Found() + .insert_header((header::LOCATION, "/signup_check_email")) + .finish()), + Err(error) => { + error!("Email validation error: {}", error); + error_response(SignUpError::UnableSendEmail, &form, user) + }, + } + }, + Err(error) => { + error!("Signup database error: {}", error); + error_response(SignUpError::DatabaseError, &form, user) + }, + } +} + +#[get("/signup_check_email")] +pub async fn sign_up_check_email(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + MessageTemplate { + user, + message: "An email has been sent, follow the link to validate your account.", + } +} + +#[get("/validation")] +pub async fn sign_up_validation(req: HttpRequest, query: web::Query>, connection: web::Data) -> Result { + let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); + let user = get_current_user(&req, connection.clone()).await; + + match query.get("token") { + Some(token) => { + match connection.validation_async(token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent).await? { + db::ValidationResult::Ok(token, user_id) => { + let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); + let user = + match connection.load_user(user_id) { + Ok(user) => + Some(user), + Err(error) => { + error!("Error retrieving user by id: {}", error); + None + } + }; + + let mut response = + MessageTemplate { + user, + message: "Email validation successful, your account has been created", + }.to_response(); + + if let Err(error) = response.add_cookie(&cookie) { + error!("Unable to set cookie after validation: {}", error); + }; + + Ok(response) + }, + db::ValidationResult::ValidationExpired => + Ok(MessageTemplate { + user, + message: "The validation has expired. Try to sign up again.", + }.to_response()), + db::ValidationResult::UnknownUser => + Ok(MessageTemplate { + user, + message: "Validation error.", + }.to_response()), + } + }, + None => { + Ok(MessageTemplate { + user, + message: &format!("No token provided"), + }.to_response()) + }, + } +} + +///// SIGN IN ///// + +#[derive(Template)] +#[template(path = "sign_in_form.html")] +struct SignInFormTemplate { + user: Option, + email: String, + message: String, +} + +#[get("/signin")] +pub async fn sign_in_get(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + SignInFormTemplate { + user, + email: String::new(), + message: String::new(), + } +} + +#[derive(Deserialize)] +pub struct SignInFormData { + email: String, + password: String, +} + +enum SignInError { + AccountNotValidated, + AuthenticationFailed, +} + +#[post("/signin")] +pub async fn sign_in_post(req: HttpRequest, form: web::Form, connection: web::Data) -> Result { + fn error_response(error: SignInError, form: &web::Form, user: Option) -> Result { + Ok(SignInFormTemplate { + user, + email: form.email.clone(), + message: + match error { + SignInError::AccountNotValidated => "This account must be validated first", + SignInError::AuthenticationFailed => "Wrong email or password", + }.to_string(), + }.to_response()) + } + + let user = get_current_user(&req, connection.clone()).await; + let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); + + match connection.sign_in_async(&form.email, &form.password, &client_ip, &client_user_agent).await { + Ok(db::SignInResult::AccountNotValidated) => + error_response(SignInError::AccountNotValidated, &form, user), + Ok(db::SignInResult::UserNotFound) | Ok(db::SignInResult::WrongPassword) => { + error_response(SignInError::AuthenticationFailed, &form, user) + }, + Ok(db::SignInResult::Ok(token, user_id)) => { + let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); + let mut response = + HttpResponse::Found() + .insert_header((header::LOCATION, "/")) + .finish(); + if let Err(error) = response.add_cookie(&cookie) { + error!("Unable to set cookie after sign in: {}", error); + }; + Ok(response) + }, + Err(error) => { + error!("Signin error: {}", error); + error_response(SignInError::AuthenticationFailed, &form, user) + }, + } +} + + +///// SIGN OUT ///// + +#[get("/signout")] +pub async fn sign_out(req: HttpRequest, connection: web::Data) -> impl Responder { + let mut response = + HttpResponse::Found() + .insert_header((header::LOCATION, "/")) + .finish(); + + if let Some(token_cookie) = req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) { + if let Err(error) = connection.sign_out_async(token_cookie.value()).await { + error!("Unable to sign out: {}", error); + }; + + if let Err(error) = response.add_removal_cookie(&Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, "")) { + error!("Unable to set a removal cookie after sign out: {}", error); + }; + }; + response +} + +pub async fn not_found(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + MessageTemplate { + user, + message: "404: Not found", + } +} diff --git a/backend/src/utils.rs b/backend/src/utils.rs new file mode 100644 index 0000000..d39a970 --- /dev/null +++ b/backend/src/utils.rs @@ -0,0 +1,11 @@ +use log::error; + +pub fn unwrap_print_err(r: Result) -> T +where + E: std::fmt::Debug +{ + if let Err(ref error) = r { + error!("{:?}", error); + } + r.unwrap() +} \ No newline at end of file diff --git a/backend/templates/base.html b/backend/templates/base.html index c6fbf69..9b7356d 100644 --- a/backend/templates/base.html +++ b/backend/templates/base.html @@ -8,18 +8,7 @@ -
-

~~ Recettes de cuisine ~~

- {% match user %} - {% when Some with (user) %} -
{{ user.email }} / Sign out
- {% when None %} - - {% endmatch %} -
-
- {% block main_container %}{% endblock %} -
+ {% block body_container %}{% endblock %} \ No newline at end of file diff --git a/backend/templates/base_with_header.html b/backend/templates/base_with_header.html new file mode 100644 index 0000000..04cdd9f --- /dev/null +++ b/backend/templates/base_with_header.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block body_container %} +
+

~~ Recettes de cuisine ~~

+ {% match user %} + {% when Some with (user) %} +
{{ user.email }} / Sign out
+ {% when None %} + + {% endmatch %} +
+
+ {% block main_container %}{% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/backend/templates/base_with_list.html b/backend/templates/base_with_list.html index 7e2e679..715c9e7 100644 --- a/backend/templates/base_with_list.html +++ b/backend/templates/base_with_list.html @@ -1,10 +1,24 @@ -{% extends "base.html" %} +{% extends "base_with_header.html" %} {% block main_container %}
    {% for (id, title) in recipes %} -
  • {{ title }}
  • +
  • + {% let item_html = "{}"|format(id, title) %} + {% match current_recipe_id %} + {# Don't know how to avoid repetition: comparing (using '==' or .eq()) current_recipe_id.unwrap() and id doesn't work. Guards for match don't exist. + See: https://github.com/djc/askama/issues/752 #} + {% when Some (current_id) %} + {% if current_id == id %} + [{{ item_html|escape("none") }}] + {% else %} + {{ item_html|escape("none") }} + {% endif %} + {% when None %} + {{ item_html|escape("none") }} + {% endmatch %} +
  • {% endfor %}
diff --git a/backend/templates/message.html b/backend/templates/message.html index a274a64..cccc4e4 100644 --- a/backend/templates/message.html +++ b/backend/templates/message.html @@ -1,7 +1,7 @@ -{% extends "base_with_list.html" %} +{% extends "base_with_header.html" %} -{% block content %} +{% block main_container %} -{{ message }} +{{ message|markdown }} {% endblock %} \ No newline at end of file diff --git a/backend/templates/message_base.html b/backend/templates/message_base.html new file mode 100644 index 0000000..f73b85f --- /dev/null +++ b/backend/templates/message_base.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block body_container %} +{% include "title.html" %} +{{ message|markdown }} +{% endblock %} \ No newline at end of file diff --git a/backend/templates/sign_in_form.html b/backend/templates/sign_in_form.html index 6b682d0..1e54353 100644 --- a/backend/templates/sign_in_form.html +++ b/backend/templates/sign_in_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base_with_header.html" %} {% block main_container %}
diff --git a/backend/templates/sign_up_form.html b/backend/templates/sign_up_form.html index ed329a1..54fe200 100644 --- a/backend/templates/sign_up_form.html +++ b/backend/templates/sign_up_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base_with_header.html" %} {% block main_container %}
diff --git a/backend/templates/title.html b/backend/templates/title.html new file mode 100644 index 0000000..2a4715c --- /dev/null +++ b/backend/templates/title.html @@ -0,0 +1 @@ +

~~ Recettes de cuisine ~~

\ No newline at end of file