Recipe edit (WIP) master
authorGreg Burri <greg.burri@gmail.com>
Sat, 21 Dec 2024 22:13:06 +0000 (23:13 +0100)
committerGreg Burri <greg.burri@gmail.com>
Sat, 21 Dec 2024 22:13:06 +0000 (23:13 +0100)
24 files changed:
Cargo.lock
backend/sql/data_test.sql
backend/sql/version_1.sql
backend/src/consts.rs
backend/src/data/db.rs
backend/src/data/db/recipe.rs
backend/src/data/db/user.rs
backend/src/data/mod.rs
backend/src/data/model.rs
backend/src/data/utils.rs [deleted file]
backend/src/html_templates.rs
backend/src/main.rs
backend/src/services.rs
backend/src/services/recipe.rs [new file with mode: 0644]
backend/src/services/user.rs [new file with mode: 0644]
backend/templates/base_with_list.html
backend/templates/home.html
backend/templates/message.html
backend/templates/profile.html
backend/templates/recipe_edit.html
backend/templates/recipe_view.html
backend/templates/reset_password.html
backend/templates/sign_in_form.html
backend/templates/sign_up_form.html

index 8598d80..e1a2ddd 100644 (file)
@@ -391,9 +391,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
 
 [[package]]
 name = "cc"
 
 [[package]]
 name = "cc"
-version = "1.2.4"
+version = "1.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf"
+checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
 dependencies = [
  "shlex",
 ]
 dependencies = [
  "shlex",
 ]
@@ -1534,9 +1534,9 @@ dependencies = [
 
 [[package]]
 name = "libc"
 
 [[package]]
 name = "libc"
-version = "0.2.168"
+version = "0.2.169"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d"
+checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
 
 [[package]]
 name = "libm"
 
 [[package]]
 name = "libm"
@@ -1748,9 +1748,9 @@ dependencies = [
 
 [[package]]
 name = "object"
 
 [[package]]
 name = "object"
-version = "0.36.5"
+version = "0.36.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
 dependencies = [
  "memchr",
 ]
 dependencies = [
  "memchr",
 ]
@@ -2006,7 +2006,7 @@ dependencies = [
  "ron",
  "serde",
  "sqlx",
  "ron",
  "serde",
  "sqlx",
- "thiserror 2.0.8",
+ "thiserror 2.0.9",
  "tokio",
  "tower",
  "tower-http",
  "tokio",
  "tower",
  "tower-http",
@@ -2225,9 +2225,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
 
 [[package]]
 name = "serde_json"
-version = "1.0.133"
+version = "1.0.134"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
+checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
 dependencies = [
  "itoa",
  "memchr",
 dependencies = [
  "itoa",
  "memchr",
@@ -2672,11 +2672,11 @@ dependencies = [
 
 [[package]]
 name = "thiserror"
 
 [[package]]
 name = "thiserror"
-version = "2.0.8"
+version = "2.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a"
+checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
 dependencies = [
 dependencies = [
- "thiserror-impl 2.0.8",
+ "thiserror-impl 2.0.9",
 ]
 
 [[package]]
 ]
 
 [[package]]
@@ -2692,9 +2692,9 @@ dependencies = [
 
 [[package]]
 name = "thiserror-impl"
 
 [[package]]
 name = "thiserror-impl"
-version = "2.0.8"
+version = "2.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943"
+checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
 dependencies = [
  "proc-macro2",
  "quote",
 dependencies = [
  "proc-macro2",
  "quote",
@@ -2754,9 +2754,9 @@ dependencies = [
 
 [[package]]
 name = "tinyvec"
 
 [[package]]
 name = "tinyvec"
-version = "1.8.0"
+version = "1.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
+checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8"
 dependencies = [
  "tinyvec_macros",
 ]
 dependencies = [
  "tinyvec_macros",
 ]
index af9cc03..4312ce9 100644 (file)
@@ -3,7 +3,7 @@ VALUES (
     1,
     'paul@atreides.com',
     'Paul',
     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
 );
     0,
     NULL
 );
index 2462b2b..3fffc66 100644 (file)
@@ -49,7 +49,7 @@ CREATE TABLE [Recipe] (
     [title] TEXT NOT NULL,
      -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
     [lang] TEXT NOT NULL DEFAULT 'en',
     [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,
     [description] TEXT NOT NULL DEFAULT '',
     [difficulty] INTEGER NOT NULL DEFAULT 0,
     [servings] INTEGER DEFAULT 4,
@@ -93,7 +93,7 @@ CREATE TABLE [Ingredient] (
     [id] INTEGER PRIMARY KEY,
     [name] TEXT NOT NULL,
     [comment] TEXT NOT NULL DEFAULT '',
     [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,
 
     [quantity_unit] TEXT NOT NULL DEFAULT '',
     [input_step_id] INTEGER NOT NULL,
 
index a8f3fff..7b776e9 100644 (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 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")];
index 3124db9..baff9d6 100644 (file)
@@ -13,7 +13,6 @@ use sqlx::{
 use thiserror::Error;
 use tracing::{event, Level};
 
 use thiserror::Error;
 use tracing::{event, Level};
 
-use super::model;
 use crate::consts;
 
 pub mod recipe;
 use crate::consts;
 
 pub mod recipe;
@@ -32,6 +31,9 @@ pub enum DBError {
     )]
     UnsupportedVersion(u32),
 
     )]
     UnsupportedVersion(u32),
 
+    #[error("Unknown language: {0}")]
+    UnknownLanguage(String),
+
     #[error("Unknown error: {0}")]
     Other(String),
 }
     #[error("Unknown error: {0}")]
     Other(String),
 }
index a942e7e..96e5996 100644 (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)>> {
 
 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#"
     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
             "#,
         )
 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?;
 
     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]
         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] = ''
 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
     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)
     }
             .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)]
 }
 
 #[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?;
 
     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#"
         connection.execute_sql(
             sqlx::query(
             r#"
@@ -93,33 +215,13 @@ VALUES
     ($1, $2, $3, $4, $5, $6)
             "#
             )
     ($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?;
             .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)
     }
 }
     }
 }
index 06d6353..698aa64 100644 (file)
@@ -2,9 +2,10 @@ use chrono::{prelude::*, Duration};
 use rand::distributions::{Alphanumeric, DistString};
 use sqlx::Sqlite;
 
 use rand::distributions::{Alphanumeric, DistString};
 use sqlx::Sqlite;
 
-use super::{model, Connection, DBError, Result};
+use super::{Connection, DBError, Result};
 use crate::{
     consts,
 use crate::{
     consts,
+    data::model,
     hash::{hash, verify_password},
 };
 
     hash::{hash, verify_password},
 };
 
index c91ea29..bdce02a 100644 (file)
@@ -1,3 +1,2 @@
 pub mod db;
 pub mod model;
 pub mod db;
 pub mod model;
-mod utils;
index f52704c..3ce9a26 100644 (file)
@@ -1,77 +1,78 @@
 use chrono::prelude::*;
 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,
 }
 
 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,
 }
 
 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 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 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 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 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,
 }
 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
+    }
+}
diff --git a/backend/src/data/utils.rs b/backend/src/data/utils.rs
deleted file mode 100644 (file)
index 73b6c1c..0000000
+++ /dev/null
@@ -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")?,
-        })
-    }
-}
index 3f3fd4c..dbaf3b6 100644 (file)
@@ -18,6 +18,7 @@ pub struct HomeTemplate {
 #[template(path = "message.html")]
 pub struct MessageTemplate {
     pub user: Option<model::User>,
 #[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.
 }
     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>,
 #[template(path = "sign_up_form.html")]
 pub struct SignUpFormTemplate {
     pub user: Option<model::User>,
+
     pub email: String,
     pub message: String,
     pub message_email: String,
     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>,
 #[template(path = "sign_in_form.html")]
 pub struct SignInFormTemplate {
     pub user: Option<model::User>,
+
     pub email: String,
     pub message: String,
 }
     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>,
 #[template(path = "ask_reset_password.html")]
 pub struct AskResetPasswordTemplate {
     pub user: Option<model::User>,
+
     pub email: String,
     pub message: String,
     pub message_email: String,
     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>,
 #[template(path = "reset_password.html")]
 pub struct ResetPasswordTemplate {
     pub user: Option<model::User>,
+
     pub reset_token: String,
     pub message: String,
     pub message_password: String,
     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>,
 #[template(path = "profile.html")]
 pub struct ProfileTemplate {
     pub user: Option<model::User>,
+
     pub username: String,
     pub email: String,
     pub message: String,
     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 struct RecipeViewTemplate {
     pub user: Option<model::User>,
     pub recipes: Recipes,
+
     pub recipe: model::Recipe,
 }
 
     pub recipe: model::Recipe,
 }
 
@@ -100,5 +107,7 @@ pub struct RecipeViewTemplate {
 pub struct RecipeEditTemplate {
     pub user: Option<model::User>,
     pub recipes: Recipes,
 pub struct RecipeEditTemplate {
     pub user: Option<model::User>,
     pub recipes: Recipes,
+
     pub recipe: model::Recipe,
     pub recipe: model::Recipe,
+    pub languages: [(&'static str, &'static str); 2],
 }
 }
index 2c00ddc..ea53f48 100644 (file)
@@ -92,31 +92,32 @@ async fn main() {
         .route("/", get(services::home_page))
         .route(
             "/signup",
         .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",
         .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",
         .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",
         )
         .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.
         )
         // 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",
         // 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));
 
         )
         .route_layer(middleware::from_fn(services::ron_error_to_html));
 
@@ -233,6 +234,11 @@ async fn process_args() -> bool {
                 )
                 .await
                 .unwrap();
                 )
                 .await
                 .unwrap();
+
+                event!(
+                    Level::INFO,
+                    "A new test database has been created successfully"
+                );
             }
             Err(error) => {
                 event!(Level::ERROR, "{}", error);
             }
             Err(error) => {
                 event!(Level::ERROR, "{}", error);
index a14eda4..dd744b9 100644 (file)
@@ -1,29 +1,21 @@
-use std::{collections::HashMap, net::SocketAddr};
-
 use axum::{
 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,
     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::{
 
 use crate::{
-    config::Config,
-    consts,
     data::{db, model},
     data::{db, model},
-    email,
     html_templates::*,
     html_templates::*,
-    ron_utils, utils, AppState,
+    ron_utils,
 };
 
 };
 
+pub mod recipe;
 pub mod ron;
 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> {
 
 // 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,
         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 /////
 ///// 404 /////
+
 #[debug_handler]
 pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
     MessageTemplate::new_with_user("404: Not found", user)
 #[debug_handler]
 pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
     MessageTemplate::new_with_user("404: Not found", user)
diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs
new file mode 100644 (file)
index 0000000..12ce6fc
--- /dev/null
@@ -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()),
+    }
+}
diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs
new file mode 100644 (file)
index 0000000..a50eff1
--- /dev/null
@@ -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),
+        )),
+    }
+}
index 76d84ac..e9c4357 100644 (file)
@@ -1,7 +1,14 @@
 {% extends "base_with_header.html" %}
 
 {% macro recipe_item(id, title, class) %}
 {% 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 %}
 {% endmacro %}
 
 {% block main_container %}
@@ -27,7 +34,5 @@
             {% endfor %}
         </ul>
     </nav>
             {% endfor %}
         </ul>
     </nav>
-    <div class="content">
-        {% block content %}{% endblock %}
-    </div>
+    {% block content %}{% endblock %}
 {% endblock %}
 {% endblock %}
index ada913f..3177dab 100644 (file)
@@ -2,6 +2,8 @@
 
 {% block content %}
 
 
 {% block content %}
 
-HOME: TODO
+<div class="content" id="home">
+    HOME: TODO
+</div>
 
 {% endblock %}
\ No newline at end of file
 
 {% endblock %}
\ No newline at end of file
index 783e25a..d41f1da 100644 (file)
@@ -2,17 +2,18 @@
 
 {% block main_container %}
 
 
 {% 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>
 
     <a href="/">Go to home</a>
+</div>
+
 {% endblock %}
\ No newline at end of file
 {% endblock %}
\ No newline at end of file
index 49211b8..67bf5a4 100644 (file)
@@ -5,10 +5,8 @@
 {% match user %}
 {% when Some with (user) %}
 
 {% match user %}
 {% when Some with (user) %}
 
-<div class="content">
-
+<div class="content" id="user-edit">
     <h1>Profile</h1>
     <h1>Profile</h1>
-
     <form action="/user/edit" method="post">
 
         <label for="input-name">Name</label>
     <form action="/user/edit" method="post">
 
         <label for="input-name">Name</label>
index 28f5a80..0ab11ea 100644 (file)
@@ -1,23 +1,69 @@
 {% extends "base_with_list.html" %}
 
 {% extends "base_with_list.html" %}
 
+{% macro is_difficulty(diff) %}
+    {% if recipe.difficulty == diff %}
+        selected
+    {% endif %}
+{% endmacro %}
+
 {% block content %}
 {% 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
 {% endblock %}
\ No newline at end of file
index e94c38e..1be8187 100644 (file)
@@ -2,17 +2,18 @@
 
 {% block content %}
 
 
 {% 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
 
 {% endblock %}
\ No newline at end of file
index a0663fe..8df3fc9 100644 (file)
@@ -1,20 +1,22 @@
 {% extends "base_with_header.html" %}
 
 {% block main_container %}
 {% 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 %}
 {% endblock %}
index 0393e64..76f6d5f 100644 (file)
@@ -2,7 +2,7 @@
 
 {% block main_container %}
 
 
 {% block main_container %}
 
-<div id="sign-in" class="content">
+<div class="content" id="sign-in">
 
     <h1>Sign in</h1>
 
 
     <h1>Sign in</h1>
 
index d7c5c27..b35970a 100644 (file)
@@ -1,7 +1,8 @@
 {% extends "base_with_header.html" %}
 
 {% block main_container %}
 {% extends "base_with_header.html" %}
 
 {% block main_container %}
-<div class="content">
+
+<div class="content" id="sign-up">
 
     <h1>Sign up</h1>
 
 
     <h1>Sign up</h1>
 
@@ -23,4 +24,5 @@
     </form>
     {{ message }}
 </div>
     </form>
     {{ message }}
 </div>
+
 {% endblock %}
 {% endblock %}