use std::{collections::HashMap, net::SocketAddr, sync::Arc}; use askama::Template; use axum::{ Form, body::Body, debug_handler, extract::{ConnectInfo, Extension, Request, State}, http::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::{ Host, Query, cookie::{self, Cookie, CookieJar}, }; use chrono::Duration; use lettre::Address; use serde::Deserialize; use tracing::{error, warn}; use crate::{ app::{AppState, Context, Result}, config::Config, consts, data::db, email, html_templates::*, translation::Sentence, utils, }; const VALIDATION_TOKEN_KEY: &str = "validation_token"; /// SIGN UP /// #[debug_handler] pub async fn sign_up_get( State(connection): State, Extension(context): Extension, ) -> Result { if connection.get_new_user_registration_enabled().await? { Ok(Html( SignUpFormTemplate { context, email: String::new(), message: "", message_email: "", message_password: "", } .render()?, ) .into_response()) } else { Ok( Html(MessageTemplate::new(context.tr.t(Sentence::SignUpClosed), context).render()?) .into_response(), ) } } #[derive(Deserialize, Debug)] pub struct SignUpFormData { email: String, password_1: String, password_2: String, } #[derive(Debug, thiserror::Error)] enum SignUpError { #[error("Invalid email")] InvalidEmail, #[error("Password not equal")] PasswordsNotEqual, #[error("Invalid password")] InvalidPassword, #[error("User already exists")] UserAlreadyExists, #[error("Database error: {0}")] DatabaseError(db::DBError), #[error("Unable to send email: {0}")] UnableToSendEmail(email::Error), } #[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, Form(form_data): Form, ) -> Result { fn error_response( error: SignUpError, form_data: &SignUpFormData, context: Context, ) -> Result { error!( "Error during sign up (email={}): {}", form_data.email, error ); let invalid_password_mess = &context.tr.tp( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); Ok(Html( SignUpFormTemplate { email: form_data.email.clone(), message_email: match error { SignUpError::InvalidEmail => context.tr.t(Sentence::InvalidEmail), SignUpError::UserAlreadyExists => context.tr.t(Sentence::EmailAlreadyTaken), _ => "", }, message_password: match error { SignUpError::PasswordsNotEqual => context.tr.t(Sentence::PasswordDontMatch), SignUpError::InvalidPassword => invalid_password_mess, _ => "", }, message: match error { // The error details is not shown to the user (it is logged above). SignUpError::DatabaseError(_) => context.tr.t(Sentence::DatabaseError), SignUpError::UnableToSendEmail(_) => context.tr.t(Sentence::UnableToSendEmail), _ => "", }, context, } .render()?, ) .into_response()) } if !connection.get_new_user_registration_enabled().await? { return Ok(Html( MessageTemplate::new(context.tr.t(Sentence::SignUpClosed), context).render()?, ) .into_response()); } // Validation of email and password. if form_data.email.parse::
().is_err() { return error_response(SignUpError::InvalidEmail, &form_data, context); } if form_data.password_1 != form_data.password_2 { return error_response(SignUpError::PasswordsNotEqual, &form_data, context); } if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form_data.password_1) { return error_response(SignUpError::InvalidPassword, &form_data, context); } match connection .sign_up( &form_data.email, &form_data.password_1, context.tr.first_day_of_week(), ) .await { Ok(db::user::SignUpResult::UserAlreadyExists) => { error_response(SignUpError::UserAlreadyExists, &form_data, context) } Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => { let url = utils::get_url_from_host(&host); let email = form_data.email.clone(); match email_service .send_email( &config.email_address, &email, context.tr.t(Sentence::SignUpEmailTitle), &context.tr.tp( Sentence::SignUpFollowEmailLink, &[Box::new(format!( "{}/validation?{}={}", url, VALIDATION_TOKEN_KEY, token ))], ), ) .await { Ok(()) => Ok(Html( MessageTemplate::new(context.tr.t(Sentence::SignUpEmailSent), context) .render()?, ) .into_response()), Err(error) => { error_response(SignUpError::UnableToSendEmail(error), &form_data, context) } } } Err(error) => error_response(SignUpError::DatabaseError(error), &form_data, context), } } #[debug_handler] pub async fn sign_up_validation( State(connection): State, Extension(context): Extension, ConnectInfo(addr): ConnectInfo, Query(query): Query>, headers: HeaderMap, ) -> Result<(CookieJar, impl IntoResponse)> { let mut jar = CookieJar::from_headers(&headers); if let Some(ref user) = context.user { warn!( "Unable to validate: user already logged. Email: {}", user.email ); return Ok(( jar, Html( MessageTemplate::new(context.tr.t(Sentence::ValidationUserAlreadyExists), context) .render()?, ), )); } let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); match query.get(VALIDATION_TOKEN_KEY) { // 'validation_token' exists only when a user tries to validate a new account. Some(token) => { match connection .validation( token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent, ) .await? { db::user::ValidationResult::Ok(token, user_id) => { let cookie = Cookie::build((consts::COOKIE_AUTH_TOKEN_NAME, token)) .secure(true) .same_site(cookie::SameSite::Strict); jar = jar.add(cookie); let user = connection.load_user(user_id).await?; Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::SignUpEmailValidationSuccess), Context { user, ..context }, ) .render()?, ), )) } db::user::ValidationResult::ValidationExpired => { warn!("Unable to validate: validation expired. Token: {}", token); Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::SignUpValidationExpired), context, ) .render()?, ), )) } db::user::ValidationResult::UnknownUser => { warn!("Unable to validate: unknown user. Token: {}", token); Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::SignUpValidationErrorTryAgain), context, ) .render()?, ), )) } } } None => { warn!("Unable to validate: no token provided"); Ok(( jar, Html( MessageTemplate::new(context.tr.t(Sentence::ValidationError), context) .render()?, ), )) } } } /// SIGN IN /// #[debug_handler] pub async fn sign_in_get(Extension(context): Extension) -> Result { Ok(Html( SignInFormTemplate { context, email: "", message: "", } .render()?, )) } #[derive(Deserialize, Debug)] pub struct SignInFormData { email: String, password: String, } #[debug_handler] pub async fn sign_in_post( ConnectInfo(addr): ConnectInfo, State(connection): State, Extension(context): Extension, headers: HeaderMap, Form(form_data): Form, ) -> Result<(CookieJar, Response)> { let jar = CookieJar::from_headers(&headers); let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); match connection .sign_in( &form_data.email, &form_data.password, &client_ip, &client_user_agent, ) .await? { error @ db::user::SignInResult::AccountNotValidated => { warn!( "Account not validated, email: {}: {}", form_data.email, error ); Ok(( jar, Html( SignInFormTemplate { email: &form_data.email, message: context.tr.t(Sentence::AccountMustBeValidatedFirst), context, } .render()?, ) .into_response(), )) } error @ (db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword) => { warn!("Email: {}: {}", form_data.email, error); Ok(( jar, Html( SignInFormTemplate { email: &form_data.email, message: context.tr.t(Sentence::WrongEmailOrPassword), context, } .render()?, ) .into_response(), )) } db::user::SignInResult::Ok(token, _user_id) => { let cookie = Cookie::build((consts::COOKIE_AUTH_TOKEN_NAME, token)) .secure(true) .same_site(cookie::SameSite::Strict); Ok(( jar.add(cookie), Redirect::to(&format!( "/?{}={}&{}={}", common::consts::GET_PARAMETER_USER_MESSAGE, Sentence::SignInSuccess as i64, common::consts::GET_PARAMETER_USER_MESSAGE_LEVEL, common::toast::Level::Success as usize )) .into_response(), )) } } } /// SIGN OUT /// #[debug_handler] pub async fn sign_out( State(connection): State, req: Request, ) -> Result<(CookieJar, Redirect)> { let mut jar = CookieJar::from_headers(req.headers()); if let Some(token_cookie) = jar.get(consts::COOKIE_AUTH_TOKEN_NAME) { let token = token_cookie.value().to_string(); jar = jar.remove(consts::COOKIE_AUTH_TOKEN_NAME); connection.sign_out(&token).await?; } Ok((jar, Redirect::to("/"))) } /// RESET PASSWORD /// #[debug_handler] pub async fn ask_reset_password_get(Extension(context): Extension) -> Result { if context.user.is_some() { Ok(Html( MessageTemplate::new( context.tr.t(Sentence::AskResetAlreadyLoggedInError), context, ) .render()?, ) .into_response()) } else { Ok(Html( AskResetPasswordTemplate { context, email: "", message: "", message_email: "", } .render()?, ) .into_response()) } } #[derive(Deserialize, Debug)] pub struct AskResetPasswordForm { email: String, } #[derive(Debug, thiserror::Error)] enum AskResetPasswordError { #[error("Invalid email")] InvalidEmail, #[error("Email already reset")] EmailAlreadyReset, #[error("Email unknown")] EmailUnknown, #[error("Database Error: {0}")] DatabaseError(db::DBError), #[error("Unable to send email: {0}")] UnableSendEmail(email::Error), } #[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, Form(form_data): Form, ) -> Result { fn error_response( error: AskResetPasswordError, email: &str, context: Context, ) -> Result { error!( "Error when asking password reset (email={}): {}", email, error ); Ok(Html( AskResetPasswordTemplate { email, message_email: match error { AskResetPasswordError::InvalidEmail => context.tr.t(Sentence::InvalidEmail), AskResetPasswordError::EmailAlreadyReset => { context.tr.t(Sentence::AskResetEmailAlreadyResetError) } AskResetPasswordError::EmailUnknown => context.tr.t(Sentence::EmailUnknown), AskResetPasswordError::UnableSendEmail(_) => { context.tr.t(Sentence::UnableToSendResetEmail) } _ => "", }, message: match error { AskResetPasswordError::DatabaseError(_) => { context.tr.t(Sentence::DatabaseError) } _ => "", }, context, } .render()?, ) .into_response()) } // Validation of email. if form_data.email.parse::
().is_err() { return error_response( AskResetPasswordError::InvalidEmail, &form_data.email, context, ); } match connection .get_token_reset_password( &form_data.email, Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION), ) .await { Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response( AskResetPasswordError::EmailAlreadyReset, &form_data.email, context, ), Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => error_response( AskResetPasswordError::EmailUnknown, &form_data.email, context, ), Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => { 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( Sentence::AskResetFollowEmailLink, &[Box::new(format!( "{}/reset_password?reset_token={}", url, token ))], ), ) .await { Ok(()) => Ok(Html( MessageTemplate::new(context.tr.t(Sentence::AskResetEmailSent), context) .render()?, ) .into_response()), Err(error) => error_response( AskResetPasswordError::UnableSendEmail(error), &form_data.email, context, ), } } Err(error) => error_response( AskResetPasswordError::DatabaseError(error), &form_data.email, context, ), } } #[debug_handler] pub async fn reset_password_get( State(connection): State, Extension(context): Extension, Query(query): Query>, ) -> Result { if let Some(reset_token) = query.get("reset_token") { // Check if the token is valid. if connection .is_reset_password_token_valid( reset_token, Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION), ) .await? { Ok(Html( ResetPasswordTemplate { context, reset_token, message: "", message_password: "", } .render()?, ) .into_response()) } else { Ok(Html( MessageTemplate::new(context.tr.t(Sentence::AskResetTokenMissing), context) .render()?, ) .into_response()) } } else { Ok(Html( MessageTemplate::new(context.tr.t(Sentence::AskResetTokenMissing), context).render()?, ) .into_response()) } } #[derive(Deserialize, Debug)] pub struct ResetPasswordForm { password_1: String, password_2: String, reset_token: String, } #[derive(Debug, thiserror::Error)] enum ResetPasswordError { #[error("Password not equal")] PasswordsNotEqual, #[error("Invalid password")] InvalidPassword, #[error("Token expired")] TokenExpired, #[error("Database error: {0}")] DatabaseError(db::DBError), } #[debug_handler] pub async fn reset_password_post( State(connection): State, Extension(context): Extension, Form(form_data): Form, ) -> Result { fn error_response( error: ResetPasswordError, form_data: &ResetPasswordForm, context: Context, ) -> Result { error!( "Error during password reset (email={}): {}", if let Some(ref user) = context.user { &user.email } else { "" }, error ); let reset_password_mess = &context.tr.tp( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); Ok(Html( ResetPasswordTemplate { reset_token: &form_data.reset_token, message_password: match error { ResetPasswordError::PasswordsNotEqual => { context.tr.t(Sentence::PasswordDontMatch) } ResetPasswordError::InvalidPassword => reset_password_mess, _ => "", }, message: match error { ResetPasswordError::TokenExpired => { context.tr.t(Sentence::AskResetTokenExpired) } ResetPasswordError::DatabaseError(_) => context.tr.t(Sentence::DatabaseError), _ => "", }, context, } .render()?, ) .into_response()) } if form_data.password_1 != form_data.password_2 { return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, context); } if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form_data.password_1) { return error_response(ResetPasswordError::InvalidPassword, &form_data, context); } match connection .reset_password( &form_data.password_1, &form_data.reset_token, Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION), ) .await { Ok(db::user::ResetPasswordResult::Ok) => Ok(Html( MessageTemplate::new(context.tr.t(Sentence::PasswordReset), context).render()?, ) .into_response()), Ok(db::user::ResetPasswordResult::ResetTokenExpired) => { error_response(ResetPasswordError::TokenExpired, &form_data, context) } Err(error) => error_response( ResetPasswordError::DatabaseError(error), &form_data, context, ), } } /// EDIT PROFILE /// #[debug_handler] pub async fn edit_user_get(Extension(context): Extension) -> Result { Ok(if let Some(ref user) = context.user { Html( ProfileTemplate { username: &user.name, email: &user.email, default_servings: user.default_servings, message: "", message_email: "", message_password: "", context: context.clone(), } .render()?, ) .into_response() } else { Html(MessageTemplate::new(context.tr.t(Sentence::NotLoggedIn), context).render()?) .into_response() }) } #[derive(Deserialize, Debug)] pub struct EditUserForm { name: String, email: String, default_servings: u32, first_day_of_the_week: chrono::Weekday, password_1: String, password_2: String, } #[derive(Debug, thiserror::Error)] enum ProfileUpdateError { #[error("Invalid email")] InvalidEmail, #[error("Email already taken")] EmailAlreadyTaken, #[error("Password not equal")] PasswordsNotEqual, #[error("Invalid password")] InvalidPassword, #[error("Database error: {0}")] DatabaseError(db::DBError), #[error("Unable to send email: {0}")] UnableToSendEmail(email::Error), } #[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, Form(form_data): Form, ) -> Result { if let Some(ref user) = context.user { fn error_response( error: ProfileUpdateError, form_data: &EditUserForm, context: Context, ) -> Result { error!( "Error during edit user (email={}): {}", if let Some(ref user) = context.user { &user.email } else { "" }, error ); let invalid_password_mess = &context.tr.tp( Sentence::InvalidPassword, &[Box::new(common::consts::MIN_PASSWORD_SIZE)], ); Ok(Html( ProfileTemplate { username: &form_data.name, email: &form_data.email, default_servings: form_data.default_servings, message_email: match error { ProfileUpdateError::InvalidEmail => context.tr.t(Sentence::InvalidEmail), ProfileUpdateError::EmailAlreadyTaken => { context.tr.t(Sentence::EmailAlreadyTaken) } _ => "", }, message_password: match error { ProfileUpdateError::PasswordsNotEqual => { context.tr.t(Sentence::PasswordDontMatch) } ProfileUpdateError::InvalidPassword => invalid_password_mess, _ => "", }, message: match error { ProfileUpdateError::DatabaseError(_) => { context.tr.t(Sentence::DatabaseError) } ProfileUpdateError::UnableToSendEmail(_) => { context.tr.t(Sentence::UnableToSendEmail) } _ => "", }, context, } .render()?, ) .into_response()) } if form_data.email.parse::
().is_err() { return error_response(ProfileUpdateError::InvalidEmail, &form_data, context); } let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() { if form_data.password_1 != form_data.password_2 { return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, context); } if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form_data.password_1) { return error_response(ProfileUpdateError::InvalidPassword, &form_data, context); } Some(form_data.password_1.as_ref()) } else { None }; let email_trimmed = form_data.email.trim(); let message: &str; match connection .update_user( user.id, Some(email_trimmed), Some(&form_data.name), Some(form_data.default_servings), Some(form_data.first_day_of_the_week), new_password, ) .await { Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => { return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, context); } Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation( token, old_email, old_token, old_token_datetime, )) => { let url = utils::get_url_from_host(&host); let email = form_data.email.clone(); match email_service .send_email( &config.email_address, &email, context.tr.t(Sentence::ProfileFollowEmailTitle), &context.tr.tp( Sentence::ProfileFollowEmailLink, &[Box::new(format!( "{}/revalidation?{}={}", url, VALIDATION_TOKEN_KEY, token ))], ), ) .await { Ok(()) => { message = context.tr.t(Sentence::ProfileEmailSent); } Err(error) => { // If the email can't be set we revert the changes about email and token. if let Err(error) = connection .update_user_email_and_token( user.id, &old_email, old_token.as_deref(), &old_token_datetime, ) .await { error!( "Unable to set email and token: (email={}): {}", email, error ); } return error_response( ProfileUpdateError::UnableToSendEmail(error), &form_data, context, ); } } } Ok(db::user::UpdateUserResult::Ok) => { message = context.tr.t(Sentence::ProfileSaved); } Err(error) => { return error_response( ProfileUpdateError::DatabaseError(error), &form_data, context, ); } } // Reload after update. let user = connection.load_user(user.id).await?; Ok(Html( ProfileTemplate { username: &form_data.name, email: &form_data.email, default_servings: form_data.default_servings, message, message_email: "", message_password: "", context: Context { user, ..context }, } .render()?, ) .into_response()) } else { Ok( Html(MessageTemplate::new(context.tr.t(Sentence::NotLoggedIn), context).render()?) .into_response(), ) } } #[debug_handler] pub async fn email_revalidation( State(connection): State, Extension(context): Extension, ConnectInfo(addr): ConnectInfo, Query(query): Query>, headers: HeaderMap, ) -> Result<(CookieJar, impl IntoResponse)> { let mut jar = CookieJar::from_headers(&headers); if context.user.is_some() { return Ok(( jar, Html( MessageTemplate::new(context.tr.t(Sentence::ValidationUserAlreadyExists), context) .render()?, ), )); } let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); match query.get(VALIDATION_TOKEN_KEY) { // 'validation_token' exists only when a user must validate a new email. Some(token) => { match connection .validation( token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent, ) .await? { db::user::ValidationResult::Ok(token, user_id) => { let cookie = Cookie::build((consts::COOKIE_AUTH_TOKEN_NAME, token)) .secure(true) .same_site(cookie::SameSite::Strict); jar = jar.add(cookie); let user = connection.load_user(user_id).await?; Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::ValidationSuccessful), Context { user, ..context }, ) .render()?, ), )) } error @ db::user::ValidationResult::ValidationExpired => { error!("Token: {}: {}", token, error); Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::ValidationExpired), context, ) .render()?, ), )) } error @ db::user::ValidationResult::UnknownUser => { error!("(email={}): {}", token, error); Ok(( jar, Html( MessageTemplate::new( context.tr.t(Sentence::ValidationErrorTryToSignUpAgain), context, ) .render()?, ), )) } } } None => Ok(( jar, Html(MessageTemplate::new(context.tr.t(Sentence::ValidationError), context).render()?), )), } }