Recipe edit (WIP)

This commit is contained in:
Greg Burri 2024-12-21 23:13:06 +01:00
parent fce4eade73
commit c6dfff065c
24 changed files with 1157 additions and 971 deletions

View file

@ -20,3 +20,5 @@ pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60);
pub const REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx).
pub const MAX_DB_CONNECTION: u32 = 1024;
pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")];

View file

@ -13,7 +13,6 @@ use sqlx::{
use thiserror::Error;
use tracing::{event, Level};
use super::model;
use crate::consts;
pub mod recipe;
@ -32,6 +31,9 @@ pub enum DBError {
)]
UnsupportedVersion(u32),
#[error("Unknown language: {0}")]
UnknownLanguage(String),
#[error("Unknown error: {0}")]
Other(String),
}

View file

@ -1,4 +1,8 @@
use super::{model, Connection, DBError, Result};
use super::{Connection, DBError, Result};
use crate::{
consts,
data::model::{self, Difficulty},
};
impl Connection {
pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
@ -11,7 +15,10 @@ impl Connection {
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as(
r#"
SELECT [id], [user_id], [title], [description]
SELECT
[id], [user_id], [title], [lang],
[estimated_time], [description], [difficulty], [servings],
[is_published]
FROM [Recipe] WHERE [id] = $1
"#,
)
@ -24,6 +31,7 @@ FROM [Recipe] WHERE [id] = $1
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
let mut tx = self.tx().await?;
// Search for an existing empty recipe and return its id instead of creating a new one.
match sqlx::query_scalar::<_, i64>(
r#"
SELECT [Recipe].[id] FROM [Recipe]
@ -31,7 +39,7 @@ LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id]
LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
WHERE [Recipe].[user_id] = $1
AND [Recipe].[title] = ''
AND [Recipe].[estimate_time] IS NULL
AND [Recipe].[estimated_time] IS NULL
AND [Recipe].[description] = ''
AND [Image].[id] IS NULL
AND [Group].[id] IS NULL
@ -74,6 +82,57 @@ WHERE [Recipe].[user_id] = $1
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_recipe_estimated_time(
&self,
recipe_id: i64,
estimated_time: Option<u32>,
) -> Result<()> {
sqlx::query("UPDATE [Recipe] SET [estimated_time] = $2 WHERE [id] = $1")
.bind(recipe_id)
.bind(estimated_time)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_recipe_difficulty(
&self,
recipe_id: i64,
difficulty: Difficulty,
) -> Result<()> {
sqlx::query("UPDATE [Recipe] SET [difficulty] = $2 WHERE [id] = $1")
.bind(recipe_id)
.bind(u32::from(difficulty))
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_recipe_language(&self, recipe_id: i64, lang: &str) -> Result<()> {
if !consts::LANGUAGES.iter().any(|(_, l)| *l == lang) {
return Err(DBError::UnknownLanguage(lang.to_string()));
}
sqlx::query("UPDATE [Recipe] SET [lang] = $2 WHERE [id] = $1")
.bind(recipe_id)
.bind(lang)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
pub async fn set_recipe_is_published(&self, recipe_id: i64, is_published: bool) -> Result<()> {
sqlx::query("UPDATE [Recipe] SET [is_published] = $2 WHERE [id] = $1")
.bind(recipe_id)
.bind(is_published)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(DBError::from)
}
}
#[cfg(test)]
@ -84,6 +143,69 @@ mod tests {
async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
let connection = Connection::new_in_memory().await?;
let user_id = create_a_user(&connection).await?;
let recipe_id = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id, "Crêpe").await?;
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
assert_eq!(recipe.title, "Crêpe".to_string());
Ok(())
}
#[tokio::test]
async fn setters() -> Result<()> {
let connection = Connection::new_in_memory().await?;
let user_id = create_a_user(&connection).await?;
let recipe_id = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id, "Ouiche").await?;
connection
.set_recipe_description(recipe_id, "C'est bon, mangez-en")
.await?;
connection
.set_recipe_estimated_time(recipe_id, Some(420))
.await?;
connection
.set_recipe_difficulty(recipe_id, Difficulty::Medium)
.await?;
connection.set_recipe_language(recipe_id, "fr").await?;
connection.set_recipe_is_published(recipe_id, true).await?;
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
assert_eq!(recipe.id, recipe_id);
assert_eq!(recipe.title, "Ouiche");
assert_eq!(recipe.description, "C'est bon, mangez-en");
assert_eq!(recipe.estimated_time, Some(420));
assert_eq!(recipe.difficulty, Difficulty::Medium);
assert_eq!(recipe.lang, "fr");
assert_eq!(recipe.is_published, true);
Ok(())
}
#[tokio::test]
async fn set_nonexistent_language() -> Result<()> {
let connection = Connection::new_in_memory().await?;
let user_id = create_a_user(&connection).await?;
let recipe_id = connection.create_recipe(user_id).await?;
match connection.set_recipe_language(recipe_id, "asdf").await {
// Nominal case.
Err(DBError::UnknownLanguage(message)) => {
println!("Ok: {}", message);
}
other => panic!("Set an nonexistent language must fail: {:?}", other),
}
Ok(())
}
async fn create_a_user(connection: &Connection) -> Result<i64> {
let user_id = 1;
connection.execute_sql(
sqlx::query(
r#"
@ -93,33 +215,13 @@ VALUES
($1, $2, $3, $4, $5, $6)
"#
)
.bind(1)
.bind(user_id)
.bind("paul@atreides.com")
.bind("paul")
.bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
.bind("2022-11-29 22:05:04.121407300+00:00")
.bind(None::<&str>) // 'null'.
).await?;
match connection.create_recipe(2).await {
Err(DBError::Sqlx(sqlx::Error::Database(err))) => {
// SQLITE_CONSTRAINT_FOREIGNKEY
// https://www.sqlite.org/rescode.html#constraint_foreignkey
assert_eq!(err.code(), Some(std::borrow::Cow::from("787")));
} // Nominal case. TODO: check 'err' value.
other => panic!(
"Creating a recipe with an inexistant user must fail: {:?}",
other
),
}
let recipe_id = connection.create_recipe(1).await?;
assert_eq!(recipe_id, 1);
connection.set_recipe_title(recipe_id, "Crêpe").await?;
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
assert_eq!(recipe.title, "Crêpe".to_string());
Ok(())
Ok(user_id)
}
}

View file

@ -2,9 +2,10 @@ use chrono::{prelude::*, Duration};
use rand::distributions::{Alphanumeric, DistString};
use sqlx::Sqlite;
use super::{model, Connection, DBError, Result};
use super::{Connection, DBError, Result};
use crate::{
consts,
data::model,
hash::{hash, verify_password},
};

View file

@ -1,3 +1,2 @@
pub mod db;
pub mod model;
mod utils;

View file

@ -1,77 +1,78 @@
use chrono::prelude::*;
use sqlx::{self, FromRow};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, FromRow)]
pub struct User {
pub id: i64,
pub name: String,
pub email: String,
}
#[derive(FromRow)]
pub struct UserLoginInfo {
pub last_login_datetime: DateTime<Utc>,
pub ip: String,
pub user_agent: String,
}
#[derive(FromRow)]
pub struct Recipe {
pub id: i64,
pub user_id: i64,
pub title: String,
pub description: String,
pub estimate_time: Option<i32>, // [s].
pub difficulty: Difficulty,
pub lang: String,
pub estimated_time: Option<u32>, // [s].
pub description: String,
//ingredients: Vec<Ingredient>, // For four people.
pub process: Vec<Group>,
}
#[sqlx(try_from = "u32")]
pub difficulty: Difficulty,
impl Recipe {
pub fn empty(id: i64, user_id: i64) -> Recipe {
Self::new(id, user_id, String::new(), String::new())
}
pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {
Recipe {
id,
user_id,
title,
description,
estimate_time: None,
difficulty: Difficulty::Unknown,
lang: "en".to_string(),
process: Vec::new(),
}
}
}
pub struct Ingredient {
pub quantity: Option<Quantity>,
pub name: String,
}
pub struct Quantity {
pub value: f32,
pub unit: String,
pub servings: u32,
pub is_published: bool,
// pub tags: Vec<String>,
// pub groups: Vec<Group>,
}
pub struct Group {
pub name: Option<String>,
pub input: Vec<StepInput>,
pub name: String,
pub comment: String,
pub steps: Vec<Step>,
}
pub struct Step {
pub action: String,
pub ingredients: Vec<Ingredient>,
}
pub enum StepInput {
Ingredient(Ingredient),
pub struct Ingredient {
pub name: String,
pub comment: String,
pub quantity: i32,
pub quantity_unit: String,
}
#[derive(PartialEq, Debug)]
pub enum Difficulty {
Unknown = 0,
Easy = 1,
Medium = 2,
Hard = 3,
}
impl TryFrom<u32> for Difficulty {
type Error = &'static str;
fn try_from(value: u32) -> Result<Self, Self::Error> {
Ok(match value {
1 => Self::Easy,
2 => Self::Medium,
3 => Self::Hard,
_ => Self::Unknown,
})
}
}
impl From<Difficulty> for u32 {
fn from(value: Difficulty) -> Self {
value as u32
}
}

View file

@ -1,34 +0,0 @@
use sqlx::{sqlite::SqliteRow, FromRow, Row};
use super::model;
impl FromRow<'_, SqliteRow> for model::Recipe {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
Ok(model::Recipe::new(
row.try_get("id")?,
row.try_get("user_id")?,
row.try_get("title")?,
row.try_get("description")?,
))
}
}
impl FromRow<'_, SqliteRow> for model::UserLoginInfo {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
Ok(model::UserLoginInfo {
last_login_datetime: row.try_get("last_login_datetime")?,
ip: row.try_get("ip")?,
user_agent: row.try_get("user_agent")?,
})
}
}
impl FromRow<'_, SqliteRow> for model::User {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
Ok(model::User {
id: row.try_get("id")?,
email: row.try_get("email")?,
name: row.try_get("name")?,
})
}
}

View file

@ -18,6 +18,7 @@ pub struct HomeTemplate {
#[template(path = "message.html")]
pub struct MessageTemplate {
pub user: Option<model::User>,
pub message: String,
pub as_code: bool, // Display the message in <pre> markup.
}
@ -44,6 +45,7 @@ impl MessageTemplate {
#[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
pub message_email: String,
@ -54,6 +56,7 @@ pub struct SignUpFormTemplate {
#[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
}
@ -62,6 +65,7 @@ pub struct SignInFormTemplate {
#[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
pub message_email: String,
@ -71,6 +75,7 @@ pub struct AskResetPasswordTemplate {
#[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate {
pub user: Option<model::User>,
pub reset_token: String,
pub message: String,
pub message_password: String,
@ -80,6 +85,7 @@ pub struct ResetPasswordTemplate {
#[template(path = "profile.html")]
pub struct ProfileTemplate {
pub user: Option<model::User>,
pub username: String,
pub email: String,
pub message: String,
@ -92,6 +98,7 @@ pub struct ProfileTemplate {
pub struct RecipeViewTemplate {
pub user: Option<model::User>,
pub recipes: Recipes,
pub recipe: model::Recipe,
}
@ -100,5 +107,7 @@ pub struct RecipeViewTemplate {
pub struct RecipeEditTemplate {
pub user: Option<model::User>,
pub recipes: Recipes,
pub recipe: model::Recipe,
pub languages: [(&'static str, &'static str); 2],
}

View file

@ -92,31 +92,32 @@ async fn main() {
.route("/", get(services::home_page))
.route(
"/signup",
get(services::sign_up_get).post(services::sign_up_post),
get(services::user::sign_up_get).post(services::user::sign_up_post),
)
.route("/validation", get(services::sign_up_validation))
.route("/revalidation", get(services::email_revalidation))
.route("/validation", get(services::user::sign_up_validation))
.route("/revalidation", get(services::user::email_revalidation))
.route(
"/signin",
get(services::sign_in_get).post(services::sign_in_post),
get(services::user::sign_in_get).post(services::user::sign_in_post),
)
.route("/signout", get(services::sign_out))
.route("/signout", get(services::user::sign_out))
.route(
"/ask_reset_password",
get(services::ask_reset_password_get).post(services::ask_reset_password_post),
get(services::user::ask_reset_password_get)
.post(services::user::ask_reset_password_post),
)
.route(
"/reset_password",
get(services::reset_password_get).post(services::reset_password_post),
get(services::user::reset_password_get).post(services::user::reset_password_post),
)
// Recipes.
.route("/recipe/new", get(services::create_recipe))
// .route("/recipe/edit/:id", get(services::edit_recipe))
.route("/recipe/view/:id", get(services::view_recipe))
.route("/recipe/new", get(services::recipe::create))
.route("/recipe/edit/:id", get(services::recipe::edit_recipe))
.route("/recipe/view/:id", get(services::recipe::view))
// User.
.route(
"/user/edit",
get(services::edit_user_get).post(services::edit_user_post),
get(services::user::edit_user_get).post(services::user::edit_user_post),
)
.route_layer(middleware::from_fn(services::ron_error_to_html));
@ -233,6 +234,11 @@ async fn process_args() -> bool {
)
.await
.unwrap();
event!(
Level::INFO,
"A new test database has been created successfully"
);
}
Err(error) => {
event!(Level::ERROR, "{}", error);

View file

@ -1,29 +1,21 @@
use std::{collections::HashMap, net::SocketAddr};
use axum::{
body::{self, Body},
debug_handler,
extract::{ConnectInfo, Extension, Host, Path, Query, Request, State},
http::{header, HeaderMap},
body, debug_handler,
extract::{Extension, Request, State},
http::header,
middleware::Next,
response::{IntoResponse, Redirect, Response, Result},
Form,
response::{IntoResponse, Response, Result},
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Duration;
use serde::Deserialize;
use tracing::{event, Level};
// use tracing::{event, Level};
use crate::{
config::Config,
consts,
data::{db, model},
email,
html_templates::*,
ron_utils, utils, AppState,
ron_utils,
};
pub mod recipe;
pub mod ron;
pub mod user;
// Will embed RON error in HTML page.
pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
@ -61,774 +53,12 @@ pub async fn home_page(
recipes: Recipes {
list: recipes,
current_id: None,
}, // current_recipe_id: None,
// recipes,
},
})
}
///// RECIPE /////
#[debug_handler]
pub async fn create_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
if let Some(user) = user {
let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}
}
// #[debug_handler]
// pub async fn edit_recipe(
// State(connection): State<db::Connection>,
// Extension(user): Extension<Option<model::User>>,
// Path(recipe_id): Path<i64>,
// ) -> Result<Response> {
// if let Some(user) = user {
// Ok(RecipeEditTemplate { user }.into_response())
// } else {
// Ok(MessageTemplate::new("Not logged in").into_response())
// }
// }
#[debug_handler]
pub async fn view_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
let recipes = connection.get_all_recipe_titles().await?;
match connection.get_recipe(recipe_id).await? {
Some(recipe) => Ok(RecipeViewTemplate {
user,
recipes: Recipes {
list: recipes,
current_id: Some(recipe.id),
},
recipe,
}
.into_response()),
None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id),
user,
)
.into_response()),
}
}
//// SIGN UP /////
#[debug_handler]
pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate {
user,
email: String::new(),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
})
}
#[derive(Deserialize, Debug)]
pub struct SignUpFormData {
email: String,
password_1: String,
password_2: String,
}
enum SignUpError {
InvalidEmail,
PasswordsNotEqual,
InvalidPassword,
UserAlreadyExists,
DatabaseError,
UnableSendEmail,
}
#[debug_handler(state = AppState)]
pub async fn sign_up_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
) -> Result<Response> {
Ok(SignUpFormTemplate {
user,
email: form_data.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 not available",
SignUpError::DatabaseError => "Database error",
SignUpError::UnableSendEmail => "Unable to send the validation email",
_ => "",
}
.to_string(),
}
.into_response())
}
// Validation of email and password.
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(SignUpError::InvalidEmail, &form_data, user);
}
if form_data.password_1 != form_data.password_2 {
return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(SignUpError::InvalidPassword, &form_data, user);
}
match connection
.sign_up(&form_data.email, &form_data.password_1)
.await
{
Ok(db::user::SignUpResult::UserAlreadyExists) => {
error_response(SignUpError::UserAlreadyExists, &form_data, user)
}
Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
&email,
&format!(
"Follow this link to confirm your inscription: {}/validation?validation_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => Ok(
MessageTemplate::new_with_user(
"An email has been sent, follow the link to validate your account",
user).into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user)
}
}
}
Err(_) => {
// error!("Signup database error: {}", error); // TODO: log
error_response(SignUpError::DatabaseError, &form_data, user)
}
}
}
#[debug_handler]
pub async fn sign_up_validation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<(CookieJar, impl IntoResponse)> {
let mut jar = CookieJar::from_headers(&headers);
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
match query.get("validation_token") {
// '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::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
jar = jar.add(cookie);
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user(
"Email validation successful, your account has been created",
user,
),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
)),
}
}
///// SIGN IN /////
#[debug_handler]
pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate {
user,
email: String::new(),
message: String::new(),
})
}
#[derive(Deserialize, Debug)]
pub struct SignInFormData {
email: String,
password: String,
}
#[debug_handler]
pub async fn sign_in_post(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> 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?
{
db::user::SignInResult::AccountNotValidated => Ok((
jar,
SignInFormTemplate {
user,
email: form_data.email,
message: "This account must be validated first".to_string(),
}
.into_response(),
)),
db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
jar,
SignInFormTemplate {
user,
email: form_data.email,
message: "Wrong email or password".to_string(),
}
.into_response(),
)),
db::user::SignInResult::Ok(token, _user_id) => {
let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
Ok((jar.add(cookie), Redirect::to("/").into_response()))
}
}
}
///// SIGN OUT /////
#[debug_handler]
pub async fn sign_out(
State(connection): State<db::Connection>,
req: Request<Body>,
) -> 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(user): Extension<Option<model::User>>,
) -> Result<Response> {
if user.is_some() {
Ok(MessageTemplate::new_with_user(
"Can't ask to reset password when already logged in",
user,
)
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
email: String::new(),
message: String::new(),
message_email: String::new(),
}
.into_response())
}
}
#[derive(Deserialize, Debug)]
pub struct AskResetPasswordForm {
email: String,
}
enum AskResetPasswordError {
InvalidEmail,
EmailAlreadyReset,
EmailUnknown,
UnableSendEmail,
DatabaseError,
}
#[debug_handler(state = AppState)]
pub async fn ask_reset_password_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
) -> Result<Response> {
Ok(AskResetPasswordTemplate {
user,
email: email.to_string(),
message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email",
_ => "",
}
.to_string(),
message: match error {
AskResetPasswordError::EmailAlreadyReset => {
"The password has already been reset for this email"
}
AskResetPasswordError::EmailUnknown => "Email unknown",
AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
AskResetPasswordError::DatabaseError => "Database error",
_ => "",
}
.to_string(),
}
.into_response())
}
// Validation of email.
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
}
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,
user,
),
Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
}
Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host);
match email::send_email(
&form_data.email,
&format!(
"Follow this link to reset your password: {}/reset_password?reset_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.",
user,
)
.into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(
AskResetPasswordError::UnableSendEmail,
&form_data.email,
user,
)
}
}
}
Err(error) => {
event!(Level::ERROR, "{}", error);
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
}
}
}
#[debug_handler]
pub async fn reset_password_get(
Extension(user): Extension<Option<model::User>>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
if let Some(reset_token) = query.get("reset_token") {
Ok(ResetPasswordTemplate {
user,
reset_token: reset_token.to_string(),
message: String::new(),
message_password: String::new(),
}
.into_response())
} else {
Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
}
}
#[derive(Deserialize, Debug)]
pub struct ResetPasswordForm {
password_1: String,
password_2: String,
reset_token: String,
}
enum ResetPasswordError {
PasswordsNotEqual,
InvalidPassword,
TokenExpired,
DatabaseError,
}
#[debug_handler]
pub async fn reset_password_post(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
) -> Result<Response> {
Ok(ResetPasswordTemplate {
user,
reset_token: form_data.reset_token.clone(),
message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
ResetPasswordError::InvalidPassword => {
"Password must have at least eight characters"
}
_ => "",
}
.to_string(),
message: match error {
ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
ResetPasswordError::DatabaseError => "Database error",
_ => "",
}
.to_string(),
}
.into_response())
}
if form_data.password_1 != form_data.password_2 {
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
}
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(MessageTemplate::new_with_user(
"Your password has been reset",
user,
)
.into_response()),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
}
}
///// EDIT PROFILE /////
#[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
if let Some(user) = user {
ProfileTemplate {
username: user.name.clone(),
email: user.email.clone(),
user: Some(user),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
}
.into_response()
} else {
MessageTemplate::new("Not logged in").into_response()
}
}
#[derive(Deserialize, Debug)]
pub struct EditUserForm {
name: String,
email: String,
password_1: String,
password_2: String,
}
enum ProfileUpdateError {
InvalidEmail,
EmailAlreadyTaken,
PasswordsNotEqual,
InvalidPassword,
DatabaseError,
UnableSendEmail,
}
// TODO: A lot of code are similar to 'sign_up_post', maybe find a way to factorize some.
#[debug_handler(state = AppState)]
pub async fn edit_user_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
fn error_response(
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
) -> Result<Response> {
Ok(ProfileTemplate {
user: Some(user),
username: form_data.name.clone(),
email: form_data.email.clone(),
message_email: match error {
ProfileUpdateError::InvalidEmail => "Invalid email",
ProfileUpdateError::EmailAlreadyTaken => "Email already taken",
_ => "",
}
.to_string(),
message_password: match error {
ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
ProfileUpdateError::InvalidPassword => {
"Password must have at least eight characters"
}
_ => "",
}
.to_string(),
message: match error {
ProfileUpdateError::DatabaseError => "Database error",
ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
_ => "",
}
.to_string(),
}
.into_response())
}
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
}
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, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
}
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),
new_password,
)
.await
{
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
}
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
&email,
&format!(
"Follow this link to validate this email address: {}/revalidation?validation_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => {
message =
"An email has been sent, follow the link to validate your new email";
}
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
}
}
}
Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved";
}
Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
}
// Reload after update.
let user = connection.load_user(user.id).await?;
Ok(ProfileTemplate {
user,
username: form_data.name,
email: form_data.email,
message: message.to_string(),
message_email: String::new(),
message_password: String::new(),
}
.into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}
}
#[debug_handler]
pub async fn email_revalidation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<(CookieJar, impl IntoResponse)> {
let mut jar = CookieJar::from_headers(&headers);
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
match query.get("validation_token") {
// '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::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
jar = jar.add(cookie);
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user("Email validation successful", user),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again with the same email",
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email",
user,
),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
)),
}
}
///// 404 /////
#[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
MessageTemplate::new_with_user("404: Not found", user)

View file

@ -0,0 +1,79 @@
use axum::{
debug_handler,
extract::{Extension, Path, State},
response::{IntoResponse, Redirect, Response, Result},
};
// use tracing::{event, Level};
use crate::{
consts,
data::{db, model},
html_templates::*,
};
///// RECIPE /////
#[debug_handler]
pub async fn create(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
if let Some(user) = user {
let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}
}
#[debug_handler]
pub async fn edit_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
if let Some(user) = user {
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
if recipe.user_id == user.id {
Ok(RecipeEditTemplate {
user: Some(user),
recipes: Recipes {
list: connection.get_all_recipe_titles().await?,
current_id: Some(recipe_id),
},
recipe,
languages: consts::LANGUAGES,
}
.into_response())
} else {
Ok(MessageTemplate::new("Unable to edit this recipe").into_response())
}
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}
}
#[debug_handler]
pub async fn view(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
let recipes = connection.get_all_recipe_titles().await?;
match connection.get_recipe(recipe_id).await? {
Some(recipe) => Ok(RecipeViewTemplate {
user,
recipes: Recipes {
list: recipes,
current_id: Some(recipe.id),
},
recipe,
}
.into_response()),
None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id),
user,
)
.into_response()),
}
}

View file

@ -0,0 +1,732 @@
use std::{collections::HashMap, net::SocketAddr};
use axum::{
body::Body,
debug_handler,
extract::{ConnectInfo, Extension, Host, Query, Request, State},
http::HeaderMap,
response::{IntoResponse, Redirect, Response, Result},
Form,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use chrono::Duration;
use serde::Deserialize;
use tracing::{event, Level};
use crate::{
config::Config,
consts,
data::{db, model},
email,
html_templates::*,
utils, AppState,
};
//// SIGN UP /////
#[debug_handler]
pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate {
user,
email: String::new(),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
})
}
#[derive(Deserialize, Debug)]
pub struct SignUpFormData {
email: String,
password_1: String,
password_2: String,
}
enum SignUpError {
InvalidEmail,
PasswordsNotEqual,
InvalidPassword,
UserAlreadyExists,
DatabaseError,
UnableSendEmail,
}
#[debug_handler(state = AppState)]
pub async fn sign_up_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
) -> Result<Response> {
Ok(SignUpFormTemplate {
user,
email: form_data.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 not available",
SignUpError::DatabaseError => "Database error",
SignUpError::UnableSendEmail => "Unable to send the validation email",
_ => "",
}
.to_string(),
}
.into_response())
}
// Validation of email and password.
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(SignUpError::InvalidEmail, &form_data, user);
}
if form_data.password_1 != form_data.password_2 {
return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(SignUpError::InvalidPassword, &form_data, user);
}
match connection
.sign_up(&form_data.email, &form_data.password_1)
.await
{
Ok(db::user::SignUpResult::UserAlreadyExists) => {
error_response(SignUpError::UserAlreadyExists, &form_data, user)
}
Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
&email,
&format!(
"Follow this link to confirm your inscription: {}/validation?validation_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => Ok(
MessageTemplate::new_with_user(
"An email has been sent, follow the link to validate your account",
user).into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user)
}
}
}
Err(_) => {
// error!("Signup database error: {}", error); // TODO: log
error_response(SignUpError::DatabaseError, &form_data, user)
}
}
}
#[debug_handler]
pub async fn sign_up_validation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<(CookieJar, impl IntoResponse)> {
let mut jar = CookieJar::from_headers(&headers);
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
match query.get("validation_token") {
// '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::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
jar = jar.add(cookie);
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user(
"Email validation successful, your account has been created",
user,
),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
)),
}
}
///// SIGN IN /////
#[debug_handler]
pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>,
) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate {
user,
email: String::new(),
message: String::new(),
})
}
#[derive(Deserialize, Debug)]
pub struct SignInFormData {
email: String,
password: String,
}
#[debug_handler]
pub async fn sign_in_post(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> 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?
{
db::user::SignInResult::AccountNotValidated => Ok((
jar,
SignInFormTemplate {
user,
email: form_data.email,
message: "This account must be validated first".to_string(),
}
.into_response(),
)),
db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
jar,
SignInFormTemplate {
user,
email: form_data.email,
message: "Wrong email or password".to_string(),
}
.into_response(),
)),
db::user::SignInResult::Ok(token, _user_id) => {
let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
Ok((jar.add(cookie), Redirect::to("/").into_response()))
}
}
}
///// SIGN OUT /////
#[debug_handler]
pub async fn sign_out(
State(connection): State<db::Connection>,
req: Request<Body>,
) -> 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(user): Extension<Option<model::User>>,
) -> Result<Response> {
if user.is_some() {
Ok(MessageTemplate::new_with_user(
"Can't ask to reset password when already logged in",
user,
)
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
email: String::new(),
message: String::new(),
message_email: String::new(),
}
.into_response())
}
}
#[derive(Deserialize, Debug)]
pub struct AskResetPasswordForm {
email: String,
}
enum AskResetPasswordError {
InvalidEmail,
EmailAlreadyReset,
EmailUnknown,
UnableSendEmail,
DatabaseError,
}
#[debug_handler(state = AppState)]
pub async fn ask_reset_password_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
) -> Result<Response> {
Ok(AskResetPasswordTemplate {
user,
email: email.to_string(),
message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email",
_ => "",
}
.to_string(),
message: match error {
AskResetPasswordError::EmailAlreadyReset => {
"The password has already been reset for this email"
}
AskResetPasswordError::EmailUnknown => "Email unknown",
AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
AskResetPasswordError::DatabaseError => "Database error",
_ => "",
}
.to_string(),
}
.into_response())
}
// Validation of email.
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
}
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,
user,
),
Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
}
Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host);
match email::send_email(
&form_data.email,
&format!(
"Follow this link to reset your password: {}/reset_password?reset_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.",
user,
)
.into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(
AskResetPasswordError::UnableSendEmail,
&form_data.email,
user,
)
}
}
}
Err(error) => {
event!(Level::ERROR, "{}", error);
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
}
}
}
#[debug_handler]
pub async fn reset_password_get(
Extension(user): Extension<Option<model::User>>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
if let Some(reset_token) = query.get("reset_token") {
Ok(ResetPasswordTemplate {
user,
reset_token: reset_token.to_string(),
message: String::new(),
message_password: String::new(),
}
.into_response())
} else {
Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
}
}
#[derive(Deserialize, Debug)]
pub struct ResetPasswordForm {
password_1: String,
password_2: String,
reset_token: String,
}
enum ResetPasswordError {
PasswordsNotEqual,
InvalidPassword,
TokenExpired,
DatabaseError,
}
#[debug_handler]
pub async fn reset_password_post(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
) -> Result<Response> {
Ok(ResetPasswordTemplate {
user,
reset_token: form_data.reset_token.clone(),
message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
ResetPasswordError::InvalidPassword => {
"Password must have at least eight characters"
}
_ => "",
}
.to_string(),
message: match error {
ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
ResetPasswordError::DatabaseError => "Database error",
_ => "",
}
.to_string(),
}
.into_response())
}
if form_data.password_1 != form_data.password_2 {
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
}
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(MessageTemplate::new_with_user(
"Your password has been reset",
user,
)
.into_response()),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
}
}
///// EDIT PROFILE /////
#[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
if let Some(user) = user {
ProfileTemplate {
username: user.name.clone(),
email: user.email.clone(),
user: Some(user),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
}
.into_response()
} else {
MessageTemplate::new("Not logged in").into_response()
}
}
#[derive(Deserialize, Debug)]
pub struct EditUserForm {
name: String,
email: String,
password_1: String,
password_2: String,
}
enum ProfileUpdateError {
InvalidEmail,
EmailAlreadyTaken,
PasswordsNotEqual,
InvalidPassword,
DatabaseError,
UnableSendEmail,
}
// TODO: A lot of code are similar to 'sign_up_post', maybe find a way to factorize some.
#[debug_handler(state = AppState)]
pub async fn edit_user_post(
Host(host): Host,
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
fn error_response(
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
) -> Result<Response> {
Ok(ProfileTemplate {
user: Some(user),
username: form_data.name.clone(),
email: form_data.email.clone(),
message_email: match error {
ProfileUpdateError::InvalidEmail => "Invalid email",
ProfileUpdateError::EmailAlreadyTaken => "Email already taken",
_ => "",
}
.to_string(),
message_password: match error {
ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
ProfileUpdateError::InvalidPassword => {
"Password must have at least eight characters"
}
_ => "",
}
.to_string(),
message: match error {
ProfileUpdateError::DatabaseError => "Database error",
ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
_ => "",
}
.to_string(),
}
.into_response())
}
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
}
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, user);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
}
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),
new_password,
)
.await
{
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
}
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
&email,
&format!(
"Follow this link to validate this email address: {}/revalidation?validation_token={}",
url, token
),
&config.smtp_relay_address,
&config.smtp_login,
&config.smtp_password,
)
.await
{
Ok(()) => {
message =
"An email has been sent, follow the link to validate your new email";
}
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
}
}
}
Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved";
}
Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
}
// Reload after update.
let user = connection.load_user(user.id).await?;
Ok(ProfileTemplate {
user,
username: form_data.name,
email: form_data.email,
message: message.to_string(),
message_email: String::new(),
message_password: String::new(),
}
.into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
}
}
#[debug_handler]
pub async fn email_revalidation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<(CookieJar, impl IntoResponse)> {
let mut jar = CookieJar::from_headers(&headers);
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
match query.get("validation_token") {
// '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::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
jar = jar.add(cookie);
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user("Email validation successful", user),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again with the same email",
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email",
user,
),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
)),
}
}