Upgrade to Axum 0.6 and refactor

This commit is contained in:
Mo 2022-12-03 17:08:23 +01:00
parent ef93f8a76a
commit be7b29d631
12 changed files with 188 additions and 111 deletions

11
.gitignore vendored
View file

@ -1,6 +1,11 @@
# Rust
/Cargo.lock /Cargo.lock
/target/
# Dev
config.yaml
/logs
# npm
/node_modules/ /node_modules/
/package-lock.json /package-lock.json
/target/
config.json
logs

6
.typos.toml Normal file
View file

@ -0,0 +1,6 @@
[files]
extend-exclude = [
"/static/",
"/config.yaml",
]

View file

@ -10,19 +10,13 @@ license-file = "LICENSE.txt"
anyhow = "1.0" anyhow = "1.0"
askama = { git = "https://github.com/djc/askama.git" } askama = { git = "https://github.com/djc/askama.git" }
askama_axum = { git = "https://github.com/djc/askama.git", package = "askama_axum" } askama_axum = { git = "https://github.com/djc/askama.git", package = "askama_axum" }
axum = { version = "0.5", default-features = false, features = [ axum = { version = "0.6", default-features = false, features = ["http1", "query", "form", "tokio", "macros"] }
"http1", axum-extra = { version = "0.4", features = ["spa"] }
"query", captcha = { version = "0.0.9", default-features = false }
"form", lettre = { version = "0.10", default-features = false, features = ["smtp-transport", "hostname", "rustls-tls", "pool", "builder"] }
] }
axum-extra = { version = "0.3", features = ["spa"] }
captcha = "0.0.9"
lettre = "0.10"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_yaml = "0.9"
tokio = { version = "1.21", features = ["full"] } tokio = { version = "1.22", features = ["full"] }
tower = { version = "0.4", features = ["limit", "buffer"] }
tower-http = { version = "0.3", features = ["trace"] }
tracing = "0.1" tracing = "0.1"
tracing-appender = "0.2" tracing-appender = "0.2"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"

View file

@ -9,8 +9,8 @@ pub struct SharedCaptchaSolutions {
arc: Arc<Mutex<CaptchaSolutions>>, arc: Arc<Mutex<CaptchaSolutions>>,
} }
impl SharedCaptchaSolutions { impl Default for SharedCaptchaSolutions {
pub fn new() -> Self { fn default() -> Self {
let max_size = (u16::MAX as usize) + 1; let max_size = (u16::MAX as usize) + 1;
let mut solutions = Vec::with_capacity(max_size); let mut solutions = Vec::with_capacity(max_size);
solutions.resize(max_size, None); solutions.resize(max_size, None);
@ -22,7 +22,9 @@ impl SharedCaptchaSolutions {
})), })),
} }
} }
}
impl SharedCaptchaSolutions {
fn lock(&self) -> MutexGuard<CaptchaSolutions> { fn lock(&self) -> MutexGuard<CaptchaSolutions> {
self.arc.lock().unwrap() self.arc.lock().unwrap()
} }

View file

@ -27,7 +27,32 @@ pub struct Address {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Logging { pub struct Logging {
pub directory: String, 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)] #[derive(Deserialize)]
@ -38,6 +63,8 @@ pub struct Config {
pub email_from: Address, pub email_from: Address,
pub email_to: Address, pub email_to: Address,
pub logging: Logging, pub logging: Logging,
pub error_messages: ErrorMessages,
pub strings: Strings,
} }
impl Config { impl Config {
@ -49,8 +76,8 @@ impl Config {
let config_file = File::open(&config_path) let config_file = File::open(&config_path)
.with_context(|| format!("Can not open the config file at the path {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_reader = BufReader::new(config_file);
let config: Self = serde_json::from_reader(config_reader) let config: Self = serde_yaml::from_reader(config_reader)
.context("Can not parse the config file as JSON!")?; .context("Can not parse the YAML config file!")?;
Ok(config) Ok(config)
} }

View file

@ -1,10 +1,13 @@
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use tracing::error;
pub struct AppError(anyhow::Error); pub struct AppError(anyhow::Error);
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
error!("{:?}", self.0);
StatusCode::BAD_REQUEST.into_response() StatusCode::BAD_REQUEST.into_response()
} }
} }

View file

@ -4,10 +4,8 @@ use tracing_subscriber::filter::LevelFilter;
use crate::config; use crate::config;
pub fn init_logger(logging_config: &config::Logging) -> WorkerGuard { pub fn init_logger(logging_config: &config::Logging) -> WorkerGuard {
let file_appender = tracing_appender::rolling::hourly( let file_appender =
&logging_config.directory, tracing_appender::rolling::never(&logging_config.directory, &logging_config.filename);
&logging_config.filename_prefix,
);
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt() tracing_subscriber::fmt()

View file

@ -5,23 +5,17 @@ mod forms;
mod logging; mod logging;
mod mailer; mod mailer;
mod routes; mod routes;
mod states;
mod templates; mod templates;
use anyhow::Result; use anyhow::Result;
use axum::extract::Extension;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{error_handling::HandleErrorLayer, http::StatusCode, BoxError};
use axum::{Router, Server}; use axum::{Router, Server};
use axum_extra::routing::SpaRouter; use axum_extra::routing::SpaRouter;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::process; use std::process;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use tracing::info;
use tower::buffer::BufferLayer;
use tower::limit::RateLimitLayer;
use tower::ServiceBuilder;
use tower_http::trace::{DefaultOnResponse, TraceLayer};
use tracing::Level;
use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::non_blocking::WorkerGuard;
async fn init() -> Result<WorkerGuard> { async fn init() -> Result<WorkerGuard> {
@ -40,34 +34,25 @@ async fn init() -> Result<WorkerGuard> {
let tracing_worker_gurad = logging::init_logger(&config.logging); let tracing_worker_gurad = logging::init_logger(&config.logging);
let config = Arc::new(config); 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 spa = SpaRouter::new(&format!("{}/static", &path_prefix), "static");
let routes = Router::new() let routes = Router::new()
.route("/", get(routes::index)) .route("/", get(routes::index))
.route("/submit", post(routes::submit)) .route("/", post(routes::submit))
.route("/success", get(routes::success)); .route("/success", get(routes::success))
.with_state(app_state);
let app = Router::new() let app = Router::new().nest(&path_prefix, routes).merge(spa);
.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));
info!("Starting server");
Server::bind(&socket_address) Server::bind(&socket_address)
.serve(app.into_make_service()) .serve(app.into_make_service())
.await .await

View file

@ -1,92 +1,132 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use askama_axum::IntoResponse; use askama_axum::IntoResponse;
use axum::extract::{Extension, Form, Query}; use axum::extract::{Form, State};
use axum::response::Response; use axum::response::Response;
use serde::Deserialize;
use std::mem; use std::mem;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info};
use crate::{captcha_solutions, config, errors, forms, mailer, templates}; use crate::{captcha_solutions, config, errors, forms, mailer, templates};
#[derive(Deserialize)] pub struct IndexParams<'a> {
pub struct IndexParams { config: Arc<config::Config>,
was_validated: Option<bool>, captcha_solutions: Arc<captcha_solutions::SharedCaptchaSolutions>,
was_validated: bool,
name: Option<String>, name: Option<String>,
email: Option<String>, email: Option<String>,
telefon: Option<String>, telefon: Option<String>,
message: Option<String>, message: Option<String>,
error_message: Option<&'a str>,
} }
pub async fn index( pub async fn index(
Query(params): Query<IndexParams>, State(config): State<Arc<config::Config>>,
config: Extension<Arc<config::Config>>, State(captcha_solutions): State<Arc<captcha_solutions::SharedCaptchaSolutions>>,
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
) -> Result<Response, errors::AppError> { ) -> Result<Response, errors::AppError> {
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<Response, errors::AppError> {
let captcha = captcha::by_name(captcha::Difficulty::Easy, captcha::CaptchaName::Lucy); let captcha = captcha::by_name(captcha::Difficulty::Easy, captcha::CaptchaName::Lucy);
let captcha_base64 = captcha.as_base64().context("Failed to create a captcha!")?; 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 { let template = templates::ContactForm {
path_prefix: &config.path_prefix, path_prefix: &params.config.path_prefix,
was_validated: params.was_validated.unwrap_or(false), was_validated: params.was_validated,
id, id,
name: &params.name.unwrap_or_default(), name: params.name.unwrap_or_default(),
email: &params.email.unwrap_or_default(), email: params.email.unwrap_or_default(),
telefon: &params.telefon.unwrap_or_default(), telefon: params.telefon.unwrap_or_default(),
message: &params.message.unwrap_or_default(), message: params.message.unwrap_or_default(),
captcha: &captcha_base64, captcha: captcha_base64,
error_message: params.error_message.unwrap_or_default(),
strings: &params.config.strings,
}; };
Ok(template.into_response()) Ok(template.into_response())
} }
async fn back_to_index( async fn failed_submission(
config: Arc<config::Config>,
captcha_solutions: Arc<captcha_solutions::SharedCaptchaSolutions>,
error_message: &str,
mut form: forms::ContactForm, mut form: forms::ContactForm,
config: Extension<Arc<config::Config>>,
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
) -> Result<Response, errors::AppError> { ) -> Result<Response, errors::AppError> {
let name = mem::take(&mut form.name); let name = mem::take(&mut form.name);
let email = mem::take(&mut form.email); let email = mem::take(&mut form.email);
let telefon = mem::take(&mut form.telefon); let telefon = mem::take(&mut form.telefon);
let message = mem::take(&mut form.message); let message = mem::take(&mut form.message);
let params = Query(IndexParams { let params = IndexParams {
was_validated: Some(true), config,
captcha_solutions,
was_validated: true,
name: Some(name), name: Some(name),
email: Some(email), email: Some(email),
telefon: Some(telefon), telefon: Some(telefon),
message: Some(message), message: Some(message),
}); error_message: Some(error_message),
};
index(params, config, captcha_solutions).await render_contact_form(params).await
} }
pub async fn submit( pub async fn submit(
State(config): State<Arc<config::Config>>,
State(captcha_solutions): State<Arc<captcha_solutions::SharedCaptchaSolutions>>,
State(mailer): State<Arc<mailer::Mailer>>,
Form(form): Form<forms::ContactForm>, Form(form): Form<forms::ContactForm>,
config: Extension<Arc<config::Config>>,
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
mailer: Extension<Arc<mailer::Mailer>>,
) -> Result<Response, errors::AppError> { ) -> Result<Response, errors::AppError> {
if !captcha_solutions.check_answer(form.id, &form.captcha_answer) { 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) { match mailer.send(&form.name, &form.email, &form.telefon, &form.message) {
Ok(_) => (), Ok(_) => (),
Err(_) => { Err(e) => {
return back_to_index(form, config, captcha_solutions).await; 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<Arc<config::Config>>) -> Result<Response, errors::AppError> { pub async fn success(
State(config): State<Arc<config::Config>>,
) -> Result<Response, errors::AppError> {
let template = templates::Success { let template = templates::Success {
path_prefix: &config.path_prefix, path_prefix: &config.path_prefix,
message: message: &config.strings.success,
"Ihre Anfrage wurde erfolgreich übermittelt! Wir melden uns bald bei Ihnen zurück.",
}; };
Ok(template.into_response()) Ok(template.into_response())

11
src/states.rs Normal file
View file

@ -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<config::Config>,
pub mailer: Arc<mailer::Mailer>,
pub captcha_solutions: Arc<captcha_solutions::SharedCaptchaSolutions>,
}

View file

@ -1,16 +1,20 @@
use askama::Template; use askama::Template;
use crate::config;
#[derive(Template)] #[derive(Template)]
#[template(path = "contact_form.askama.html")] #[template(path = "contact_form.askama.html")]
pub struct ContactForm<'a> { pub struct ContactForm<'a> {
pub path_prefix: &'a str, pub path_prefix: &'a str,
pub was_validated: bool, pub was_validated: bool,
pub id: u16, pub id: u16,
pub name: &'a str, pub name: String,
pub email: &'a str, pub email: String,
pub telefon: &'a str, pub telefon: String,
pub message: &'a str, pub message: String,
pub captcha: &'a str, pub captcha: String,
pub error_message: &'a str,
pub strings: &'a config::Strings,
} }
#[derive(Template)] #[derive(Template)]

View file

@ -1,41 +1,43 @@
{% extends "base.askama.html" %} {% extends "base.askama.html" %}
{% block body %} {% block body %}
<p> {% if error_message.len() > 0 %}
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. <div class="alert alert-warning" role="alert">{{ error_message }}</div>
</p> {% endif %}
<h2>Kontakt-Formular</h2> <p>{{ strings.description }}</p>
<h2>{{ strings.title }}</h2>
<form id="contact-form" <form id="contact-form"
action="{{ path_prefix }}/submit" action="{{ path_prefix }}/"
method="post" method="post"
class="{% if was_validated %}was-validated{% endif %}" {% if was_validated %} class="was-validated"{% endif %}
novalidate> novalidate>
<input type="hidden" name="id" value="{{ id }}" required> <input type="hidden" name="id" value="{{ id }}" required>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Name</label> <label for="name" class="form-label">{{ strings.name_field.label }}</label>
<input type="text" <input type="text"
name="name" name="name"
value="{{ name }}" value="{{ name }}"
class="form-control" class="form-control"
id="exampleInputEmail1" id="exampleInputEmail1"
required> required>
<div class="invalid-feedback">Geben Sie bitte Ihren Namen ein</div> <div class="invalid-feedback">{{ strings.name_field.invalid_feedback }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">E-Mail</label> <label for="email" class="form-label">{{ strings.email_field.label }}</label>
<input type="email" <input type="email"
name="email" name="email"
value="{{ email }}" value="{{ email }}"
class="form-control" class="form-control"
id="email" id="email"
required> required>
<div class="invalid-feedback">Geben Sie bitte Ihre E-Mail-Adresse ein</div> <div class="invalid-feedback">{{ strings.email_field.invalid_feedback }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="telefon" class="form-label">Telefon (optional)</label> <label for="telefon" class="form-label">{{ strings.telefon_field_label }}</label>
<input type="text" <input type="text"
name="telefon" name="telefon"
value="{{ telefon }}" value="{{ telefon }}"
@ -43,26 +45,26 @@
id="telefon"> id="telefon">
</div> </div>
<div class="mb-5"> <div class="mb-5">
<label for="message" class="form-label">Nachricht</label> <label for="message" class="form-label">{{ strings.message_field.label }}</label>
<textarea name="message" rows="5" class="form-control" id="message" style="resize: none;" required>{{ message }}</textarea> <textarea name="message" rows="5" class="form-control" id="message" style="resize: none;" required>{{ message }}</textarea>
<div class="invalid-feedback">Geben Sie bitte eine Nachricht mit Ihrem Anliegen ein</div> <div class="invalid-feedback">{{ strings.message_field.invalid_feedback }}</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<img src="data:image/png;base64,{{ captcha }}"> <img src="data:image/png;base64,{{ captcha }}">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="captcha_answer" class="form-label">Code vom oberen Bild eingeben</label> <label for="captcha_answer" class="form-label">{{ strings.captcha_field.label }}</label>
<input type="text" <input type="text"
name="captcha_answer" name="captcha_answer"
class="form-control" class="form-control"
id="captcha_answer" id="captcha_answer"
required> required>
<div class="invalid-feedback">Geben Sie bitte den Code vom oberen Bild ein</div> <div class="invalid-feedback">{{ strings.captcha_field.invalid_feedback }}</div>
</div> </div>
<div class="d-grid"> <div class="d-grid">
<button type="submit" class="btn btn-primary">Abschicken</button> <button type="submit" class="btn btn-primary">{{ strings.submit }}</button>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
@ -71,7 +73,7 @@
<script> <script>
"use strict"; "use strict";
const form = document.querySelector("#contact-form"); const form = document.querySelector("form#contact-form");
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
if (!form.checkValidity()) { if (!form.checkValidity()) {