diff --git a/backend/src/app.rs b/backend/src/app.rs new file mode 100644 index 0000000..453561a --- /dev/null +++ b/backend/src/app.rs @@ -0,0 +1,437 @@ +use std::net::SocketAddr; + +use axum::{ + BoxError, Router, ServiceExt, + error_handling::HandleErrorLayer, + extract::{ + ConnectInfo, Extension, FromRef, Request, State, + connect_info::IntoMakeServiceWithConnectInfo, + }, + http::{StatusCode, Uri}, + middleware::{self, Next}, + response::Response, + routing::{delete, get, patch, post, put}, +}; +use axum_extra::extract::cookie::CookieJar; +use chrono::prelude::*; +use itertools::Itertools; +use tower::layer::Layer; +use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer}; +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; +use tracing::{Level, event}; + +use crate::{ + config::Config, + consts, + data::{db, model}, + log::Log, + ron_utils, services, + translation::{self, Tr}, + utils, +}; + +#[derive(Clone)] +pub struct AppState { + pub config: Config, + pub db_connection: db::Connection, + pub log: Log, +} + +impl FromRef for Config { + fn from_ref(app_state: &AppState) -> Config { + app_state.config.clone() + } +} + +impl FromRef for db::Connection { + fn from_ref(app_state: &AppState) -> db::Connection { + app_state.db_connection.clone() + } +} + +impl FromRef for Log { + fn from_ref(app_state: &AppState) -> Log { + app_state.log.clone() + } +} + +impl axum::response::IntoResponse for db::DBError { + fn into_response(self) -> Response { + ron_utils::ron_error(StatusCode::INTERNAL_SERVER_ERROR, &self.to_string()).into_response() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("Database error: {0}")] + Database(#[from] db::DBError), + + #[error("Template error: {0}")] + Render(#[from] askama::Error), +} + +pub type Result = std::result::Result; + +impl axum::response::IntoResponse for AppError { + fn into_response(self) -> Response { + (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() + } +} + +#[derive(Debug, Clone)] +pub struct Context { + pub user: Option, + pub tr: Tr, + pub dark_theme: bool, +} + +impl Context { + pub fn first_day_of_the_week(&self) -> Weekday { + if let Some(user) = &self.user { + user.first_day_of_the_week + } else { + self.tr.first_day_of_week() + } + } +} + +#[derive(Debug, Clone)] +struct Lang(Option); + +// TODO: Refactor the signature into something like 'impl Service<...>'. +pub fn make_service( + state: AppState, +) -> IntoMakeServiceWithConnectInfo< + tower::util::MapRequest) -> Request>, + SocketAddr, +> +// ) -> impl Service, Error = core::convert::Infallible, Response = S> + // where + // L: axum::serve::Listener, + // S: Service + // + Clone + // + Send + // + 'static, + // S::Future: Send, + // T: , + // std::net::SocketAddr: + // axum::extract::connect_info::Connected>, +{ + let ron_api_routes = Router::new() + // Disabled: update user profile is now made with a post data ('edit_user_post'). + // .route("/user/update", put(services::ron::update_user)) + .route("/lang", put(services::ron::set_lang)) + .route("/recipe/titles", get(services::ron::recipe::get_titles)) + .route("/recipe/title", patch(services::ron::recipe::set_title)) + .route( + "/recipe/description", + patch(services::ron::recipe::set_description), + ) + .route( + "/recipe/servings", + patch(services::ron::recipe::set_servings), + ) + .route( + "/recipe/estimated_time", + patch(services::ron::recipe::set_estimated_time), + ) + .route( + "/recipe/tags", + get(services::ron::recipe::get_tags) + .post(services::ron::recipe::add_tags) + .delete(services::ron::recipe::rm_tags), + ) + .route( + "/recipe/difficulty", + patch(services::ron::recipe::set_difficulty), + ) + .route( + "/recipe/language", + patch(services::ron::recipe::set_language), + ) + .route( + "/recipe/is_public", + patch(services::ron::recipe::set_is_public), + ) + .route("/recipe", delete(services::ron::recipe::rm)) + .route("/recipe/groups", get(services::ron::recipe::get_groups)) + .route( + "/recipe/group", + post(services::ron::recipe::add_group).delete(services::ron::recipe::rm_group), + ) + .route( + "/recipe/group_name", + patch(services::ron::recipe::set_group_name), + ) + .route( + "/recipe/group_comment", + patch(services::ron::recipe::set_group_comment), + ) + .route( + "/recipe/groups_order", + patch(services::ron::recipe::set_groups_order), + ) + .route( + "/recipe/step", + post(services::ron::recipe::add_step).delete(services::ron::recipe::rm_step), + ) + .route( + "/recipe/step_action", + patch(services::ron::recipe::set_step_action), + ) + .route( + "/recipe/steps_order", + patch(services::ron::recipe::set_steps_order), + ) + .route( + "/recipe/ingredient", + post(services::ron::recipe::add_ingredient) + .delete(services::ron::recipe::rm_ingredient), + ) + .route( + "/recipe/ingredient_name", + patch(services::ron::recipe::set_ingredient_name), + ) + .route( + "/recipe/ingredient_comment", + patch(services::ron::recipe::set_ingredient_comment), + ) + .route( + "/recipe/ingredient_quantity", + patch(services::ron::recipe::set_ingredient_quantity), + ) + .route( + "/recipe/ingredient_unit", + patch(services::ron::recipe::set_ingredient_unit), + ) + .route( + "/recipe/ingredients_order", + patch(services::ron::recipe::set_ingredients_order), + ) + .route( + "/calendar/scheduled_recipes", + get(services::ron::calendar::get_scheduled_recipes), + ) + .route( + "/calendar/scheduled_recipe", + post(services::ron::calendar::add_scheduled_recipe) + .delete(services::ron::calendar::rm_scheduled_recipe), + ) + .route("/shopping_list", get(services::ron::shopping_list::get)) + .route( + "/shopping_list/checked", + patch(services::ron::shopping_list::set_entry_checked), + ) + .fallback(services::ron::not_found); + + let fragments_routes = Router::new().route( + "/recipes_list", + get(services::fragments::recipes_list_fragments), + ); + + let html_routes_with_rate_limit = Router::new() + .route("/signin", post(services::user::sign_in_post)) + .route("/signup", post(services::user::sign_up_post)) + .route( + "/ask_reset_password", + post(services::user::ask_reset_password_post), + ) + .layer( + ServiceBuilder::new() + .layer(HandleErrorLayer::new(|err: BoxError| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled error: {}", err), + ) + })) + .layer(BufferLayer::new(1024)) + .layer(RateLimitLayer::new( + consts::NUMBER_OF_CONCURRENT_HTTP_REQUEST_FOR_RATE_LIMIT, + consts::DURATION_FOR_RATE_LIMIT, + )), + ); + + let html_routes = Router::new() + .route("/", get(services::home_page)) + .route("/dev_panel", get(services::dev_panel)) + .route("/logs", get(services::logs)) + .route("/signup", get(services::user::sign_up_get)) + .route("/validation", get(services::user::sign_up_validation)) + .route("/revalidation", get(services::user::email_revalidation)) + .route("/signin", get(services::user::sign_in_get)) + .route("/signout", get(services::user::sign_out)) + .route( + "/ask_reset_password", + get(services::user::ask_reset_password_get), + ) + .route( + "/reset_password", + get(services::user::reset_password_get).post(services::user::reset_password_post), + ) + // Recipes. + .route("/recipe/new", get(services::recipe::create)) + .route("/recipe/edit/{id}", get(services::recipe::edit)) + .route("/recipe/view/{id}", get(services::recipe::view)) + // User. + .route( + "/user/edit", + get(services::user::edit_user_get).post(services::user::edit_user_post), + ) + .merge(html_routes_with_rate_limit) + .nest("/fragments", fragments_routes) + .route_layer(middleware::from_fn(services::ron_error_to_html)); + + let app = Router::new() + .merge(html_routes) + .nest("/ron-api", ron_api_routes) + .fallback(services::not_found) + .layer(middleware::from_fn_with_state(state.clone(), context)) + .with_state(state) + .nest_service("/favicon.ico", ServeFile::new("static/favicon.ico")) + .nest_service("/static", ServeDir::new("static")) + .layer(TraceLayer::new_for_http()); + + let url_rewriting_middleware = tower::util::MapRequestLayer::new( + url_rewriting + as fn(axum::http::Request) -> axum::http::Request, + ); + + url_rewriting_middleware + .layer(app) + .into_make_service_with_connect_info::() +} + +fn url_rewriting(mut req: Request) -> Request { + // Here we are extracting the language from the url then rewriting it. + // For example: + // "/fr/recipe/view/1" + // lang = "fr" and uri rewritten as = "/recipe/view/1" + let lang_and_new_uri = 'lang_and_new_uri: { + if let Some(path_query) = req.uri().path_and_query() { + let mut parts = path_query.path().split('/'); + let _ = parts.next(); // Empty part due to the first '/'. + if let Some(lang) = parts.next() { + let available_codes = translation::available_codes(); + if available_codes.contains(&lang) { + let mut rest = String::from(""); + for part in parts { + rest.push('/'); + rest.push_str(part); + } + if let Some(query) = path_query.query() { + rest.push('?'); + rest.push_str(query); + } + + if let Ok(new_uri) = rest.parse::() { + break 'lang_and_new_uri Some((lang.to_string(), new_uri)); + } + } + } + } + None + }; + + if let Some((lang, new_uri)) = lang_and_new_uri { + *req.uri_mut() = new_uri; + req.extensions_mut().insert(Lang(Some(lang))); + } else { + req.extensions_mut().insert(Lang(None)); + } + + req +} + +/// The language associated to the current HTTP request is defined in the current order: +/// - Extraction from the url: like in `/fr/recipe/view/42` +/// - Get from the user database record. +/// - Get from the cookie. +/// - Get from the HTTP header `accept-language`. +/// - Set as `translation::DEFAULT_LANGUAGE_CODE`. +async fn context( + ConnectInfo(addr): ConnectInfo, + State(connection): State, + Extension(lang_from_url): Extension, + mut req: Request, + next: Next, +) -> Result { + let jar = CookieJar::from_headers(req.headers()); + let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(req.headers(), addr); + let user = get_current_user(connection, &jar, &client_ip, &client_user_agent).await; + + let language = if let Some(lang) = lang_from_url.0 { + lang + } else if let Some(ref user) = user { + user.lang.clone() + } else { + let available_codes = translation::available_codes(); + let jar = CookieJar::from_headers(req.headers()); + match jar.get(consts::COOKIE_LANG_NAME) { + Some(lang) if available_codes.contains(&lang.value()) => lang.value().to_string(), + _ => { + let accept_language = req + .headers() + .get(axum::http::header::ACCEPT_LANGUAGE) + .map(|v| v.to_str().unwrap_or_default()) + .unwrap_or_default() + .split(',') + .map(|l| l.split('-').next().unwrap_or_default()) + .find_or_first(|l| available_codes.contains(l)); + + match accept_language { + Some(lang) if !lang.is_empty() => lang, + _ => translation::DEFAULT_LANGUAGE_CODE, + } + .to_string() + } + } + }; + + let tr = Tr::new(&language); + + let dark_theme = match jar.get(common::consts::COOKIE_DARK_THEME) { + Some(dark_theme_cookie) => dark_theme_cookie.value().parse().unwrap_or_default(), + None => false, + }; + + req.extensions_mut().insert(Context { + user, + tr, + dark_theme, + }); + + Ok(next.run(req).await) +} + +async fn get_current_user( + connection: db::Connection, + jar: &CookieJar, + client_ip: &str, + client_user_agent: &str, +) -> Option { + match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) { + Some(token_cookie) => match connection + .authentication(token_cookie.value(), client_ip, client_user_agent) + .await + { + 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) => { + event!(Level::WARN, "Error during authentication: {}", error); + None + } + } + } + Err(error) => { + event!(Level::WARN, "Error during authentication: {}", error); + None + } + }, + None => None, + } +} diff --git a/backend/src/data/backup.rs b/backend/src/data/backup.rs index 63ac222..2f58037 100644 --- a/backend/src/data/backup.rs +++ b/backend/src/data/backup.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use async_compression::tokio::bufread::GzipEncoder; use chrono::{NaiveTime, TimeDelta}; use tokio::{fs::File, io::BufReader}; @@ -5,19 +7,103 @@ use tracing::{Level, event}; use super::db; -/// This function starts a backup process that runs at a specified time of day forever. +/// This function starts a backup task that runs at a specified time of day forever. /// It creates a backup of the database at the specified directory. /// The backup file is named with the date and time at the time of the backup. -pub fn start

( +pub fn start_task

( directory: P, db_connection: db::Connection, time_of_day: NaiveTime, ) -> tokio::task::JoinHandle<()> +where + P: AsRef + Send + Sync + 'static, +{ + create_directory(&directory); + let path = build_path(&directory); + + tokio::task::spawn(async move { + loop { + let mut time_to_wait = time_of_day - chrono::Local::now().time(); + if time_to_wait < TimeDelta::zero() { + time_to_wait += TimeDelta::days(1); + } + event!(Level::DEBUG, "Backup in {}s", time_to_wait.num_seconds()); + tokio::time::sleep(time_to_wait.to_std().unwrap()).await; + + start(&db_connection, &path).await; + } + }) +} + +// TODO: Return a result (modify `start`) +pub async fn do_backup

(db_connection: &db::Connection, directory: P) +where + P: AsRef + Send + Sync + 'static, +{ + create_directory(&directory); + start(db_connection, &build_path(&directory)).await +} + +async fn start(db_connection: &db::Connection, path: &PathBuf) { + let path_compressed = path.with_extension("sqlite.gz"); + event!( + Level::INFO, + "Starting backup process to {}...", + path_compressed.display() + ); + + if let Err(error) = db_connection.backup(&path).await { + event!(Level::ERROR, "Error when backing up database: {}", error); + } + + // Compress the backup file. + match File::open(&path).await { + Ok(file_input) => { + let buf_reader = BufReader::new(file_input); + let mut encoder = GzipEncoder::new(buf_reader); + match File::create(&path_compressed).await { + Ok(mut file_output) => { + if let Err(error) = tokio::io::copy(&mut encoder, &mut file_output).await { + event!( + Level::ERROR, + "Error when compressing backup file: {}", + error + ); + } else if let Err(error) = std::fs::remove_file(path) { + event!( + Level::ERROR, + "Error when removing uncompressed backup file: {}", + error + ); + } else { + event!(Level::INFO, "Backup done: {}", path_compressed.display()); + } + } + Err(error) => { + event!( + Level::ERROR, + "Error when creating compressed backup file: {}", + error + ); + } + } + } + Err(error) => { + event!( + Level::ERROR, + "Error when opening backup file for compression: {}", + error + ); + } + } +} + +fn create_directory

(directory: &P) where P: AsRef + Send + Sync + 'static, { if !directory.as_ref().exists() { - std::fs::DirBuilder::new().create(&directory).unwrap(); + std::fs::DirBuilder::new().create(directory).unwrap(); } if !directory.as_ref().is_dir() { @@ -29,74 +115,14 @@ where .unwrap_or("") ); } - - tokio::task::spawn(async move { - loop { - let mut time_to_wait = time_of_day - chrono::Local::now().time(); - if time_to_wait < TimeDelta::zero() { - time_to_wait += TimeDelta::days(1); - } - event!(Level::DEBUG, "Backup in {}s", time_to_wait.num_seconds()); - tokio::time::sleep(time_to_wait.to_std().unwrap()).await; - - let path = directory.as_ref().join(format!( - "recipes_backup_{}.sqlite", - chrono::Local::now().format("%Y-%m-%d_%H%M%S") - )); - let path_compressed = path.with_extension("sqlite.gz"); - - event!( - Level::INFO, - "Starting backup process to {}...", - path_compressed.display() - ); - - if let Err(error) = db_connection.backup(&path).await { - event!(Level::ERROR, "Error when backing up database: {}", error); - } - - // Compress the backup file. - match File::open(&path).await { - Ok(file_input) => { - let buf_reader = BufReader::new(file_input); - let mut encoder = GzipEncoder::new(buf_reader); - match File::create(&path_compressed).await { - Ok(mut file_output) => { - if let Err(error) = - tokio::io::copy(&mut encoder, &mut file_output).await - { - event!( - Level::ERROR, - "Error when compressing backup file: {}", - error - ); - } else if let Err(error) = std::fs::remove_file(&path) { - event!( - Level::ERROR, - "Error when removing uncompressed backup file: {}", - error - ); - } else { - event!(Level::INFO, "Backup done: {}", path_compressed.display()); - } - } - Err(error) => { - event!( - Level::ERROR, - "Error when creating compressed backup file: {}", - error - ); - } - } - } - Err(error) => { - event!( - Level::ERROR, - "Error when opening backup file for compression: {}", - error - ); - } - } - } - }) +} + +fn build_path

(directory: &P) -> PathBuf +where + P: AsRef + Send + Sync + 'static, +{ + directory.as_ref().join(format!( + "recipes_backup_{}.sqlite", + chrono::Local::now().format("%Y-%m-%d_%H%M%S") + )) } diff --git a/backend/src/html_templates.rs b/backend/src/html_templates.rs index 1f7ee47..1277b29 100644 --- a/backend/src/html_templates.rs +++ b/backend/src/html_templates.rs @@ -1,7 +1,7 @@ use askama::Template; use crate::{ - Context, + app::Context, data::{db, model}, log::Log, translation::{self, Sentence, Tr}, diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..b5ee36f --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod config; +pub mod consts; +pub mod data; +pub mod log; + +mod email; +mod hash; +mod html_templates; +mod ron_extractor; +mod ron_utils; +mod services; +mod translation; +mod utils; diff --git a/backend/src/main.rs b/backend/src/main.rs index f94689b..1f56a0a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,132 +2,35 @@ use std::{net::SocketAddr, path::Path}; -use axum::{ - BoxError, Router, ServiceExt, - error_handling::HandleErrorLayer, - extract::{ConnectInfo, Extension, FromRef, Request, State}, - http::{StatusCode, Uri}, - middleware::{self, Next}, - response::Response, - routing::{delete, get, patch, post, put}, -}; -use axum_extra::extract::cookie::CookieJar; -use chrono::prelude::*; use clap::Parser; -use config::Config; -use itertools::Itertools; use tokio::signal; -use tower::layer::Layer; -use tower::{ServiceBuilder, buffer::BufferLayer, limit::RateLimitLayer}; -use tower_http::{ - services::{ServeDir, ServeFile}, - trace::TraceLayer, -}; use tracing::{Level, event}; -use data::{backup, db, model}; -use log::Log; -use translation::Tr; +use recipes::{ + app, config, consts, + data::{backup, db}, + log::Log, +}; -mod config; -mod consts; -mod data; -mod email; -mod hash; -mod html_templates; -mod log; -mod ron_extractor; -mod ron_utils; -mod services; -mod translation; -mod utils; - -#[derive(Clone)] -struct AppState { - config: Config, - db_connection: db::Connection, - log: Log, -} - -impl FromRef for Config { - fn from_ref(app_state: &AppState) -> Config { - app_state.config.clone() - } -} - -impl FromRef for db::Connection { - fn from_ref(app_state: &AppState) -> db::Connection { - app_state.db_connection.clone() - } -} - -impl FromRef for Log { - fn from_ref(app_state: &AppState) -> Log { - app_state.log.clone() - } -} - -impl axum::response::IntoResponse for db::DBError { - fn into_response(self) -> Response { - ron_utils::ron_error(StatusCode::INTERNAL_SERVER_ERROR, &self.to_string()).into_response() - } -} - -#[derive(Debug, thiserror::Error)] -enum AppError { - #[error("Database error: {0}")] - Database(#[from] db::DBError), - - #[error("Template error: {0}")] - Render(#[from] askama::Error), -} - -type Result = std::result::Result; - -impl axum::response::IntoResponse for AppError { - fn into_response(self) -> Response { - (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() - } -} - -#[derive(Debug, Clone)] -struct Context { - user: Option, - tr: Tr, - dark_theme: bool, -} - -impl Context { - pub fn first_day_of_the_week(&self) -> Weekday { - if let Some(user) = &self.user { - user.first_day_of_the_week - } else { - self.tr.first_day_of_week() - } - } -} - -// TODO: Should main returns 'Result'? #[tokio::main] -async fn main() { +async fn main() -> Result<(), Box> { let config = config::load(); let log = Log::new(&config.logs_directory); event!(Level::INFO, "Configuration: {:?}", config); if !process_args(&config.database_directory).await { - return; + return Ok(()); } event!(Level::INFO, "Starting Recipes as web server..."); - let Ok(db_connection) = db::Connection::new(&config.database_directory).await else { - event!(Level::ERROR, "Unable to connect to the database"); - return; - }; + let db_connection = db::Connection::new(&config.database_directory) + .await + .inspect_err(|err| event!(Level::ERROR, "Unable to connect to the database: {}", err))?; if let Some(backup_time) = config.backup_time { - backup::start( + backup::start_task( config.backups_directory.clone(), db_connection.clone(), backup_time, @@ -138,336 +41,24 @@ async fn main() { let port = config.port; - let state = AppState { + let state = app::AppState { config, db_connection, log, }; - let ron_api_routes = Router::new() - // Disabled: update user profile is now made with a post data ('edit_user_post'). - // .route("/user/update", put(services::ron::update_user)) - .route("/lang", put(services::ron::set_lang)) - .route("/recipe/titles", get(services::ron::recipe::get_titles)) - .route("/recipe/title", patch(services::ron::recipe::set_title)) - .route( - "/recipe/description", - patch(services::ron::recipe::set_description), - ) - .route( - "/recipe/servings", - patch(services::ron::recipe::set_servings), - ) - .route( - "/recipe/estimated_time", - patch(services::ron::recipe::set_estimated_time), - ) - .route( - "/recipe/tags", - get(services::ron::recipe::get_tags) - .post(services::ron::recipe::add_tags) - .delete(services::ron::recipe::rm_tags), - ) - .route( - "/recipe/difficulty", - patch(services::ron::recipe::set_difficulty), - ) - .route( - "/recipe/language", - patch(services::ron::recipe::set_language), - ) - .route( - "/recipe/is_public", - patch(services::ron::recipe::set_is_public), - ) - .route("/recipe", delete(services::ron::recipe::rm)) - .route("/recipe/groups", get(services::ron::recipe::get_groups)) - .route( - "/recipe/group", - post(services::ron::recipe::add_group).delete(services::ron::recipe::rm_group), - ) - .route( - "/recipe/group_name", - patch(services::ron::recipe::set_group_name), - ) - .route( - "/recipe/group_comment", - patch(services::ron::recipe::set_group_comment), - ) - .route( - "/recipe/groups_order", - patch(services::ron::recipe::set_groups_order), - ) - .route( - "/recipe/step", - post(services::ron::recipe::add_step).delete(services::ron::recipe::rm_step), - ) - .route( - "/recipe/step_action", - patch(services::ron::recipe::set_step_action), - ) - .route( - "/recipe/steps_order", - patch(services::ron::recipe::set_steps_order), - ) - .route( - "/recipe/ingredient", - post(services::ron::recipe::add_ingredient) - .delete(services::ron::recipe::rm_ingredient), - ) - .route( - "/recipe/ingredient_name", - patch(services::ron::recipe::set_ingredient_name), - ) - .route( - "/recipe/ingredient_comment", - patch(services::ron::recipe::set_ingredient_comment), - ) - .route( - "/recipe/ingredient_quantity", - patch(services::ron::recipe::set_ingredient_quantity), - ) - .route( - "/recipe/ingredient_unit", - patch(services::ron::recipe::set_ingredient_unit), - ) - .route( - "/recipe/ingredients_order", - patch(services::ron::recipe::set_ingredients_order), - ) - .route( - "/calendar/scheduled_recipes", - get(services::ron::calendar::get_scheduled_recipes), - ) - .route( - "/calendar/scheduled_recipe", - post(services::ron::calendar::add_scheduled_recipe) - .delete(services::ron::calendar::rm_scheduled_recipe), - ) - .route("/shopping_list", get(services::ron::shopping_list::get)) - .route( - "/shopping_list/checked", - patch(services::ron::shopping_list::set_entry_checked), - ) - .fallback(services::ron::not_found); - - let fragments_routes = Router::new().route( - "/recipes_list", - get(services::fragments::recipes_list_fragments), - ); - - let html_routes_with_rate_limit = Router::new() - .route("/signin", post(services::user::sign_in_post)) - .route("/signup", post(services::user::sign_up_post)) - .route( - "/ask_reset_password", - post(services::user::ask_reset_password_post), - ) - .layer( - ServiceBuilder::new() - .layer(HandleErrorLayer::new(|err: BoxError| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled error: {}", err), - ) - })) - .layer(BufferLayer::new(1024)) - .layer(RateLimitLayer::new( - consts::NUMBER_OF_CONCURRENT_HTTP_REQUEST_FOR_RATE_LIMIT, - consts::DURATION_FOR_RATE_LIMIT, - )), - ); - - let html_routes = Router::new() - .route("/", get(services::home_page)) - .route("/dev_panel", get(services::dev_panel)) - .route("/logs", get(services::logs)) - .route("/signup", get(services::user::sign_up_get)) - .route("/validation", get(services::user::sign_up_validation)) - .route("/revalidation", get(services::user::email_revalidation)) - .route("/signin", get(services::user::sign_in_get)) - .route("/signout", get(services::user::sign_out)) - .route( - "/ask_reset_password", - get(services::user::ask_reset_password_get), - ) - .route( - "/reset_password", - get(services::user::reset_password_get).post(services::user::reset_password_post), - ) - // Recipes. - .route("/recipe/new", get(services::recipe::create)) - .route("/recipe/edit/{id}", get(services::recipe::edit)) - .route("/recipe/view/{id}", get(services::recipe::view)) - // User. - .route( - "/user/edit", - get(services::user::edit_user_get).post(services::user::edit_user_post), - ) - .merge(html_routes_with_rate_limit) - .nest("/fragments", fragments_routes) - .route_layer(middleware::from_fn(services::ron_error_to_html)); - - let app = Router::new() - .merge(html_routes) - .nest("/ron-api", ron_api_routes) - .fallback(services::not_found) - .layer(middleware::from_fn_with_state(state.clone(), context)) - .with_state(state) - .nest_service("/favicon.ico", ServeFile::new("static/favicon.ico")) - .nest_service("/static", ServeDir::new("static")) - .layer(TraceLayer::new_for_http()); - - let url_rewriting_middleware = tower::util::MapRequestLayer::new(url_rewriting); - let app_with_url_rewriting = url_rewriting_middleware.layer(app); + let make_service = app::make_service(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve( - listener, - app_with_url_rewriting.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await - .unwrap(); + axum::serve(listener, make_service) + .with_graceful_shutdown(shutdown_signal()) + .await?; event!(Level::INFO, "Recipes stopped"); -} -#[derive(Debug, Clone)] -struct Lang(Option); - -fn url_rewriting(mut req: Request) -> Request { - // Here we are extracting the language from the url then rewriting it. - // For example: - // "/fr/recipe/view/1" - // lang = "fr" and uri rewritten as = "/recipe/view/1" - let lang_and_new_uri = 'lang_and_new_uri: { - if let Some(path_query) = req.uri().path_and_query() { - let mut parts = path_query.path().split('/'); - let _ = parts.next(); // Empty part due to the first '/'. - if let Some(lang) = parts.next() { - let available_codes = translation::available_codes(); - if available_codes.contains(&lang) { - let mut rest = String::from(""); - for part in parts { - rest.push('/'); - rest.push_str(part); - } - if let Some(query) = path_query.query() { - rest.push('?'); - rest.push_str(query); - } - - if let Ok(new_uri) = rest.parse::() { - break 'lang_and_new_uri Some((lang.to_string(), new_uri)); - } - } - } - } - None - }; - - if let Some((lang, new_uri)) = lang_and_new_uri { - *req.uri_mut() = new_uri; - req.extensions_mut().insert(Lang(Some(lang))); - } else { - req.extensions_mut().insert(Lang(None)); - } - - req -} - -/// The language associated to the current HTTP request is defined in the current order: -/// - Extraction from the url: like in `/fr/recipe/view/42` -/// - Get from the user database record. -/// - Get from the cookie. -/// - Get from the HTTP header `accept-language`. -/// - Set as `translation::DEFAULT_LANGUAGE_CODE`. -async fn context( - ConnectInfo(addr): ConnectInfo, - State(connection): State, - Extension(lang_from_url): Extension, - mut req: Request, - next: Next, -) -> Result { - let jar = CookieJar::from_headers(req.headers()); - let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(req.headers(), addr); - let user = get_current_user(connection, &jar, &client_ip, &client_user_agent).await; - - let language = if let Some(lang) = lang_from_url.0 { - lang - } else if let Some(ref user) = user { - user.lang.clone() - } else { - let available_codes = translation::available_codes(); - let jar = CookieJar::from_headers(req.headers()); - match jar.get(consts::COOKIE_LANG_NAME) { - Some(lang) if available_codes.contains(&lang.value()) => lang.value().to_string(), - _ => { - let accept_language = req - .headers() - .get(axum::http::header::ACCEPT_LANGUAGE) - .map(|v| v.to_str().unwrap_or_default()) - .unwrap_or_default() - .split(',') - .map(|l| l.split('-').next().unwrap_or_default()) - .find_or_first(|l| available_codes.contains(l)); - - match accept_language { - Some(lang) if !lang.is_empty() => lang, - _ => translation::DEFAULT_LANGUAGE_CODE, - } - .to_string() - } - } - }; - - let tr = Tr::new(&language); - - let dark_theme = match jar.get(common::consts::COOKIE_DARK_THEME) { - Some(dark_theme_cookie) => dark_theme_cookie.value().parse().unwrap_or_default(), - None => false, - }; - - req.extensions_mut().insert(Context { - user, - tr, - dark_theme, - }); - - Ok(next.run(req).await) -} - -async fn get_current_user( - connection: db::Connection, - jar: &CookieJar, - client_ip: &str, - client_user_agent: &str, -) -> Option { - match jar.get(consts::COOKIE_AUTH_TOKEN_NAME) { - Some(token_cookie) => match connection - .authentication(token_cookie.value(), client_ip, client_user_agent) - .await - { - 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) => { - event!(Level::WARN, "Error during authentication: {}", error); - None - } - } - } - Err(error) => { - event!(Level::WARN, "Error during authentication: {}", error); - None - } - }, - None => None, - } + Ok(()) } #[derive(Parser, Debug)] @@ -491,6 +82,7 @@ where if args.dbtest { // Make a backup of the database. + // TODO: use the `backup` module instead of copying the file manually. let db_path = database_directory.as_ref().join(consts::DB_FILENAME); if db_path.exists() { let db_path_bckup = (1..) @@ -515,14 +107,6 @@ where if let Err(error) = con.execute_file("sql/data_test.sql").await { event!(Level::ERROR, "{}", error); } - // Set the creation datetime to 'now'. - con.execute_sql( - sqlx::query( - "UPDATE [User] SET [validation_token_datetime] = $1 WHERE [email] = 'paul@test.org'") - .bind(Utc::now()) - ) - .await - .unwrap(); event!( Level::INFO, diff --git a/backend/src/services/fragments.rs b/backend/src/services/fragments.rs index 75f675e..27f3fdb 100644 --- a/backend/src/services/fragments.rs +++ b/backend/src/services/fragments.rs @@ -7,7 +7,11 @@ use axum::{ use serde::Deserialize; // use tracing::{event, Level}; -use crate::{Context, Result, data::db, html_templates::*}; +use crate::{ + app::{Context, Result}, + data::db, + html_templates::*, +}; #[derive(Deserialize)] pub struct CurrentRecipeId { diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 6fdbbc9..d8b156b 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -8,7 +8,14 @@ use axum::{ }; use serde::Deserialize; -use crate::{AppState, Context, Result, consts, data::db, html_templates::*, log::Log, ron_utils}; +use crate::{ + app::{AppState, Context, Result}, + consts, + data::db, + html_templates::*, + log::Log, + ron_utils, +}; pub mod fragments; pub mod recipe; diff --git a/backend/src/services/recipe.rs b/backend/src/services/recipe.rs index 0a09171..178b076 100644 --- a/backend/src/services/recipe.rs +++ b/backend/src/services/recipe.rs @@ -6,8 +6,8 @@ use axum::{ }; use crate::{ - Context, Result, - data::{db, model}, + app::{Context, Result}, + data::db, html_templates::*, translation::Sentence, }; diff --git a/backend/src/services/ron/calendar.rs b/backend/src/services/ron/calendar.rs index ef7d042..da1b992 100644 --- a/backend/src/services/ron/calendar.rs +++ b/backend/src/services/ron/calendar.rs @@ -7,7 +7,8 @@ use axum::{ use axum_extra::extract::Query; use crate::{ - Context, consts, + app::Context, + consts, data::{self, db}, ron_extractor::ExtractRon, ron_utils::{ron_error, ron_response_ok}, diff --git a/backend/src/services/ron/mod.rs b/backend/src/services/ron/mod.rs index dab2a05..7654301 100644 --- a/backend/src/services/ron/mod.rs +++ b/backend/src/services/ron/mod.rs @@ -7,7 +7,9 @@ use axum::{ use axum_extra::extract::cookie::{Cookie, CookieJar}; // use tracing::{event, Level}; -use crate::{Context, consts, data::db, model, ron_extractor::ExtractRon, ron_utils::ron_error}; +use crate::{ + app::Context, consts, data::db, data::model, ron_extractor::ExtractRon, ron_utils::ron_error, +}; pub mod calendar; pub mod recipe; diff --git a/backend/src/services/ron/recipe.rs b/backend/src/services/ron/recipe.rs index edc78ff..ca2f6ac 100644 --- a/backend/src/services/ron/recipe.rs +++ b/backend/src/services/ron/recipe.rs @@ -8,7 +8,9 @@ use axum_extra::extract::Query; use common::ron_api; // use tracing::{event, Level}; -use crate::{Context, data::db, model, ron_extractor::ExtractRon, ron_utils::ron_response_ok}; +use crate::{ + app::Context, data::db, data::model, ron_extractor::ExtractRon, ron_utils::ron_response_ok, +}; use super::rights::*; diff --git a/backend/src/services/ron/rights.rs b/backend/src/services/ron/rights.rs index 229be88..8e64109 100644 --- a/backend/src/services/ron/rights.rs +++ b/backend/src/services/ron/rights.rs @@ -3,7 +3,7 @@ use axum::{ response::{ErrorResponse, Result}, }; -use crate::{consts, data::db, model, ron_utils::ron_error}; +use crate::{consts, data::db, data::model, ron_utils::ron_error}; pub async fn check_user_rights_recipe( connection: &db::Connection, diff --git a/backend/src/services/ron/shopping_list.rs b/backend/src/services/ron/shopping_list.rs index 6d62469..034ce8a 100644 --- a/backend/src/services/ron/shopping_list.rs +++ b/backend/src/services/ron/shopping_list.rs @@ -7,9 +7,10 @@ use axum::{ use common::ron_api; use crate::{ - Context, consts, + app::Context, + consts, data::db, - model, + data::model, ron_extractor::ExtractRon, ron_utils::{ron_error, ron_response_ok}, }; diff --git a/backend/src/services/user.rs b/backend/src/services/user.rs index 50b17ea..cde852e 100644 --- a/backend/src/services/user.rs +++ b/backend/src/services/user.rs @@ -20,8 +20,14 @@ use strum_macros::Display; use tracing::{Level, event}; use crate::{ - AppState, Context, Result, config::Config, consts, data::db, email, html_templates::*, - translation::Sentence, utils, + app::{AppState, Context, Result}, + config::Config, + consts, + data::db, + email, + html_templates::*, + translation::Sentence, + utils, }; const VALIDATION_TOKEN_KEY: &str = "validation_token";