Add frontend tests and other stuff
[recipes.git] / backend / src / services.rs
1 use std::collections::HashMap;
2
3 use actix_web::{http::{header, header::ContentType, StatusCode}, get, post, web, Responder, HttpRequest, HttpResponse, cookie::Cookie};
4 use askama_actix::{Template, TemplateToResponse};
5 use chrono::Duration;
6 use serde::Deserialize;
7 use log::{debug, error, log_enabled, info, Level};
8
9 use crate::utils;
10 use crate::email;
11 use crate::consts;
12 use crate::config::Config;
13 use crate::user::User;
14 use crate::model;
15 use crate::data::{db, asynchronous};
16
17 mod api;
18
19 ///// UTILS /////
20
21 fn get_ip_and_user_agent(req: &HttpRequest) -> (String, String) {
22 let ip =
23 match req.headers().get(consts::REVERSE_PROXY_IP_HTTP_FIELD) {
24 Some(v) => v.to_str().unwrap_or_default().to_string(),
25 None => req.peer_addr().map(|addr| addr.ip().to_string()).unwrap_or_default()
26 };
27
28 let user_agent = req.headers().get(header::USER_AGENT).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default().to_string();
29
30 (ip, user_agent)
31 }
32
33 async fn get_current_user(req: &HttpRequest, connection: web::Data<db::Connection>) -> Option<User> {
34 let (client_ip, client_user_agent) = get_ip_and_user_agent(req);
35
36 match req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) {
37 Some(token_cookie) =>
38 match connection.authentication_async(token_cookie.value(), &client_ip, &client_user_agent).await {
39 Ok(db::AuthenticationResult::NotValidToken) =>
40 // TODO: remove cookie?
41 None,
42 Ok(db::AuthenticationResult::Ok(user_id)) =>
43 match connection.load_user_async(user_id).await {
44 Ok(user) =>
45 Some(user),
46 Err(error) => {
47 error!("Error during authentication: {}", error);
48 None
49 }
50 },
51 Err(error) => {
52 error!("Error during authentication: {}", error);
53 None
54 },
55 },
56 None => None
57 }
58 }
59
60 type Result<T> = std::result::Result<T, ServiceError>;
61
62 ///// ERROR /////
63
64 #[derive(Debug)]
65 pub struct ServiceError {
66 status_code: StatusCode,
67 message: Option<String>,
68 }
69
70 impl From<asynchronous::DBAsyncError> for ServiceError {
71 fn from(error: asynchronous::DBAsyncError) -> Self {
72 ServiceError {
73 status_code: StatusCode::INTERNAL_SERVER_ERROR,
74 message: Some(format!("{:?}", error)),
75 }
76 }
77 }
78
79 impl From<email::Error> for ServiceError {
80 fn from(error: email::Error) -> Self {
81 ServiceError {
82 status_code: StatusCode::INTERNAL_SERVER_ERROR,
83 message: Some(format!("{:?}", error)),
84 }
85 }
86 }
87
88 impl From<actix_web::error::BlockingError> for ServiceError {
89 fn from(error: actix_web::error::BlockingError) -> Self {
90 ServiceError {
91 status_code: StatusCode::INTERNAL_SERVER_ERROR,
92 message: Some(format!("{:?}", error)),
93 }
94 }
95 }
96
97 impl std::fmt::Display for ServiceError {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
99 if let Some(ref m) = self.message {
100 write!(f, "**{}**\n\n", m)?;
101 }
102 write!(f, "Code: {}", self.status_code)
103 }
104 }
105
106 impl actix_web::error::ResponseError for ServiceError {
107 fn error_response(&self) -> HttpResponse {
108 MessageBaseTemplate {
109 message: &self.to_string(),
110 }.to_response()
111 }
112
113 fn status_code(&self) -> StatusCode {
114 self.status_code
115 }
116 }
117
118 ///// HOME /////
119
120 #[derive(Template)]
121 #[template(path = "home.html")]
122 struct HomeTemplate {
123 user: Option<User>,
124 recipes: Vec<(i64, String)>,
125 current_recipe_id: Option<i64>,
126 }
127
128 #[get("/")]
129 pub async fn home_page(req: HttpRequest, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
130 let user = get_current_user(&req, connection.clone()).await;
131 let recipes = connection.get_all_recipe_titles_async().await?;
132
133 Ok(HomeTemplate { user, current_recipe_id: None, recipes }.to_response())
134 }
135
136 ///// VIEW RECIPE /////
137
138 #[derive(Template)]
139 #[template(path = "view_recipe.html")]
140 struct ViewRecipeTemplate {
141 user: Option<User>,
142 recipes: Vec<(i64, String)>,
143 current_recipe_id: Option<i64>,
144 current_recipe: model::Recipe,
145 }
146
147 #[get("/recipe/view/{id}")]
148 pub async fn view_recipe(req: HttpRequest, path: web::Path<(i64,)>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
149 let (id,)= path.into_inner();
150 let user = get_current_user(&req, connection.clone()).await;
151 let recipes = connection.get_all_recipe_titles_async().await?;
152 let recipe = connection.get_recipe_async(id).await?;
153
154 Ok(ViewRecipeTemplate {
155 user,
156 current_recipe_id: Some(recipe.id),
157 recipes,
158 current_recipe: recipe,
159 }.to_response())
160 }
161
162 ///// MESSAGE /////
163
164 #[derive(Template)]
165 #[template(path = "message_base.html")]
166 struct MessageBaseTemplate<'a> {
167 message: &'a str,
168 }
169
170 #[derive(Template)]
171 #[template(path = "message.html")]
172 struct MessageTemplate<'a> {
173 user: Option<User>,
174 message: &'a str,
175 }
176
177 //// SIGN UP /////
178
179 #[derive(Template)]
180 #[template(path = "sign_up_form.html")]
181 struct SignUpFormTemplate {
182 user: Option<User>,
183 email: String,
184 message: String,
185 message_email: String,
186 message_password: String,
187 }
188
189 #[get("/signup")]
190 pub async fn sign_up_get(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
191 let user = get_current_user(&req, connection.clone()).await;
192 SignUpFormTemplate { user, email: String::new(), message: String::new(), message_email: String::new(), message_password: String::new() }
193 }
194
195 #[derive(Deserialize)]
196 pub struct SignUpFormData {
197 email: String,
198 password_1: String,
199 password_2: String,
200 }
201
202 enum SignUpError {
203 InvalidEmail,
204 PasswordsNotEqual,
205 InvalidPassword,
206 UserAlreadyExists,
207 DatabaseError,
208 UnableSendEmail,
209 }
210
211 #[post("/signup")]
212 pub async fn sign_up_post(req: HttpRequest, form: web::Form<SignUpFormData>, connection: web::Data<db::Connection>, config: web::Data<Config>) -> Result<HttpResponse> {
213 fn error_response(error: SignUpError, form: &web::Form<SignUpFormData>, user: Option<User>) -> Result<HttpResponse> {
214 Ok(SignUpFormTemplate {
215 user,
216 email: form.email.clone(),
217 message_email:
218 match error {
219 SignUpError::InvalidEmail => "Invalid email",
220 _ => "",
221 }.to_string(),
222 message_password:
223 match error {
224 SignUpError::PasswordsNotEqual => "Passwords don't match",
225 SignUpError::InvalidPassword => "Password must have at least eight characters",
226 _ => "",
227 }.to_string(),
228 message:
229 match error {
230 SignUpError::UserAlreadyExists => "This email is already taken",
231 SignUpError::DatabaseError => "Database error",
232 SignUpError::UnableSendEmail => "Unable to send the validation email",
233 _ => "",
234 }.to_string(),
235 }.to_response())
236 }
237
238 let user = get_current_user(&req, connection.clone()).await;
239
240 // Validation of email and password.
241 if let common::utils::EmailValidation::NotValid = common::utils::validate_email(&form.email) {
242 return error_response(SignUpError::InvalidEmail, &form, user);
243 }
244
245 if form.password_1 != form.password_2 {
246 return error_response(SignUpError::PasswordsNotEqual, &form, user);
247 }
248
249 if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form.password_1) {
250 return error_response(SignUpError::InvalidPassword, &form, user);
251 }
252
253 match connection.sign_up_async(&form.email, &form.password_1).await {
254 Ok(db::SignUpResult::UserAlreadyExists) => {
255 error_response(SignUpError::UserAlreadyExists, &form, user)
256 },
257 Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => {
258 let url = {
259 let host = req.headers().get(header::HOST).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default();
260 let port: Option<u16> = 'p: {
261 let split_port: Vec<&str> = host.split(':').collect();
262 if split_port.len() == 2 {
263 if let Ok(p) = split_port[1].parse::<u16>() {
264 break 'p Some(p)
265 }
266 }
267 None
268 };
269 format!("http{}://{}", if port.is_some() && port.unwrap() != 443 { "" } else { "s" }, host)
270 };
271
272 let email = form.email.clone();
273
274 match web::block(move || { email::send_validation(&url, &email, &token, &config.smtp_login, &config.smtp_password) }).await? {
275 Ok(()) =>
276 Ok(HttpResponse::Found()
277 .insert_header((header::LOCATION, "/signup_check_email"))
278 .finish()),
279 Err(error) => {
280 error!("Email validation error: {}", error);
281 error_response(SignUpError::UnableSendEmail, &form, user)
282 },
283 }
284 },
285 Err(error) => {
286 error!("Signup database error: {}", error);
287 error_response(SignUpError::DatabaseError, &form, user)
288 },
289 }
290 }
291
292 #[get("/signup_check_email")]
293 pub async fn sign_up_check_email(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
294 let user = get_current_user(&req, connection.clone()).await;
295 MessageTemplate {
296 user,
297 message: "An email has been sent, follow the link to validate your account.",
298 }
299 }
300
301 #[get("/validation")]
302 pub async fn sign_up_validation(req: HttpRequest, query: web::Query<HashMap<String, String>>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
303 let (client_ip, client_user_agent) = get_ip_and_user_agent(&req);
304 let user = get_current_user(&req, connection.clone()).await;
305
306 match query.get("token") {
307 Some(token) => {
308 match connection.validation_async(token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent).await? {
309 db::ValidationResult::Ok(token, user_id) => {
310 let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
311 let user =
312 match connection.load_user(user_id) {
313 Ok(user) =>
314 Some(user),
315 Err(error) => {
316 error!("Error retrieving user by id: {}", error);
317 None
318 }
319 };
320
321 let mut response =
322 MessageTemplate {
323 user,
324 message: "Email validation successful, your account has been created",
325 }.to_response();
326
327 if let Err(error) = response.add_cookie(&cookie) {
328 error!("Unable to set cookie after validation: {}", error);
329 };
330
331 Ok(response)
332 },
333 db::ValidationResult::ValidationExpired =>
334 Ok(MessageTemplate {
335 user,
336 message: "The validation has expired. Try to sign up again.",
337 }.to_response()),
338 db::ValidationResult::UnknownUser =>
339 Ok(MessageTemplate {
340 user,
341 message: "Validation error.",
342 }.to_response()),
343 }
344 },
345 None => {
346 Ok(MessageTemplate {
347 user,
348 message: &format!("No token provided"),
349 }.to_response())
350 },
351 }
352 }
353
354 ///// SIGN IN /////
355
356 #[derive(Template)]
357 #[template(path = "sign_in_form.html")]
358 struct SignInFormTemplate {
359 user: Option<User>,
360 email: String,
361 message: String,
362 }
363
364 #[get("/signin")]
365 pub async fn sign_in_get(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
366 let user = get_current_user(&req, connection.clone()).await;
367 SignInFormTemplate {
368 user,
369 email: String::new(),
370 message: String::new(),
371 }
372 }
373
374 #[derive(Deserialize)]
375 pub struct SignInFormData {
376 email: String,
377 password: String,
378 }
379
380 enum SignInError {
381 AccountNotValidated,
382 AuthenticationFailed,
383 }
384
385 #[post("/signin")]
386 pub async fn sign_in_post(req: HttpRequest, form: web::Form<SignInFormData>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
387 fn error_response(error: SignInError, form: &web::Form<SignInFormData>, user: Option<User>) -> Result<HttpResponse> {
388 Ok(SignInFormTemplate {
389 user,
390 email: form.email.clone(),
391 message:
392 match error {
393 SignInError::AccountNotValidated => "This account must be validated first",
394 SignInError::AuthenticationFailed => "Wrong email or password",
395 }.to_string(),
396 }.to_response())
397 }
398
399 let user = get_current_user(&req, connection.clone()).await;
400 let (client_ip, client_user_agent) = get_ip_and_user_agent(&req);
401
402 match connection.sign_in_async(&form.email, &form.password, &client_ip, &client_user_agent).await {
403 Ok(db::SignInResult::AccountNotValidated) =>
404 error_response(SignInError::AccountNotValidated, &form, user),
405 Ok(db::SignInResult::UserNotFound) | Ok(db::SignInResult::WrongPassword) => {
406 error_response(SignInError::AuthenticationFailed, &form, user)
407 },
408 Ok(db::SignInResult::Ok(token, user_id)) => {
409 let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
410 let mut response =
411 HttpResponse::Found()
412 .insert_header((header::LOCATION, "/"))
413 .finish();
414 if let Err(error) = response.add_cookie(&cookie) {
415 error!("Unable to set cookie after sign in: {}", error);
416 };
417 Ok(response)
418 },
419 Err(error) => {
420 error!("Signin error: {}", error);
421 error_response(SignInError::AuthenticationFailed, &form, user)
422 },
423 }
424 }
425
426
427 ///// SIGN OUT /////
428
429 #[get("/signout")]
430 pub async fn sign_out(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
431 let mut response =
432 HttpResponse::Found()
433 .insert_header((header::LOCATION, "/"))
434 .finish();
435
436 if let Some(token_cookie) = req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) {
437 if let Err(error) = connection.sign_out_async(token_cookie.value()).await {
438 error!("Unable to sign out: {}", error);
439 };
440
441 if let Err(error) = response.add_removal_cookie(&Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, "")) {
442 error!("Unable to set a removal cookie after sign out: {}", error);
443 };
444 };
445 response
446 }
447
448 pub async fn not_found(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
449 let user = get_current_user(&req, connection.clone()).await;
450 MessageTemplate {
451 user,
452 message: "404: Not found",
453 }
454 }