X-Git-Url: http://git.euphorik.ch/?p=recipes.git;a=blobdiff_plain;f=backend%2Fsrc%2Fservices.rs;fp=backend%2Fsrc%2Fservices.rs;h=df81c437b0224d098461a761704df75c54f57d1a;hp=0000000000000000000000000000000000000000;hb=d28e765e39e70ad2ab9a42885c786d5d8ba9ba40;hpb=8a3fef096d720666dc8a54789aee02250642d8a1 diff --git a/backend/src/services.rs b/backend/src/services.rs new file mode 100644 index 0000000..df81c43 --- /dev/null +++ b/backend/src/services.rs @@ -0,0 +1,452 @@ +use std::collections::HashMap; + +use actix_web::{http::{header, header::ContentType, StatusCode}, get, post, web, Responder, HttpRequest, HttpResponse, cookie::Cookie}; +use askama_actix::{Template, TemplateToResponse}; +use chrono::Duration; +use serde::Deserialize; +use log::{debug, error, log_enabled, info, Level}; + +use crate::utils; +use crate::email; +use crate::consts; +use crate::config::Config; +use crate::user::User; +use crate::model; +use crate::data::{db, asynchronous}; + +///// UTILS ///// + +fn get_ip_and_user_agent(req: &HttpRequest) -> (String, String) { + let ip = + match req.headers().get(consts::REVERSE_PROXY_IP_HTTP_FIELD) { + Some(v) => v.to_str().unwrap_or_default().to_string(), + None => req.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_default() + }; + + let user_agent = req.headers().get(header::USER_AGENT).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default().to_string(); + + (ip, user_agent) +} + +async fn get_current_user(req: &HttpRequest, connection: web::Data) -> Option { + let (client_ip, client_user_agent) = get_ip_and_user_agent(req); + + match req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) { + Some(token_cookie) => + match connection.authentication_async(token_cookie.value(), &client_ip, &client_user_agent).await { + Ok(db::AuthenticationResult::NotValidToken) => + // TODO: remove cookie? + None, + Ok(db::AuthenticationResult::Ok(user_id)) => + match connection.load_user_async(user_id).await { + Ok(user) => + Some(user), + Err(error) => { + error!("Error during authentication: {}", error); + None + } + }, + Err(error) => { + error!("Error during authentication: {}", error); + None + }, + }, + None => None + } +} + +type Result = std::result::Result; + +///// ERROR ///// + +#[derive(Debug)] +pub struct ServiceError { + status_code: StatusCode, + message: Option, +} + +impl From for ServiceError { + fn from(error: asynchronous::DBAsyncError) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl From for ServiceError { + fn from(error: email::Error) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl From for ServiceError { + fn from(error: actix_web::error::BlockingError) -> Self { + ServiceError { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + message: Some(format!("{:?}", error)), + } + } +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> { + if let Some(ref m) = self.message { + write!(f, "**{}**\n\n", m)?; + } + write!(f, "Code: {}", self.status_code) + } +} + +impl actix_web::error::ResponseError for ServiceError { + fn error_response(&self) -> HttpResponse { + MessageBaseTemplate { + message: &self.to_string(), + }.to_response() + } + + fn status_code(&self) -> StatusCode { + self.status_code + } +} + +///// HOME ///// + +#[derive(Template)] +#[template(path = "home.html")] +struct HomeTemplate { + user: Option, + recipes: Vec<(i32, String)>, + current_recipe_id: Option, +} + +#[get("/")] +pub async fn home_page(req: HttpRequest, connection: web::Data) -> Result { + let user = get_current_user(&req, connection.clone()).await; + let recipes = connection.get_all_recipe_titles_async().await?; + + Ok(HomeTemplate { user, current_recipe_id: None, recipes }.to_response()) +} + +///// VIEW RECIPE ///// + +#[derive(Template)] +#[template(path = "view_recipe.html")] +struct ViewRecipeTemplate { + user: Option, + recipes: Vec<(i32, String)>, + current_recipe_id: Option, + current_recipe: model::Recipe, +} + +#[get("/recipe/view/{id}")] +pub async fn view_recipe(req: HttpRequest, path: web::Path<(i32,)>, connection: web::Data) -> Result { + let (id,)= path.into_inner(); + let user = get_current_user(&req, connection.clone()).await; + let recipes = connection.get_all_recipe_titles_async().await?; + let recipe = connection.get_recipe_async(id).await?; + + Ok(ViewRecipeTemplate { + user, + current_recipe_id: Some(recipe.id), + recipes, + current_recipe: recipe, + }.to_response()) +} + +///// MESSAGE ///// + +#[derive(Template)] +#[template(path = "message_base.html")] +struct MessageBaseTemplate<'a> { + message: &'a str, +} + +#[derive(Template)] +#[template(path = "message.html")] +struct MessageTemplate<'a> { + user: Option, + message: &'a str, +} + +//// SIGN UP ///// + +#[derive(Template)] +#[template(path = "sign_up_form.html")] +struct SignUpFormTemplate { + user: Option, + email: String, + message: String, + message_email: String, + message_password: String, +} + +#[get("/signup")] +pub async fn sign_up_get(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + SignUpFormTemplate { user, email: String::new(), message: String::new(), message_email: String::new(), message_password: String::new() } +} + +#[derive(Deserialize)] +pub struct SignUpFormData { + email: String, + password_1: String, + password_2: String, +} + +enum SignUpError { + InvalidEmail, + PasswordsNotEqual, + InvalidPassword, + UserAlreadyExists, + DatabaseError, + UnableSendEmail, +} + +#[post("/signup")] +pub async fn sign_up_post(req: HttpRequest, form: web::Form, connection: web::Data, config: web::Data) -> Result { + fn error_response(error: SignUpError, form: &web::Form, user: Option) -> Result { + Ok(SignUpFormTemplate { + user, + email: form.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 already taken", + SignUpError::DatabaseError => "Database error", + SignUpError::UnableSendEmail => "Unable to send the validation email", + _ => "", + }.to_string(), + }.to_response()) + } + + let user = get_current_user(&req, connection.clone()).await; + + // Validation of email and password. + if let common::utils::EmailValidation::NotValid = common::utils::validate_email(&form.email) { + return error_response(SignUpError::InvalidEmail, &form, user); + } + + if form.password_1 != form.password_2 { + return error_response(SignUpError::PasswordsNotEqual, &form, user); + } + + if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form.password_1) { + return error_response(SignUpError::InvalidPassword, &form, user); + } + + match connection.sign_up_async(&form.email, &form.password_1).await { + Ok(db::SignUpResult::UserAlreadyExists) => { + error_response(SignUpError::UserAlreadyExists, &form, user) + }, + Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => { + let url = { + let host = req.headers().get(header::HOST).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default(); + let port: Option = 'p: { + let split_port: Vec<&str> = host.split(':').collect(); + if split_port.len() == 2 { + if let Ok(p) = split_port[1].parse::() { + break 'p Some(p) + } + } + None + }; + format!("http{}://{}", if port.is_some() && port.unwrap() != 443 { "" } else { "s" }, host) + }; + + let email = form.email.clone(); + + match web::block(move || { email::send_validation(&url, &email, &token, &config.smtp_login, &config.smtp_password) }).await? { + Ok(()) => + Ok(HttpResponse::Found() + .insert_header((header::LOCATION, "/signup_check_email")) + .finish()), + Err(error) => { + error!("Email validation error: {}", error); + error_response(SignUpError::UnableSendEmail, &form, user) + }, + } + }, + Err(error) => { + error!("Signup database error: {}", error); + error_response(SignUpError::DatabaseError, &form, user) + }, + } +} + +#[get("/signup_check_email")] +pub async fn sign_up_check_email(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + MessageTemplate { + user, + message: "An email has been sent, follow the link to validate your account.", + } +} + +#[get("/validation")] +pub async fn sign_up_validation(req: HttpRequest, query: web::Query>, connection: web::Data) -> Result { + let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); + let user = get_current_user(&req, connection.clone()).await; + + match query.get("token") { + Some(token) => { + match connection.validation_async(token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent).await? { + db::ValidationResult::Ok(token, user_id) => { + let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); + let user = + match connection.load_user(user_id) { + Ok(user) => + Some(user), + Err(error) => { + error!("Error retrieving user by id: {}", error); + None + } + }; + + let mut response = + MessageTemplate { + user, + message: "Email validation successful, your account has been created", + }.to_response(); + + if let Err(error) = response.add_cookie(&cookie) { + error!("Unable to set cookie after validation: {}", error); + }; + + Ok(response) + }, + db::ValidationResult::ValidationExpired => + Ok(MessageTemplate { + user, + message: "The validation has expired. Try to sign up again.", + }.to_response()), + db::ValidationResult::UnknownUser => + Ok(MessageTemplate { + user, + message: "Validation error.", + }.to_response()), + } + }, + None => { + Ok(MessageTemplate { + user, + message: &format!("No token provided"), + }.to_response()) + }, + } +} + +///// SIGN IN ///// + +#[derive(Template)] +#[template(path = "sign_in_form.html")] +struct SignInFormTemplate { + user: Option, + email: String, + message: String, +} + +#[get("/signin")] +pub async fn sign_in_get(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + SignInFormTemplate { + user, + email: String::new(), + message: String::new(), + } +} + +#[derive(Deserialize)] +pub struct SignInFormData { + email: String, + password: String, +} + +enum SignInError { + AccountNotValidated, + AuthenticationFailed, +} + +#[post("/signin")] +pub async fn sign_in_post(req: HttpRequest, form: web::Form, connection: web::Data) -> Result { + fn error_response(error: SignInError, form: &web::Form, user: Option) -> Result { + Ok(SignInFormTemplate { + user, + email: form.email.clone(), + message: + match error { + SignInError::AccountNotValidated => "This account must be validated first", + SignInError::AuthenticationFailed => "Wrong email or password", + }.to_string(), + }.to_response()) + } + + let user = get_current_user(&req, connection.clone()).await; + let (client_ip, client_user_agent) = get_ip_and_user_agent(&req); + + match connection.sign_in_async(&form.email, &form.password, &client_ip, &client_user_agent).await { + Ok(db::SignInResult::AccountNotValidated) => + error_response(SignInError::AccountNotValidated, &form, user), + Ok(db::SignInResult::UserNotFound) | Ok(db::SignInResult::WrongPassword) => { + error_response(SignInError::AuthenticationFailed, &form, user) + }, + Ok(db::SignInResult::Ok(token, user_id)) => { + let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token); + let mut response = + HttpResponse::Found() + .insert_header((header::LOCATION, "/")) + .finish(); + if let Err(error) = response.add_cookie(&cookie) { + error!("Unable to set cookie after sign in: {}", error); + }; + Ok(response) + }, + Err(error) => { + error!("Signin error: {}", error); + error_response(SignInError::AuthenticationFailed, &form, user) + }, + } +} + + +///// SIGN OUT ///// + +#[get("/signout")] +pub async fn sign_out(req: HttpRequest, connection: web::Data) -> impl Responder { + let mut response = + HttpResponse::Found() + .insert_header((header::LOCATION, "/")) + .finish(); + + if let Some(token_cookie) = req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) { + if let Err(error) = connection.sign_out_async(token_cookie.value()).await { + error!("Unable to sign out: {}", error); + }; + + if let Err(error) = response.add_removal_cookie(&Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, "")) { + error!("Unable to set a removal cookie after sign out: {}", error); + }; + }; + response +} + +pub async fn not_found(req: HttpRequest, connection: web::Data) -> impl Responder { + let user = get_current_user(&req, connection.clone()).await; + MessageTemplate { + user, + message: "404: Not found", + } +}