Translation (WIP)

This commit is contained in:
Greg Burri 2025-01-05 22:38:46 +01:00
parent 9b0fcec5e2
commit e9873c1943
29 changed files with 586 additions and 285 deletions

View file

@ -22,18 +22,14 @@ chrono = "0.4"
ron = "0.8"
serde = { version = "1.0", features = ["derive"] }
itertools = "0.13"
itertools = "0.14"
rustc-hash = "2.1"
clap = { version = "4", features = ["derive"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] }
askama = { version = "0.12", features = [
"with-axum",
"mime",
"mime_guess",
"markdown",
] }
askama_axum = "0.4"
rinja = { version = "0.3", features = ["with-axum"] }
rinja_axum = "0.3"
argon2 = { version = "0.5", features = ["default", "std"] }
rand_core = { version = "0.6", features = ["std"] }

View file

@ -10,6 +10,7 @@ CREATE TABLE [User] (
[email] TEXT NOT NULL,
[name] TEXT NOT NULL DEFAULT '',
[default_servings] INTEGER DEFAULT 4,
[lang] TEXT NOT NULL DEFAULT 'en',
[password] TEXT NOT NULL, -- argon2(password_plain, salt).

View file

@ -1,6 +1,7 @@
use std::{sync::LazyLock, time::Duration};
pub const FILE_CONF: &str = "conf.ron";
pub const TRANSLATION_FILE: &str = "translation.ron";
pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";

View file

@ -102,8 +102,8 @@ WHERE [Ingredient].[id] = $1 AND [user_id] = $2
.map_err(DBError::from)
}
pub async fn get_recipe(&self, id: i64) -> Result<Option<model::Recipe>> {
sqlx::query_as(
pub async fn get_recipe(&self, id: i64, with_groups: bool) -> Result<Option<model::Recipe>> {
match sqlx::query_as::<_, model::Recipe>(
r#"
SELECT
[id], [user_id], [title], [lang],
@ -114,8 +114,14 @@ FROM [Recipe] WHERE [id] = $1
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(DBError::from)
.await?
{
Some(mut recipe) if with_groups => {
recipe.groups = self.get_groups(id).await?;
Ok(Some(recipe))
}
recipe => Ok(recipe),
}
}
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
@ -543,8 +549,6 @@ ORDER BY [name]
#[cfg(test)]
mod tests {
use axum::routing::connect;
use super::*;
#[tokio::test]
@ -555,7 +559,7 @@ mod tests {
let recipe_id = connection.create_recipe(user_id).await?;
connection.set_recipe_title(recipe_id, "Crêpe").await?;
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
assert_eq!(recipe.title, "Crêpe".to_string());
Ok(())
@ -581,7 +585,7 @@ mod tests {
connection.set_recipe_language(recipe_id, "fr").await?;
connection.set_recipe_is_published(recipe_id, true).await?;
let recipe = connection.get_recipe(recipe_id).await?.unwrap();
let recipe = connection.get_recipe(recipe_id, false).await?.unwrap();
assert_eq!(recipe.id, recipe_id);
assert_eq!(recipe.title, "Ouiche");

View file

@ -76,7 +76,7 @@ FROM [UserLoginToken] WHERE [token] = $1
}
pub async fn load_user(&self, user_id: i64) -> Result<Option<model::User>> {
sqlx::query_as("SELECT [id], [email], [name] FROM [User] WHERE [id] = $1")
sqlx::query_as("SELECT [id], [email], [name], [lang] FROM [User] WHERE [id] = $1")
.bind(user_id)
.fetch_optional(&self.pool)
.await
@ -102,13 +102,14 @@ FROM [UserLoginToken] WHERE [token] = $1
.fetch_one(&mut *tx)
.await?;
let new_email = new_email.map(str::trim);
let email_changed = new_email.is_some_and(|new_email| new_email != email);
// Check if email not already taken.
let validation_token = if email_changed {
if sqlx::query_scalar::<_, i64>(
if sqlx::query_scalar(
r#"
SELECT COUNT(*)
SELECT COUNT(*) > 0
FROM [User]
WHERE [email] = $1
"#,
@ -116,7 +117,6 @@ WHERE [email] = $1
.bind(new_email.unwrap())
.fetch_one(&mut *tx)
.await?
> 0
{
return Ok(UpdateUserResult::EmailAlreadyTaken);
}
@ -148,7 +148,7 @@ WHERE [id] = $1
)
.bind(user_id)
.bind(new_email.unwrap_or(&email))
.bind(new_name.unwrap_or(&name))
.bind(new_name.map(str::trim).unwrap_or(&name))
.bind(hashed_new_password.unwrap_or(hashed_password))
.execute(&mut *tx)
.await?;

View file

@ -7,6 +7,7 @@ pub struct User {
pub id: i64,
pub name: String,
pub email: String,
pub lang: String,
}
#[derive(FromRow)]
@ -30,8 +31,9 @@ pub struct Recipe {
pub servings: Option<u32>,
pub is_published: bool,
// pub tags: Vec<String>,
// pub groups: Vec<Group>,
#[sqlx(skip)]
pub groups: Vec<Group>,
}
#[derive(FromRow)]

View file

@ -1,6 +1,9 @@
use askama::Template;
use rinja_axum::Template;
use crate::data::model;
use crate::{
data::model,
translation::{Sentence, Tr},
};
pub struct Recipes {
pub published: Vec<(i64, String)>,
@ -18,6 +21,8 @@ impl Recipes {
#[template(path = "home.html")]
pub struct HomeTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes,
}
@ -25,23 +30,26 @@ pub struct HomeTemplate {
#[template(path = "message.html")]
pub struct MessageTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub message: String,
pub as_code: bool, // Display the message in <pre> markup.
}
impl MessageTemplate {
pub fn new(message: &str) -> MessageTemplate {
pub fn new(message: &str, tr: Tr) -> MessageTemplate {
MessageTemplate {
user: None,
tr,
message: message.to_string(),
as_code: false,
}
}
pub fn new_with_user(message: &str, user: Option<model::User>) -> MessageTemplate {
pub fn new_with_user(message: &str, tr: Tr, user: Option<model::User>) -> MessageTemplate {
MessageTemplate {
user,
tr,
message: message.to_string(),
as_code: false,
}
@ -52,6 +60,7 @@ impl MessageTemplate {
#[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub email: String,
pub message: String,
@ -63,6 +72,7 @@ pub struct SignUpFormTemplate {
#[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub email: String,
pub message: String,
@ -72,6 +82,7 @@ pub struct SignInFormTemplate {
#[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub email: String,
pub message: String,
@ -82,6 +93,7 @@ pub struct AskResetPasswordTemplate {
#[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub reset_token: String,
pub message: String,
@ -92,6 +104,7 @@ pub struct ResetPasswordTemplate {
#[template(path = "profile.html")]
pub struct ProfileTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub username: String,
pub email: String,
@ -104,6 +117,8 @@ pub struct ProfileTemplate {
#[template(path = "recipe_view.html")]
pub struct RecipeViewTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes,
pub recipe: model::Recipe,
@ -113,6 +128,8 @@ pub struct RecipeViewTemplate {
#[template(path = "recipe_edit.html")]
pub struct RecipeEditTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes,
pub recipe: model::Recipe,
@ -123,5 +140,7 @@ pub struct RecipeEditTemplate {
#[template(path = "recipes_list_fragment.html")]
pub struct RecipesListFragmentTemplate {
pub user: Option<model::User>,
pub tr: Tr,
pub recipes: Recipes,
}

View file

@ -1,7 +1,7 @@
use std::{net::SocketAddr, path::Path};
use axum::{
extract::{ConnectInfo, FromRef, Request, State},
extract::{ConnectInfo, Extension, FromRef, Request, State},
http::StatusCode,
middleware::{self, Next},
response::{Response, Result},
@ -12,10 +12,12 @@ use axum_extra::extract::cookie::CookieJar;
use chrono::prelude::*;
use clap::Parser;
use config::Config;
use itertools::Itertools;
use tower_http::{services::ServeDir, trace::TraceLayer};
use tracing::{event, Level};
use data::{db, model};
use translation::Tr;
mod config;
mod consts;
@ -26,6 +28,7 @@ mod html_templates;
mod ron_extractor;
mod ron_utils;
mod services;
mod translation;
mod utils;
#[derive(Clone)]
@ -191,6 +194,7 @@ async fn main() {
.fallback(services::not_found)
.layer(TraceLayer::new_for_http())
// FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'.
.layer(middleware::from_fn(translation))
.layer(middleware::from_fn_with_state(
state.clone(),
user_authentication,
@ -218,6 +222,39 @@ async fn user_authentication(
Ok(next.run(req).await)
}
async fn translation(
Extension(user): Extension<Option<model::User>>,
mut req: Request,
next: Next,
) -> Result<Response> {
let language = if let Some(user) = user {
user.lang
} else {
let available_codes = Tr::available_codes();
// TODO: Check cookies before http headers.
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));
// TODO: Save to cookies.
accept_language.unwrap_or("en").to_string()
};
let tr = Tr::new(&language);
// let jar = CookieJar::from_headers(req.headers());
req.extensions_mut().insert(tr);
Ok(next.run(req).await)
}
async fn get_current_user(
connection: db::Connection,
jar: &CookieJar,

View file

@ -10,7 +10,7 @@ use axum::{
use crate::{
data::{db, model},
html_templates::*,
ron_utils,
ron_utils, translation,
};
pub mod fragments;
@ -19,7 +19,11 @@ pub mod ron;
pub mod user;
// Will embed RON error in HTML page.
pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
pub async fn ron_error_to_html(
Extension(tr): Extension<translation::Tr>,
req: Request,
next: Next,
) -> Result<Response> {
let response = next.run(req).await;
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
@ -32,6 +36,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
user: None,
message,
as_code: true,
tr,
}
.into_response());
}
@ -46,6 +51,7 @@ pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
pub async fn home_page(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
@ -59,15 +65,18 @@ pub async fn home_page(
current_id: None,
};
Ok(HomeTemplate { user, recipes })
Ok(HomeTemplate { user, recipes, tr })
}
///// 404 /////
#[debug_handler]
pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
pub async fn not_found(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
MessageTemplate::new_with_user("404: Not found", user),
MessageTemplate::new_with_user("404: Not found", tr, user),
)
}

View file

@ -9,6 +9,7 @@ use serde::Deserialize;
use crate::{
data::{db, model},
html_templates::*,
translation,
};
#[derive(Deserialize)]
@ -21,6 +22,7 @@ pub async fn recipes_list_fragments(
State(connection): State<db::Connection>,
current_recipe: Query<CurrentRecipeId>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
@ -33,5 +35,5 @@ pub async fn recipes_list_fragments(
},
current_id: current_recipe.current_recipe_id,
};
Ok(RecipesListFragmentTemplate { user, recipes })
Ok(RecipesListFragmentTemplate { user, tr, recipes })
}

View file

@ -9,20 +9,20 @@ use crate::{
consts,
data::{db, model},
html_templates::*,
translation,
};
///// RECIPE /////
#[debug_handler]
pub async fn create(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<Response> {
if let Some(user) = user {
let recipe_id = connection.create_recipe(user.id).await?;
Ok(Redirect::to(&format!("/recipe/edit/{}", recipe_id)).into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
@ -30,10 +30,11 @@ pub async fn create(
pub async fn edit_recipe(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
if let Some(user) = user {
if let Some(recipe) = connection.get_recipe(recipe_id).await? {
if let Some(recipe) = connection.get_recipe(recipe_id, false).await? {
if recipe.user_id == user.id {
let recipes = Recipes {
published: connection.get_all_published_recipe_titles().await?,
@ -45,19 +46,20 @@ pub async fn edit_recipe(
Ok(RecipeEditTemplate {
user: Some(user),
tr,
recipes,
recipe,
languages: *consts::LANGUAGES,
}
.into_response())
} else {
Ok(MessageTemplate::new("Not allowed to edit this recipe").into_response())
Ok(MessageTemplate::new("Not allowed to edit this recipe", tr).into_response())
}
} else {
Ok(MessageTemplate::new("Recipe not found").into_response())
Ok(MessageTemplate::new("Recipe not found", tr).into_response())
}
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
@ -65,15 +67,17 @@ pub async fn edit_recipe(
pub async fn view(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Path(recipe_id): Path<i64>,
) -> Result<Response> {
match connection.get_recipe(recipe_id).await? {
match connection.get_recipe(recipe_id, true).await? {
Some(recipe) => {
if !recipe.is_published
&& (user.is_none() || recipe.user_id != user.as_ref().unwrap().id)
{
return Ok(MessageTemplate::new_with_user(
&format!("Not allowed the view the recipe {}", recipe_id),
tr,
user,
)
.into_response());
@ -93,6 +97,7 @@ pub async fn view(
Ok(RecipeViewTemplate {
user,
tr,
recipes,
recipe,
}
@ -100,6 +105,7 @@ pub async fn view(
}
None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id),
tr,
user,
)
.into_response()),

View file

@ -19,6 +19,7 @@ use crate::{
data::{db, model},
email,
html_templates::*,
translation::{self, Sentence},
utils, AppState,
};
@ -27,9 +28,11 @@ use crate::{
#[debug_handler]
pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignUpFormTemplate {
user,
tr,
email: String::new(),
message: String::new(),
message_email: String::new(),
@ -59,34 +62,37 @@ pub async fn sign_up_post(
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<SignUpFormData>,
) -> Result<Response> {
fn error_response(
error: SignUpError,
form_data: &SignUpFormData,
user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> {
Ok(SignUpFormTemplate {
user,
email: form_data.email.clone(),
message_email: match error {
SignUpError::InvalidEmail => "Invalid email",
_ => "",
}
.to_string(),
SignUpError::InvalidEmail => tr.t(Sentence::InvalidEmail),
_ => String::new(),
},
message_password: match error {
SignUpError::PasswordsNotEqual => "Passwords don't match",
SignUpError::InvalidPassword => "Password must have at least eight characters",
_ => "",
}
.to_string(),
SignUpError::PasswordsNotEqual => tr.t(Sentence::PasswordDontMatch),
SignUpError::InvalidPassword => tr.tp(
Sentence::InvalidPassword,
&[Box::new(common::consts::MIN_PASSWORD_SIZE)],
),
_ => String::new(),
},
message: match error {
SignUpError::UserAlreadyExists => "This email is not available",
SignUpError::DatabaseError => "Database error",
SignUpError::UnableSendEmail => "Unable to send the validation email",
_ => "",
}
.to_string(),
SignUpError::UserAlreadyExists => tr.t(Sentence::EmailAlreadyTaken),
SignUpError::DatabaseError => "Database error".to_string(),
SignUpError::UnableSendEmail => tr.t(Sentence::UnableToSendEmail),
_ => String::new(),
},
tr,
}
.into_response())
}
@ -95,17 +101,17 @@ pub async fn sign_up_post(
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(SignUpError::InvalidEmail, &form_data, user);
return error_response(SignUpError::InvalidEmail, &form_data, user, tr);
}
if form_data.password_1 != form_data.password_2 {
return error_response(SignUpError::PasswordsNotEqual, &form_data, user);
return error_response(SignUpError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(SignUpError::InvalidPassword, &form_data, user);
return error_response(SignUpError::InvalidPassword, &form_data, user, tr);
}
match connection
@ -113,7 +119,7 @@ pub async fn sign_up_post(
.await
{
Ok(db::user::SignUpResult::UserAlreadyExists) => {
error_response(SignUpError::UserAlreadyExists, &form_data, user)
error_response(SignUpError::UserAlreadyExists, &form_data, user, tr)
}
Ok(db::user::SignUpResult::UserCreatedWaitingForValidation(token)) => {
let url = utils::get_url_from_host(&host);
@ -133,16 +139,16 @@ pub async fn sign_up_post(
Ok(()) => Ok(
MessageTemplate::new_with_user(
"An email has been sent, follow the link to validate your account",
user).into_response()),
tr, user).into_response()),
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user)
error_response(SignUpError::UnableSendEmail, &form_data, user, tr)
}
}
}
Err(_) => {
// error!("Signup database error: {}", error); // TODO: log
error_response(SignUpError::DatabaseError, &form_data, user)
error_response(SignUpError::DatabaseError, &form_data, user, tr)
}
}
}
@ -151,6 +157,7 @@ pub async fn sign_up_post(
pub async fn sign_up_validation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
@ -159,7 +166,7 @@ pub async fn sign_up_validation(
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
MessageTemplate::new_with_user("User already exists", tr, user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -183,6 +190,7 @@ pub async fn sign_up_validation(
jar,
MessageTemplate::new_with_user(
"Email validation successful, your account has been created",
tr,
user,
),
))
@ -191,18 +199,23 @@ pub async fn sign_up_validation(
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
tr,
user,
),
)),
db::user::ValidationResult::UnknownUser => Ok((
jar,
MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
MessageTemplate::new_with_user(
"Validation error. Try to sign up again",
tr,
user,
),
)),
}
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
MessageTemplate::new_with_user("Validation error", tr, user),
)),
}
}
@ -212,9 +225,11 @@ pub async fn sign_up_validation(
#[debug_handler]
pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<impl IntoResponse> {
Ok(SignInFormTemplate {
user,
tr,
email: String::new(),
message: String::new(),
})
@ -231,6 +246,7 @@ pub async fn sign_in_post(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
headers: HeaderMap,
Form(form_data): Form<SignInFormData>,
) -> Result<(CookieJar, Response)> {
@ -251,7 +267,8 @@ pub async fn sign_in_post(
SignInFormTemplate {
user,
email: form_data.email,
message: "This account must be validated first".to_string(),
message: tr.t(Sentence::AccountMustBeValidatedFirst),
tr,
}
.into_response(),
)),
@ -260,7 +277,8 @@ pub async fn sign_in_post(
SignInFormTemplate {
user,
email: form_data.email,
message: "Wrong email or password".to_string(),
message: tr.t(Sentence::WrongEmailOrPassword),
tr,
}
.into_response(),
)),
@ -292,16 +310,19 @@ pub async fn sign_out(
#[debug_handler]
pub async fn ask_reset_password_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Result<Response> {
if user.is_some() {
Ok(MessageTemplate::new_with_user(
"Can't ask to reset password when already logged in",
tr,
user,
)
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
tr,
email: String::new(),
message: String::new(),
message_email: String::new(),
@ -329,15 +350,18 @@ pub async fn ask_reset_password_post(
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<AskResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: AskResetPasswordError,
email: &str,
user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> {
Ok(AskResetPasswordTemplate {
user,
tr,
email: email.to_string(),
message_email: match error {
AskResetPasswordError::InvalidEmail => "Invalid email",
@ -362,7 +386,12 @@ pub async fn ask_reset_password_post(
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
return error_response(
AskResetPasswordError::InvalidEmail,
&form_data.email,
user,
tr,
);
}
match connection
@ -376,10 +405,14 @@ pub async fn ask_reset_password_post(
AskResetPasswordError::EmailAlreadyReset,
&form_data.email,
user,
tr,
),
Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => error_response(
AskResetPasswordError::EmailUnknown,
&form_data.email,
user,
tr,
),
Ok(db::user::GetTokenResetPasswordResult::EmailUnknown) => {
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
}
Ok(db::user::GetTokenResetPasswordResult::Ok(token)) => {
let url = utils::get_url_from_host(&host);
match email::send_email(
@ -396,6 +429,7 @@ pub async fn ask_reset_password_post(
{
Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.",
tr,
user,
)
.into_response()),
@ -405,13 +439,19 @@ pub async fn ask_reset_password_post(
AskResetPasswordError::UnableSendEmail,
&form_data.email,
user,
tr,
)
}
}
}
Err(error) => {
event!(Level::ERROR, "{}", error);
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
error_response(
AskResetPasswordError::DatabaseError,
&form_data.email,
user,
tr,
)
}
}
}
@ -419,18 +459,20 @@ pub async fn ask_reset_password_post(
#[debug_handler]
pub async fn reset_password_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Query(query): Query<HashMap<String, String>>,
) -> Result<Response> {
if let Some(reset_token) = query.get("reset_token") {
Ok(ResetPasswordTemplate {
user,
tr,
reset_token: reset_token.to_string(),
message: String::new(),
message_password: String::new(),
}
.into_response())
} else {
Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
Ok(MessageTemplate::new_with_user("Reset token missing", tr, user).into_response())
}
}
@ -452,15 +494,18 @@ enum ResetPasswordError {
pub async fn reset_password_post(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<ResetPasswordForm>,
) -> Result<Response> {
fn error_response(
error: ResetPasswordError,
form_data: &ResetPasswordForm,
user: Option<model::User>,
tr: translation::Tr,
) -> Result<Response> {
Ok(ResetPasswordTemplate {
user,
tr,
reset_token: form_data.reset_token.clone(),
message_password: match error {
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
@ -481,13 +526,13 @@ pub async fn reset_password_post(
}
if form_data.password_1 != form_data.password_2 {
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
return error_response(ResetPasswordError::InvalidPassword, &form_data, user, tr);
}
match connection
@ -498,34 +543,39 @@ pub async fn reset_password_post(
)
.await
{
Ok(db::user::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
"Your password has been reset",
user,
)
.into_response()),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user)
Ok(db::user::ResetPasswordResult::Ok) => {
Ok(
MessageTemplate::new_with_user("Your password has been reset", tr, user)
.into_response(),
)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
Ok(db::user::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user, tr)
}
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user, tr),
}
}
/// EDIT PROFILE ///
#[debug_handler]
pub async fn edit_user_get(Extension(user): Extension<Option<model::User>>) -> Response {
pub async fn edit_user_get(
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
) -> Response {
if let Some(user) = user {
ProfileTemplate {
username: user.name.clone(),
email: user.email.clone(),
user: Some(user),
message: String::new(),
message_email: String::new(),
message_password: String::new(),
user: Some(user),
tr,
}
.into_response()
} else {
MessageTemplate::new("Not logged in").into_response()
MessageTemplate::new("Not logged in", tr).into_response()
}
}
@ -552,6 +602,7 @@ pub async fn edit_user_post(
State(connection): State<db::Connection>,
State(config): State<Config>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
Form(form_data): Form<EditUserForm>,
) -> Result<Response> {
if let Some(user) = user {
@ -559,6 +610,7 @@ pub async fn edit_user_post(
error: ProfileUpdateError,
form_data: &EditUserForm,
user: model::User,
tr: translation::Tr,
) -> Result<Response> {
Ok(ProfileTemplate {
user: Some(user),
@ -584,6 +636,7 @@ pub async fn edit_user_post(
_ => "",
}
.to_string(),
tr,
}
.into_response())
}
@ -591,17 +644,17 @@ pub async fn edit_user_post(
if let common::utils::EmailValidation::NotValid =
common::utils::validate_email(&form_data.email)
{
return error_response(ProfileUpdateError::InvalidEmail, &form_data, user);
return error_response(ProfileUpdateError::InvalidEmail, &form_data, user, tr);
}
let new_password = if !form_data.password_1.is_empty() || !form_data.password_2.is_empty() {
if form_data.password_1 != form_data.password_2 {
return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user);
return error_response(ProfileUpdateError::PasswordsNotEqual, &form_data, user, tr);
}
if let common::utils::PasswordValidation::TooShort =
common::utils::validate_password(&form_data.password_1)
{
return error_response(ProfileUpdateError::InvalidPassword, &form_data, user);
return error_response(ProfileUpdateError::InvalidPassword, &form_data, user, tr);
}
Some(form_data.password_1.as_ref())
} else {
@ -621,7 +674,7 @@ pub async fn edit_user_post(
.await
{
Ok(db::user::UpdateUserResult::EmailAlreadyTaken) => {
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user);
return error_response(ProfileUpdateError::EmailAlreadyTaken, &form_data, user, tr);
}
Ok(db::user::UpdateUserResult::UserUpdatedWaitingForRevalidation(token)) => {
let url = utils::get_url_from_host(&host);
@ -644,14 +697,17 @@ pub async fn edit_user_post(
}
Err(_) => {
// error!("Email validation error: {}", error); // TODO: log
return error_response(ProfileUpdateError::UnableSendEmail, &form_data, user);
return error_response(
ProfileUpdateError::UnableSendEmail, &form_data, user, tr);
}
}
}
Ok(db::user::UpdateUserResult::Ok) => {
message = "Profile saved";
}
Err(_) => return error_response(ProfileUpdateError::DatabaseError, &form_data, user),
Err(_) => {
return error_response(ProfileUpdateError::DatabaseError, &form_data, user, tr)
}
}
// Reload after update.
@ -664,10 +720,11 @@ pub async fn edit_user_post(
message: message.to_string(),
message_email: String::new(),
message_password: String::new(),
tr,
}
.into_response())
} else {
Ok(MessageTemplate::new("Not logged in").into_response())
Ok(MessageTemplate::new("Not logged in", tr).into_response())
}
}
@ -675,6 +732,7 @@ pub async fn edit_user_post(
pub async fn email_revalidation(
State(connection): State<db::Connection>,
Extension(user): Extension<Option<model::User>>,
Extension(tr): Extension<translation::Tr>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
@ -683,7 +741,7 @@ pub async fn email_revalidation(
if user.is_some() {
return Ok((
jar,
MessageTemplate::new_with_user("User already exists", user),
MessageTemplate::new_with_user("User already exists", tr, user),
));
}
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -705,13 +763,14 @@ pub async fn email_revalidation(
let user = connection.load_user(user_id).await?;
Ok((
jar,
MessageTemplate::new_with_user("Email validation successful", user),
MessageTemplate::new_with_user("Email validation successful", tr, user),
))
}
db::user::ValidationResult::ValidationExpired => Ok((
jar,
MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again with the same email",
tr,
user,
),
)),
@ -719,6 +778,7 @@ pub async fn email_revalidation(
jar,
MessageTemplate::new_with_user(
"Validation error. Try to sign up again with the same email",
tr,
user,
),
)),
@ -726,7 +786,7 @@ pub async fn email_revalidation(
}
None => Ok((
jar,
MessageTemplate::new_with_user("Validation error", user),
MessageTemplate::new_with_user("Validation error", tr, user),
)),
}
}

142
backend/src/translation.rs Normal file
View file

@ -0,0 +1,142 @@
use std::{fs::File, sync::LazyLock};
use ron::de::from_reader;
use rustc_hash::FxHashMap;
use serde::Deserialize;
use tracing::{event, Level};
use crate::consts;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Clone)]
pub enum Sentence {
ProfileTitle,
MainTitle,
CreateNewRecipe,
UnpublishedRecipes,
UntitledRecipe,
EmailAddress,
Password,
// Sign in page.
SignInMenu,
SignInTitle,
SignInButton,
WrongEmailOrPassword,
// Sign up page.
SignUpMenu,
SignUpTitle,
SignUpButton,
ChooseAPassword,
ReEnterPassword,
AccountMustBeValidatedFirst,
InvalidEmail,
PasswordDontMatch,
InvalidPassword,
EmailAlreadyTaken,
UnableToSendEmail,
// Reset password page.
LostPassword,
AskResetButton,
}
#[derive(Clone)]
pub struct Tr {
lang: &'static Language,
}
impl Tr {
pub fn new(code: &str) -> Self {
for lang in TRANSLATIONS.iter() {
if lang.code == code {
return Self { lang };
}
}
event!(
Level::WARN,
"Unable to find translation for language {}",
code
);
Tr::new("en")
}
pub fn t(&self, sentence: Sentence) -> String {
match self.lang.translation.get(&sentence) {
Some(str) => str.clone(),
None => format!(
"Translation missing, lang: {}/{}, element: {:?}",
self.lang.name, self.lang.code, sentence
),
}
}
pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString>]) -> String {
match self.lang.translation.get(&sentence) {
Some(str) => {
let mut result = str.clone();
for p in params {
result = result.replacen("{}", &p.to_string(), 1);
}
result
}
None => format!(
"Translation missing, lang: {}/{}, element: {:?}",
self.lang.name, self.lang.code, sentence
),
}
}
pub fn available_languages() -> Vec<(&'static str, &'static str)> {
TRANSLATIONS
.iter()
.map(|tr| (tr.code.as_ref(), tr.name.as_ref()))
.collect()
}
pub fn available_codes() -> Vec<&'static str> {
TRANSLATIONS.iter().map(|tr| tr.code.as_ref()).collect()
}
}
// #[macro_export]
// macro_rules! t {
// ($self:expr, $str:expr) => {
// $self.t($str)
// };
// ($self:expr, $str:expr, $( $x:expr ),+ ) => {
// {
// let mut result = $self.t($str);
// $( result = result.replacen("{}", &$x.to_string(), 1); )+
// result
// }
// };
// }
#[derive(Debug, Deserialize, Clone)]
struct Language {
code: String,
name: String,
translation: FxHashMap<Sentence, String>,
}
static TRANSLATIONS: LazyLock<Vec<Language>> =
LazyLock::new(|| match File::open(consts::TRANSLATION_FILE) {
Ok(file) => from_reader(file).unwrap_or_else(|error| {
panic!(
"Failed to read translation file {}: {}",
consts::TRANSLATION_FILE,
error
)
}),
Err(error) => {
panic!(
"Failed to open translation file {}: {}",
consts::TRANSLATION_FILE,
error
)
}
});

View file

@ -2,6 +2,7 @@
{% block main_container %}
<div class="content">
<h1></h1>
<form action="/ask_reset_password" method="post">
<label for="email_field">Your email address</label>
<input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />

View file

@ -6,7 +6,7 @@
{% match user %}
{% when Some with (user) %}
<a class="create-recipe" href="/recipe/new" >Create a new recipe</a>
<a class="create-recipe" href="/recipe/new" >{{ tr.t(Sentence::CreateNewRecipe) }}</a>
<span><a href="/user/edit">
{% if user.name == "" %}
{{ user.email }}
@ -16,7 +16,7 @@
</a> / <a href="/signout" />Sign out</a></span>
{% when None %}
<span>
<a href="/signin" >Sign in</a>/<a href="/signup">Sign up</a>/<a href="/ask_reset_password">Lost password</a>
<a href="/signin" >{{ tr.t(Sentence::SignInMenu) }}</a>/<a href="/signup">{{ tr.t(Sentence::SignUpMenu) }}</a>/<a href="/ask_reset_password">{{ tr.t(Sentence::LostPassword) }}</a>
</span>
{% endmatch %}

View file

@ -7,7 +7,7 @@
<pre><code>
{% endif %}
{{ message|markdown }}
{{ message }}
{% if as_code %}
</code></pre>

View file

@ -6,7 +6,8 @@
{% when Some with (user) %}
<div class="content" id="user-edit">
<h1>Profile</h1>
<h1>{{ tr.t(Sentence::ProfileTitle) }}</h1>
<form action="/user/edit" method="post">
<label for="input-name">Name</label>
@ -20,7 +21,9 @@
autofocus="autofocus" />
<label for="input-email">Email (need to be revalidated if changed)</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
<input id="input-email" type="email"
name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}

View file

@ -11,7 +11,7 @@
{% if !recipe.description.is_empty() %}
<div class="recipe-description" >
{{ recipe.description.clone()|markdown }}
{{ recipe.description.clone() }}
</div>
{% endif %}
</div>

View file

@ -1,8 +1,7 @@
{% macro recipe_item(id, title, class) %}
<a href="/recipe/view/{{ id }}" class="{{ class }}" id="recipe-{{ id }}">
{% if title == "" %}
{# TODO: Translation #}
Untitled recipe
{{ tr.t(Sentence::UntitledRecipe) }}
{% else %}
{{ title }}
{% endif %}
@ -13,7 +12,7 @@
<div id="recipes-list">
{% if !recipes.unpublished.is_empty() %}
Unpublished recipes
{{ tr.t(Sentence::UnpublishedRecipes) }}
{% endif %}
<nav class="recipes-list-unpublished">

View file

@ -4,16 +4,16 @@
<div class="content" id="sign-in">
<h1>Sign in</h1>
<h1>{{ tr.t(Sentence::SignInTitle) }}</h1>
<form action="/signin" method="post">
<label for="input-email">Email address</label>
<label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
<label for="input-password">Password</label>
<label for="input-password">{{ tr.t(Sentence::Password) }}</label>
<input id="input-password" type="password" name="password" autocomplete="current-password" />
<input type="submit" value="Sign in" />
<input type="submit" value="{{ tr.t(Sentence::SignInMenu) }}" />
</form>
{{ message }}
</div>

View file

@ -4,23 +4,27 @@
<div class="content" id="sign-up">
<h1>Sign up</h1>
<h1>{{ tr.t(Sentence::SignUpTitle) }}</h1>
<form action="/signup" method="post">
<label for="input-email">Your email address</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
<label for="input-email">{{ tr.t(Sentence::EmailAddress) }}</label>
<input id="input-email" type="email"
name="email" value="{{ email }}"
autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}
<label for="input-password-1">Choose a password (minimum 8 characters)</label>
<label for="input-password-1">
{{ tr.tp(Sentence::ChooseAPassword, [Box::new(common::consts::MIN_PASSWORD_SIZE)]) }}
</label>
<input id="input-password-1" type="password" name="password_1" autocomplete="new-password" />
<label for="input-password-2">Re-enter password</label>
<label for="input-password-2">{{ tr.t(Sentence::ReEnterPassword) }}</label>
<input id="input-password-2" type="password" name="password_2" autocomplete="new-password" />
{{ message_password }}
<input type="submit" name="commit" value="Sign up" />
<input type="submit" name="commit" value="{{ tr.t(Sentence::SignUpButton) }}" />
</form>
{{ message }}
</div>

View file

@ -1 +1 @@
<a class="title" href="/">~~ Recettes de cuisine ~~</a>
<a class="title" href="/">{{ tr.t(Sentence::MainTitle) }}</a>

70
backend/translation.ron Normal file
View file

@ -0,0 +1,70 @@
[
(
code: "en",
name: "English",
translation: {
ProfileTitle: "Profile",
MainTitle: "Cooking Recipes",
CreateNewRecipe: "Create a new recipe",
UnpublishedRecipes: "Unpublished recipes",
UntitledRecipe: "Untitled recipe",
EmailAddress: "Email address",
Password: "Password",
SignInMenu: "Sign in",
SignInTitle: "Sign in",
SignInButton: "Sign in",
WrongEmailOrPassword: "Wrong email or password",
AccountMustBeValidatedFirst: "This account must be validated first",
InvalidEmail: "Invalid email",
PasswordDontMatch: "Passwords don't match",
InvalidPassword: "Password must have at least {} characters",
EmailAlreadyTaken: "This email is not available",
UnableToSendEmail: "Unable to send the validation email",
SignUpMenu: "Sign up",
SignUpTitle: "Sign up",
SignUpButton: "Sign up",
ChooseAPassword: "Choose a password (minimum {} characters)",
ReEnterPassword: "Re-enter password",
LostPassword: "Lost password",
AskResetButton: "Ask reset",
}
),
(
code: "fr",
name: "Français",
translation: {
ProfileTitle: "Profile",
MainTitle: "Recette de Cuisine",
CreateNewRecipe: "Créer une nouvelle recette",
UnpublishedRecipes: "Recettes non-publiés",
UntitledRecipe: "Recette sans nom",
EmailAddress: "Adresse email",
Password: "Mot de passe",
SignInMenu: "Se connecter",
SignInTitle: "Se connecter",
SignInButton: "Se connecter",
WrongEmailOrPassword: "Mot de passe ou email invalide",
AccountMustBeValidatedFirst: "Ce compte doit d'abord être validé",
InvalidEmail: "Adresse email invalide",
PasswordDontMatch: "Les mots de passe ne correspondent pas",
InvalidPassword: "Le mot de passe doit avoir au moins {} caractères",
EmailAlreadyTaken: "Cette adresse email n'est pas disponible",
UnableToSendEmail: "L'email de validation n'a pas pu être envoyé",
SignUpMenu: "S'inscrire",
SignUpTitle: "Inscription",
SignUpButton: "Valider",
ChooseAPassword: "Choisir un mot de passe (minimum {} caractères)",
ReEnterPassword: "Entrez à nouveau le mot de passe",
LostPassword: "Mot de passe perdu",
AskResetButton: "Demander la réinitialisation",
}
)
]