diff --git a/backend/src/app.rs b/backend/src/app.rs index bafaae5..4973419 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -355,11 +355,11 @@ fn url_rewriting(mut req: Request) -> Request { } /// The language associated to the current HTTP request is defined in the current order: -/// - Extraction from the url: like in `/fr/recipe/view/42` -/// - Get from the user database record. -/// - Get from the cookie. -/// - Get from the HTTP header `accept-language`. -/// - Set as `translation::DEFAULT_LANGUAGE_CODE`. +/// 1. Extraction from the url: like in `/fr/recipe/view/42` +/// 2. Get from the user database record. +/// 3. Get from the cookie. +/// 4. Get from the HTTP header `accept-language`. +/// 5. Set as [translation::DEFAULT_LANGUAGE_CODE]. async fn context( ConnectInfo(addr): ConnectInfo, State(connection): State, diff --git a/backend/src/config.rs b/backend/src/config.rs index e4a5d62..c843aeb 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -17,6 +17,10 @@ pub struct Config { #[serde(default = "port_default")] pub port: u16, + /// The email address used when sending validation email. + #[serde(default = "email_address_default")] + pub email_address: String, + #[serde(default = "smtp_relay_address_default")] pub smtp_relay_address: String, @@ -44,11 +48,14 @@ pub struct Config { fn port_default() -> u16 { 8082 } - fn smtp_relay_address_default() -> String { "mail.something.com".to_string() } +fn email_address_default() -> String { + "".to_string() +} + fn smtp_login_default() -> String { "login".to_string() } diff --git a/backend/src/consts.rs b/backend/src/consts.rs index 073de69..af15062 100644 --- a/backend/src/consts.rs +++ b/backend/src/consts.rs @@ -38,9 +38,6 @@ pub const COOKIE_LANG_NAME: &str = "lang"; /// (cookie authentication, password reset, validation token). pub const TOKEN_SIZE: usize = 32; -// TODO: Move it in conf.ron. -pub const EMAIL_ADDRESS: &str = "recipes@gburri.org"; - /// When sending a validation email, /// the server has this duration to wait for a response from the SMTP server. pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60); diff --git a/backend/src/email.rs b/backend/src/email.rs index 250a0d8..d00376b 100644 --- a/backend/src/email.rs +++ b/backend/src/email.rs @@ -4,7 +4,6 @@ use lettre::{ AsyncTransport, Message, Tokio1Executor, transport::smtp::{AsyncSmtpTransport, authentication::Credentials}, }; -use tracing::error; use crate::consts; @@ -22,7 +21,13 @@ pub enum Error { #[async_trait::async_trait] pub trait EmailServiceTrait: Send + Sync { - async fn send_email(&self, email: &str, title: &str, message: &str) -> Result<(), Error>; + async fn send_email( + &self, + email_sender: &str, + email_receiver: &str, + title: &str, + message: &str, + ) -> Result<(), Error>; } pub struct EmailService { @@ -49,11 +54,17 @@ impl EmailService { impl EmailServiceTrait for EmailService { /// A function to send an email using the given SMTP address. /// It may timeout if the SMTP server is not reachable, see [consts::SEND_EMAIL_TIMEOUT]. - async fn send_email(&self, email: &str, title: &str, message: &str) -> Result<(), Error> { + async fn send_email( + &self, + email_sender: &str, + email_receiver: &str, + title: &str, + message: &str, + ) -> Result<(), Error> { let email = Message::builder() .message_id(None) - .from(consts::EMAIL_ADDRESS.parse()?) - .to(email.parse()?) + .from(email_sender.parse()?) + .to(email_receiver.parse()?) .subject(title) .body(message.to_string())?; diff --git a/backend/src/hash.rs b/backend/src/hash.rs index c2626ad..b0e457a 100644 --- a/backend/src/hash.rs +++ b/backend/src/hash.rs @@ -5,8 +5,10 @@ use argon2::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; +const MAX_LENGTH_PASSWORD: usize = 255; + fn get_argon2<'k>() -> Argon2<'k> { - // Note: It's not neccessary to have only one Argon2 object, creating a new one + // Note: It's not necessary to have only one Argon2 object, creating a new one // when we need it is lightweight. Argon2::new( argon2::Algorithm::Argon2id, @@ -22,6 +24,14 @@ fn get_argon2<'k>() -> Argon2<'k> { } pub fn hash(password: &str) -> Result> { + if password.len() > MAX_LENGTH_PASSWORD { + return Err(format!("Password max length is {}", MAX_LENGTH_PASSWORD).into()); + } + + if password.is_empty() { + return Err("Password can't be empty".into()); + } + let salt = SaltString::generate(&mut OsRng); let argon2 = get_argon2(); argon2 diff --git a/backend/src/log.rs b/backend/src/log.rs index 66a692c..2c86ab0 100644 --- a/backend/src/log.rs +++ b/backend/src/log.rs @@ -44,7 +44,14 @@ impl Log { P: AsRef, { if !directory.as_ref().exists() { - fs::DirBuilder::new().create(&directory).unwrap(); + fs::DirBuilder::new() + .create(&directory) + .unwrap_or_else(|_| { + panic!( + "Unable to create directory: {}", + directory.as_ref().to_string_lossy() + ) + }); } let file_appender = RollingFileAppender::builder() @@ -78,7 +85,7 @@ impl Log { } } - pub fn new_stdout_only() -> Self { + pub fn new_to_stdout_only() -> Self { let layer_stdout = tracing_subscriber::fmt::layer() .with_writer(std::io::stdout.with_max_level(TRACING_LEVEL)) .with_thread_ids(TRACING_DISPLAY_THREAD) diff --git a/backend/src/ron_extractor.rs b/backend/src/ron_extractor.rs index bdd6655..d5a586e 100644 --- a/backend/src/ron_extractor.rs +++ b/backend/src/ron_extractor.rs @@ -1,4 +1,5 @@ //! An Axum extractor for HTTP body containing RON data (Rusty Object Notation). + use axum::{ body::Bytes, extract::{FromRequest, Request}, diff --git a/backend/src/ron_utils.rs b/backend/src/ron_utils.rs index bd1999d..19caf89 100644 --- a/backend/src/ron_utils.rs +++ b/backend/src/ron_utils.rs @@ -18,13 +18,15 @@ pub struct RonError { impl axum::response::IntoResponse for RonError { fn into_response(self) -> Response { - let ron_as_str = ron_api::to_string(&self); - ( - StatusCode::BAD_REQUEST, - [(header::CONTENT_TYPE, RON_CONTENT_TYPE)], - ron_as_str, - ) - .into_response() + match ron_api::to_string(&self) { + Ok(ron_as_str) => ( + StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, RON_CONTENT_TYPE)], + ron_as_str, + ) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } } } @@ -44,7 +46,7 @@ pub fn ron_error(status: StatusCode, message: &str) -> impl IntoResponse { ) } -pub fn ron_error_not_autorized() -> ErrorResponse { +pub fn ron_error_not_authorized() -> ErrorResponse { ErrorResponse::from(ron_error( StatusCode::UNAUTHORIZED, consts::NOT_AUTHORIZED_MESSAGE, @@ -58,16 +60,19 @@ where ron_response(StatusCode::OK, ron) } -pub fn ron_response(status: StatusCode, ron: T) -> impl IntoResponse +pub fn ron_response(status: StatusCode, ron: T) -> Response where T: Serialize, { - let ron_as_str = ron_api::to_string(&ron); - ( - status, - [(header::CONTENT_TYPE, RON_CONTENT_TYPE)], - ron_as_str, - ) + match ron_api::to_string(&ron) { + Ok(ron_as_str) => ( + status, + [(header::CONTENT_TYPE, RON_CONTENT_TYPE)], + ron_as_str, + ) + .into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } } pub fn parse_body(body: Bytes) -> Result diff --git a/backend/src/services/fragments.rs b/backend/src/services/fragments.rs index 27f3fdb..0d32ace 100644 --- a/backend/src/services/fragments.rs +++ b/backend/src/services/fragments.rs @@ -5,7 +5,6 @@ use axum::{ response::{Html, IntoResponse}, }; use serde::Deserialize; -// use tracing::{event, Level}; use crate::{ app::{Context, Result}, diff --git a/backend/src/services/ron/rights.rs b/backend/src/services/ron/rights.rs index a0b0cec..f9dee08 100644 --- a/backend/src/services/ron/rights.rs +++ b/backend/src/services/ron/rights.rs @@ -1,6 +1,6 @@ use axum::response::Result; -use crate::{data::db, data::model, ron_utils::ron_error_not_autorized}; +use crate::{data::db, data::model, ron_utils::ron_error_not_authorized}; pub async fn check_user_rights_recipe( connection: &db::Connection, @@ -9,7 +9,7 @@ pub async fn check_user_rights_recipe( ) -> Result<()> { match user { Some(user) if connection.can_edit_recipe(user.id, recipe_id).await? => Ok(()), - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -20,7 +20,7 @@ pub async fn check_user_rights_recipe_group( ) -> Result<()> { match user { Some(user) if connection.can_edit_recipe_group(user.id, group_id).await? => Ok(()), - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -37,7 +37,7 @@ pub async fn check_user_rights_recipe_groups( { Ok(()) } - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -48,7 +48,7 @@ pub async fn check_user_rights_recipe_step( ) -> Result<()> { match user { Some(user) if connection.can_edit_recipe_step(user.id, step_id).await? => Ok(()), - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -65,7 +65,7 @@ pub async fn check_user_rights_recipe_steps( { Ok(()) } - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -82,7 +82,7 @@ pub async fn check_user_rights_recipe_ingredient( { Ok(()) } - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -99,7 +99,7 @@ pub async fn check_user_rights_recipe_ingredients( { Ok(()) } - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } @@ -116,6 +116,6 @@ pub async fn check_user_rights_shopping_list_entry( { Ok(()) } - _ => Err(ron_error_not_autorized()), + _ => Err(ron_error_not_authorized()), } } diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index 4b295ec..db7b482 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -20,6 +20,7 @@ use tracing::{error, warn}; use crate::{ app::{AppState, Context, Result}, + config::Config, consts, data::db, email, @@ -88,6 +89,7 @@ enum SignUpError { #[debug_handler(state = AppState)] pub async fn sign_up_post( Host(host): Host, + State(config): State, State(connection): State, State(email_service): State>, Extension(context): Extension, @@ -171,6 +173,7 @@ pub async fn sign_up_post( let email = form_data.email.clone(); match email_service .send_email( + &config.email_address, &email, context.tr.t(Sentence::SignUpEmailTitle), &context.tr.tp( @@ -385,7 +388,6 @@ pub async fn sign_in_post( #[debug_handler] pub async fn sign_out( State(connection): State, - Extension(context): Extension, req: Request, ) -> Result<(CookieJar, Redirect)> { let mut jar = CookieJar::from_headers(req.headers()); @@ -450,6 +452,7 @@ enum AskResetPasswordError { #[debug_handler(state = AppState)] pub async fn ask_reset_password_post( Host(host): Host, + State(config): State, State(connection): State, State(email_service): State>, Extension(context): Extension, @@ -522,6 +525,7 @@ pub async fn ask_reset_password_post( let url = utils::get_url_from_host(&host); match email_service .send_email( + &config.email_address, &form_data.email, context.tr.t(Sentence::AskResetEmailTitle), &context.tr.tp( @@ -755,6 +759,7 @@ enum ProfileUpdateError { #[debug_handler(state = AppState)] pub async fn edit_user_post( Host(host): Host, + State(config): State, State(connection): State, State(email_service): State>, Extension(context): Extension, @@ -859,6 +864,7 @@ pub async fn edit_user_post( let email = form_data.email.clone(); match email_service .send_email( + &config.email_address, &email, context.tr.t(Sentence::ProfileFollowEmailTitle), &context.tr.tp( diff --git a/backend/src/translation.rs b/backend/src/translation.rs index 99db5fa..b140916 100644 --- a/backend/src/translation.rs +++ b/backend/src/translation.rs @@ -19,12 +19,15 @@ pub struct Tr { } impl Tr { + /// Create a new translation object. + /// See [available_codes]. pub fn new(code: &str) -> Self { Self { lang: get_language_translation(code), } } + /// Translate the given sentence according to the current language. pub fn t(&self, sentence: T) -> &'static str where T: Borrow, @@ -32,11 +35,13 @@ impl Tr { self.lang.get(sentence) } + /// Translate the given sentence id according to the current language. pub fn t_from_id(&self, sentence_id: i64) -> &'static str { self.lang.get_from_id(sentence_id) } /// Translate a sentence with parameters. + /// Placeholders "{}" are replaced in the same order as the given parameters. pub fn tp(&self, sentence: Sentence, params: &[Box]) -> String { let text = self.lang.get(sentence); let params_as_string: Vec = params.iter().map(|p| p.to_string()).collect(); @@ -118,20 +123,6 @@ impl Tr { } } -// #[macro_export] -// macro_rules! t { -// ($self:expr, $str:expr) => { -// $self.t($str) -// }; -// ($self:expr, $str:expr, $( $x:expr ),+ ) => { -// { -// let mut result = $self.t($str); -// $( result = result.replacen("{}", &$x.to_string(), 1); )+ -// result -// } -// }; -// } - #[derive(Debug, Deserialize)] struct StoredLanguage { code: String, @@ -151,7 +142,7 @@ struct Language { const UNABLE_TO_FIND_TRANSLATION_MESSAGE: &str = "Unable to find translation"; impl Language { - pub fn from_stored_language(stored_language: StoredLanguage) -> Self { + fn from_stored_language(stored_language: StoredLanguage) -> Self { Self { code: stored_language.code, territory: stored_language.territory, @@ -166,15 +157,14 @@ impl Language { } } - pub fn get(&'static self, sentence: T) -> &'static str + fn get(&'static self, sentence: T) -> &'static str where T: Borrow, { - let sentence_cloned: Sentence = sentence.borrow().clone(); - self.get_from_id(sentence_cloned as i64) + self.get_from_id(*sentence.borrow() as i64) } - pub fn get_from_id(&'static self, sentence_id: i64) -> &'static str { + fn get_from_id(&'static self, sentence_id: i64) -> &'static str { let text: &str = match self.translation.get(sentence_id as usize) { None => UNABLE_TO_FIND_TRANSLATION_MESSAGE, Some(text) => text, @@ -187,6 +177,7 @@ impl Language { } } +/// Returns all available languages as a tuple (code, name). pub fn available_languages() -> Vec<(&'static str, &'static str)> { TRANSLATIONS .iter() @@ -194,6 +185,7 @@ pub fn available_languages() -> Vec<(&'static str, &'static str)> { .collect() } +/// Returns all available codes. pub fn available_codes() -> Vec<&'static str> { TRANSLATIONS.iter().map(|tr| tr.code.as_ref()).collect() } diff --git a/backend/src/utils.rs b/backend/src/utils.rs index f8715f3..8b623af 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -20,14 +20,13 @@ pub fn get_ip_and_user_agent(headers: &HeaderMap, remote_address: SocketAddr) -> } pub fn get_url_from_host(host: &str) -> String { - let port: Option = 'p: { + let port: Option = { 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); - } + split_port[1].parse::().ok() + } else { + None } - None }; format!( "http{}://{}", diff --git a/backend/templates/toast.html b/backend/templates/toast.html index bb5b2f6..9d26979 100644 --- a/backend/templates/toast.html +++ b/backend/templates/toast.html @@ -1,7 +1,7 @@ {# Needed by the frontend toast module. #}
- icon + icon
diff --git a/backend/tests/http.rs b/backend/tests/http.rs index 11287bc..fa80c9f 100644 --- a/backend/tests/http.rs +++ b/backend/tests/http.rs @@ -119,18 +119,19 @@ async fn sign_up() -> Result<(), Box> { let mut mock_email_service = utils::mock_email::MockEmailService::new(); mock_email_service .expect_send_email() - .withf_st(move |email, _title, message| { + .withf(|_email_sender, email_receiver, _title, _message| { + email_receiver == "president@spaceball.planet" + }) + .times(1) + .returning_st(move |_email_sender, _email_receiver, _title, message| { sscanf!( message, "Follow this link to confirm your inscription, http://127.0.0.1:8000{}", *validation_url_clone.borrow_mut() ) .unwrap(); - println!("{}", message); - email == "president@spaceball.planet" - }) - .times(1) - .returning(|_email, _title, _message| Ok(())); + Ok(()) + }); let state = utils::common_state_with_email_service(Arc::new(mock_email_service)).await?; let server = TestServer::new(app::make_service(state))?; diff --git a/backend/tests/utils/mock_email.rs b/backend/tests/utils/mock_email.rs index 5e9debe..22a33b1 100644 --- a/backend/tests/utils/mock_email.rs +++ b/backend/tests/utils/mock_email.rs @@ -9,7 +9,7 @@ mock! { pub EmailService {} #[async_trait] impl email::EmailServiceTrait for EmailService { - async fn send_email(&self, email: &str, title: &str, message: &str) + async fn send_email(&self, email_sender: &str, email_receiver: &str, title: &str, message: &str) -> Result<(), email::Error>; } } diff --git a/common/src/ron_api.rs b/common/src/ron_api.rs index 596fbe3..65f39f3 100644 --- a/common/src/ron_api.rs +++ b/common/src/ron_api.rs @@ -240,10 +240,9 @@ pub struct ShoppingListItem { /*** Misc ***/ -pub fn to_string(ron: T) -> String +pub fn to_string(ron: T) -> Result where T: Serialize, { - // TODO: handle'unwrap'. - to_string_pretty(&ron, PrettyConfig::new()).unwrap() + to_string_pretty(&ron, PrettyConfig::new()) } diff --git a/common/src/translation.rs b/common/src/translation.rs index 2be2366..787fde5 100644 --- a/common/src/translation.rs +++ b/common/src/translation.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use strum::EnumCount; #[repr(i64)] -#[derive(Debug, Clone, EnumCount, Deserialize)] +#[derive(Debug, Clone, Copy, EnumCount, Deserialize)] pub enum Sentence { MainTitle = 0, CreateNewRecipe, diff --git a/frontend/src/request.rs b/frontend/src/request.rs index 707126c..4436a25 100644 --- a/frontend/src/request.rs +++ b/frontend/src/request.rs @@ -15,7 +15,10 @@ pub enum Error { Gloo(#[from] gloo::net::Error), #[error("RON Spanned error: {0}")] - Ron(#[from] ron::error::SpannedError), + RonSpanned(#[from] ron::error::SpannedError), + + #[error("RON Error: {0}")] + Ron(#[from] ron::error::Error), #[error("HTTP error: {0}")] Http(String), @@ -40,7 +43,7 @@ where { let url = format!("/ron-api/{}", api_name); let request_builder = method_fn(&url).header(CONTENT_TYPE, CONTENT_TYPE_RON); - send_req(request_builder.body(ron_api::to_string(body))?).await + send_req(request_builder.body(ron_api::to_string(body)?)?).await } async fn req_with_params(