"ron",
"serde",
"sqlx",
- "thiserror 2.0.7",
+ "thiserror 2.0.8",
"tokio",
"tower",
"tower-http",
[[package]]
name = "thiserror"
-version = "2.0.7"
+version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767"
+checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a"
dependencies = [
- "thiserror-impl 2.0.7",
+ "thiserror-impl 2.0.8",
]
[[package]]
[[package]]
name = "thiserror-impl"
-version = "2.0.7"
+version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36"
+checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943"
dependencies = [
"proc-macro2",
"quote",
-INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
VALUES (
1,
'paul@atreides.com',
NULL
);
-INSERT INTO [User] ([id], [email], [name], [password], [creation_datetime], [validation_token])
+INSERT INTO [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
VALUES (
2,
'alia@atreides.com',
[id] INTEGER PRIMARY KEY,
[user_id] INTEGER, -- Can be null if a user is deleted.
[title] TEXT NOT NULL,
- [estimate_time] INTEGER,
+ -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
+ [lang] TEXT NOT NULL DEFAULT 'en',
+ [estimate_time] INTEGER, -- in [s].
[description] TEXT NOT NULL DEFAULT '',
[difficulty] INTEGER NOT NULL DEFAULT 0,
[servings] INTEGER DEFAULT 4,
[recipe_id] INTEGER NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
[description] TEXT NOT NULL DEFAULT '',
- [image] BLOB,
+ [image] BLOB NOT NULL,
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
) STRICT;
CREATE TABLE [Tag] (
[id] INTEGER PRIMARY KEY,
- [name] TEXT NOT NULL
+ [name] TEXT NOT NULL,
+ -- https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
+ [lang] TEXT NOT NULL DEFAULT 'en'
) STRICT;
-CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]);
+CREATE UNIQUE INDEX [Tag_name_lang_index] ON [Tag] ([name], [lang]);
CREATE TABLE [Ingredient] (
[id] INTEGER PRIMARY KEY,
[name] TEXT NOT NULL,
+ [comment] TEXT NOT NULL DEFAULT '',
[quantity_value] REAL,
[quantity_unit] TEXT NOT NULL DEFAULT '',
- [input_group_id] INTEGER NOT NULL,
+ [input_step_id] INTEGER NOT NULL,
- FOREIGN KEY([input_group_id]) REFERENCES [Group]([id]) ON DELETE CASCADE
+ FOREIGN KEY([input_step_id]) REFERENCES [Step]([id]) ON DELETE CASCADE
) STRICT;
CREATE TABLE [Group] (
[id] INTEGER PRIMARY KEY,
[order] INTEGER NOT NULL DEFAULT 0,
- [recipe_id] INTEGER,
+ [recipe_id] INTEGER NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
+ [comment] TEXT NOT NULL DEFAULT '',
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE
) STRICT;
CREATE INDEX [Step_order_index] ON [Group]([order]);
-CREATE TABLE [IntermediateSubstance] (
- [id] INTEGER PRIMARY KEY,
- [name] TEXT NOT NULL DEFAULT '',
- [quantity_value] REAL,
- [quantity_unit] TEXT NOT NULL DEFAULT '',
- [output_group_id] INTEGER NOT NULL,
- [input_group_id] INTEGER NOT NULL,
-
- FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
- FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
-) STRICT;
+-- CREATE TABLE [IntermediateSubstance] (
+-- [id] INTEGER PRIMARY KEY,
+-- [name] TEXT NOT NULL DEFAULT '',
+-- [quantity_value] REAL,
+-- [quantity_unit] TEXT NOT NULL DEFAULT '',
+-- [output_group_id] INTEGER NOT NULL,
+-- [input_group_id] INTEGER NOT NULL,
+
+-- FOREIGN KEY([output_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE,
+-- FOREIGN KEY([input_group_id]) REFERENCES [group]([id]) ON DELETE CASCADE
+-- ) STRICT;
str::FromStr,
};
-use chrono::{prelude::*, Duration};
-use rand::distributions::{Alphanumeric, DistString};
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
Pool, Sqlite, Transaction,
use thiserror::Error;
use tracing::{event, Level};
-use crate::{
- consts,
- hash::{hash, verify_password},
- model,
-};
+use super::model;
+use crate::consts;
+
+pub mod recipe;
+pub mod user;
const CURRENT_DB_VERSION: u32 = 1;
type Result<T> = std::result::Result<T, DBError>;
-#[derive(Debug)]
-pub enum SignUpResult {
- UserAlreadyExists,
- UserCreatedWaitingForValidation(String), // Validation token.
-}
-
-#[derive(Debug)]
-pub enum UpdateUserResult {
- EmailAlreadyTaken,
- UserUpdatedWaitingForRevalidation(String), // Validation token.
- Ok,
-}
-
-#[derive(Debug)]
-pub enum ValidationResult {
- UnknownUser,
- ValidationExpired,
- Ok(String, i64), // Returns token and user id.
-}
-
-#[derive(Debug)]
-pub enum SignInResult {
- UserNotFound,
- WrongPassword,
- AccountNotValidated,
- Ok(String, i64), // Returns token and user id.
-}
-
-#[derive(Debug)]
-pub enum AuthenticationResult {
- NotValidToken,
- Ok(i64), // Returns user id.
-}
-
-#[derive(Debug)]
-pub enum GetTokenResetPasswordResult {
- PasswordAlreadyReset,
- EmailUnknown,
- Ok(String),
-}
-
-#[derive(Debug)]
-pub enum ResetPasswordResult {
- ResetTokenExpired,
- Ok,
-}
-
#[derive(Clone)]
pub struct Connection {
pool: Pool<Sqlite>,
}
}
- pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
- sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")
- .fetch_all(&self.pool)
- .await
- .map_err(DBError::from)
- }
-
- pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
- sqlx::query_as(
- r#"
-SELECT [id], [user_id], [title], [description]
-FROM [Recipe] WHERE [id] = $1
- "#,
- )
- .bind(id)
- .fetch_optional(&self.pool)
- .await
- .map_err(DBError::from)
- }
-
- #[cfg(test)]
- pub async fn get_user_login_info(&self, token: &str) -> Result<model::UserLoginInfo> {
- sqlx::query_as(
- r#"
-SELECT [last_login_datetime], [ip], [user_agent]
-FROM [UserLoginToken] WHERE [token] = $1
- "#,
- )
- .bind(token)
- .fetch_one(&self.pool)
- .await
- .map_err(DBError::from)
- }
-
- pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
- sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
- .bind(user_id)
- .fetch_optional(&self.pool)
- .await
- .map_err(DBError::from)
- }
-
- /// If a new email is given and it doesn't match the current one then it has to be
- /// Revalidated.
- pub async fn update_user(
- &self,
- user_id: i64,
- new_email: Option<&str>,
- new_name: Option<&str>,
- new_password: Option<&str>,
- ) -> Result<UpdateUserResult> {
- let mut tx = self.tx().await?;
- let hashed_new_password = new_password.map(|p| hash(p).unwrap());
-
- let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>(
- "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1",
- )
- .bind(user_id)
- .fetch_one(&mut *tx)
- .await?;
-
- let email_changed = new_email.is_some_and(|new_email| new_email != email);
-
- // Check if email not already taken.
- let validation_token = if email_changed {
- if sqlx::query_scalar::<_, i64>(
- r#"
-SELECT COUNT(*)
-FROM [User]
-WHERE [email] = $1
- "#,
- )
- .bind(new_email.unwrap())
- .fetch_one(&mut *tx)
- .await?
- > 0
- {
- return Ok(UpdateUserResult::EmailAlreadyTaken);
- }
-
- let token = Some(generate_token());
- sqlx::query(
- r#"
-UPDATE [User]
-SET [validation_token] = $2, [validation_token_datetime] = $3
-WHERE [id] = $1
- "#,
- )
- .bind(user_id)
- .bind(&token)
- .bind(Utc::now())
- .execute(&mut *tx)
- .await?;
- token
- } else {
- None
- };
-
- sqlx::query(
- r#"
-UPDATE [User]
-SET [email] = $2, [name] = $3, [password] = $4
-WHERE [id] = $1
- "#,
- )
- .bind(user_id)
- .bind(new_email.unwrap_or(&email))
- .bind(new_name.unwrap_or(&name))
- .bind(hashed_new_password.unwrap_or(hashed_password))
- .execute(&mut *tx)
- .await?;
-
- tx.commit().await?;
-
- Ok(if let Some(validation_token) = validation_token {
- UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token)
- } else {
- UpdateUserResult::Ok
- })
- }
-
- pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
- self.sign_up_with_given_time(email, password, Utc::now())
- .await
- }
-
- async fn sign_up_with_given_time(
- &self,
- email: &str,
- password: &str,
- datetime: DateTime<Utc>,
- ) -> Result<SignUpResult> {
- let mut tx = self.tx().await?;
-
- let token = match sqlx::query_as::<_, (i64, Option<String>)>(
- r#"
-SELECT [id], [validation_token]
-FROM [User] WHERE [email] = $1
- "#,
- )
- .bind(email)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some((id, validation_token)) => {
- if validation_token.is_none() {
- return Ok(SignUpResult::UserAlreadyExists);
- }
- let token = generate_token();
- let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
- sqlx::query(
- r#"
-UPDATE [User]
-SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4
-WHERE [id] = $1
- "#,
- )
- .bind(id)
- .bind(&token)
- .bind(datetime)
- .bind(hashed_password)
- .execute(&mut *tx)
- .await?;
- token
- }
- None => {
- let token = generate_token();
- let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
- sqlx::query(
- r#"
-INSERT INTO [User]
-([email], [validation_token], [validation_token_datetime], [password])
-VALUES ($1, $2, $3, $4)
- "#,
- )
- .bind(email)
- .bind(&token)
- .bind(datetime)
- .bind(hashed_password)
- .execute(&mut *tx)
- .await?;
- token
- }
- };
-
- tx.commit().await?;
-
- Ok(SignUpResult::UserCreatedWaitingForValidation(token))
- }
-
- pub async fn validation(
- &self,
- token: &str,
- validation_time: Duration,
- ip: &str,
- user_agent: &str,
- ) -> Result<ValidationResult> {
- let mut tx = self.tx().await?;
-
- // There is no index on [validation_token]. Is it useful?
- let user_id = match sqlx::query_as::<_, (i64, DateTime<Utc>)>(
- "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1",
- )
- .bind(token)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some((id, validation_token_datetime)) => {
- if Utc::now() - validation_token_datetime > validation_time {
- return Ok(ValidationResult::ValidationExpired);
- }
- sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1")
- .bind(id)
- .execute(&mut *tx)
- .await?;
- id
- }
- None => return Ok(ValidationResult::UnknownUser),
- };
-
- let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?;
-
- tx.commit().await?;
- Ok(ValidationResult::Ok(token, user_id))
- }
-
- pub async fn sign_in(
- &self,
- email: &str,
- password: &str,
- ip: &str,
- user_agent: &str,
- ) -> Result<SignInResult> {
- let mut tx = self.tx().await?;
- match sqlx::query_as::<_, (i64, String, Option<String>)>(
- "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1",
- )
- .bind(email)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some((id, stored_password, validation_token)) => {
- if validation_token.is_some() {
- Ok(SignInResult::AccountNotValidated)
- } else if verify_password(password, &stored_password)
- .map_err(DBError::from_dyn_error)?
- {
- let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?;
- tx.commit().await?;
- Ok(SignInResult::Ok(token, id))
- } else {
- Ok(SignInResult::WrongPassword)
- }
- }
- None => Ok(SignInResult::UserNotFound),
- }
- }
-
- pub async fn authentication(
- &self,
- token: &str,
- ip: &str,
- user_agent: &str,
- ) -> Result<AuthenticationResult> {
- let mut tx = self.tx().await?;
- match sqlx::query_as::<_, (i64, i64)>(
- "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1",
- )
- .bind(token)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some((login_id, user_id)) => {
- sqlx::query(
- r#"
-UPDATE [UserLoginToken]
-SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4
-WHERE [id] = $1
- "#,
- )
- .bind(login_id)
- .bind(Utc::now())
- .bind(ip)
- .bind(user_agent)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- Ok(AuthenticationResult::Ok(user_id))
- }
- None => Ok(AuthenticationResult::NotValidToken),
- }
- }
-
- pub async fn sign_out(&self, token: &str) -> Result<()> {
- let mut tx = self.tx().await?;
- match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
- .bind(token)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some(login_id) => {
- sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
- .bind(login_id)
- .execute(&mut *tx)
- .await?;
- tx.commit().await?;
- }
- None => (),
- }
- Ok(())
- }
-
- pub async fn get_token_reset_password(
- &self,
- email: &str,
- validation_time: Duration,
- ) -> Result<GetTokenResetPasswordResult> {
- let mut tx = self.tx().await?;
-
- if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
- r#"
-SELECT [password_reset_datetime]
-FROM [User]
-WHERE [email] = $1
- "#,
- )
- .bind(email)
- .fetch_optional(&mut *tx)
- .await?
- {
- if let Some(db_datetime) = db_datetime_nullable {
- if Utc::now() - db_datetime <= validation_time {
- return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset);
- }
- }
- } else {
- return Ok(GetTokenResetPasswordResult::EmailUnknown);
- }
-
- let token = generate_token();
-
- sqlx::query(
- r#"
-UPDATE [User]
-SET [password_reset_token] = $2, [password_reset_datetime] = $3
-WHERE [email] = $1
- "#,
- )
- .bind(email)
- .bind(&token)
- .bind(Utc::now())
- .execute(&mut *tx)
- .await?;
-
- tx.commit().await?;
-
- Ok(GetTokenResetPasswordResult::Ok(token))
- }
-
- pub async fn reset_password(
- &self,
- new_password: &str,
- token: &str,
- validation_time: Duration,
- ) -> Result<ResetPasswordResult> {
- let mut tx = self.tx().await?;
- // There is no index on [password_reset_token]. Is it useful?
- if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
- r#"
-SELECT [id], [password_reset_datetime]
-FROM [User]
-WHERE [password_reset_token] = $1
- "#,
- )
- .bind(token)
- .fetch_one(&mut *tx)
- .await?
- {
- if Utc::now() - db_datetime > validation_time {
- return Ok(ResetPasswordResult::ResetTokenExpired);
- }
-
- // Remove all login tokens (for security reasons).
- sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1")
- .bind(user_id)
- .execute(&mut *tx)
- .await?;
-
- let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
-
- sqlx::query(
- r#"
-UPDATE [User]
-SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL
-WHERE [id] = $1
- "#,
- )
- .bind(user_id)
- .bind(hashed_new_password)
- .execute(&mut *tx)
- .await?;
-
- tx.commit().await?;
-
- Ok(ResetPasswordResult::Ok)
- } else {
- Err(DBError::Other(
- "Can't reset password: stored token or datetime not set (NULL)".to_string(),
- ))
- }
- }
-
- pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
- let mut tx = self.tx().await?;
-
- match sqlx::query_scalar::<_, i64>(
- r#"
-SELECT [Recipe].[id] FROM [Recipe]
-LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id]
-LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
-WHERE [Recipe].[user_id] = $1
- AND [Recipe].[title] = ''
- AND [Recipe].[estimate_time] IS NULL
- AND [Recipe].[description] = ''
- AND [Image].[id] IS NULL
- AND [Group].[id] IS NULL
- "#,
- )
- .bind(user_id)
- .fetch_optional(&mut *tx)
- .await?
- {
- Some(recipe_id) => Ok(recipe_id),
- None => {
- let db_result =
- sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')")
- .bind(user_id)
- .execute(&mut *tx)
- .await?;
-
- tx.commit().await?;
- Ok(db_result.last_insert_rowid())
- }
- }
- }
-
- pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> {
- sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1")
- .bind(recipe_id)
- .bind(title)
- .execute(&self.pool)
- .await
- .map(|_| ())
- .map_err(DBError::from)
- }
-
- pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> {
- sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1")
- .bind(recipe_id)
- .bind(description)
- .execute(&self.pool)
- .await
- .map(|_| ())
- .map_err(DBError::from)
- }
-
/// Execute a given SQL file.
pub async fn execute_file<P: AsRef<Path> + fmt::Display>(&self, file: P) -> Result<()> {
let sql = load_sql_file(file)?;
.map(|db_result| db_result.rows_affected())
.map_err(DBError::from)
}
-
- // Return the token.
- async fn create_login_token(
- tx: &mut sqlx::Transaction<'_, Sqlite>,
- user_id: i64,
- ip: &str,
- user_agent: &str,
- ) -> Result<String> {
- let token = generate_token();
- sqlx::query(
- r#"
-INSERT INTO [UserLoginToken]
-([user_id], [last_login_datetime], [token], [ip], [user_agent])
-VALUES ($1, $2, $3, $4, $5)
- "#,
- )
- .bind(user_id)
- .bind(Utc::now())
- .bind(&token)
- .bind(ip)
- .bind(user_agent)
- .execute(&mut **tx)
- .await?;
- Ok(token)
- }
}
fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
Ok(sql)
}
-fn generate_token() -> String {
- Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[tokio::test]
- async fn sign_up() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- match connection.sign_up("paul@atreides.com", "12345").await? {
- SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_to_an_already_existing_user() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- connection.execute_sql(
- sqlx::query(
- r#"
-INSERT INTO
- [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
- VALUES (
- 1,
- 'paul@atreides.com',
- 'paul',
- '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
- 0,
- NULL
- );
- "#)).await?;
- match connection.sign_up("paul@atreides.com", "12345").await? {
- SignUpResult::UserAlreadyExists => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_and_sign_in_without_validation() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
- let password = "12345";
-
- match connection.sign_up(email, password).await? {
- SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
- other => panic!("{:?}", other),
- }
-
- match connection
- .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
- .await?
- {
- SignInResult::AccountNotValidated => (), // Nominal case.
- other => panic!("{:?}", other),
- }
-
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- let token = generate_token();
- connection.execute_sql(
- sqlx::query(
- r#"
-INSERT INTO [User]
- ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES (
- 1,
- 'paul@atreides.com',
- 'paul',
- '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
- 0,
- $1
-)
- "#
- ).bind(token)).await?;
- match connection.sign_up("paul@atreides.com", "12345").await? {
- SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_at_time() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
- match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla/5.0",
- )
- .await?
- {
- ValidationResult::Ok(_, _) => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_too_late() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- let validation_token = match connection
- .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1))
- .await?
- {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
- match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla/5.0",
- )
- .await?
- {
- ValidationResult::ValidationExpired => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_with_bad_token() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
- let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
- let random_token = generate_token();
- match connection
- .validation(
- &random_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla/5.0",
- )
- .await?
- {
- ValidationResult::UnknownUser => (), // Nominal case.
- other => panic!("{:?}", other),
- }
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_then_sign_in() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
- let password = "12345";
-
- // Sign up.
- let validation_token = match connection.sign_up(email, password).await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
-
- // Validation.
- match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla/5.0",
- )
- .await?
- {
- ValidationResult::Ok(_, _) => (),
- other => panic!("{:?}", other),
- };
-
- // Sign in.
- match connection
- .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
- .await?
- {
- SignInResult::Ok(_, _) => (), // Nominal case.
- other => panic!("{:?}", other),
- }
-
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_then_authentication() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
- let password = "12345";
-
- // Sign up.
- let validation_token = match connection.sign_up(email, password).await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
-
- // Validation.
- let (authentication_token, _user_id) = match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla",
- )
- .await?
- {
- ValidationResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
-
- // Check user login information.
- let user_login_info_1 = connection
- .get_user_login_info(&authentication_token)
- .await?;
- assert_eq!(user_login_info_1.ip, "127.0.0.1");
- assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
- // Authentication.
- let _user_id = match connection
- .authentication(&authentication_token, "192.168.1.1", "Chrome")
- .await?
- {
- AuthenticationResult::Ok(user_id) => user_id, // Nominal case.
- other => panic!("{:?}", other),
- };
-
- // Check user login information.
- let user_login_info_2 = connection
- .get_user_login_info(&authentication_token)
- .await?;
-
- assert_eq!(user_login_info_2.ip, "192.168.1.1");
- assert_eq!(user_login_info_2.user_agent, "Chrome");
-
- Ok(())
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
- let password = "12345";
-
- // Sign up.
- let validation_token = match connection.sign_up(email, password).await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
-
- // Validation.
- let (authentication_token_1, user_id_1) = match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla",
- )
- .await?
- {
- ValidationResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
-
- // Check user login information.
- let user_login_info_1 = connection
- .get_user_login_info(&authentication_token_1)
- .await?;
- assert_eq!(user_login_info_1.ip, "127.0.0.1");
- assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
- // Sign out.
- connection.sign_out(&authentication_token_1).await?;
-
- // Sign in.
- let (authentication_token_2, user_id_2) = match connection
- .sign_in(email, password, "192.168.1.1", "Chrome")
- .await?
- {
- SignInResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
-
- assert_eq!(user_id_1, user_id_2);
- assert_ne!(authentication_token_1, authentication_token_2);
-
- // Check user login information.
- let user_login_info_2 = connection
- .get_user_login_info(&authentication_token_2)
- .await?;
-
- assert_eq!(user_login_info_2.ip, "192.168.1.1");
- assert_eq!(user_login_info_2.user_agent, "Chrome");
-
- Ok(())
- }
-
- #[tokio::test]
- async fn ask_to_reset_password_for_unknown_email() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
-
- // Ask for password reset.
- match connection
- .get_token_reset_password(email, Duration::hours(1))
- .await?
- {
- GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case.
- other => panic!("{:?}", other),
- }
- }
-
- #[tokio::test]
- async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- let email = "paul@atreides.com";
- let password = "12345";
- let new_password = "54321";
-
- // Sign up.
- let validation_token = match connection.sign_up(email, password).await? {
- SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
- other => panic!("{:?}", other),
- };
-
- // Validation.
- let (authentication_token_1, user_id_1) = match connection
- .validation(
- &validation_token,
- Duration::hours(1),
- "127.0.0.1",
- "Mozilla",
- )
- .await?
- {
- ValidationResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
-
- // Check user login information.
- let user_login_info_1 = connection
- .get_user_login_info(&authentication_token_1)
- .await?;
- assert_eq!(user_login_info_1.ip, "127.0.0.1");
- assert_eq!(user_login_info_1.user_agent, "Mozilla");
-
- // Sign out.
- connection.sign_out(&authentication_token_1).await?;
-
- // Ask for password reset.
- let token = match connection
- .get_token_reset_password(email, Duration::hours(1))
- .await?
- {
- GetTokenResetPasswordResult::Ok(token) => token,
- other => panic!("{:?}", other),
- };
-
- connection
- .reset_password(&new_password, &token, Duration::hours(1))
- .await?;
+// #[cfg(test)]
+// mod tests {
+// use super::*;
- // Sign in.
- let (authentication_token_2, user_id_2) = match connection
- .sign_in(email, new_password, "192.168.1.1", "Chrome")
- .await?
- {
- SignInResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
-
- assert_eq!(user_id_1, user_id_2);
- assert_ne!(authentication_token_1, authentication_token_2);
-
- // Check user login information.
- let user_login_info_2 = connection
- .get_user_login_info(&authentication_token_2)
- .await?;
-
- assert_eq!(user_login_info_2.ip, "192.168.1.1");
- assert_eq!(user_login_info_2.user_agent, "Chrome");
-
- Ok(())
- }
-
- #[tokio::test]
- async fn update_user() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- connection.execute_sql(
- sqlx::query(
- r#"
-INSERT INTO [User]
- ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES
- ($1, $2, $3, $4, $5, $6)
- "#
- )
- .bind(1)
- .bind("paul@atreides.com")
- .bind("paul")
- .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
- .bind("2022-11-29 22:05:04.121407300+00:00")
- .bind(None::<&str>) // 'null'.
- ).await?;
-
- let user = connection.load_user(1).await?.unwrap();
-
- assert_eq!(user.name, "paul");
- assert_eq!(user.email, "paul@atreides.com");
-
- if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection
- .update_user(
- 1,
- Some("muaddib@fremen.com"),
- Some("muaddib"),
- Some("Chani"),
- )
- .await?
- {
- let (_authentication_token_1, user_id_1) = match connection
- .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")
- .await?
- {
- ValidationResult::Ok(token, user_id) => (token, user_id),
- other => panic!("{:?}", other),
- };
- assert_eq!(user_id_1, 1);
- } else {
- panic!("A revalidation token must be created when changin e-mail");
- }
-
- let user = connection.load_user(1).await?.unwrap();
-
- assert_eq!(user.name, "muaddib");
- assert_eq!(user.email, "muaddib@fremen.com");
-
- // Tests if password has been updated correctly.
- if let SignInResult::Ok(_token, id) = connection
- .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0")
- .await?
- {
- assert_eq!(id, 1);
- } else {
- panic!("Can't sign in");
- }
-
- Ok(())
- }
-
- #[tokio::test]
- async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
- let connection = Connection::new_in_memory().await?;
-
- connection.execute_sql(
- sqlx::query(
- r#"
-INSERT INTO [User]
- ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
-VALUES
- ($1, $2, $3, $4, $5, $6)
- "#
- )
- .bind(1)
- .bind("paul@atreides.com")
- .bind("paul")
- .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
- .bind("2022-11-29 22:05:04.121407300+00:00")
- .bind(None::<&str>) // 'null'.
- ).await?;
-
- match connection.create_recipe(2).await {
- Err(DBError::Sqlx(sqlx::Error::Database(err))) => {
- // SQLITE_CONSTRAINT_FOREIGNKEY
- // https://www.sqlite.org/rescode.html#constraint_foreignkey
- assert_eq!(err.code(), Some(std::borrow::Cow::from("787")));
- } // Nominal case. TODO: check 'err' value.
- other => panic!(
- "Creating a recipe with an inexistant user must fail: {:?}",
- other
- ),
- }
-
- let recipe_id = connection.create_recipe(1).await?;
- assert_eq!(recipe_id, 1);
-
- connection.set_recipe_title(recipe_id, "Crêpe").await?;
- let recipe = connection.get_recipe(recipe_id).await?.unwrap();
- assert_eq!(recipe.title, "Crêpe".to_string());
-
- Ok(())
- }
-}
+// }
--- /dev/null
+use super::{model, Connection, DBError, Result};
+
+impl Connection {
+ pub async fn get_all_recipe_titles(&self) -> Result<Vec<(i64, String)>> {
+ sqlx::query_as("SELECT [id], [title] FROM [Recipe] ORDER BY [title]")
+ .fetch_all(&self.pool)
+ .await
+ .map_err(DBError::from)
+ }
+
+ pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
+ sqlx::query_as(
+ r#"
+SELECT [id], [user_id], [title], [description]
+FROM [Recipe] WHERE [id] = $1
+ "#,
+ )
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(DBError::from)
+ }
+
+ pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
+ let mut tx = self.tx().await?;
+
+ match sqlx::query_scalar::<_, i64>(
+ r#"
+SELECT [Recipe].[id] FROM [Recipe]
+LEFT JOIN [Image] ON [Image].[recipe_id] = [Recipe].[id]
+LEFT JOIN [Group] ON [Group].[recipe_id] = [Recipe].[id]
+WHERE [Recipe].[user_id] = $1
+ AND [Recipe].[title] = ''
+ AND [Recipe].[estimate_time] IS NULL
+ AND [Recipe].[description] = ''
+ AND [Image].[id] IS NULL
+ AND [Group].[id] IS NULL
+ "#,
+ )
+ .bind(user_id)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some(recipe_id) => Ok(recipe_id),
+ None => {
+ let db_result =
+ sqlx::query("INSERT INTO [Recipe] ([user_id], [title]) VALUES ($1, '')")
+ .bind(user_id)
+ .execute(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+ Ok(db_result.last_insert_rowid())
+ }
+ }
+ }
+
+ pub async fn set_recipe_title(&self, recipe_id: i64, title: &str) -> Result<()> {
+ sqlx::query("UPDATE [Recipe] SET [title] = $2 WHERE [id] = $1")
+ .bind(recipe_id)
+ .bind(title)
+ .execute(&self.pool)
+ .await
+ .map(|_| ())
+ .map_err(DBError::from)
+ }
+
+ pub async fn set_recipe_description(&self, recipe_id: i64, description: &str) -> Result<()> {
+ sqlx::query("UPDATE [Recipe] SET [description] = $2 WHERE [id] = $1")
+ .bind(recipe_id)
+ .bind(description)
+ .execute(&self.pool)
+ .await
+ .map(|_| ())
+ .map_err(DBError::from)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ connection.execute_sql(
+ sqlx::query(
+ r#"
+INSERT INTO [User]
+ ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES
+ ($1, $2, $3, $4, $5, $6)
+ "#
+ )
+ .bind(1)
+ .bind("paul@atreides.com")
+ .bind("paul")
+ .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
+ .bind("2022-11-29 22:05:04.121407300+00:00")
+ .bind(None::<&str>) // 'null'.
+ ).await?;
+
+ match connection.create_recipe(2).await {
+ Err(DBError::Sqlx(sqlx::Error::Database(err))) => {
+ // SQLITE_CONSTRAINT_FOREIGNKEY
+ // https://www.sqlite.org/rescode.html#constraint_foreignkey
+ assert_eq!(err.code(), Some(std::borrow::Cow::from("787")));
+ } // Nominal case. TODO: check 'err' value.
+ other => panic!(
+ "Creating a recipe with an inexistant user must fail: {:?}",
+ other
+ ),
+ }
+
+ let recipe_id = connection.create_recipe(1).await?;
+ assert_eq!(recipe_id, 1);
+
+ connection.set_recipe_title(recipe_id, "Crêpe").await?;
+ let recipe = connection.get_recipe(recipe_id).await?.unwrap();
+ assert_eq!(recipe.title, "Crêpe".to_string());
+
+ Ok(())
+ }
+}
--- /dev/null
+use chrono::{prelude::*, Duration};
+use rand::distributions::{Alphanumeric, DistString};
+use sqlx::Sqlite;
+
+use super::{model, Connection, DBError, Result};
+use crate::{
+ consts,
+ hash::{hash, verify_password},
+};
+
+#[derive(Debug)]
+pub enum SignUpResult {
+ UserAlreadyExists,
+ UserCreatedWaitingForValidation(String), // Validation token.
+}
+
+#[derive(Debug)]
+pub enum UpdateUserResult {
+ EmailAlreadyTaken,
+ UserUpdatedWaitingForRevalidation(String), // Validation token.
+ Ok,
+}
+
+#[derive(Debug)]
+pub enum ValidationResult {
+ UnknownUser,
+ ValidationExpired,
+ Ok(String, i64), // Returns token and user id.
+}
+
+#[derive(Debug)]
+pub enum SignInResult {
+ UserNotFound,
+ WrongPassword,
+ AccountNotValidated,
+ Ok(String, i64), // Returns token and user id.
+}
+
+#[derive(Debug)]
+pub enum AuthenticationResult {
+ NotValidToken,
+ Ok(i64), // Returns user id.
+}
+
+#[derive(Debug)]
+pub enum GetTokenResetPasswordResult {
+ PasswordAlreadyReset,
+ EmailUnknown,
+ Ok(String),
+}
+
+#[derive(Debug)]
+pub enum ResetPasswordResult {
+ ResetTokenExpired,
+ Ok,
+}
+
+fn generate_token() -> String {
+ Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
+}
+
+impl Connection {
+ #[cfg(test)]
+ pub async fn get_user_login_info(&self, token: &str) -> Result<model::UserLoginInfo> {
+ sqlx::query_as(
+ r#"
+SELECT [last_login_datetime], [ip], [user_agent]
+FROM [UserLoginToken] WHERE [token] = $1
+ "#,
+ )
+ .bind(token)
+ .fetch_one(&self.pool)
+ .await
+ .map_err(DBError::from)
+ }
+
+ pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
+ sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
+ .bind(user_id)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(DBError::from)
+ }
+
+ /// If a new email is given and it doesn't match the current one then it has to be
+ /// Revalidated.
+ pub async fn update_user(
+ &self,
+ user_id: i64,
+ new_email: Option<&str>,
+ new_name: Option<&str>,
+ new_password: Option<&str>,
+ ) -> Result<UpdateUserResult> {
+ let mut tx = self.tx().await?;
+ let hashed_new_password = new_password.map(|p| hash(p).unwrap());
+
+ let (email, name, hashed_password) = sqlx::query_as::<_, (String, String, String)>(
+ "SELECT [email], [name], [password] FROM [User] WHERE [id] = $1",
+ )
+ .bind(user_id)
+ .fetch_one(&mut *tx)
+ .await?;
+
+ let email_changed = new_email.is_some_and(|new_email| new_email != email);
+
+ // Check if email not already taken.
+ let validation_token = if email_changed {
+ if sqlx::query_scalar::<_, i64>(
+ r#"
+SELECT COUNT(*)
+FROM [User]
+WHERE [email] = $1
+ "#,
+ )
+ .bind(new_email.unwrap())
+ .fetch_one(&mut *tx)
+ .await?
+ > 0
+ {
+ return Ok(UpdateUserResult::EmailAlreadyTaken);
+ }
+
+ let token = Some(generate_token());
+ sqlx::query(
+ r#"
+UPDATE [User]
+SET [validation_token] = $2, [validation_token_datetime] = $3
+WHERE [id] = $1
+ "#,
+ )
+ .bind(user_id)
+ .bind(&token)
+ .bind(Utc::now())
+ .execute(&mut *tx)
+ .await?;
+ token
+ } else {
+ None
+ };
+
+ sqlx::query(
+ r#"
+UPDATE [User]
+SET [email] = $2, [name] = $3, [password] = $4
+WHERE [id] = $1
+ "#,
+ )
+ .bind(user_id)
+ .bind(new_email.unwrap_or(&email))
+ .bind(new_name.unwrap_or(&name))
+ .bind(hashed_new_password.unwrap_or(hashed_password))
+ .execute(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+
+ Ok(if let Some(validation_token) = validation_token {
+ UpdateUserResult::UserUpdatedWaitingForRevalidation(validation_token)
+ } else {
+ UpdateUserResult::Ok
+ })
+ }
+
+ pub async fn sign_up(&self, email: &str, password: &str) -> Result<SignUpResult> {
+ self.sign_up_with_given_time(email, password, Utc::now())
+ .await
+ }
+
+ async fn sign_up_with_given_time(
+ &self,
+ email: &str,
+ password: &str,
+ datetime: DateTime<Utc>,
+ ) -> Result<SignUpResult> {
+ let mut tx = self.tx().await?;
+
+ let token = match sqlx::query_as::<_, (i64, Option<String>)>(
+ r#"
+SELECT [id], [validation_token]
+FROM [User] WHERE [email] = $1
+ "#,
+ )
+ .bind(email)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some((id, validation_token)) => {
+ if validation_token.is_none() {
+ return Ok(SignUpResult::UserAlreadyExists);
+ }
+ let token = generate_token();
+ let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+ sqlx::query(
+ r#"
+UPDATE [User]
+SET [validation_token] = $2, [validation_token_datetime] = $3, [password] = $4
+WHERE [id] = $1
+ "#,
+ )
+ .bind(id)
+ .bind(&token)
+ .bind(datetime)
+ .bind(hashed_password)
+ .execute(&mut *tx)
+ .await?;
+ token
+ }
+ None => {
+ let token = generate_token();
+ let hashed_password = hash(password).map_err(|e| DBError::from_dyn_error(e))?;
+ sqlx::query(
+ r#"
+INSERT INTO [User]
+([email], [validation_token], [validation_token_datetime], [password])
+VALUES ($1, $2, $3, $4)
+ "#,
+ )
+ .bind(email)
+ .bind(&token)
+ .bind(datetime)
+ .bind(hashed_password)
+ .execute(&mut *tx)
+ .await?;
+ token
+ }
+ };
+
+ tx.commit().await?;
+
+ Ok(SignUpResult::UserCreatedWaitingForValidation(token))
+ }
+
+ pub async fn validation(
+ &self,
+ token: &str,
+ validation_time: Duration,
+ ip: &str,
+ user_agent: &str,
+ ) -> Result<ValidationResult> {
+ let mut tx = self.tx().await?;
+
+ // There is no index on [validation_token]. Is it useful?
+ let user_id = match sqlx::query_as::<_, (i64, DateTime<Utc>)>(
+ "SELECT [id], [validation_token_datetime] FROM [User] WHERE [validation_token] = $1",
+ )
+ .bind(token)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some((id, validation_token_datetime)) => {
+ if Utc::now() - validation_token_datetime > validation_time {
+ return Ok(ValidationResult::ValidationExpired);
+ }
+ sqlx::query("UPDATE [User] SET [validation_token] = NULL WHERE [id] = $1")
+ .bind(id)
+ .execute(&mut *tx)
+ .await?;
+ id
+ }
+ None => return Ok(ValidationResult::UnknownUser),
+ };
+
+ let token = Self::create_login_token(&mut tx, user_id, ip, user_agent).await?;
+
+ tx.commit().await?;
+ Ok(ValidationResult::Ok(token, user_id))
+ }
+
+ pub async fn sign_in(
+ &self,
+ email: &str,
+ password: &str,
+ ip: &str,
+ user_agent: &str,
+ ) -> Result<SignInResult> {
+ let mut tx = self.tx().await?;
+ match sqlx::query_as::<_, (i64, String, Option<String>)>(
+ "SELECT [id], [password], [validation_token] FROM [User] WHERE [email] = $1",
+ )
+ .bind(email)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some((id, stored_password, validation_token)) => {
+ if validation_token.is_some() {
+ Ok(SignInResult::AccountNotValidated)
+ } else if verify_password(password, &stored_password)
+ .map_err(DBError::from_dyn_error)?
+ {
+ let token = Self::create_login_token(&mut tx, id, ip, user_agent).await?;
+ tx.commit().await?;
+ Ok(SignInResult::Ok(token, id))
+ } else {
+ Ok(SignInResult::WrongPassword)
+ }
+ }
+ None => Ok(SignInResult::UserNotFound),
+ }
+ }
+
+ pub async fn authentication(
+ &self,
+ token: &str,
+ ip: &str,
+ user_agent: &str,
+ ) -> Result<AuthenticationResult> {
+ let mut tx = self.tx().await?;
+ match sqlx::query_as::<_, (i64, i64)>(
+ "SELECT [id], [user_id] FROM [UserLoginToken] WHERE [token] = $1",
+ )
+ .bind(token)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some((login_id, user_id)) => {
+ sqlx::query(
+ r#"
+UPDATE [UserLoginToken]
+SET [last_login_datetime] = $2, [ip] = $3, [user_agent] = $4
+WHERE [id] = $1
+ "#,
+ )
+ .bind(login_id)
+ .bind(Utc::now())
+ .bind(ip)
+ .bind(user_agent)
+ .execute(&mut *tx)
+ .await?;
+ tx.commit().await?;
+ Ok(AuthenticationResult::Ok(user_id))
+ }
+ None => Ok(AuthenticationResult::NotValidToken),
+ }
+ }
+
+ pub async fn sign_out(&self, token: &str) -> Result<()> {
+ let mut tx = self.tx().await?;
+ match sqlx::query_scalar::<_, i64>("SELECT [id] FROM [UserLoginToken] WHERE [token] = $1")
+ .bind(token)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ Some(login_id) => {
+ sqlx::query("DELETE FROM [UserLoginToken] WHERE [id] = $1")
+ .bind(login_id)
+ .execute(&mut *tx)
+ .await?;
+ tx.commit().await?;
+ }
+ None => (),
+ }
+ Ok(())
+ }
+
+ pub async fn get_token_reset_password(
+ &self,
+ email: &str,
+ validation_time: Duration,
+ ) -> Result<GetTokenResetPasswordResult> {
+ let mut tx = self.tx().await?;
+
+ if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
+ r#"
+SELECT [password_reset_datetime]
+FROM [User]
+WHERE [email] = $1
+ "#,
+ )
+ .bind(email)
+ .fetch_optional(&mut *tx)
+ .await?
+ {
+ if let Some(db_datetime) = db_datetime_nullable {
+ if Utc::now() - db_datetime <= validation_time {
+ return Ok(GetTokenResetPasswordResult::PasswordAlreadyReset);
+ }
+ }
+ } else {
+ return Ok(GetTokenResetPasswordResult::EmailUnknown);
+ }
+
+ let token = generate_token();
+
+ sqlx::query(
+ r#"
+UPDATE [User]
+SET [password_reset_token] = $2, [password_reset_datetime] = $3
+WHERE [email] = $1
+ "#,
+ )
+ .bind(email)
+ .bind(&token)
+ .bind(Utc::now())
+ .execute(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+
+ Ok(GetTokenResetPasswordResult::Ok(token))
+ }
+
+ pub async fn reset_password(
+ &self,
+ new_password: &str,
+ token: &str,
+ validation_time: Duration,
+ ) -> Result<ResetPasswordResult> {
+ let mut tx = self.tx().await?;
+ // There is no index on [password_reset_token]. Is it useful?
+ if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
+ r#"
+SELECT [id], [password_reset_datetime]
+FROM [User]
+WHERE [password_reset_token] = $1
+ "#,
+ )
+ .bind(token)
+ .fetch_one(&mut *tx)
+ .await?
+ {
+ if Utc::now() - db_datetime > validation_time {
+ return Ok(ResetPasswordResult::ResetTokenExpired);
+ }
+
+ // Remove all login tokens (for security reasons).
+ sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1")
+ .bind(user_id)
+ .execute(&mut *tx)
+ .await?;
+
+ let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
+
+ sqlx::query(
+ r#"
+UPDATE [User]
+SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL
+WHERE [id] = $1
+ "#,
+ )
+ .bind(user_id)
+ .bind(hashed_new_password)
+ .execute(&mut *tx)
+ .await?;
+
+ tx.commit().await?;
+
+ Ok(ResetPasswordResult::Ok)
+ } else {
+ Err(DBError::Other(
+ "Can't reset password: stored token or datetime not set (NULL)".to_string(),
+ ))
+ }
+ }
+
+ // Return the token.
+ async fn create_login_token(
+ tx: &mut sqlx::Transaction<'_, Sqlite>,
+ user_id: i64,
+ ip: &str,
+ user_agent: &str,
+ ) -> Result<String> {
+ let token = generate_token();
+ sqlx::query(
+ r#"
+INSERT INTO [UserLoginToken]
+([user_id], [last_login_datetime], [token], [ip], [user_agent])
+VALUES ($1, $2, $3, $4, $5)
+ "#,
+ )
+ .bind(user_id)
+ .bind(Utc::now())
+ .bind(&token)
+ .bind(ip)
+ .bind(user_agent)
+ .execute(&mut **tx)
+ .await?;
+ Ok(token)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn sign_up() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ match connection.sign_up("paul@atreides.com", "12345").await? {
+ SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_to_an_already_existing_user() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ connection.execute_sql(
+ sqlx::query(
+ r#"
+INSERT INTO
+ [User] ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+ VALUES (
+ 1,
+ 'paul@atreides.com',
+ 'paul',
+ '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
+ 0,
+ NULL
+ );
+ "#)).await?;
+ match connection.sign_up("paul@atreides.com", "12345").await? {
+ SignUpResult::UserAlreadyExists => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_and_sign_in_without_validation() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+ let password = "12345";
+
+ match connection.sign_up(email, password).await? {
+ SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+
+ match connection
+ .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
+ .await?
+ {
+ SignInResult::AccountNotValidated => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_to_an_unvalidated_already_existing_user() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ let token = generate_token();
+ connection.execute_sql(
+ sqlx::query(
+ r#"
+INSERT INTO [User]
+ ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES (
+ 1,
+ 'paul@atreides.com',
+ 'paul',
+ '$argon2id$v=19$m=4096,t=3,p=1$1vtXcacYjUHZxMrN6b2Xng$wW8Z59MIoMcsIljnjHmxn3EBcc5ymEySZPUVXHlRxcY',
+ 0,
+ $1
+)
+ "#
+ ).bind(token)).await?;
+ match connection.sign_up("paul@atreides.com", "12345").await? {
+ SignUpResult::UserCreatedWaitingForValidation(_) => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_at_time() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ let validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+ match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla/5.0",
+ )
+ .await?
+ {
+ ValidationResult::Ok(_, _) => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_too_late() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ let validation_token = match connection
+ .sign_up_with_given_time("paul@atreides.com", "12345", Utc::now() - Duration::days(1))
+ .await?
+ {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+ match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla/5.0",
+ )
+ .await?
+ {
+ ValidationResult::ValidationExpired => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_with_bad_token() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+ let _validation_token = match connection.sign_up("paul@atreides.com", "12345").await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+ let random_token = generate_token();
+ match connection
+ .validation(
+ &random_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla/5.0",
+ )
+ .await?
+ {
+ ValidationResult::UnknownUser => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_then_sign_in() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+ let password = "12345";
+
+ // Sign up.
+ let validation_token = match connection.sign_up(email, password).await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+
+ // Validation.
+ match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla/5.0",
+ )
+ .await?
+ {
+ ValidationResult::Ok(_, _) => (),
+ other => panic!("{:?}", other),
+ };
+
+ // Sign in.
+ match connection
+ .sign_in(email, password, "127.0.0.1", "Mozilla/5.0")
+ .await?
+ {
+ SignInResult::Ok(_, _) => (), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_then_authentication() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+ let password = "12345";
+
+ // Sign up.
+ let validation_token = match connection.sign_up(email, password).await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+
+ // Validation.
+ let (authentication_token, _user_id) = match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla",
+ )
+ .await?
+ {
+ ValidationResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+
+ // Check user login information.
+ let user_login_info_1 = connection
+ .get_user_login_info(&authentication_token)
+ .await?;
+ assert_eq!(user_login_info_1.ip, "127.0.0.1");
+ assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+ // Authentication.
+ let _user_id = match connection
+ .authentication(&authentication_token, "192.168.1.1", "Chrome")
+ .await?
+ {
+ AuthenticationResult::Ok(user_id) => user_id, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+
+ // Check user login information.
+ let user_login_info_2 = connection
+ .get_user_login_info(&authentication_token)
+ .await?;
+
+ assert_eq!(user_login_info_2.ip, "192.168.1.1");
+ assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_then_sign_out_then_sign_in() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+ let password = "12345";
+
+ // Sign up.
+ let validation_token = match connection.sign_up(email, password).await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+
+ // Validation.
+ let (authentication_token_1, user_id_1) = match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla",
+ )
+ .await?
+ {
+ ValidationResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+
+ // Check user login information.
+ let user_login_info_1 = connection
+ .get_user_login_info(&authentication_token_1)
+ .await?;
+ assert_eq!(user_login_info_1.ip, "127.0.0.1");
+ assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+ // Sign out.
+ connection.sign_out(&authentication_token_1).await?;
+
+ // Sign in.
+ let (authentication_token_2, user_id_2) = match connection
+ .sign_in(email, password, "192.168.1.1", "Chrome")
+ .await?
+ {
+ SignInResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+
+ assert_eq!(user_id_1, user_id_2);
+ assert_ne!(authentication_token_1, authentication_token_2);
+
+ // Check user login information.
+ let user_login_info_2 = connection
+ .get_user_login_info(&authentication_token_2)
+ .await?;
+
+ assert_eq!(user_login_info_2.ip, "192.168.1.1");
+ assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn ask_to_reset_password_for_unknown_email() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+
+ // Ask for password reset.
+ match connection
+ .get_token_reset_password(email, Duration::hours(1))
+ .await?
+ {
+ GetTokenResetPasswordResult::EmailUnknown => Ok(()), // Nominal case.
+ other => panic!("{:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ let email = "paul@atreides.com";
+ let password = "12345";
+ let new_password = "54321";
+
+ // Sign up.
+ let validation_token = match connection.sign_up(email, password).await? {
+ SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
+ other => panic!("{:?}", other),
+ };
+
+ // Validation.
+ let (authentication_token_1, user_id_1) = match connection
+ .validation(
+ &validation_token,
+ Duration::hours(1),
+ "127.0.0.1",
+ "Mozilla",
+ )
+ .await?
+ {
+ ValidationResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+
+ // Check user login information.
+ let user_login_info_1 = connection
+ .get_user_login_info(&authentication_token_1)
+ .await?;
+ assert_eq!(user_login_info_1.ip, "127.0.0.1");
+ assert_eq!(user_login_info_1.user_agent, "Mozilla");
+
+ // Sign out.
+ connection.sign_out(&authentication_token_1).await?;
+
+ // Ask for password reset.
+ let token = match connection
+ .get_token_reset_password(email, Duration::hours(1))
+ .await?
+ {
+ GetTokenResetPasswordResult::Ok(token) => token,
+ other => panic!("{:?}", other),
+ };
+
+ connection
+ .reset_password(&new_password, &token, Duration::hours(1))
+ .await?;
+
+ // Sign in.
+ let (authentication_token_2, user_id_2) = match connection
+ .sign_in(email, new_password, "192.168.1.1", "Chrome")
+ .await?
+ {
+ SignInResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+
+ assert_eq!(user_id_1, user_id_2);
+ assert_ne!(authentication_token_1, authentication_token_2);
+
+ // Check user login information.
+ let user_login_info_2 = connection
+ .get_user_login_info(&authentication_token_2)
+ .await?;
+
+ assert_eq!(user_login_info_2.ip, "192.168.1.1");
+ assert_eq!(user_login_info_2.user_agent, "Chrome");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn update_user() -> Result<()> {
+ let connection = Connection::new_in_memory().await?;
+
+ connection.execute_sql(
+ sqlx::query(
+ r#"
+INSERT INTO [User]
+ ([id], [email], [name], [password], [validation_token_datetime], [validation_token])
+VALUES
+ ($1, $2, $3, $4, $5, $6)
+ "#
+ )
+ .bind(1)
+ .bind("paul@atreides.com")
+ .bind("paul")
+ .bind("$argon2id$v=19$m=4096,t=3,p=1$G4fjepS05MkRbTqEImUdYg$GGziE8uVQe1L1oFHk37lBno10g4VISnVqynSkLCH3Lc")
+ .bind("2022-11-29 22:05:04.121407300+00:00")
+ .bind(None::<&str>) // 'null'.
+ ).await?;
+
+ let user = connection.load_user(1).await?.unwrap();
+
+ assert_eq!(user.name, "paul");
+ assert_eq!(user.email, "paul@atreides.com");
+
+ if let UpdateUserResult::UserUpdatedWaitingForRevalidation(token) = connection
+ .update_user(
+ 1,
+ Some("muaddib@fremen.com"),
+ Some("muaddib"),
+ Some("Chani"),
+ )
+ .await?
+ {
+ let (_authentication_token_1, user_id_1) = match connection
+ .validation(&token, Duration::hours(1), "127.0.0.1", "Mozilla/5.0")
+ .await?
+ {
+ ValidationResult::Ok(token, user_id) => (token, user_id),
+ other => panic!("{:?}", other),
+ };
+ assert_eq!(user_id_1, 1);
+ } else {
+ panic!("A revalidation token must be created when changin e-mail");
+ }
+
+ let user = connection.load_user(1).await?.unwrap();
+
+ assert_eq!(user.name, "muaddib");
+ assert_eq!(user.email, "muaddib@fremen.com");
+
+ // Tests if password has been updated correctly.
+ if let SignInResult::Ok(_token, id) = connection
+ .sign_in("muaddib@fremen.com", "Chani", "127.0.0.1", "Mozilla/5.0")
+ .await?
+ {
+ assert_eq!(id, 1);
+ } else {
+ panic!("Can't sign in");
+ }
+
+ Ok(())
+ }
+}
pub mod db;
+pub mod model;
mod utils;
--- /dev/null
+use chrono::prelude::*;
+
+#[derive(Debug, Clone)]
+pub struct User {
+ pub id: i64,
+ pub name: String,
+ pub email: String,
+}
+
+pub struct UserLoginInfo {
+ pub last_login_datetime: DateTime<Utc>,
+ pub ip: String,
+ pub user_agent: String,
+}
+
+pub struct Recipe {
+ pub id: i64,
+ pub user_id: i64,
+ pub title: String,
+ pub description: String,
+ pub estimate_time: Option<i32>, // [s].
+ pub difficulty: Difficulty,
+ pub lang: String,
+
+ //ingredients: Vec<Ingredient>, // For four people.
+ pub process: Vec<Group>,
+}
+
+impl Recipe {
+ pub fn empty(id: i64, user_id: i64) -> Recipe {
+ Self::new(id, user_id, String::new(), String::new())
+ }
+
+ pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {
+ Recipe {
+ id,
+ user_id,
+ title,
+ description,
+ estimate_time: None,
+ difficulty: Difficulty::Unknown,
+ lang: "en".to_string(),
+ process: Vec::new(),
+ }
+ }
+}
+
+pub struct Ingredient {
+ pub quantity: Option<Quantity>,
+ pub name: String,
+}
+
+pub struct Quantity {
+ pub value: f32,
+ pub unit: String,
+}
+
+pub struct Group {
+ pub name: Option<String>,
+ pub input: Vec<StepInput>,
+ pub steps: Vec<Step>,
+}
+
+pub struct Step {
+ pub action: String,
+}
+
+pub enum StepInput {
+ Ingredient(Ingredient),
+}
+
+pub enum Difficulty {
+ Unknown = 0,
+ Easy = 1,
+ Medium = 2,
+ Hard = 3,
+}
use sqlx::{sqlite::SqliteRow, FromRow, Row};
-use crate::model;
+use super::model;
impl FromRow<'_, SqliteRow> for model::Recipe {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
use askama::Template;
-use crate::model;
+use crate::data::model;
-#[derive(Template)]
-#[template(path = "home.html")]
-pub struct HomeTemplate {
- pub user: Option<model::User>,
- pub recipes: Vec<(i64, String)>,
- pub current_recipe_id: Option<i64>,
+pub struct Recipes {
+ pub list: Vec<(i64, String)>,
+ pub current_id: Option<i64>,
}
#[derive(Template)]
-#[template(path = "view_recipe.html")]
-pub struct ViewRecipeTemplate {
+#[template(path = "home.html")]
+pub struct HomeTemplate {
pub user: Option<model::User>,
- pub recipes: Vec<(i64, String)>,
- pub current_recipe_id: Option<i64>,
- pub current_recipe: model::Recipe,
+ pub recipes: Recipes,
}
#[derive(Template)]
pub message_email: String,
pub message_password: String,
}
+
+#[derive(Template)]
+#[template(path = "recipe_view.html")]
+pub struct RecipeViewTemplate {
+ pub user: Option<model::User>,
+ pub recipes: Recipes,
+ pub recipe: model::Recipe,
+}
+
+#[derive(Template)]
+#[template(path = "recipe_edit.html")]
+pub struct RecipeEditTemplate {
+ pub user: Option<model::User>,
+ pub recipes: Recipes,
+ pub recipe: model::Recipe,
+}
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::{event, Level};
-use data::db;
+use data::{db, model};
mod config;
mod consts;
mod email;
mod hash;
mod html_templates;
-mod model;
mod ron_extractor;
mod ron_utils;
mod services;
get(services::reset_password_get).post(services::reset_password_post),
)
// Recipes.
+ .route("/recipe/new", get(services::create_recipe))
+ // .route("/recipe/edit/:id", get(services::edit_recipe))
.route("/recipe/view/:id", get(services::view_recipe))
// User.
.route(
.authentication(token_cookie.value(), &client_ip, &client_user_agent)
.await
{
- Ok(db::AuthenticationResult::NotValidToken) => None,
- Ok(db::AuthenticationResult::Ok(user_id)) => {
+ Ok(db::user::AuthenticationResult::NotValidToken) => None,
+ Ok(db::user::AuthenticationResult::Ok(user_id)) => {
match connection.load_user(user_id).await {
Ok(user) => user,
Err(error) => {
// Set the creation datetime to 'now'.
con.execute_sql(
sqlx::query(
- "UPDATE [User] SET [creation_datetime] = ?1 WHERE [email] = 'paul@test.org'")
+ "UPDATE [User] SET [validation_token_datetime] = $1 WHERE [email] = 'paul@test.org'")
.bind(Utc::now())
)
.await
+++ /dev/null
-use chrono::prelude::*;\r
-\r
-#[derive(Debug, Clone)]\r
-pub struct User {\r
- pub id: i64,\r
- pub name: String,\r
- pub email: String,\r
-}\r
-\r
-pub struct UserLoginInfo {\r
- pub last_login_datetime: DateTime<Utc>,\r
- pub ip: String,\r
- pub user_agent: String,\r
-}\r
-\r
-pub struct Recipe {\r
- pub id: i64,\r
- pub user_id: i64,\r
- pub title: String,\r
- pub description: String,\r
- pub estimate_time: Option<i32>, // [min].\r
- pub difficulty: Difficulty,\r
-\r
- //ingredients: Vec<Ingredient>, // For four people.\r
- pub process: Vec<Group>,\r
-}\r
-\r
-impl Recipe {\r
- pub fn empty(id: i64, user_id: i64) -> Recipe {\r
- Self::new(id, user_id, String::new(), String::new())\r
- }\r
-\r
- pub fn new(id: i64, user_id: i64, title: String, description: String) -> Recipe {\r
- Recipe {\r
- id,\r
- user_id,\r
- title,\r
- description,\r
- estimate_time: None,\r
- difficulty: Difficulty::Unknown,\r
- process: Vec::new(),\r
- }\r
- }\r
-}\r
-\r
-pub struct Ingredient {\r
- pub quantity: Option<Quantity>,\r
- pub name: String,\r
-}\r
-\r
-pub struct Quantity {\r
- pub value: f32,\r
- pub unit: String,\r
-}\r
-\r
-pub struct Group {\r
- pub name: Option<String>,\r
- pub input: Vec<StepInput>,\r
- pub output: Vec<IntermediateSubstance>,\r
- pub steps: Vec<Step>,\r
-}\r
-\r
-pub struct Step {\r
- pub action: String,\r
-}\r
-\r
-pub struct IntermediateSubstance {\r
- pub name: String,\r
- pub quantity: Option<Quantity>,\r
-}\r
-\r
-pub enum StepInput {\r
- Ingredient(Ingredient),\r
- IntermediateSubstance(IntermediateSubstance),\r
-}\r
-\r
-pub enum Difficulty {\r
- Unknown,\r
- Easy,\r
- Medium,\r
- Hard,\r
-}\r
use tracing::{event, Level};
use crate::{
- config::Config, consts, data::db, email, html_templates::*, model, ron_utils, utils, AppState,
+ config::Config,
+ consts,
+ data::{db, model},
+ email,
+ html_templates::*,
+ ron_utils, utils, AppState,
};
pub mod ron;
Ok(HomeTemplate {
user,
- current_recipe_id: None,
- recipes,
+ recipes: Recipes {
+ list: recipes,
+ current_id: None,
+ }, // current_recipe_id: None,
+ // recipes,
})
}
-///// VIEW RECIPE /////
+///// 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(
) -> Result<Response> {
let recipes = connection.get_all_recipe_titles().await?;
match connection.get_recipe(recipe_id).await? {
- Some(recipe) => Ok(ViewRecipeTemplate {
+ Some(recipe) => Ok(RecipeViewTemplate {
user,
- current_recipe_id: Some(recipe.id),
- recipes,
- current_recipe: recipe,
+ recipes: Recipes {
+ list: recipes,
+ current_id: Some(recipe.id),
+ },
+ recipe,
}
.into_response()),
None => Ok(MessageTemplate::new_with_user(
.sign_up(&form_data.email, &form_data.password_1)
.await
{
- Ok(db::SignUpResult::UserAlreadyExists) => {
+ Ok(db::user::SignUpResult::UserAlreadyExists) => {
error_response(SignUpError::UserAlreadyExists, &form_data, user)
}
- Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => {
+ Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
)
.await?
{
- db::ValidationResult::Ok(token, user_id) => {
+ 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?;
),
))
}
- db::ValidationResult::ValidationExpired => Ok((
+ db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
user,
),
)),
- db::ValidationResult::UnknownUser => Ok((
+ db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
)),
)
.await?
{
- db::SignInResult::AccountNotValidated => Ok((
+ db::user::SignInResult::AccountNotValidated => Ok((
jar,
SignInFormTemplate {
user,
}
.into_response(),
)),
- db::SignInResult::UserNotFound | db::SignInResult::WrongPassword => Ok((
+ db::user::SignInResult::UserNotFound | db::user::SignInResult::WrongPassword => Ok((
jar,
SignInFormTemplate {
user,
}
.into_response(),
)),
- db::SignInResult::Ok(token, _user_id) => {
+ 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()))
}
)
.await
{
- Ok(db::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
+ Ok(db::user::GetTokenResetPasswordResult::PasswordAlreadyReset) => error_response(
AskResetPasswordError::EmailAlreadyReset,
&form_data.email,
user,
),
- Ok(db::GetTokenResetPasswordResult::EmailUnknown) => {
+ Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
}
- Ok(db::GetTokenResetPasswordResult::Ok(token)) => {
+ Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host);
match email::send_email(
&form_data.email,
)
.await
{
- Ok(db::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
+ Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
"Your password has been reset",
user,
)
.into_response()),
- Ok(db::ResetPasswordResult::ResetTokenExpired) => {
+ Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
)
.await
{
- Ok(db::UpdateUserResult::EmailAlreadyTaken) => {
+ Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
}
- Ok(db::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
+ Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host);
let email = form_data.email.clone();
match email::send_email(
}
}
}
- Ok(db::UpdateUserResult::Ok) => {
+ Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved";
}
Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
)
.await?
{
- db::ValidationResult::Ok(token, user_id) => {
+ 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?;
MessageTemplate::new_with_user("Email validation successful", user),
))
}
- db::ValidationResult::ValidationExpired => Ok((
+ 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::ValidationResult::UnknownUser => Ok((
+ db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email",
{% block main_container %}
<nav class="recipes-list">
<ul>
- {% for (id, title) in recipes %}
+ {% for (id, title) in recipes.list %}
<li>
- {% match current_recipe_id %}
+ {% match recipes.current_id %}
{# Don't know how to avoid
- repetition: comparing (using '==' or .eq()) current_recipe_id.unwrap() and id doesn't work.
+ repetition: comparing (using '==' or .eq()) recipes.current_recipe_id.unwrap() and id doesn't work.
Guards for match don't exist.
See: https://github.com/djc/askama/issues/752 #}
- {% when Some (current_id) %}
+ {% when Some(current_id) %}
{% if current_id == id %}
{% call recipe_item(id, title, "recipe-item-current") %}
{% else %}
id="title_field"
type="text"
name="title"
- value="{{ current_recipe.title }}"
+ value="{{ recipe.title }}"
autocapitalize="none"
autocomplete="title"
autofocus="autofocus" />
id="title_field"
type="text"
name="title"
- value="{{ current_recipe.description }}"
+ value="{{ recipe.description }}"
autocapitalize="none"
autocomplete="title"
autofocus="autofocus" />
--- /dev/null
+{% extends "base_with_list.html" %}
+
+{% block content %}
+
+<h2 class="recipe-title" >{{ recipe.title }}</h2>
+
+
+{% if user.is_some() && recipe.user_id == user.as_ref().unwrap().id %}
+ <a class="edit-recipe" href="/recipe/edit/{{ recipe.id }}" >Edit</a>
+{% endif %}
+
+{% if !recipe.description.is_empty() %}
+ <div class="recipe-description" >
+ {{ recipe.description.clone()|markdown }}
+ </div>
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
{{ message_email }}
<label for="input-password-1">Choose a password (minimum 8 characters)</label>
- <input id="input-password-1" type="password" name="password_1" />
+ <input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
<label for="input-password-2">Re-enter password</label>
- <input id="input-password-2" type="password" name="password_2" />
+ <input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
{{ message_password }}
+++ /dev/null
-{% extends "base_with_list.html" %}
-
-{% block content %}
-
-<h2 class="recipe-title" >{{ current_recipe.title }}</h2>
-
-
-{% if user.is_some() && current_recipe.user_id == user.as_ref().unwrap().id %}
- <a class="edit-recipe" href="/recipe/edit/{{ current_recipe.id }}" >Edit</a>
-{% endif %}
-
-{% if !current_recipe.description.is_empty() %}
- <div class="recipe-description" >
- {{ current_recipe.description.clone()|markdown }}
- </div>
-{% endif %}
-
-{% endblock %}
\ No newline at end of file