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