This commit is contained in:
Greg Burri 2025-05-07 20:12:49 +02:00
parent 198bff6e4a
commit 6f014ef238
19 changed files with 118 additions and 81 deletions

View file

@ -355,11 +355,11 @@ fn url_rewriting(mut req: Request) -> Request {
}
/// 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`.
/// 1. Extraction from the url: like in `/fr/recipe/view/42`
/// 2. Get from the user database record.
/// 3. Get from the cookie.
/// 4. Get from the HTTP header `accept-language`.
/// 5. Set as [translation::DEFAULT_LANGUAGE_CODE].
async fn context(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
State(connection): State<db::Connection>,

View file

@ -17,6 +17,10 @@ pub struct Config {
#[serde(default = "port_default")]
pub port: u16,
/// The email address used when sending validation email.
#[serde(default = "email_address_default")]
pub email_address: String,
#[serde(default = "smtp_relay_address_default")]
pub smtp_relay_address: String,
@ -44,11 +48,14 @@ pub struct Config {
fn port_default() -> u16 {
8082
}
fn smtp_relay_address_default() -> String {
"mail.something.com".to_string()
}
fn email_address_default() -> String {
"".to_string()
}
fn smtp_login_default() -> String {
"login".to_string()
}

View file

@ -38,9 +38,6 @@ pub const COOKIE_LANG_NAME: &str = "lang";
/// (cookie authentication, password reset, validation token).
pub const TOKEN_SIZE: usize = 32;
// TODO: Move it in conf.ron.
pub const EMAIL_ADDRESS: &str = "recipes@gburri.org";
/// When sending a validation email,
/// the server has this duration to wait for a response from the SMTP server.
pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60);

View file

@ -4,7 +4,6 @@ use lettre::{
AsyncTransport, Message, Tokio1Executor,
transport::smtp::{AsyncSmtpTransport, authentication::Credentials},
};
use tracing::error;
use crate::consts;
@ -22,7 +21,13 @@ pub enum Error {
#[async_trait::async_trait]
pub trait EmailServiceTrait: Send + Sync {
async fn send_email(&self, email: &str, title: &str, message: &str) -> Result<(), Error>;
async fn send_email(
&self,
email_sender: &str,
email_receiver: &str,
title: &str,
message: &str,
) -> Result<(), Error>;
}
pub struct EmailService {
@ -49,11 +54,17 @@ impl EmailService {
impl EmailServiceTrait for EmailService {
/// A function to send an email using the given SMTP address.
/// It may timeout if the SMTP server is not reachable, see [consts::SEND_EMAIL_TIMEOUT].
async fn send_email(&self, email: &str, title: &str, message: &str) -> Result<(), Error> {
async fn send_email(
&self,
email_sender: &str,
email_receiver: &str,
title: &str,
message: &str,
) -> Result<(), Error> {
let email = Message::builder()
.message_id(None)
.from(consts::EMAIL_ADDRESS.parse()?)
.to(email.parse()?)
.from(email_sender.parse()?)
.to(email_receiver.parse()?)
.subject(title)
.body(message.to_string())?;

View file

@ -5,8 +5,10 @@ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
const MAX_LENGTH_PASSWORD: usize = 255;
fn get_argon2<'k>() -> Argon2<'k> {
// Note: It's not neccessary to have only one Argon2 object, creating a new one
// Note: It's not necessary to have only one Argon2 object, creating a new one
// when we need it is lightweight.
Argon2::new(
argon2::Algorithm::Argon2id,
@ -22,6 +24,14 @@ fn get_argon2<'k>() -> Argon2<'k> {
}
pub fn hash(password: &str) -> Result<String, Box<dyn std::error::Error>> {
if password.len() > MAX_LENGTH_PASSWORD {
return Err(format!("Password max length is {}", MAX_LENGTH_PASSWORD).into());
}
if password.is_empty() {
return Err("Password can't be empty".into());
}
let salt = SaltString::generate(&mut OsRng);
let argon2 = get_argon2();
argon2

View file

@ -44,7 +44,14 @@ impl Log {
P: AsRef<Path>,
{
if !directory.as_ref().exists() {
fs::DirBuilder::new().create(&directory).unwrap();
fs::DirBuilder::new()
.create(&directory)
.unwrap_or_else(|_| {
panic!(
"Unable to create directory: {}",
directory.as_ref().to_string_lossy()
)
});
}
let file_appender = RollingFileAppender::builder()
@ -78,7 +85,7 @@ impl Log {
}
}
pub fn new_stdout_only() -> Self {
pub fn new_to_stdout_only() -> Self {
let layer_stdout = tracing_subscriber::fmt::layer()
.with_writer(std::io::stdout.with_max_level(TRACING_LEVEL))
.with_thread_ids(TRACING_DISPLAY_THREAD)

View file

@ -1,4 +1,5 @@
//! An Axum extractor for HTTP body containing RON data (Rusty Object Notation).
use axum::{
body::Bytes,
extract::{FromRequest, Request},

View file

@ -18,13 +18,15 @@ pub struct RonError {
impl axum::response::IntoResponse for RonError {
fn into_response(self) -> Response {
let ron_as_str = ron_api::to_string(&self);
(
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
.into_response()
match ron_api::to_string(&self) {
Ok(ron_as_str) => (
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
.into_response(),
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
}
}
}
@ -44,7 +46,7 @@ pub fn ron_error(status: StatusCode, message: &str) -> impl IntoResponse {
)
}
pub fn ron_error_not_autorized() -> ErrorResponse {
pub fn ron_error_not_authorized() -> ErrorResponse {
ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED,
consts::NOT_AUTHORIZED_MESSAGE,
@ -58,16 +60,19 @@ where
ron_response(StatusCode::OK, ron)
}
pub fn ron_response<T>(status: StatusCode, ron: T) -> impl IntoResponse
pub fn ron_response<T>(status: StatusCode, ron: T) -> Response
where
T: Serialize,
{
let ron_as_str = ron_api::to_string(&ron);
(
status,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
match ron_api::to_string(&ron) {
Ok(ron_as_str) => (
status,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
.into_response(),
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
}
}
pub fn parse_body<T>(body: Bytes) -> Result<T, RonError>

View file

@ -5,7 +5,6 @@ use axum::{
response::{Html, IntoResponse},
};
use serde::Deserialize;
// use tracing::{event, Level};
use crate::{
app::{Context, Result},

View file

@ -1,6 +1,6 @@
use axum::response::Result;
use crate::{data::db, data::model, ron_utils::ron_error_not_autorized};
use crate::{data::db, data::model, ron_utils::ron_error_not_authorized};
pub async fn check_user_rights_recipe(
connection: &db::Connection,
@ -9,7 +9,7 @@ pub async fn check_user_rights_recipe(
) -> Result<()> {
match user {
Some(user) if connection.can_edit_recipe(user.id, recipe_id).await? => Ok(()),
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -20,7 +20,7 @@ pub async fn check_user_rights_recipe_group(
) -> Result<()> {
match user {
Some(user) if connection.can_edit_recipe_group(user.id, group_id).await? => Ok(()),
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -37,7 +37,7 @@ pub async fn check_user_rights_recipe_groups(
{
Ok(())
}
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -48,7 +48,7 @@ pub async fn check_user_rights_recipe_step(
) -> Result<()> {
match user {
Some(user) if connection.can_edit_recipe_step(user.id, step_id).await? => Ok(()),
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -65,7 +65,7 @@ pub async fn check_user_rights_recipe_steps(
{
Ok(())
}
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -82,7 +82,7 @@ pub async fn check_user_rights_recipe_ingredient(
{
Ok(())
}
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -99,7 +99,7 @@ pub async fn check_user_rights_recipe_ingredients(
{
Ok(())
}
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}
@ -116,6 +116,6 @@ pub async fn check_user_rights_shopping_list_entry(
{
Ok(())
}
_ => Err(ron_error_not_autorized()),
_ => Err(ron_error_not_authorized()),
}
}

View file

@ -20,6 +20,7 @@ use tracing::{error, warn};
use crate::{
app::{AppState, Context, Result},
config::Config,
consts,
data::db,
email,
@ -88,6 +89,7 @@ enum SignUpError {
#[debug_handler(state = AppState)]
pub async fn sign_up_post(
Host(host): Host,
State(config): State<Config>,
State(connection): State<db::Connection>,
State(email_service): State<Arc<dyn email::EmailServiceTrait>>,
Extension(context): Extension<Context>,
@ -171,6 +173,7 @@ pub async fn sign_up_post(
let email = form_data.email.clone();
match email_service
.send_email(
&config.email_address,
&email,
context.tr.t(Sentence::SignUpEmailTitle),
&context.tr.tp(
@ -385,7 +388,6 @@ pub async fn sign_in_post(
#[debug_handler]
pub async fn sign_out(
State(connection): State<db::Connection>,
Extension(context): Extension<Context>,
req: Request<Body>,
) -> Result<(CookieJar, Redirect)> {
let mut jar = CookieJar::from_headers(req.headers());
@ -450,6 +452,7 @@ enum AskResetPasswordError {
#[debug_handler(state = AppState)]
pub async fn ask_reset_password_post(
Host(host): Host,
State(config): State<Config>,
State(connection): State<db::Connection>,
State(email_service): State<Arc<dyn email::EmailServiceTrait>>,
Extension(context): Extension<Context>,
@ -522,6 +525,7 @@ pub async fn ask_reset_password_post(
let url = utils::get_url_from_host(&host);
match email_service
.send_email(
&config.email_address,
&form_data.email,
context.tr.t(Sentence::AskResetEmailTitle),
&context.tr.tp(
@ -755,6 +759,7 @@ enum ProfileUpdateError {
#[debug_handler(state = AppState)]
pub async fn edit_user_post(
Host(host): Host,
State(config): State<Config>,
State(connection): State<db::Connection>,
State(email_service): State<Arc<dyn email::EmailServiceTrait>>,
Extension(context): Extension<Context>,
@ -859,6 +864,7 @@ pub async fn edit_user_post(
let email = form_data.email.clone();
match email_service
.send_email(
&config.email_address,
&email,
context.tr.t(Sentence::ProfileFollowEmailTitle),
&context.tr.tp(

View file

@ -19,12 +19,15 @@ pub struct Tr {
}
impl Tr {
/// Create a new translation object.
/// See [available_codes].
pub fn new(code: &str) -> Self {
Self {
lang: get_language_translation(code),
}
}
/// Translate the given sentence according to the current language.
pub fn t<T>(&self, sentence: T) -> &'static str
where
T: Borrow<Sentence>,
@ -32,11 +35,13 @@ impl Tr {
self.lang.get(sentence)
}
/// Translate the given sentence id according to the current language.
pub fn t_from_id(&self, sentence_id: i64) -> &'static str {
self.lang.get_from_id(sentence_id)
}
/// Translate a sentence with parameters.
/// Placeholders "{}" are replaced in the same order as the given parameters.
pub fn tp(&self, sentence: Sentence, params: &[Box<dyn ToString + Send>]) -> String {
let text = self.lang.get(sentence);
let params_as_string: Vec<String> = params.iter().map(|p| p.to_string()).collect();
@ -118,20 +123,6 @@ impl Tr {
}
}
// #[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)]
struct StoredLanguage {
code: String,
@ -151,7 +142,7 @@ struct Language {
const UNABLE_TO_FIND_TRANSLATION_MESSAGE: &str = "Unable to find translation";
impl Language {
pub fn from_stored_language(stored_language: StoredLanguage) -> Self {
fn from_stored_language(stored_language: StoredLanguage) -> Self {
Self {
code: stored_language.code,
territory: stored_language.territory,
@ -166,15 +157,14 @@ impl Language {
}
}
pub fn get<T>(&'static self, sentence: T) -> &'static str
fn get<T>(&'static self, sentence: T) -> &'static str
where
T: Borrow<Sentence>,
{
let sentence_cloned: Sentence = sentence.borrow().clone();
self.get_from_id(sentence_cloned as i64)
self.get_from_id(*sentence.borrow() as i64)
}
pub fn get_from_id(&'static self, sentence_id: i64) -> &'static str {
fn get_from_id(&'static self, sentence_id: i64) -> &'static str {
let text: &str = match self.translation.get(sentence_id as usize) {
None => UNABLE_TO_FIND_TRANSLATION_MESSAGE,
Some(text) => text,
@ -187,6 +177,7 @@ impl Language {
}
}
/// Returns all available languages as a tuple (code, name).
pub fn available_languages() -> Vec<(&'static str, &'static str)> {
TRANSLATIONS
.iter()
@ -194,6 +185,7 @@ pub fn available_languages() -> Vec<(&'static str, &'static str)> {
.collect()
}
/// Returns all available codes.
pub fn available_codes() -> Vec<&'static str> {
TRANSLATIONS.iter().map(|tr| tr.code.as_ref()).collect()
}

View file

@ -20,14 +20,13 @@ pub fn get_ip_and_user_agent(headers: &HeaderMap, remote_address: SocketAddr) ->
}
pub fn get_url_from_host(host: &str) -> String {
let port: Option<u16> = 'p: {
let port: Option<u16> = {
let split_port: Vec<&str> = host.split(':').collect();
if split_port.len() == 2 {
if let Ok(p) = split_port[1].parse::<u16>() {
break 'p Some(p);
}
split_port[1].parse::<u16>().ok()
} else {
None
}
None
};
format!(
"http{}://{}",

View file

@ -1,7 +1,7 @@
{# Needed by the frontend toast module. #}
<div id="toasts">
<div class="toast">
<img class="icon" width="24" height="24" alt="icon" src="">
<img class="icon" width="24" height="24" alt="icon" src="/static/success.svg">
<div class="content user-message"></div>
<span class="close button"></span>
</div>

View file

@ -119,18 +119,19 @@ async fn sign_up() -> Result<(), Box<dyn Error>> {
let mut mock_email_service = utils::mock_email::MockEmailService::new();
mock_email_service
.expect_send_email()
.withf_st(move |email, _title, message| {
.withf(|_email_sender, email_receiver, _title, _message| {
email_receiver == "president@spaceball.planet"
})
.times(1)
.returning_st(move |_email_sender, _email_receiver, _title, message| {
sscanf!(
message,
"Follow this link to confirm your inscription, http://127.0.0.1:8000{}",
*validation_url_clone.borrow_mut()
)
.unwrap();
println!("{}", message);
email == "president@spaceball.planet"
})
.times(1)
.returning(|_email, _title, _message| Ok(()));
Ok(())
});
let state = utils::common_state_with_email_service(Arc::new(mock_email_service)).await?;
let server = TestServer::new(app::make_service(state))?;

View file

@ -9,7 +9,7 @@ mock! {
pub EmailService {}
#[async_trait]
impl email::EmailServiceTrait for EmailService {
async fn send_email(&self, email: &str, title: &str, message: &str)
async fn send_email(&self, email_sender: &str, email_receiver: &str, title: &str, message: &str)
-> Result<(), email::Error>;
}
}

View file

@ -240,10 +240,9 @@ pub struct ShoppingListItem {
/*** Misc ***/
pub fn to_string<T>(ron: T) -> String
pub fn to_string<T>(ron: T) -> Result<String, ron::Error>
where
T: Serialize,
{
// TODO: handle'unwrap'.
to_string_pretty(&ron, PrettyConfig::new()).unwrap()
to_string_pretty(&ron, PrettyConfig::new())
}

View file

@ -2,7 +2,7 @@ use serde::Deserialize;
use strum::EnumCount;
#[repr(i64)]
#[derive(Debug, Clone, EnumCount, Deserialize)]
#[derive(Debug, Clone, Copy, EnumCount, Deserialize)]
pub enum Sentence {
MainTitle = 0,
CreateNewRecipe,

View file

@ -15,7 +15,10 @@ pub enum Error {
Gloo(#[from] gloo::net::Error),
#[error("RON Spanned error: {0}")]
Ron(#[from] ron::error::SpannedError),
RonSpanned(#[from] ron::error::SpannedError),
#[error("RON Error: {0}")]
Ron(#[from] ron::error::Error),
#[error("HTTP error: {0}")]
Http(String),
@ -40,7 +43,7 @@ where
{
let url = format!("/ron-api/{}", api_name);
let request_builder = method_fn(&url).header(CONTENT_TYPE, CONTENT_TYPE_RON);
send_req(request_builder.body(ron_api::to_string(body))?).await
send_req(request_builder.body(ron_api::to_string(body)?)?).await
}
async fn req_with_params<T, U>(