[[package]]
name = "cc"
-version = "1.2.4"
+version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf"
+checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
dependencies = [
"shlex",
]
[[package]]
name = "libc"
-version = "0.2.168"
+version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d"
+checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "libm"
[[package]]
name = "object"
-version = "0.36.5"
+version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
"ron",
"serde",
"sqlx",
- "thiserror 2.0.8",
+ "thiserror 2.0.9",
"tokio",
"tower",
"tower-http",
[[package]]
name = "serde_json"
-version = "1.0.133"
+version = "1.0.134"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
+checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
dependencies = [
"itoa",
"memchr",
[[package]]
name = "thiserror"
-version = "2.0.8"
+version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a"
+checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
dependencies = [
- "thiserror-impl 2.0.8",
+ "thiserror-impl 2.0.9",
]
[[package]]
[[package]]
name = "thiserror-impl"
-version = "2.0.8"
+version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943"
+checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [
"proc-macro2",
"quote",
[[package]]
name = "tinyvec"
-version = "1.8.0"
+version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8"
dependencies = [
"tinyvec_macros",
]
1,
'paul@atreides.com',
'Paul',
- '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
+ '$argon2id$v=19$m=4096,t=4,p=2$l1fAMRc0VfkNzqpEfFEReg$/gsUsY2aML8EbKjPeCxucenxkxhiFSXDmizWZPLvNuo',
0,
NULL
);
[title] TEXT NOT NULL,
-- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
[lang] TEXT NOT NULL DEFAULT 'en',
- [estimate_time] INTEGER, -- in [s].
+ [estimated_time] INTEGER, -- in [s].
[description] TEXT NOT NULL DEFAULT '',
[difficulty] INTEGER NOT NULL DEFAULT 0,
[servings] INTEGER DEFAULT 4,
[id] INTEGER PRIMARY KEY,
[name] TEXT NOT NULL,
[comment] TEXT NOT NULL DEFAULT '',
- [quantity_value] REAL,
+ [quantity_value] INTEGER,
[quantity_unit] TEXT NOT NULL DEFAULT '',
[input_step_id] INTEGER NOT NULL,
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")];
use thiserror::Error;
use tracing::{event, Level};
-use super::model;
use crate::consts;
pub mod recipe;
)]
UnsupportedVersion(u32),
+ #[error("Unknown language: {0}")]
+ UnknownLanguage(String),
+
#[error("Unknown error: {0}")]
Other(String),
}
-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)>> {
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
"#,
)
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]
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
.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)]
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#"
($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)
}
}
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},
};
pub mod db;
pub mod model;
-mod utils;
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>,
-}
-
-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,
-}
+ #[sqlx(try_from = "u32")]
+ pub difficulty: Difficulty,
-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
+ }
+}
+++ /dev/null
-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")?,
- })
- }
-}
#[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.
}
#[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate {
pub user: Option<model::User>,
+
pub email: String,
pub message: String,
pub message_email: String,
#[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate {
pub user: Option<model::User>,
+
pub email: String,
pub message: String,
}
#[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate {
pub user: Option<model::User>,
+
pub email: String,
pub message: String,
pub message_email: String,
#[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate {
pub user: Option<model::User>,
+
pub reset_token: String,
pub message: String,
pub message_password: String,
#[template(path = "profile.html")]
pub struct ProfileTemplate {
pub user: Option<model::User>,
+
pub username: String,
pub email: String,
pub message: String,
pub struct RecipeViewTemplate {
pub user: Option<model::User>,
pub recipes: Recipes,
+
pub recipe: model::Recipe,
}
pub struct RecipeEditTemplate {
pub user: Option<model::User>,
pub recipes: Recipes,
+
pub recipe: model::Recipe,
+ pub languages: [(&'static str, &'static str); 2],
}
.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));
)
.await
.unwrap();
+
+ event!(
+ Level::INFO,
+ "A new test database has been created successfully"
+ );
}
Err(error) => {
event!(Level::ERROR, "{}", error);
-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> {
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)
--- /dev/null
+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()),
+ }
+}
--- /dev/null
+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),
+ )),
+ }
+}
{% extends "base_with_header.html" %}
{% macro recipe_item(id, title, class) %}
-<a href="/recipe/view/{{ id }}" class="{{ class }}">{{ title }}</a>
+<a href="/recipe/view/{{ id }}" class="{{ class }}">
+ {% if title == "" %}
+ {# TODO: Translation #}
+ No title defined
+ {% else %}
+ {{ title }}
+ {% endif %}
+</a>
{% endmacro %}
{% block main_container %}
{% endfor %}
</ul>
</nav>
- <div class="content">
- {% block content %}{% endblock %}
- </div>
+ {% block content %}{% endblock %}
{% endblock %}
{% block content %}
-HOME: TODO
+<div class="content" id="home">
+ HOME: TODO
+</div>
{% endblock %}
\ No newline at end of file
{% block main_container %}
- <div class="message">
- {% if as_code %}
- <pre><code>
- {% endif %}
+<div class="content" id="message">
+ {% if as_code %}
+ <pre><code>
+ {% endif %}
- {{ message|markdown }}
+ {{ message|markdown }}
- {% if as_code %}
- </code></pre>
- {% endif %}
- </div>
+ {% if as_code %}
+ </code></pre>
+ {% endif %}
<a href="/">Go to home</a>
+</div>
+
{% endblock %}
\ No newline at end of file
{% match user %}
{% when Some with (user) %}
-<div class="content">
-
+<div class="content" id="user-edit">
<h1>Profile</h1>
-
<form action="/user/edit" method="post">
<label for="input-name">Name</label>
{% extends "base_with_list.html" %}
+{% macro is_difficulty(diff) %}
+ {% if recipe.difficulty == diff %}
+ selected
+ {% endif %}
+{% endmacro %}
+
{% block content %}
-<label for="title_field">Title</label>
-<input
- id="title_field"
- type="text"
- name="title"
- value="{{ recipe.title }}"
- autocapitalize="none"
- autocomplete="title"
- autofocus="autofocus" />
-
-<label for="description_field">Description</label>
-<input
- id="title_field"
- type="text"
- name="title"
- value="{{ recipe.description }}"
- autocapitalize="none"
- autocomplete="title"
- autofocus="autofocus" />
+
+<div class="content" id="recipe-edit">
+ <label for="input-title">Title</label>
+ <input
+ id="input-title"
+ type="text"
+ name="title"
+ value="{{ recipe.title }}"
+ autocomplete="title"
+ autofocus="true" />
+
+ <label for="input-description">Description</label>
+ <input
+ id="input-description"
+ type="text"
+ name="description"
+ value="{{ recipe.description }}"
+ autocomplete="title" />
+
+ <label for="input-description">Estimated time</label>
+ <input
+ id="input-estimated-time"
+ type="number"
+ name="estimated-time"
+ value="
+ {% match recipe.estimated_time %}
+ {% when Some with (t) %}
+ {{ t }}
+ {% when None %}
+ 0
+ {% endmatch %}"
+ autocomplete="title" />
+
+ <label for="select-difficulty">Difficulty</label>
+ <select id="select-difficulty" name="difficulty">
+ <option value="0" {%+ call is_difficulty(crate::data::model::Difficulty::Unknown) %}> - </option>
+ <option value="1" {%+ call is_difficulty(crate::data::model::Difficulty::Easy) %}>Easy</option>
+ <option value="2" {%+ call is_difficulty(crate::data::model::Difficulty::Medium) %}>Medium</option>
+ <option value="3" {%+ call is_difficulty(crate::data::model::Difficulty::Hard) %}>Hard</option>
+ </select>
+
+ <label for="select-language">Language</label>
+ <select id="select-language" name="language">
+ {% for lang in languages %}
+ <option value="{{ lang.1 }}">{{ lang.0 }}</option>
+ {% endfor %}
+ </select>
+
+ <input
+ id="input-is-published"
+ type="checkbox"
+ name="is-published"
+ value="{{ recipe.is_published }}" />
+ <label for="input-is-published">Is published</label>
+
+ <div id="groups-container">
+ </div>
+</div>
+
{% endblock %}
\ No newline at end of file
{% block content %}
-<h2 class="recipe-title" >{{ recipe.title }}</h2>
+<div class="content" id="recipe-view">
+ <h2 class="recipe-title" >{{ recipe.title }}</h2>
+ {% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
+ <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
+ {% endif %}
-{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
- <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
-{% endif %}
-
-{% if !recipe.description.is_empty() %}
- <div class="recipe-description" >
- {{ recipe.description.clone()|markdown }}
- </div>
-{% endif %}
+ {% if !recipe.description.is_empty() %}
+ <div class="recipe-description" >
+ {{ recipe.description.clone()|markdown }}
+ </div>
+ {% endif %}
+</div>
{% endblock %}
\ No newline at end of file
{% extends "base_with_header.html" %}
{% block main_container %}
- <div class="content">
- <form action="/reset_password" method="post">
- <label for="password_field_1">Choose a new password (minimum 8 characters)</label>
- <input id="password_field_1" type="password" name="password_1" />
- <label for="password_field_1">Re-enter password</label>
- <input id="password_field_2" type="password" name="password_2" />
-
- {{ message_password }}
+<div class="content" id="reset-password">
+ <form action="/reset_password" method="post">
+ <label for="password_field_1">Choose a new password (minimum 8 characters)</label>
+ <input id="password_field_1" type="password" name="password_1" />
- <input type="hidden" name="reset_token" value="{{ reset_token }}" />
+ <label for="password_field_1">Re-enter password</label>
+ <input id="password_field_2" type="password" name="password_2" />
+
+ {{ message_password }}
+
+ <input type="hidden" name="reset_token" value="{{ reset_token }}" />
+
+ <input type="submit" name="commit" value="Reset password" />
+ </form>
+ {{ message }}
+</div>
- <input type="submit" name="commit" value="Reset password" />
- </form>
- {{ message }}
- </div>
{% endblock %}
{% block main_container %}
-<div id="sign-in" class="content">
+<div class="content" id="sign-in">
<h1>Sign in</h1>
{% extends "base_with_header.html" %}
{% block main_container %}
-<div class="content">
+
+<div class="content" id="sign-up">
<h1>Sign up</h1>
</form>
{{ message }}
</div>
+
{% endblock %}