Recipe edit (WIP)
This commit is contained in:
parent
fce4eade73
commit
c6dfff065c
24 changed files with 1157 additions and 971 deletions
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -391,9 +391,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
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 = [
|
dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
@ -1534,9 +1534,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
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]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
|
|
@ -1748,9 +1748,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
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 = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
@ -2006,7 +2006,7 @@ dependencies = [
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.8",
|
"thiserror 2.0.9",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|
@ -2225,9 +2225,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
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 = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -2672,11 +2672,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
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]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
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 = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -2754,9 +2754,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
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 = [
|
dependencies = [
|
||||||
"tinyvec_macros",
|
"tinyvec_macros",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ VALUES (
|
||||||
1,
|
1,
|
||||||
'paul@atreides.com',
|
'paul@atreides.com',
|
||||||
'Paul',
|
'Paul',
|
||||||
'$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
|
'$argon2id$v=19$m=4096,t=4,p=2$l1fAMRc0VfkNzqpEfFEReg$/gsUsY2aML8EbKjPeCxucenxkxhiFSXDmizWZPLvNuo',
|
||||||
0,
|
0,
|
||||||
NULL
|
NULL
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ CREATE TABLE [Recipe] (
|
||||||
[title] TEXT NOT NULL,
|
[title] TEXT NOT NULL,
|
||||||
-- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
-- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
||||||
[lang] TEXT NOT NULL DEFAULT 'en',
|
[lang] TEXT NOT NULL DEFAULT 'en',
|
||||||
[estimate_time] INTEGER, -- in [s].
|
[estimated_time] INTEGER, -- in [s].
|
||||||
[description] TEXT NOT NULL DEFAULT '',
|
[description] TEXT NOT NULL DEFAULT '',
|
||||||
[difficulty] INTEGER NOT NULL DEFAULT 0,
|
[difficulty] INTEGER NOT NULL DEFAULT 0,
|
||||||
[servings] INTEGER DEFAULT 4,
|
[servings] INTEGER DEFAULT 4,
|
||||||
|
|
@ -93,7 +93,7 @@ CREATE TABLE [Ingredient] (
|
||||||
[id] INTEGER PRIMARY KEY,
|
[id] INTEGER PRIMARY KEY,
|
||||||
[name] TEXT NOT NULL,
|
[name] TEXT NOT NULL,
|
||||||
[comment] TEXT NOT NULL DEFAULT '',
|
[comment] TEXT NOT NULL DEFAULT '',
|
||||||
[quantity_value] REAL,
|
[quantity_value] INTEGER,
|
||||||
[quantity_unit] TEXT NOT NULL DEFAULT '',
|
[quantity_unit] TEXT NOT NULL DEFAULT '',
|
||||||
[input_step_id] INTEGER NOT NULL,
|
[input_step_id] INTEGER NOT NULL,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 REVERSE_PROXY_IP_HTTP_FIELD: &str = "x-real-ip"; // Set by the reverse proxy (Nginx).
|
||||||
|
|
||||||
pub const MAX_DB_CONNECTION: u32 = 1024;
|
pub const MAX_DB_CONNECTION: u32 = 1024;
|
||||||
|
|
||||||
|
pub const LANGUAGES: [(&'static str, &'static str); 2] = [("Français", "fr"), ("English", "en")];
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ use sqlx::{
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{event, Level};
|
use tracing::{event, Level};
|
||||||
|
|
||||||
use super::model;
|
|
||||||
use crate::consts;
|
use crate::consts;
|
||||||
|
|
||||||
pub mod recipe;
|
pub mod recipe;
|
||||||
|
|
@ -32,6 +31,9 @@ pub enum DBError {
|
||||||
)]
|
)]
|
||||||
UnsupportedVersion(u32),
|
UnsupportedVersion(u32),
|
||||||
|
|
||||||
|
#[error("Unknown language: {0}")]
|
||||||
|
UnknownLanguage(String),
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
use super::{model, Connection, DBError, Result};
|
use super::{Connection, DBError, Result};
|
||||||
|
use crate::{
|
||||||
|
consts,
|
||||||
|
data::model::{self, Difficulty},
|
||||||
|
};
|
||||||
|
|
||||||
impl Connection {
|
impl Connection {
|
||||||
pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
|
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>> {
|
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
r#"
|
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> {
|
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
|
||||||
let mut tx = self.tx().await?;
|
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>(
|
match sqlx::query_scalar::<_, i64>(
|
||||||
r#"
|
r#"
|
||||||
SELECT [Recipe].[id] FROM [Recipe]
|
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]
|
LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
|
||||||
WHERE [Recipe].[user_id] = $1
|
WHERE [Recipe].[user_id] = $1
|
||||||
AND [Recipe].[title] = ''
|
AND [Recipe].[title] = ''
|
||||||
AND [Recipe].[estimate_time] IS NULL
|
AND [Recipe].[estimated_time] IS NULL
|
||||||
AND [Recipe].[description] = ''
|
AND [Recipe].[description] = ''
|
||||||
AND [Image].[id] IS NULL
|
AND [Image].[id] IS NULL
|
||||||
AND [Group].[id] IS NULL
|
AND [Group].[id] IS NULL
|
||||||
|
|
@ -74,6 +82,57 @@ WHERE [Recipe].[user_id] = $1
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(DBError::from)
|
.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<()> {
|
async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
|
||||||
let connection = Connection::new_in_memory().await?;
|
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(
|
connection.execute_sql(
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
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@atreides.com")
|
||||||
.bind("paul")
|
.bind("paul")
|
||||||
.bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
|
.bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
|
||||||
.bind("2022-11-29 22:05:04.121407300+00:00")
|
.bind("2022-11-29 22:05:04.121407300+00:00")
|
||||||
.bind(None::<&str>) // 'null'.
|
.bind(None::<&str>) // 'null'.
|
||||||
).await?;
|
).await?;
|
||||||
|
Ok(user_id)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ use chrono::{prelude::*, Duration};
|
||||||
use rand::distributions::{Alphanumeric, DistString};
|
use rand::distributions::{Alphanumeric, DistString};
|
||||||
use sqlx::Sqlite;
|
use sqlx::Sqlite;
|
||||||
|
|
||||||
use super::{model, Connection, DBError, Result};
|
use super::{Connection, DBError, Result};
|
||||||
use crate::{
|
use crate::{
|
||||||
consts,
|
consts,
|
||||||
|
data::model,
|
||||||
hash::{hash, verify_password},
|
hash::{hash, verify_password},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
mod utils;
|
|
||||||
|
|
|
||||||
|
|
@ -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 struct User {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
pub struct UserLoginInfo {
|
pub struct UserLoginInfo {
|
||||||
pub last_login_datetime: DateTime<Utc>,
|
pub last_login_datetime: DateTime<Utc>,
|
||||||
pub ip: String,
|
pub ip: String,
|
||||||
pub user_agent: String,
|
pub user_agent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
pub struct Recipe {
|
pub struct Recipe {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub user_id: i64,
|
pub user_id: i64,
|
||||||
pub title: String,
|
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.
|
#[sqlx(try_from = "u32")]
|
||||||
pub process: Vec<Group>,
|
pub difficulty: Difficulty,
|
||||||
}
|
|
||||||
|
|
||||||
impl Recipe {
|
pub servings: u32,
|
||||||
pub fn empty(id: i64, user_id: i64) -> Recipe {
|
pub is_published: bool,
|
||||||
Self::new(id, user_id, String::new(), String::new())
|
// pub tags: Vec<String>,
|
||||||
}
|
// pub groups: Vec<Group>,
|
||||||
|
|
||||||
pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {
|
|
||||||
Recipe {
|
|
||||||
id,
|
|
||||||
user_id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
estimate_time: None,
|
|
||||||
difficulty: Difficulty::Unknown,
|
|
||||||
lang: "en".to_string(),
|
|
||||||
process: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Ingredient {
|
|
||||||
pub quantity: Option<Quantity>,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Quantity {
|
|
||||||
pub value: f32,
|
|
||||||
pub unit: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Group {
|
pub struct Group {
|
||||||
pub name: Option<String>,
|
pub name: String,
|
||||||
pub input: Vec<StepInput>,
|
pub comment: String,
|
||||||
pub steps: Vec<Step>,
|
pub steps: Vec<Step>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Step {
|
pub struct Step {
|
||||||
pub action: String,
|
pub action: String,
|
||||||
|
pub ingredients: Vec<Ingredient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum StepInput {
|
pub struct Ingredient {
|
||||||
Ingredient(Ingredient),
|
pub name: String,
|
||||||
|
pub comment: String,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub quantity_unit: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Debug)]
|
||||||
pub enum Difficulty {
|
pub enum Difficulty {
|
||||||
Unknown = 0,
|
Unknown = 0,
|
||||||
Easy = 1,
|
Easy = 1,
|
||||||
Medium = 2,
|
Medium = 2,
|
||||||
Hard = 3,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub struct HomeTemplate {
|
||||||
#[template(path = "message.html")]
|
#[template(path = "message.html")]
|
||||||
pub struct MessageTemplate {
|
pub struct MessageTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub as_code: bool, // Display the message in <pre> markup.
|
pub as_code: bool, // Display the message in <pre> markup.
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +45,7 @@ impl MessageTemplate {
|
||||||
#[template(path = "sign_up_form.html")]
|
#[template(path = "sign_up_form.html")]
|
||||||
pub struct SignUpFormTemplate {
|
pub struct SignUpFormTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub message_email: String,
|
pub message_email: String,
|
||||||
|
|
@ -54,6 +56,7 @@ pub struct SignUpFormTemplate {
|
||||||
#[template(path = "sign_in_form.html")]
|
#[template(path = "sign_in_form.html")]
|
||||||
pub struct SignInFormTemplate {
|
pub struct SignInFormTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +65,7 @@ pub struct SignInFormTemplate {
|
||||||
#[template(path = "ask_reset_password.html")]
|
#[template(path = "ask_reset_password.html")]
|
||||||
pub struct AskResetPasswordTemplate {
|
pub struct AskResetPasswordTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub message_email: String,
|
pub message_email: String,
|
||||||
|
|
@ -71,6 +75,7 @@ pub struct AskResetPasswordTemplate {
|
||||||
#[template(path = "reset_password.html")]
|
#[template(path = "reset_password.html")]
|
||||||
pub struct ResetPasswordTemplate {
|
pub struct ResetPasswordTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub reset_token: String,
|
pub reset_token: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub message_password: String,
|
pub message_password: String,
|
||||||
|
|
@ -80,6 +85,7 @@ pub struct ResetPasswordTemplate {
|
||||||
#[template(path = "profile.html")]
|
#[template(path = "profile.html")]
|
||||||
pub struct ProfileTemplate {
|
pub struct ProfileTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|
@ -92,6 +98,7 @@ pub struct ProfileTemplate {
|
||||||
pub struct RecipeViewTemplate {
|
pub struct RecipeViewTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
pub recipes: Recipes,
|
pub recipes: Recipes,
|
||||||
|
|
||||||
pub recipe: model::Recipe,
|
pub recipe: model::Recipe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,5 +107,7 @@ pub struct RecipeViewTemplate {
|
||||||
pub struct RecipeEditTemplate {
|
pub struct RecipeEditTemplate {
|
||||||
pub user: Option<model::User>,
|
pub user: Option<model::User>,
|
||||||
pub recipes: Recipes,
|
pub recipes: Recipes,
|
||||||
|
|
||||||
pub recipe: model::Recipe,
|
pub recipe: model::Recipe,
|
||||||
|
pub languages: [(&'static str, &'static str); 2],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,31 +92,32 @@ async fn main() {
|
||||||
.route("/", get(services::home_page))
|
.route("/", get(services::home_page))
|
||||||
.route(
|
.route(
|
||||||
"/signup",
|
"/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("/validation", get(services::user::sign_up_validation))
|
||||||
.route("/revalidation", get(services::email_revalidation))
|
.route("/revalidation", get(services::user::email_revalidation))
|
||||||
.route(
|
.route(
|
||||||
"/signin",
|
"/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(
|
.route(
|
||||||
"/ask_reset_password",
|
"/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(
|
.route(
|
||||||
"/reset_password",
|
"/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/new", get(services::recipe::create))
|
||||||
// .route("/recipe/edit/:id", get(services::edit_recipe))
|
.route("/recipe/edit/:id", get(services::recipe::edit_recipe))
|
||||||
.route("/recipe/view/:id", get(services::view_recipe))
|
.route("/recipe/view/:id", get(services::recipe::view))
|
||||||
// User.
|
// User.
|
||||||
.route(
|
.route(
|
||||||
"/user/edit",
|
"/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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
event!(
|
||||||
|
Level::INFO,
|
||||||
|
"A new test database has been created successfully"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
event!(Level::ERROR, "{}", error);
|
event!(Level::ERROR, "{}", error);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,21 @@
|
||||||
use std::{collections::HashMap, net::SocketAddr};
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{self, Body},
|
body, debug_handler,
|
||||||
debug_handler,
|
extract::{Extension, Request, State},
|
||||||
extract::{ConnectInfo, Extension, Host, Path, Query, Request, State},
|
http::header,
|
||||||
http::{header, HeaderMap},
|
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Redirect, Response, Result},
|
response::{IntoResponse, Response, Result},
|
||||||
Form,
|
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
// use tracing::{event, Level};
|
||||||
use chrono::Duration;
|
|
||||||
use serde::Deserialize;
|
|
||||||
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.
|
// Will embed RON error in HTML page.
|
||||||
pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
|
pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
|
||||||
|
|
@ -61,774 +53,12 @@ pub async fn home_page(
|
||||||
recipes: Recipes {
|
recipes: Recipes {
|
||||||
list: recipes,
|
list: recipes,
|
||||||
current_id: None,
|
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]
|
#[debug_handler]
|
||||||
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
|
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
|
||||||
MessageTemplate::new_with_user("404: Not found", user)
|
MessageTemplate::new_with_user("404: Not found", user)
|
||||||
|
|
|
||||||
79
backend/src/services/recipe.rs
Normal file
79
backend/src/services/recipe.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use axum::{
|
||||||
|
debug_handler,
|
||||||
|
extract::{Extension, Path, State},
|
||||||
|
response::{IntoResponse, Redirect, Response, Result},
|
||||||
|
};
|
||||||
|
// use tracing::{event, Level};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
consts,
|
||||||
|
data::{db, model},
|
||||||
|
html_templates::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
///// RECIPE /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn create(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if let Some(user) = user {
|
||||||
|
let recipe_id = connection.create_recipe(user.id).await?;
|
||||||
|
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
|
||||||
|
} else {
|
||||||
|
Ok(MessageTemplate::new("Not logged in").into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn edit_recipe(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Path(recipe_id): Path<i64>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if let Some(user) = user {
|
||||||
|
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
|
||||||
|
if recipe.user_id == user.id {
|
||||||
|
Ok(RecipeEditTemplate {
|
||||||
|
user: Some(user),
|
||||||
|
recipes: Recipes {
|
||||||
|
list: connection.get_all_recipe_titles().await?,
|
||||||
|
current_id: Some(recipe_id),
|
||||||
|
},
|
||||||
|
recipe,
|
||||||
|
languages: consts::LANGUAGES,
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(MessageTemplate::new("Unable to edit this recipe").into_response())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(MessageTemplate::new("Not logged in").into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn view(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Path(recipe_id): Path<i64>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let recipes = connection.get_all_recipe_titles().await?;
|
||||||
|
match connection.get_recipe(recipe_id).await? {
|
||||||
|
Some(recipe) => Ok(RecipeViewTemplate {
|
||||||
|
user,
|
||||||
|
recipes: Recipes {
|
||||||
|
list: recipes,
|
||||||
|
current_id: Some(recipe.id),
|
||||||
|
},
|
||||||
|
recipe,
|
||||||
|
}
|
||||||
|
.into_response()),
|
||||||
|
None => Ok(MessageTemplate::new_with_user(
|
||||||
|
&format!("Cannot find the recipe {}", recipe_id),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
}
|
||||||
|
}
|
||||||
732
backend/src/services/user.rs
Normal file
732
backend/src/services/user.rs
Normal file
|
|
@ -0,0 +1,732 @@
|
||||||
|
use std::{collections::HashMap, net::SocketAddr};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
debug_handler,
|
||||||
|
extract::{ConnectInfo, Extension, Host, Query, Request, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response, Result},
|
||||||
|
Form,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||||
|
use chrono::Duration;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::{event, Level};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
consts,
|
||||||
|
data::{db, model},
|
||||||
|
email,
|
||||||
|
html_templates::*,
|
||||||
|
utils, AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
//// SIGN UP /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn sign_up_get(
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
Ok(SignUpFormTemplate {
|
||||||
|
user,
|
||||||
|
email: String::new(),
|
||||||
|
message: String::new(),
|
||||||
|
message_email: String::new(),
|
||||||
|
message_password: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SignUpFormData {
|
||||||
|
email: String,
|
||||||
|
password_1: String,
|
||||||
|
password_2: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SignUpError {
|
||||||
|
InvalidEmail,
|
||||||
|
PasswordsNotEqual,
|
||||||
|
InvalidPassword,
|
||||||
|
UserAlreadyExists,
|
||||||
|
DatabaseError,
|
||||||
|
UnableSendEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler(state = AppState)]
|
||||||
|
pub async fn sign_up_post(
|
||||||
|
Host(host): Host,
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
State(config): State<Config>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Form(form_data): Form<SignUpFormData>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
fn error_response(
|
||||||
|
error: SignUpError,
|
||||||
|
form_data: &SignUpFormData,
|
||||||
|
user: Option<model::User>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
Ok(SignUpFormTemplate {
|
||||||
|
user,
|
||||||
|
email: form_data.email.clone(),
|
||||||
|
message_email: match error {
|
||||||
|
SignUpError::InvalidEmail => "Invalid email",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message_password: match error {
|
||||||
|
SignUpError::PasswordsNotEqual => "Passwords don't match",
|
||||||
|
SignUpError::InvalidPassword => "Password must have at least eight characters",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: match error {
|
||||||
|
SignUpError::UserAlreadyExists => "This email is not available",
|
||||||
|
SignUpError::DatabaseError => "Database error",
|
||||||
|
SignUpError::UnableSendEmail => "Unable to send the validation email",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation of email and password.
|
||||||
|
if let common::utils::EmailValidation::NotValid =
|
||||||
|
common::utils::validate_email(&form_data.email)
|
||||||
|
{
|
||||||
|
return error_response(SignUpError::InvalidEmail, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if form_data.password_1 != form_data.password_2 {
|
||||||
|
return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let common::utils::PasswordValidation::TooShort =
|
||||||
|
common::utils::validate_password(&form_data.password_1)
|
||||||
|
{
|
||||||
|
return error_response(SignUpError::InvalidPassword, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
match connection
|
||||||
|
.sign_up(&form_data.email, &form_data.password_1)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(db::user::SignUpResult::UserAlreadyExists) => {
|
||||||
|
error_response(SignUpError::UserAlreadyExists, &form_data, user)
|
||||||
|
}
|
||||||
|
Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
|
||||||
|
let url = utils::get_url_from_host(&host);
|
||||||
|
let email = form_data.email.clone();
|
||||||
|
match email::send_email(
|
||||||
|
&email,
|
||||||
|
&format!(
|
||||||
|
"Follow this link to confirm your inscription: {}/validation?validation_token={}",
|
||||||
|
url, token
|
||||||
|
),
|
||||||
|
&config.smtp_relay_address,
|
||||||
|
&config.smtp_login,
|
||||||
|
&config.smtp_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(
|
||||||
|
MessageTemplate::new_with_user(
|
||||||
|
"An email has been sent, follow the link to validate your account",
|
||||||
|
user).into_response()),
|
||||||
|
Err(_) => {
|
||||||
|
// error!("Email validation error: {}", error); // TODO: log
|
||||||
|
error_response(SignUpError::UnableSendEmail, &form_data, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// error!("Signup database error: {}", error); // TODO: log
|
||||||
|
error_response(SignUpError::DatabaseError, &form_data, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn sign_up_validation(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<(CookieJar, impl IntoResponse)> {
|
||||||
|
let mut jar = CookieJar::from_headers(&headers);
|
||||||
|
if user.is_some() {
|
||||||
|
return Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("User already exists", user),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
|
||||||
|
match query.get("validation_token") {
|
||||||
|
// 'validation_token' exists only when a user tries to validate a new account.
|
||||||
|
Some(token) => {
|
||||||
|
match connection
|
||||||
|
.validation(
|
||||||
|
token,
|
||||||
|
Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
|
||||||
|
&client_ip,
|
||||||
|
&client_user_agent,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
db::user::ValidationResult::Ok(token, user_id) => {
|
||||||
|
let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
|
||||||
|
jar = jar.add(cookie);
|
||||||
|
let user = connection.load_user(user_id).await?;
|
||||||
|
Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user(
|
||||||
|
"Email validation successful, your account has been created",
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
db::user::ValidationResult::ValidationExpired => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user(
|
||||||
|
"The validation has expired. Try to sign up again",
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
db::user::ValidationResult::UnknownUser => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("Validation error", user),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///// SIGN IN /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn sign_in_get(
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
Ok(SignInFormTemplate {
|
||||||
|
user,
|
||||||
|
email: String::new(),
|
||||||
|
message: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct SignInFormData {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn sign_in_post(
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form_data): Form<SignInFormData>,
|
||||||
|
) -> Result<(CookieJar, Response)> {
|
||||||
|
let jar = CookieJar::from_headers(&headers);
|
||||||
|
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
|
||||||
|
|
||||||
|
match connection
|
||||||
|
.sign_in(
|
||||||
|
&form_data.email,
|
||||||
|
&form_data.password,
|
||||||
|
&client_ip,
|
||||||
|
&client_user_agent,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
db::user::SignInResult::AccountNotValidated => Ok((
|
||||||
|
jar,
|
||||||
|
SignInFormTemplate {
|
||||||
|
user,
|
||||||
|
email: form_data.email,
|
||||||
|
message: "This account must be validated first".to_string(),
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
)),
|
||||||
|
db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
|
||||||
|
jar,
|
||||||
|
SignInFormTemplate {
|
||||||
|
user,
|
||||||
|
email: form_data.email,
|
||||||
|
message: "Wrong email or password".to_string(),
|
||||||
|
}
|
||||||
|
.into_response(),
|
||||||
|
)),
|
||||||
|
db::user::SignInResult::Ok(token, _user_id) => {
|
||||||
|
let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
|
||||||
|
Ok((jar.add(cookie), Redirect::to("/").into_response()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///// SIGN OUT /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn sign_out(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
req: Request<Body>,
|
||||||
|
) -> Result<(CookieJar, Redirect)> {
|
||||||
|
let mut jar = CookieJar::from_headers(req.headers());
|
||||||
|
if let Some(token_cookie) = jar.get(consts::COOKIE_AUTH_TOKEN_NAME) {
|
||||||
|
let token = token_cookie.value().to_string();
|
||||||
|
jar = jar.remove(consts::COOKIE_AUTH_TOKEN_NAME);
|
||||||
|
connection.sign_out(&token).await?;
|
||||||
|
}
|
||||||
|
Ok((jar, Redirect::to("/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
///// RESET PASSWORD /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn ask_reset_password_get(
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if user.is_some() {
|
||||||
|
Ok(MessageTemplate::new_with_user(
|
||||||
|
"Can't ask to reset password when already logged in",
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(AskResetPasswordTemplate {
|
||||||
|
user,
|
||||||
|
email: String::new(),
|
||||||
|
message: String::new(),
|
||||||
|
message_email: String::new(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct AskResetPasswordForm {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AskResetPasswordError {
|
||||||
|
InvalidEmail,
|
||||||
|
EmailAlreadyReset,
|
||||||
|
EmailUnknown,
|
||||||
|
UnableSendEmail,
|
||||||
|
DatabaseError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler(state = AppState)]
|
||||||
|
pub async fn ask_reset_password_post(
|
||||||
|
Host(host): Host,
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
State(config): State<Config>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Form(form_data): Form<AskResetPasswordForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
fn error_response(
|
||||||
|
error: AskResetPasswordError,
|
||||||
|
email: &str,
|
||||||
|
user: Option<model::User>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
Ok(AskResetPasswordTemplate {
|
||||||
|
user,
|
||||||
|
email: email.to_string(),
|
||||||
|
message_email: match error {
|
||||||
|
AskResetPasswordError::InvalidEmail => "Invalid email",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: match error {
|
||||||
|
AskResetPasswordError::EmailAlreadyReset => {
|
||||||
|
"The password has already been reset for this email"
|
||||||
|
}
|
||||||
|
AskResetPasswordError::EmailUnknown => "Email unknown",
|
||||||
|
AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
|
||||||
|
AskResetPasswordError::DatabaseError => "Database error",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation of email.
|
||||||
|
if let common::utils::EmailValidation::NotValid =
|
||||||
|
common::utils::validate_email(&form_data.email)
|
||||||
|
{
|
||||||
|
return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
match connection
|
||||||
|
.get_token_reset_password(
|
||||||
|
&form_data.email,
|
||||||
|
Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
|
||||||
|
AskResetPasswordError::EmailAlreadyReset,
|
||||||
|
&form_data.email,
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
|
||||||
|
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
|
||||||
|
}
|
||||||
|
Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
|
||||||
|
let url = utils::get_url_from_host(&host);
|
||||||
|
match email::send_email(
|
||||||
|
&form_data.email,
|
||||||
|
&format!(
|
||||||
|
"Follow this link to reset your password: {}/reset_password?reset_token={}",
|
||||||
|
url, token
|
||||||
|
),
|
||||||
|
&config.smtp_relay_address,
|
||||||
|
&config.smtp_login,
|
||||||
|
&config.smtp_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(MessageTemplate::new_with_user(
|
||||||
|
"An email has been sent, follow the link to reset your password.",
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
Err(_) => {
|
||||||
|
// error!("Email validation error: {}", error); // TODO: log
|
||||||
|
error_response(
|
||||||
|
AskResetPasswordError::UnableSendEmail,
|
||||||
|
&form_data.email,
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
event!(Level::ERROR, "{}", error);
|
||||||
|
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn reset_password_get(
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if let Some(reset_token) = query.get("reset_token") {
|
||||||
|
Ok(ResetPasswordTemplate {
|
||||||
|
user,
|
||||||
|
reset_token: reset_token.to_string(),
|
||||||
|
message: String::new(),
|
||||||
|
message_password: String::new(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ResetPasswordForm {
|
||||||
|
password_1: String,
|
||||||
|
password_2: String,
|
||||||
|
reset_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ResetPasswordError {
|
||||||
|
PasswordsNotEqual,
|
||||||
|
InvalidPassword,
|
||||||
|
TokenExpired,
|
||||||
|
DatabaseError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn reset_password_post(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Form(form_data): Form<ResetPasswordForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
fn error_response(
|
||||||
|
error: ResetPasswordError,
|
||||||
|
form_data: &ResetPasswordForm,
|
||||||
|
user: Option<model::User>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
Ok(ResetPasswordTemplate {
|
||||||
|
user,
|
||||||
|
reset_token: form_data.reset_token.clone(),
|
||||||
|
message_password: match error {
|
||||||
|
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
|
||||||
|
ResetPasswordError::InvalidPassword => {
|
||||||
|
"Password must have at least eight characters"
|
||||||
|
}
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: match error {
|
||||||
|
ResetPasswordError::TokenExpired => "Token expired, try to reset password again",
|
||||||
|
ResetPasswordError::DatabaseError => "Database error",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
if form_data.password_1 != form_data.password_2 {
|
||||||
|
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let common::utils::PasswordValidation::TooShort =
|
||||||
|
common::utils::validate_password(&form_data.password_1)
|
||||||
|
{
|
||||||
|
return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
match connection
|
||||||
|
.reset_password(
|
||||||
|
&form_data.password_1,
|
||||||
|
&form_data.reset_token,
|
||||||
|
Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
|
||||||
|
"Your password has been reset",
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
.into_response()),
|
||||||
|
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
|
||||||
|
error_response(ResetPasswordError::TokenExpired, &form_data, user)
|
||||||
|
}
|
||||||
|
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///// EDIT PROFILE /////
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
|
||||||
|
if let Some(user) = user {
|
||||||
|
ProfileTemplate {
|
||||||
|
username: user.name.clone(),
|
||||||
|
email: user.email.clone(),
|
||||||
|
user: Some(user),
|
||||||
|
message: String::new(),
|
||||||
|
message_email: String::new(),
|
||||||
|
message_password: String::new(),
|
||||||
|
}
|
||||||
|
.into_response()
|
||||||
|
} else {
|
||||||
|
MessageTemplate::new("Not logged in").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct EditUserForm {
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
password_1: String,
|
||||||
|
password_2: String,
|
||||||
|
}
|
||||||
|
enum ProfileUpdateError {
|
||||||
|
InvalidEmail,
|
||||||
|
EmailAlreadyTaken,
|
||||||
|
PasswordsNotEqual,
|
||||||
|
InvalidPassword,
|
||||||
|
DatabaseError,
|
||||||
|
UnableSendEmail,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: A lot of code are similar to 'sign_up_post', maybe find a way to factorize some.
|
||||||
|
#[debug_handler(state = AppState)]
|
||||||
|
pub async fn edit_user_post(
|
||||||
|
Host(host): Host,
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
State(config): State<Config>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
Form(form_data): Form<EditUserForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if let Some(user) = user {
|
||||||
|
fn error_response(
|
||||||
|
error: ProfileUpdateError,
|
||||||
|
form_data: &EditUserForm,
|
||||||
|
user: model::User,
|
||||||
|
) -> Result<Response> {
|
||||||
|
Ok(ProfileTemplate {
|
||||||
|
user: Some(user),
|
||||||
|
username: form_data.name.clone(),
|
||||||
|
email: form_data.email.clone(),
|
||||||
|
message_email: match error {
|
||||||
|
ProfileUpdateError::InvalidEmail => "Invalid email",
|
||||||
|
ProfileUpdateError::EmailAlreadyTaken => "Email already taken",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message_password: match error {
|
||||||
|
ProfileUpdateError::PasswordsNotEqual => "Passwords don't match",
|
||||||
|
ProfileUpdateError::InvalidPassword => {
|
||||||
|
"Password must have at least eight characters"
|
||||||
|
}
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
message: match error {
|
||||||
|
ProfileUpdateError::DatabaseError => "Database error",
|
||||||
|
ProfileUpdateError::UnableSendEmail => "Unable to send the validation email",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let common::utils::EmailValidation::NotValid =
|
||||||
|
common::utils::validate_email(&form_data.email)
|
||||||
|
{
|
||||||
|
return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
|
||||||
|
if form_data.password_1 != form_data.password_2 {
|
||||||
|
return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
|
||||||
|
}
|
||||||
|
if let common::utils::PasswordValidation::TooShort =
|
||||||
|
common::utils::validate_password(&form_data.password_1)
|
||||||
|
{
|
||||||
|
return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
|
||||||
|
}
|
||||||
|
Some(form_data.password_1.as_ref())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let email_trimmed = form_data.email.trim();
|
||||||
|
let message: &str;
|
||||||
|
|
||||||
|
match connection
|
||||||
|
.update_user(
|
||||||
|
user.id,
|
||||||
|
Some(&email_trimmed),
|
||||||
|
Some(&form_data.name),
|
||||||
|
new_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
|
||||||
|
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
|
||||||
|
}
|
||||||
|
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
|
||||||
|
let url = utils::get_url_from_host(&host);
|
||||||
|
let email = form_data.email.clone();
|
||||||
|
match email::send_email(
|
||||||
|
&email,
|
||||||
|
&format!(
|
||||||
|
"Follow this link to validate this email address: {}/revalidation?validation_token={}",
|
||||||
|
url, token
|
||||||
|
),
|
||||||
|
&config.smtp_relay_address,
|
||||||
|
&config.smtp_login,
|
||||||
|
&config.smtp_password,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
message =
|
||||||
|
"An email has been sent, follow the link to validate your new email";
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// error!("Email validation error: {}", error); // TODO: log
|
||||||
|
return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(db::user::UpdateUserResult::Ok) => {
|
||||||
|
message = "Profile saved";
|
||||||
|
}
|
||||||
|
Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload after update.
|
||||||
|
let user = connection.load_user(user.id).await?;
|
||||||
|
|
||||||
|
Ok(ProfileTemplate {
|
||||||
|
user,
|
||||||
|
username: form_data.name,
|
||||||
|
email: form_data.email,
|
||||||
|
message: message.to_string(),
|
||||||
|
message_email: String::new(),
|
||||||
|
message_password: String::new(),
|
||||||
|
}
|
||||||
|
.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(MessageTemplate::new("Not logged in").into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn email_revalidation(
|
||||||
|
State(connection): State<db::Connection>,
|
||||||
|
Extension(user): Extension<Option<model::User>>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
Query(query): Query<HashMap<String, String>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<(CookieJar, impl IntoResponse)> {
|
||||||
|
let mut jar = CookieJar::from_headers(&headers);
|
||||||
|
if user.is_some() {
|
||||||
|
return Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("User already exists", user),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
|
||||||
|
match query.get("validation_token") {
|
||||||
|
// 'validation_token' exists only when a user must validate a new email.
|
||||||
|
Some(token) => {
|
||||||
|
match connection
|
||||||
|
.validation(
|
||||||
|
token,
|
||||||
|
Duration::seconds(consts::VALIDATION_TOKEN_DURATION),
|
||||||
|
&client_ip,
|
||||||
|
&client_user_agent,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
db::user::ValidationResult::Ok(token, user_id) => {
|
||||||
|
let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
|
||||||
|
jar = jar.add(cookie);
|
||||||
|
let user = connection.load_user(user_id).await?;
|
||||||
|
Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("Email validation successful", user),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
db::user::ValidationResult::ValidationExpired => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user(
|
||||||
|
"The validation has expired. Try to sign up again with the same email",
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
db::user::ValidationResult::UnknownUser => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user(
|
||||||
|
"Validation error. Try to sign up again with the same email",
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Ok((
|
||||||
|
jar,
|
||||||
|
MessageTemplate::new_with_user("Validation error", user),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
{% extends "base_with_header.html" %}
|
{% extends "base_with_header.html" %}
|
||||||
|
|
||||||
{% macro recipe_item(id, title, class) %}
|
{% 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 %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
|
@ -27,7 +34,5 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="content">
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content" id="home">
|
||||||
HOME: TODO
|
HOME: TODO
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
|
||||||
<div class="message">
|
<div class="content" id="message">
|
||||||
{% if as_code %}
|
{% if as_code %}
|
||||||
<pre><code>
|
<pre><code>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
{% if as_code %}
|
{% if as_code %}
|
||||||
</code></pre>
|
</code></pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/">Go to home</a>
|
<a href="/">Go to home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -5,10 +5,8 @@
|
||||||
{% match user %}
|
{% match user %}
|
||||||
{% when Some with (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">
|
<form action="/user/edit" method="post">
|
||||||
|
|
||||||
<label for="input-name">Name</label>
|
<label for="input-name">Name</label>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
<div class="content" id="recipe-edit">
|
||||||
|
<label for="input-title">Title</label>
|
||||||
<input
|
<input
|
||||||
id="title_field"
|
id="input-title"
|
||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="title"
|
||||||
value="{{ recipe.title }}"
|
value="{{ recipe.title }}"
|
||||||
autocapitalize="none"
|
|
||||||
autocomplete="title"
|
autocomplete="title"
|
||||||
autofocus="autofocus" />
|
autofocus="true" />
|
||||||
|
|
||||||
<label for="description_field">Description</label>
|
<label for="input-description">Description</label>
|
||||||
<input
|
<input
|
||||||
id="title_field"
|
id="input-description"
|
||||||
type="text"
|
type="text"
|
||||||
name="title"
|
name="description"
|
||||||
value="{{ recipe.description }}"
|
value="{{ recipe.description }}"
|
||||||
autocapitalize="none"
|
autocomplete="title" />
|
||||||
autocomplete="title"
|
|
||||||
autofocus="autofocus" />
|
<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 %}
|
{% endblock %}
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="content" id="recipe-view">
|
||||||
<h2 class="recipe-title" >{{ recipe.title }}</h2>
|
<h2 class="recipe-title" >{{ recipe.title }}</h2>
|
||||||
|
|
||||||
|
|
||||||
{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
|
{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
|
||||||
<a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
|
<a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -14,5 +14,6 @@
|
||||||
{{ recipe.description.clone()|markdown }}
|
{{ recipe.description.clone()|markdown }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{% extends "base_with_header.html" %}
|
{% extends "base_with_header.html" %}
|
||||||
|
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
<div class="content">
|
|
||||||
|
<div class="content" id="reset-password">
|
||||||
<form action="/reset_password" method="post">
|
<form action="/reset_password" method="post">
|
||||||
<label for="password_field_1">Choose a new password (minimum 8 characters)</label>
|
<label for="password_field_1">Choose a new password (minimum 8 characters)</label>
|
||||||
<input id="password_field_1" type="password" name="password_1" />
|
<input id="password_field_1" type="password" name="password_1" />
|
||||||
|
|
@ -17,4 +18,5 @@
|
||||||
</form>
|
</form>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{% extends "base_with_header.html" %}
|
{% extends "base_with_header.html" %}
|
||||||
|
|
||||||
{% block main_container %}
|
{% 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>
|
</form>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue