diff --git a/.gitignore b/.gitignore index da25fc0..df83c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ +# Rust /Cargo.lock +/target/ + +# Dev +config.yaml +/logs + +# npm /node_modules/ /package-lock.json -/target/ -config.json -logs diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..69b4e68 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,6 @@ +[files] +extend-exclude = [ + "/static/", + "/config.yaml", +] + diff --git a/Cargo.toml b/Cargo.toml index a47c709..ff46689 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,19 +10,13 @@ license-file = "LICENSE.txt" anyhow = "1.0" askama = { git = "https://github.com/djc/askama.git" } askama_axum = { git = "https://github.com/djc/askama.git", package = "askama_axum" } -axum = { version = "0.5", default-features = false, features = [ - "http1", - "query", - "form", -] } -axum-extra = { version = "0.3", features = ["spa"] } -captcha = "0.0.9" -lettre = "0.10" +axum = { version = "0.6", default-features = false, features = ["http1", "query", "form", "tokio", "macros"] } +axum-extra = { version = "0.4", features = ["spa"] } +captcha = { version = "0.0.9", default-features = false } +lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "hostname", "rustls-tls", "pool", "builder"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -tokio = { version = "1.21", features = ["full"] } -tower = { version = "0.4", features = ["limit", "buffer"] } -tower-http = { version = "0.3", features = ["trace"] } +serde_yaml = "0.9" +tokio = { version = "1.22", features = ["full"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = "0.3" diff --git a/src/captcha_solutions.rs b/src/captcha_solutions.rs index c43859c..2b1ef1f 100644 --- a/src/captcha_solutions.rs +++ b/src/captcha_solutions.rs @@ -9,8 +9,8 @@ pub struct SharedCaptchaSolutions { arc: Arc>, } -impl SharedCaptchaSolutions { - pub fn new() -> Self { +impl Default for SharedCaptchaSolutions { + fn default() -> Self { let max_size = (u16::MAX as usize) + 1; let mut solutions = Vec::with_capacity(max_size); solutions.resize(max_size, None); @@ -22,7 +22,9 @@ impl SharedCaptchaSolutions { })), } } +} +impl SharedCaptchaSolutions { fn lock(&self) -> MutexGuard { self.arc.lock().unwrap() } diff --git a/src/config.rs b/src/config.rs index 04248f0..1d96e71 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,7 +27,32 @@ pub struct Address { #[derive(Deserialize)] pub struct Logging { pub directory: String, - pub filename_prefix: String, + pub filename: String, +} + +#[derive(Deserialize)] +pub struct ErrorMessages { + pub captcha_error: String, + pub email_error: String, +} + +#[derive(Deserialize)] +pub struct Field { + pub label: String, + pub invalid_feedback: String, +} + +#[derive(Deserialize)] +pub struct Strings { + pub description: String, + pub title: String, + pub name_field: Field, + pub email_field: Field, + pub telefon_field_label: String, + pub message_field: Field, + pub captcha_field: Field, + pub submit: String, + pub success: String, } #[derive(Deserialize)] @@ -38,6 +63,8 @@ pub struct Config { pub email_from: Address, pub email_to: Address, pub logging: Logging, + pub error_messages: ErrorMessages, + pub strings: Strings, } impl Config { @@ -49,8 +76,8 @@ impl Config { let config_file = File::open(&config_path) .with_context(|| format!("Can not open the config file at the path {config_path}"))?; let config_reader = BufReader::new(config_file); - let config: Self = serde_json::from_reader(config_reader) - .context("Can not parse the config file as JSON!")?; + let config: Self = serde_yaml::from_reader(config_reader) + .context("Can not parse the YAML config file!")?; Ok(config) } diff --git a/src/errors.rs b/src/errors.rs index e55ebae..0c56fcf 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,10 +1,13 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +use tracing::error; pub struct AppError(anyhow::Error); impl IntoResponse for AppError { fn into_response(self) -> Response { + error!("{:?}", self.0); + StatusCode::BAD_REQUEST.into_response() } } diff --git a/src/logging.rs b/src/logging.rs index 01b899a..2015161 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -4,10 +4,8 @@ use tracing_subscriber::filter::LevelFilter; use crate::config; pub fn init_logger(logging_config: &config::Logging) -> WorkerGuard { - let file_appender = tracing_appender::rolling::hourly( - &logging_config.directory, - &logging_config.filename_prefix, - ); + let file_appender = + tracing_appender::rolling::never(&logging_config.directory, &logging_config.filename); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); tracing_subscriber::fmt() diff --git a/src/main.rs b/src/main.rs index 35ec8da..8d41f8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,23 +5,17 @@ mod forms; mod logging; mod mailer; mod routes; +mod states; mod templates; use anyhow::Result; -use axum::extract::Extension; use axum::routing::{get, post}; -use axum::{error_handling::HandleErrorLayer, http::StatusCode, BoxError}; use axum::{Router, Server}; use axum_extra::routing::SpaRouter; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::process; use std::sync::Arc; -use std::time::Duration; -use tower::buffer::BufferLayer; -use tower::limit::RateLimitLayer; -use tower::ServiceBuilder; -use tower_http::trace::{DefaultOnResponse, TraceLayer}; -use tracing::Level; +use tracing::info; use tracing_appender::non_blocking::WorkerGuard; async fn init() -> Result { @@ -40,34 +34,25 @@ async fn init() -> Result { let tracing_worker_gurad = logging::init_logger(&config.logging); let config = Arc::new(config); - let captcha_solutions = Arc::new(captcha_solutions::SharedCaptchaSolutions::new()); + let captcha_solutions = Arc::new(captcha_solutions::SharedCaptchaSolutions::default()); + + let app_state = states::AppState { + config, + mailer, + captcha_solutions, + }; let spa = SpaRouter::new(&format!("{}/static", &path_prefix), "static"); let routes = Router::new() .route("/", get(routes::index)) - .route("/submit", post(routes::submit)) - .route("/success", get(routes::success)); + .route("/", post(routes::submit)) + .route("/success", get(routes::success)) + .with_state(app_state); - let app = Router::new() - .nest(&path_prefix, routes) - .merge(spa) - .layer(TraceLayer::new_for_http().on_response(DefaultOnResponse::new().level(Level::INFO))) - .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(2, Duration::from_secs(3))), - ) - .layer(Extension(config)) - .layer(Extension(mailer)) - .layer(Extension(captcha_solutions)); + let app = Router::new().nest(&path_prefix, routes).merge(spa); + info!("Starting server"); Server::bind(&socket_address) .serve(app.into_make_service()) .await diff --git a/src/routes.rs b/src/routes.rs index 53f6f0b..7236628 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,92 +1,132 @@ use anyhow::{Context, Result}; use askama_axum::IntoResponse; -use axum::extract::{Extension, Form, Query}; +use axum::extract::{Form, State}; use axum::response::Response; -use serde::Deserialize; use std::mem; use std::sync::Arc; +use tracing::{error, info}; use crate::{captcha_solutions, config, errors, forms, mailer, templates}; -#[derive(Deserialize)] -pub struct IndexParams { - was_validated: Option, +pub struct IndexParams<'a> { + config: Arc, + captcha_solutions: Arc, + was_validated: bool, name: Option, email: Option, telefon: Option, message: Option, + error_message: Option<&'a str>, } pub async fn index( - Query(params): Query, - config: Extension>, - captcha_solutions: Extension>, + State(config): State>, + State(captcha_solutions): State>, ) -> Result { + info!("Visited get(index)"); + render_contact_form(IndexParams { + config, + captcha_solutions, + was_validated: false, + name: None, + email: None, + telefon: None, + message: None, + error_message: None, + }) + .await +} + +pub async fn render_contact_form(params: IndexParams<'_>) -> Result { let captcha = captcha::by_name(captcha::Difficulty::Easy, captcha::CaptchaName::Lucy); let captcha_base64 = captcha.as_base64().context("Failed to create a captcha!")?; - let id = captcha_solutions.store_solution(&captcha.chars_as_string()); + let id = params + .captcha_solutions + .store_solution(&captcha.chars_as_string()); let template = templates::ContactForm { - path_prefix: &config.path_prefix, - was_validated: params.was_validated.unwrap_or(false), + path_prefix: ¶ms.config.path_prefix, + was_validated: params.was_validated, id, - name: ¶ms.name.unwrap_or_default(), - email: ¶ms.email.unwrap_or_default(), - telefon: ¶ms.telefon.unwrap_or_default(), - message: ¶ms.message.unwrap_or_default(), - captcha: &captcha_base64, + name: params.name.unwrap_or_default(), + email: params.email.unwrap_or_default(), + telefon: params.telefon.unwrap_or_default(), + message: params.message.unwrap_or_default(), + captcha: captcha_base64, + error_message: params.error_message.unwrap_or_default(), + strings: ¶ms.config.strings, }; Ok(template.into_response()) } -async fn back_to_index( +async fn failed_submission( + config: Arc, + captcha_solutions: Arc, + error_message: &str, mut form: forms::ContactForm, - config: Extension>, - captcha_solutions: Extension>, ) -> Result { let name = mem::take(&mut form.name); let email = mem::take(&mut form.email); let telefon = mem::take(&mut form.telefon); let message = mem::take(&mut form.message); - let params = Query(IndexParams { - was_validated: Some(true), + let params = IndexParams { + config, + captcha_solutions, + was_validated: true, name: Some(name), email: Some(email), telefon: Some(telefon), message: Some(message), - }); + error_message: Some(error_message), + }; - index(params, config, captcha_solutions).await + render_contact_form(params).await } pub async fn submit( + State(config): State>, + State(captcha_solutions): State>, + State(mailer): State>, Form(form): Form, - config: Extension>, - captcha_solutions: Extension>, - mailer: Extension>, ) -> Result { if !captcha_solutions.check_answer(form.id, &form.captcha_answer) { - return back_to_index(form, config, captcha_solutions).await; + info!("Wrong CAPTCHA"); + return failed_submission( + config.clone(), + captcha_solutions, + &config.error_messages.captcha_error, + form, + ) + .await; } match mailer.send(&form.name, &form.email, &form.telefon, &form.message) { Ok(_) => (), - Err(_) => { - return back_to_index(form, config, captcha_solutions).await; + Err(e) => { + error!("{e:?}"); + return failed_submission( + config.clone(), + captcha_solutions, + &config.error_messages.email_error, + form, + ) + .await; } } - success(config).await + info!("Successful contact form submission"); + success(State(config)).await } -pub async fn success(config: Extension>) -> Result { +pub async fn success( + State(config): State>, +) -> Result { let template = templates::Success { path_prefix: &config.path_prefix, - message: - "Ihre Anfrage wurde erfolgreich übermittelt! Wir melden uns bald bei Ihnen zurück.", + message: &config.strings.success, }; Ok(template.into_response()) diff --git a/src/states.rs b/src/states.rs new file mode 100644 index 0000000..6def432 --- /dev/null +++ b/src/states.rs @@ -0,0 +1,11 @@ +use axum::extract::FromRef; +use std::sync::Arc; + +use crate::{captcha_solutions, config, mailer}; + +#[derive(Clone, FromRef)] +pub struct AppState { + pub config: Arc, + pub mailer: Arc, + pub captcha_solutions: Arc, +} diff --git a/src/templates.rs b/src/templates.rs index 98a47ce..3504ead 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,16 +1,20 @@ use askama::Template; +use crate::config; + #[derive(Template)] #[template(path = "contact_form.askama.html")] pub struct ContactForm<'a> { pub path_prefix: &'a str, pub was_validated: bool, pub id: u16, - pub name: &'a str, - pub email: &'a str, - pub telefon: &'a str, - pub message: &'a str, - pub captcha: &'a str, + pub name: String, + pub email: String, + pub telefon: String, + pub message: String, + pub captcha: String, + pub error_message: &'a str, + pub strings: &'a config::Strings, } #[derive(Template)] diff --git a/templates/contact_form.askama.html b/templates/contact_form.askama.html index be5b84a..bd6877a 100644 --- a/templates/contact_form.askama.html +++ b/templates/contact_form.askama.html @@ -1,41 +1,43 @@ {% extends "base.askama.html" %} {% block body %} -

- Alternativ können Sie im unteren Formular Ihre E-Mail-Adresse (und optional Telefon-Nummer) mit einer Nachricht mit Ihrem Anliegen hinterlassen. Wir versuchen dann, Sie so früh wie möglich zu kontaktieren. -

+ {% if error_message.len() > 0 %} + + {% endif %} -

Kontakt-Formular

+

{{ strings.description }}

+ +

{{ strings.title }}

- + -
Geben Sie bitte Ihren Namen ein
+
{{ strings.name_field.invalid_feedback }}
- + -
Geben Sie bitte Ihre E-Mail-Adresse ein
+
{{ strings.email_field.invalid_feedback }}
- +
- + -
Geben Sie bitte eine Nachricht mit Ihrem Anliegen ein
+
{{ strings.message_field.invalid_feedback }}
- + -
Geben Sie bitte den Code vom oberen Bild ein
+
{{ strings.captcha_field.invalid_feedback }}
- +
{% endblock %} @@ -70,15 +72,15 @@ {% block scripts %}