Beginning of frontend + recipe editing
[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 From<ron::error::SpannedError> for ServiceError {
98 fn from(error: ron::error::SpannedError) -> Self {
99 ServiceError {
100 status_code: StatusCode::INTERNAL_SERVER_ERROR,
101 message: Some(format!("{:?}", error)),
102 }
103 }
104 }
105
106 impl std::fmt::Display for ServiceError {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
108 if let Some(ref m) = self.message {
109 write!(f, "**{}**\n\n", m)?;
110 }
111 write!(f, "Code: {}", self.status_code)
112 }
113 }
114
115 impl actix_web::error::ResponseError for ServiceError {
116 fn error_response(&self) -> HttpResponse {
117 MessageBaseTemplate {
118 message: &self.to_string(),
119 }.to_response()
120 }
121
122 fn status_code(&self) -> StatusCode {
123 self.status_code
124 }
125 }
126
127 ///// HOME /////
128
129 #[derive(Template)]
130 #[template(path = "home.html")]
131 struct HomeTemplate {
132 user: Option<User>,
133 recipes: Vec<(i64, String)>,
134 current_recipe_id: Option<i64>,
135 }
136
137 #[get("/")]
138 pub async fn home_page(req: HttpRequest, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
139 let user = get_current_user(&req, connection.clone()).await;
140 let recipes = connection.get_all_recipe_titles_async().await?;
141
142 Ok(HomeTemplate { user, current_recipe_id: None, recipes }.to_response())
143 }
144
145 ///// VIEW RECIPE /////
146
147 #[derive(Template)]
148 #[template(path = "view_recipe.html")]
149 struct ViewRecipeTemplate {
150 user: Option<User>,
151 recipes: Vec<(i64, String)>,
152 current_recipe_id: Option<i64>,
153 current_recipe: model::Recipe,
154 }
155
156 #[get("/recipe/view/{id}")]
157 pub async fn view_recipe(req: HttpRequest, path: web::Path<(i64,)>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
158 let (id,)= path.into_inner();
159 let user = get_current_user(&req, connection.clone()).await;
160 let recipes = connection.get_all_recipe_titles_async().await?;
161 let recipe = connection.get_recipe_async(id).await?;
162
163 Ok(ViewRecipeTemplate {
164 user,
165 current_recipe_id: Some(recipe.id),
166 recipes,
167 current_recipe: recipe,
168 }.to_response())
169 }
170
171 ///// EDIT RECIPE /////
172
173 #[derive(Template)]
174 #[template(path = "edit_recipe.html")]
175 struct EditRecipeTemplate {
176 user: Option<User>,
177 recipes: Vec<(i64, String)>,
178 current_recipe_id: Option<i64>,
179 current_recipe: model::Recipe,
180 }
181
182 #[get("/recipe/edit/{id}")]
183 pub async fn edit_recipe(req: HttpRequest, path: web::Path<(i64,)>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
184 let (id,)= path.into_inner();
185 let user = get_current_user(&req, connection.clone()).await;
186 let recipes = connection.get_all_recipe_titles_async().await?;
187 let recipe = connection.get_recipe_async(id).await?;
188
189 Ok(EditRecipeTemplate {
190 user,
191 current_recipe_id: Some(recipe.id),
192 recipes,
193 current_recipe: recipe,
194 }.to_response())
195 }
196
197 ///// MESSAGE /////
198
199 #[derive(Template)]
200 #[template(path = "message_base.html")]
201 struct MessageBaseTemplate<'a> {
202 message: &'a str,
203 }
204
205 #[derive(Template)]
206 #[template(path = "message.html")]
207 struct MessageTemplate<'a> {
208 user: Option<User>,
209 message: &'a str,
210 }
211
212 //// SIGN UP /////
213
214 #[derive(Template)]
215 #[template(path = "sign_up_form.html")]
216 struct SignUpFormTemplate {
217 user: Option<User>,
218 email: String,
219 message: String,
220 message_email: String,
221 message_password: String,
222 }
223
224 #[get("/signup")]
225 pub async fn sign_up_get(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
226 let user = get_current_user(&req, connection.clone()).await;
227 SignUpFormTemplate { user, email: String::new(), message: String::new(), message_email: String::new(), message_password: String::new() }
228 }
229
230 #[derive(Deserialize)]
231 pub struct SignUpFormData {
232 email: String,
233 password_1: String,
234 password_2: String,
235 }
236
237 enum SignUpError {
238 InvalidEmail,
239 PasswordsNotEqual,
240 InvalidPassword,
241 UserAlreadyExists,
242 DatabaseError,
243 UnableSendEmail,
244 }
245
246 #[post("/signup")]
247 pub async fn sign_up_post(req: HttpRequest, form: web::Form<SignUpFormData>, connection: web::Data<db::Connection>, config: web::Data<Config>) -> Result<HttpResponse> {
248 fn error_response(error: SignUpError, form: &web::Form<SignUpFormData>, user: Option<User>) -> Result<HttpResponse> {
249 Ok(SignUpFormTemplate {
250 user,
251 email: form.email.clone(),
252 message_email:
253 match error {
254 SignUpError::InvalidEmail => "Invalid email",
255 _ => "",
256 }.to_string(),
257 message_password:
258 match error {
259 SignUpError::PasswordsNotEqual => "Passwords don't match",
260 SignUpError::InvalidPassword => "Password must have at least eight characters",
261 _ => "",
262 }.to_string(),
263 message:
264 match error {
265 SignUpError::UserAlreadyExists => "This email is already taken",
266 SignUpError::DatabaseError => "Database error",
267 SignUpError::UnableSendEmail => "Unable to send the validation email",
268 _ => "",
269 }.to_string(),
270 }.to_response())
271 }
272
273 let user = get_current_user(&req, connection.clone()).await;
274
275 // Validation of email and password.
276 if let common::utils::EmailValidation::NotValid = common::utils::validate_email(&form.email) {
277 return error_response(SignUpError::InvalidEmail, &form, user);
278 }
279
280 if form.password_1 != form.password_2 {
281 return error_response(SignUpError::PasswordsNotEqual, &form, user);
282 }
283
284 if let common::utils::PasswordValidation::TooShort = common::utils::validate_password(&form.password_1) {
285 return error_response(SignUpError::InvalidPassword, &form, user);
286 }
287
288 match connection.sign_up_async(&form.email, &form.password_1).await {
289 Ok(db::SignUpResult::UserAlreadyExists) => {
290 error_response(SignUpError::UserAlreadyExists, &form, user)
291 },
292 Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => {
293 let url = {
294 let host = req.headers().get(header::HOST).map(|v| v.to_str().unwrap_or_default()).unwrap_or_default();
295 let port: Option<u16> = 'p: {
296 let split_port: Vec<&str> = host.split(':').collect();
297 if split_port.len() == 2 {
298 if let Ok(p) = split_port[1].parse::<u16>() {
299 break 'p Some(p)
300 }
301 }
302 None
303 };
304 format!("http{}://{}", if port.is_some() && port.unwrap() != 443 { "" } else { "s" }, host)
305 };
306
307 let email = form.email.clone();
308
309 match web::block(move || { email::send_validation(&url, &email, &token, &config.smtp_login, &config.smtp_password) }).await? {
310 Ok(()) =>
311 Ok(HttpResponse::Found()
312 .insert_header((header::LOCATION, "/signup_check_email"))
313 .finish()),
314 Err(error) => {
315 error!("Email validation error: {}", error);
316 error_response(SignUpError::UnableSendEmail, &form, user)
317 },
318 }
319 },
320 Err(error) => {
321 error!("Signup database error: {}", error);
322 error_response(SignUpError::DatabaseError, &form, user)
323 },
324 }
325 }
326
327 #[get("/signup_check_email")]
328 pub async fn sign_up_check_email(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
329 let user = get_current_user(&req, connection.clone()).await;
330 MessageTemplate {
331 user,
332 message: "An email has been sent, follow the link to validate your account.",
333 }
334 }
335
336 #[get("/validation")]
337 pub async fn sign_up_validation(req: HttpRequest, query: web::Query<HashMap<String, String>>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
338 let (client_ip, client_user_agent) = get_ip_and_user_agent(&req);
339 let user = get_current_user(&req, connection.clone()).await;
340
341 match query.get("token") {
342 Some(token) => {
343 match connection.validation_async(token, Duration::seconds(consts::VALIDATION_TOKEN_DURATION), &client_ip, &client_user_agent).await? {
344 db::ValidationResult::Ok(token, user_id) => {
345 let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
346 let user =
347 match connection.load_user(user_id) {
348 Ok(user) =>
349 Some(user),
350 Err(error) => {
351 error!("Error retrieving user by id: {}", error);
352 None
353 }
354 };
355
356 let mut response =
357 MessageTemplate {
358 user,
359 message: "Email validation successful, your account has been created",
360 }.to_response();
361
362 if let Err(error) = response.add_cookie(&cookie) {
363 error!("Unable to set cookie after validation: {}", error);
364 };
365
366 Ok(response)
367 },
368 db::ValidationResult::ValidationExpired =>
369 Ok(MessageTemplate {
370 user,
371 message: "The validation has expired. Try to sign up again.",
372 }.to_response()),
373 db::ValidationResult::UnknownUser =>
374 Ok(MessageTemplate {
375 user,
376 message: "Validation error.",
377 }.to_response()),
378 }
379 },
380 None => {
381 Ok(MessageTemplate {
382 user,
383 message: &format!("No token provided"),
384 }.to_response())
385 },
386 }
387 }
388
389 ///// SIGN IN /////
390
391 #[derive(Template)]
392 #[template(path = "sign_in_form.html")]
393 struct SignInFormTemplate {
394 user: Option<User>,
395 email: String,
396 message: String,
397 }
398
399 #[get("/signin")]
400 pub async fn sign_in_get(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
401 let user = get_current_user(&req, connection.clone()).await;
402 SignInFormTemplate {
403 user,
404 email: String::new(),
405 message: String::new(),
406 }
407 }
408
409 #[derive(Deserialize)]
410 pub struct SignInFormData {
411 email: String,
412 password: String,
413 }
414
415 enum SignInError {
416 AccountNotValidated,
417 AuthenticationFailed,
418 }
419
420 #[post("/signin")]
421 pub async fn sign_in_post(req: HttpRequest, form: web::Form<SignInFormData>, connection: web::Data<db::Connection>) -> Result<HttpResponse> {
422 fn error_response(error: SignInError, form: &web::Form<SignInFormData>, user: Option<User>) -> Result<HttpResponse> {
423 Ok(SignInFormTemplate {
424 user,
425 email: form.email.clone(),
426 message:
427 match error {
428 SignInError::AccountNotValidated => "This account must be validated first",
429 SignInError::AuthenticationFailed => "Wrong email or password",
430 }.to_string(),
431 }.to_response())
432 }
433
434 let user = get_current_user(&req, connection.clone()).await;
435 let (client_ip, client_user_agent) = get_ip_and_user_agent(&req);
436
437 match connection.sign_in_async(&form.email, &form.password, &client_ip, &client_user_agent).await {
438 Ok(db::SignInResult::AccountNotValidated) =>
439 error_response(SignInError::AccountNotValidated, &form, user),
440 Ok(db::SignInResult::UserNotFound) | Ok(db::SignInResult::WrongPassword) => {
441 error_response(SignInError::AuthenticationFailed, &form, user)
442 },
443 Ok(db::SignInResult::Ok(token, user_id)) => {
444 let cookie = Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, token);
445 let mut response =
446 HttpResponse::Found()
447 .insert_header((header::LOCATION, "/"))
448 .finish();
449 if let Err(error) = response.add_cookie(&cookie) {
450 error!("Unable to set cookie after sign in: {}", error);
451 };
452 Ok(response)
453 },
454 Err(error) => {
455 error!("Signin error: {}", error);
456 error_response(SignInError::AuthenticationFailed, &form, user)
457 },
458 }
459 }
460
461
462 ///// SIGN OUT /////
463
464 #[get("/signout")]
465 pub async fn sign_out(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
466 let mut response =
467 HttpResponse::Found()
468 .insert_header((header::LOCATION, "/"))
469 .finish();
470
471 if let Some(token_cookie) = req.cookie(consts::COOKIE_AUTH_TOKEN_NAME) {
472 if let Err(error) = connection.sign_out_async(token_cookie.value()).await {
473 error!("Unable to sign out: {}", error);
474 };
475
476 if let Err(error) = response.add_removal_cookie(&Cookie::new(consts::COOKIE_AUTH_TOKEN_NAME, "")) {
477 error!("Unable to set a removal cookie after sign out: {}", error);
478 };
479 };
480 response
481 }
482
483 pub async fn not_found(req: HttpRequest, connection: web::Data<db::Connection>) -> impl Responder {
484 let user = get_current_user(&req, connection.clone()).await;
485 MessageTemplate {
486 user,
487 message: "404: Not found",
488 }
489 }