diff --git a/.gitignore b/.gitignore index 3af9302..2aa177c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Dev config.yaml +log.txt # npm /node_modules/ diff --git a/Cargo.lock b/Cargo.lock index 4723c52..b3a58fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" [[package]] name = "askama" version = "0.11.2" -source = "git+https://github.com/djc/askama.git#70c5784a9ebc1e2f9e97d5358c7b686111ea18f4" +source = "git+https://github.com/djc/askama.git#c9613ff6029ee58dc3f94c3dd955b905ed7fc9ef" dependencies = [ "askama_derive", "askama_escape", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "askama_axum" version = "0.1.0" -source = "git+https://github.com/djc/askama.git#70c5784a9ebc1e2f9e97d5358c7b686111ea18f4" +source = "git+https://github.com/djc/askama.git#c9613ff6029ee58dc3f94c3dd955b905ed7fc9ef" dependencies = [ "askama", "axum-core", @@ -50,7 +50,7 @@ dependencies = [ [[package]] name = "askama_derive" version = "0.12.0" -source = "git+https://github.com/djc/askama.git#70c5784a9ebc1e2f9e97d5358c7b686111ea18f4" +source = "git+https://github.com/djc/askama.git#c9613ff6029ee58dc3f94c3dd955b905ed7fc9ef" dependencies = [ "basic-toml", "mime", @@ -65,7 +65,7 @@ dependencies = [ [[package]] name = "askama_escape" version = "0.10.3" -source = "git+https://github.com/djc/askama.git#70c5784a9ebc1e2f9e97d5358c7b686111ea18f4" +source = "git+https://github.com/djc/askama.git#c9613ff6029ee58dc3f94c3dd955b905ed7fc9ef" [[package]] name = "async-trait" @@ -86,9 +86,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fb79c228270dcf2426e74864cabc94babb5dbab01a4314e702d2f16540e1591" +checksum = "2bd379e511536bad07447f899300aa526e9bae8e6f66dc5e5ca45d7587b7c1ec" dependencies = [ "async-trait", "axum-core", @@ -111,7 +111,7 @@ dependencies = [ "sync_wrapper", "tokio", "tower", - "tower-http", + "tower-http 0.3.5", "tower-layer", "tower-service", ] @@ -241,7 +241,7 @@ dependencies = [ "serde_yaml", "time", "tokio", - "tower-http", + "tower-http 0.4.0", "tracing", "tracing-subscriber", ] @@ -995,9 +995,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "syn" -version = "1.0.108" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e159d99e6c2b93995d171050271edb50ecc5288fbc7cc17de8fdce4e58c14" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", @@ -1022,9 +1022,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53250a3b3fed8ff8fd988587d8925d26a83ac3845d9e03b220b37f34c2b8d6c2" +checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" dependencies = [ "itoa", "serde", @@ -1040,9 +1040,9 @@ checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" [[package]] name = "time-macros" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a460aeb8de6dcb0f381e1ee05f1cd56fcf5a5f6eb8187ff3d8f0b11078d38b7c" +checksum = "fd80a657e71da814b8e5d60d3374fc6d35045062245d80224748ae522dd76f36" dependencies = [ "time-core", ] @@ -1135,6 +1135,25 @@ name = "tower-http" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d1d42a9b3f3ec46ba828e8d376aec14592ea199f70a06a548587ecd1c4ab658" dependencies = [ "bitflags", "bytes", @@ -1150,9 +1169,9 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3ffea95..10e3bf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,6 @@ serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" time = "0.3" tokio = { version = "1.25", default-features = false, features = ["macros", "rt"] } -tower-http = { version = "0.3", features = ["fs"] } +tower-http = { version = "0.4", features = ["fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["time"] } diff --git a/bacon.toml b/bacon.toml new file mode 100644 index 0000000..2c9ef7e --- /dev/null +++ b/bacon.toml @@ -0,0 +1,6 @@ +default_job = "clippy" + +[jobs.clippy] +command = ["cargo", "clippy", "--all-targets", "--color", "always", "--workspace"] +need_stdout = false +watch = ["tests", "benches", "examples", "crates", "templates"] diff --git a/src/config.rs b/src/config.rs index e474e31..b56957f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,12 +4,19 @@ use std::{env, fs::File, io::BufReader}; /// Email server credentials. #[derive(Deserialize)] -pub struct EmailServer { - pub server_name: String, - pub email: String, +pub struct EmailCredentials { + pub domain: String, + pub username: String, pub password: String, } +#[derive(Deserialize)] +pub struct Email { + pub from: String, + pub to: String, + pub credentials: EmailCredentials, +} + /// UTC offset for time formatting. #[derive(Deserialize)] pub struct UtcOffset { @@ -17,6 +24,22 @@ pub struct UtcOffset { pub minutes: i8, } +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum CustomFieldType { + Text, + Textarea { rows: u8 }, +} + +#[derive(Deserialize)] +pub struct CustomField { + pub key: String, + pub label: String, + #[serde(default)] + pub required_feedback: Option, + pub field_type: CustomFieldType, +} + /// Error messages for localization. #[derive(Deserialize)] pub struct ErrorMessages { @@ -24,11 +47,46 @@ pub struct ErrorMessages { pub email_error: String, } -/// Field localization strings in a form with label and invalid feedback on wrong field input. +fn default_name_label() -> String { + "Name".to_string() +} +fn default_name_required_feedback() -> String { + "Please enter your name".to_string() +} #[derive(Deserialize)] -pub struct Field { +pub struct NameField { + #[serde(default = "default_name_label")] pub label: String, - pub invalid_feedback: String, + #[serde(default = "default_name_required_feedback")] + pub required_feedback: String, +} + +fn default_email_label() -> String { + "Email".to_string() +} +fn default_email_required_feedback() -> String { + "Please enter your email".to_string() +} +#[derive(Deserialize)] +pub struct EmailField { + #[serde(default = "default_email_label")] + pub label: String, + #[serde(default = "default_email_required_feedback")] + pub required_feedback: String, +} + +fn default_captcha_label() -> String { + "Enter the code above".to_string() +} +fn default_captcha_required_feedback() -> String { + "Please enter code from the image above".to_string() +} +#[derive(Deserialize)] +pub struct CaptchaField { + #[serde(default = "default_captcha_label")] + pub label: String, + #[serde(default = "default_captcha_required_feedback")] + pub required_feedback: String, } /// Localization strings. @@ -36,34 +94,41 @@ pub struct Field { pub struct Strings { pub description: String, pub title: String, - pub name_field: Field, - pub email_field: Field, - /// No invalid feedback because it is optional. - pub telefon_field_label: String, - pub message_field: Field, - pub captcha_field: Field, + pub optional: String, pub submit: String, pub success: String, + pub message_from: String, + pub name_field: NameField, + pub email_field: EmailField, + pub captcha_field: CaptchaField, + pub error_messages: ErrorMessages, +} + +fn default_lang() -> String { + "en".to_string() +} + +#[derive(Deserialize)] +pub struct StateConfig { + /// The path prefix of all routes. + pub path_prefix: String, + pub custom_fields: Vec, + /// The language tag of the HTML file. + #[serde(default = "default_lang")] + pub lang: String, + pub strings: Strings, } /// Configuration. #[derive(Deserialize)] pub struct Config { - /// The language tag of the HTML file. - pub lang: String, - /// The path prefix of all routes. - pub path_prefix: String, /// The server socket address including port. pub socket_address: String, - pub email_server: EmailServer, - /// From mailbox. - pub email_from: String, - /// To mailbox. - pub email_to: String, + pub email: Email, pub log_file: String, pub utc_offset: UtcOffset, - pub error_messages: ErrorMessages, - pub strings: Strings, + #[serde(flatten)] + pub state_config: StateConfig, } impl Config { @@ -77,9 +142,13 @@ impl Config { let file = File::open(&config_path) .with_context(|| format!("Can not open the config file at the path {config_path}"))?; let reader = BufReader::new(file); - let config = + let mut config: Self = serde_yaml::from_reader(reader).context("Can not parse the YAML config file!")?; + // Add a space at the end for the email subject. + config.state_config.strings.message_from = + config.state_config.strings.message_from.trim().to_string() + " "; + Ok(config) } } diff --git a/src/errors.rs b/src/errors.rs index 140598a..780864d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -12,7 +12,7 @@ impl IntoResponse for AppError { // Log error. error!("{:?}", self.0); - StatusCode::BAD_REQUEST.into_response() + (StatusCode::BAD_REQUEST, self.0.to_string()).into_response() } } diff --git a/src/forms.rs b/src/forms.rs index 2db51a4..4360ac9 100644 --- a/src/forms.rs +++ b/src/forms.rs @@ -1,13 +1,26 @@ +use anyhow::{Context, Result}; use serde::Deserialize; +use std::collections::HashMap; + +use crate::config::CustomField; /// Fields of the contact form that persist after a redirection /// (example after failed server side validation). -#[derive(Deserialize, Default)] -pub struct PersistantContactFormFields { +#[derive(Deserialize)] +pub struct PersistantFieldContents { pub name: String, pub email: String, - pub telefon: String, - pub message: String, + pub custom: Vec, +} + +impl PersistantFieldContents { + pub fn new_empty(custom_fields: &[CustomField]) -> Self { + Self { + name: String::default(), + email: String::default(), + custom: custom_fields.iter().map(|_| String::default()).collect(), + } + } } /// The contact form. @@ -16,6 +29,47 @@ pub struct ContactForm { /// The id for the captcha. pub id: u16, #[serde(flatten)] - pub persistant_fields: PersistantContactFormFields, + pub persistant_field_contents: PersistantFieldContents, pub captcha_answer: String, } + +impl ContactForm { + /// Build the form from the form map. + pub fn from_map( + mut map: HashMap, + custom_fields: &Vec, + ) -> Result { + let id = map + .get("id") + .context("id missing in the submitted form!")? + .parse() + .context("Failed to parse the submitted id!")?; + + // Remove an item with the given key from the map. + let mut remove = |key| { + map.remove(key) + .with_context(|| format!("{} missing in the submitted form!", key)) + }; + + // Default fields. + let captcha_answer = remove("captcha_answer")?; + let name = remove("name")?; + let email = remove("email")?; + + // Custom fields. + let mut custom = Vec::with_capacity(custom_fields.len()); + for field in custom_fields.iter() { + custom.push(remove(&field.key)?); + } + + Ok(Self { + id, + persistant_field_contents: PersistantFieldContents { + name, + email, + custom, + }, + captcha_answer, + }) + } +} diff --git a/src/mailer.rs b/src/mailer.rs index af74aa2..34957f6 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -7,8 +7,12 @@ use lettre::{ }, Tokio1Executor, }; +use tracing::info; -use crate::config::Config; +use crate::{ + config::{Config, StateConfig}, + forms::PersistantFieldContents, +}; type ASmtpTransport = AsyncSmtpTransport; @@ -23,12 +27,12 @@ impl Mailer { pub async fn build(config: &Config) -> Result { // Mail server credentials for login. let credentials = Credentials::new( - config.email_server.email.clone(), - config.email_server.password.clone(), + config.email.credentials.username.clone(), + config.email.credentials.password.clone(), ); // Establish the connection. - let mailer = ASmtpTransport::relay(&config.email_server.server_name) + let mailer = ASmtpTransport::relay(&config.email.credentials.domain) .context("Failed to connect to the email server!")? .credentials(credentials) .build(); @@ -36,17 +40,20 @@ impl Mailer { .test_connection() .await .context("Email connection test failed!")?; + info!("Successful email connection!"); // Set the From and To mailboxes for every sent mail. let message_builder = Message::builder() .from( config - .email_from + .email + .from .parse() .context("Failed to parse the From mailbox!")?, ) .to(config - .email_to + .email + .to .parse() .context("Failed to parse the To mailbox!")?); @@ -57,23 +64,40 @@ impl Mailer { } /// Sends a mail with data from the contact form. - pub async fn send(&self, name: &str, email: &str, telefon: &str, message: &str) -> Result<()> { - let name = name.trim().to_string(); - let subject = "Message from ".to_string() + &name; + pub async fn send( + &self, + persistant_field_contents: &PersistantFieldContents, + config: &StateConfig, + ) -> Result<()> { + let name = persistant_field_contents.name.trim().to_string(); + let subject = config.strings.message_from.clone() + &name; let reply_to = Mailbox::new( Some(name), - email + persistant_field_contents + .email .parse() .context("Failed to parse the email from the form to an address!")?, ); - let telefon = telefon.trim(); - let message = message.trim().to_string(); - let body = if !telefon.is_empty() { - message + "\n\nTelefon: " + telefon - } else { - message - }; + let default_fields_content = format!( + "{}: {}\n\n{}: {}\n\n\n", + config.strings.name_field.label, + persistant_field_contents.name, + config.strings.email_field.label, + persistant_field_contents.email + ); + let body = config + .custom_fields + .iter() + .map(|field| &field.label) + .zip( + persistant_field_contents + .custom + .iter() + .map(|content| content.trim()), + ) + .map(|(label, content)| format!("{label}:\n{content}\n\n\n")) + .fold(default_fields_content, |acc, s| acc + &s); let email = self .message_builder diff --git a/src/main.rs b/src/main.rs index 17e6699..76129b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,11 +10,10 @@ mod templates; use anyhow::{Context, Result}; use axum::{ - http::StatusCode, routing::{get, get_service, Router}, Server, }; -use std::{future::ready, net::SocketAddr, process}; +use std::{net::SocketAddr, process}; use tower_http::services::ServeDir; use tracing::{error, info}; @@ -31,7 +30,7 @@ async fn init(logger_initialized: &mut bool) -> Result<()> { *logger_initialized = true; // The path prefix of all routes. - let path_prefix = config.path_prefix.clone(); + let path_prefix = config.state_config.path_prefix.clone(); let socket_address = config .socket_address @@ -41,8 +40,7 @@ async fn init(logger_initialized: &mut bool) -> Result<()> { let app_state = AppState::build(config).await?; // The service for serving the static files. - let static_service = - get_service(ServeDir::new("static")).handle_error(|_| ready(StatusCode::NOT_FOUND)); + let static_service = get_service(ServeDir::new("static")); let routes = Router::new() .route("/", get(routes::index).post(routes::submit)) diff --git a/src/routes.rs b/src/routes.rs index 81edcee..4242f64 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,23 +4,26 @@ use axum::{ extract::{Form, State}, response::Response, }; -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; use tracing::{error, info}; use crate::{ captcha_solutions::CaptchaSolutions, - config::Config, + config::StateConfig, errors::AppError, - forms::{ContactForm, PersistantContactFormFields}, + forms::{ContactForm, PersistantFieldContents}, mailer::Mailer, templates, }; /// Parameters to render the contact form. pub struct ContactFormParams<'a> { - config: Arc, + config: Arc, captcha_solutions: Arc>, - persistant_fields: Option, + persistant_field_contents: Option, error_message: Option<&'a str>, } @@ -40,13 +43,16 @@ pub fn render_contact_form(params: ContactFormParams<'_>) -> Result) -> Result>, + State(config): State>, State(captcha_solutions): State>>, ) -> Result { info!("Visited get(index)"); @@ -62,18 +68,20 @@ pub async fn index( render_contact_form(ContactFormParams { config, captcha_solutions, - persistant_fields: None, + persistant_field_contents: None, error_message: None, }) } /// Submit handler. pub async fn submit( - State(config): State>, + State(config): State>, State(captcha_solutions): State>>, State(mailer): State>, - Form(form): Form, + Form(map): Form>, ) -> Result { + let form = ContactForm::from_map(map, &config.custom_fields)?; + let right_captcha_answer = captcha_solutions .lock() .unwrap() @@ -85,27 +93,19 @@ pub async fn submit( return render_contact_form(ContactFormParams { config: Arc::clone(&config), captcha_solutions, - persistant_fields: Some(form.persistant_fields), - error_message: Some(&config.error_messages.captcha_error), + persistant_field_contents: Some(form.persistant_field_contents), + error_message: Some(&config.strings.error_messages.captcha_error), }); } - if let Err(e) = mailer - .send( - &form.persistant_fields.name, - &form.persistant_fields.email, - &form.persistant_fields.telefon, - &form.persistant_fields.message, - ) - .await - { + if let Err(e) = mailer.send(&form.persistant_field_contents, &config).await { error!("{e:?}"); return render_contact_form(ContactFormParams { config: Arc::clone(&config), captcha_solutions, - persistant_fields: Some(form.persistant_fields), - error_message: Some(&config.error_messages.email_error), + persistant_field_contents: Some(form.persistant_field_contents), + error_message: Some(&config.strings.error_messages.email_error), }); } @@ -113,7 +113,7 @@ pub async fn submit( } /// Called on successful contact form submission. -pub fn success(config: Arc) -> Result { +pub fn success(config: Arc) -> Result { info!("Successful contact form submission"); // Initialize template. diff --git a/src/states.rs b/src/states.rs index da546cd..d47c5f8 100644 --- a/src/states.rs +++ b/src/states.rs @@ -2,12 +2,16 @@ use anyhow::Result; use axum::extract::FromRef; use std::sync::{Arc, Mutex}; -use crate::{captcha_solutions::CaptchaSolutions, config::Config, mailer::Mailer}; +use crate::{ + captcha_solutions::CaptchaSolutions, + config::{Config, StateConfig}, + mailer::Mailer, +}; /// The application state. #[derive(Clone, FromRef)] pub struct AppState { - pub config: Arc, + pub config: Arc, pub mailer: Arc, pub captcha_solutions: Arc>, } @@ -17,12 +21,12 @@ impl AppState { pub async fn build(config: Config) -> Result { let mailer = Arc::new(Mailer::build(&config).await?); - let config = Arc::new(config); + let state_config = Arc::new(config.state_config); // Mutex for write access. let captcha_solutions = Arc::new(Mutex::new(CaptchaSolutions::default())); Ok(Self { - config, + config: state_config, mailer, captcha_solutions, }) diff --git a/src/templates.rs b/src/templates.rs index 47bd51d..fd2d51b 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,6 +1,9 @@ use askama::Template; -use crate::{config, forms::PersistantContactFormFields}; +use crate::{ + config::{self, CustomField}, + forms::PersistantFieldContents, +}; /// Base template. pub struct Base<'a> { @@ -15,10 +18,11 @@ pub struct ContactForm<'a> { pub base: Base<'a>, pub was_validated: bool, pub id: u16, - pub persistant_fields: PersistantContactFormFields, + pub persistant_field_contents: PersistantFieldContents, pub captcha: String, pub error_message: &'a str, pub strings: &'a config::Strings, + pub custom_fields: &'a Vec, } /// Sucessful contact form submission template. diff --git a/templates/contact_form.askama.html b/templates/contact_form.askama.html index d21e7e8..b6a4784 100644 --- a/templates/contact_form.askama.html +++ b/templates/contact_form.askama.html @@ -16,42 +16,56 @@ -
{{ strings.name_field.invalid_feedback }}
+
{{ strings.name_field.required_feedback }}
-
{{ strings.email_field.invalid_feedback }}
-
-
- - -
-
- - -
{{ strings.message_field.invalid_feedback }}
+
{{ strings.email_field.required_feedback }}
-
+ {% for custom_field in custom_fields %} +
+ {% let value = persistant_field_contents.custom[loop.index0] %} + {% let required = custom_field.required_feedback.is_some() %} + + + + {% match custom_field.field_type %} + {% when config::CustomFieldType::Text %} + + {% when config::CustomFieldType::Textarea with { rows } %} + + {% endmatch %} + + {% match custom_field.required_feedback %} + {% when Some with (feedback) %} +
{{ feedback }}
+ {% when None %} + {% endmatch %} +
+ {% endfor %} + +
@@ -61,7 +75,7 @@ class="form-control" id="captcha_answer" required> -
{{ strings.captcha_field.invalid_feedback }}
+
{{ strings.captcha_field.required_feedback }}
{% if error_message.len() > 0 %}