Compare commits
4 commits
5b007a3220
...
eb3cca99da
Author | SHA1 | Date | |
---|---|---|---|
eb3cca99da | |||
6b5bd9f113 | |||
ba01105058 | |||
14c418dacc |
19 changed files with 207 additions and 100 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
|||
*.json
|
||||
/Cargo.lock
|
||||
/node_modules/
|
||||
/target/
|
||||
logs
|
||||
|
|
15
Cargo.toml
15
Cargo.toml
|
@ -8,10 +8,21 @@ license-file = "LICENSE.txt"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.5"
|
||||
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"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tera = "1.17"
|
||||
tokio = { version = "1.21", features = ["full"] }
|
||||
tower = { version = "0.4", features = ["limit", "buffer"] }
|
||||
tower-http = { version = "0.3", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = "0.3"
|
||||
|
|
|
@ -4,6 +4,12 @@ use std::env;
|
|||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SocketAddress {
|
||||
pub address: [u8; 4],
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EmailServer {
|
||||
pub server_name: String,
|
||||
|
@ -18,12 +24,20 @@ pub struct Address {
|
|||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Logging {
|
||||
pub directory: String,
|
||||
pub filename_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
pub path_prefix: String,
|
||||
pub socket_address: SocketAddress,
|
||||
pub email_server: EmailServer,
|
||||
pub email_from: Address,
|
||||
pub email_to: Address,
|
||||
pub logging: Logging,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
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,
|
||||
}
|
16
src/errors.rs
Normal file
16
src/errors.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
pub struct AppError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self(err)
|
||||
}
|
||||
}
|
19
src/logging.rs
Normal file
19
src/logging.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
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 (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(LevelFilter::INFO)
|
||||
.with_writer(non_blocking)
|
||||
.init();
|
||||
|
||||
guard
|
||||
}
|
67
src/main.rs
67
src/main.rs
|
@ -1,50 +1,85 @@
|
|||
mod captcha_solutions;
|
||||
mod config;
|
||||
mod context;
|
||||
mod errors;
|
||||
mod forms;
|
||||
mod logging;
|
||||
mod mailer;
|
||||
mod routes;
|
||||
mod templates;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
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 tera::Tera;
|
||||
|
||||
async fn init() -> Result<()> {
|
||||
let tera = Arc::new(Tera::new("templates/*").context("Failed to parse templates!")?);
|
||||
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_appender::non_blocking::WorkerGuard;
|
||||
|
||||
async fn init() -> Result<WorkerGuard> {
|
||||
let mut config = config::Config::new()?;
|
||||
let path_prefix = config.path_prefix.clone();
|
||||
let mailer = Arc::new(mailer::Mailer::new(&mut config)?);
|
||||
|
||||
let address = config.socket_address.address;
|
||||
let socket_address = SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
address[0], address[1], address[2], address[3],
|
||||
)),
|
||||
config.socket_address.port,
|
||||
);
|
||||
|
||||
let tracing_worker_gurad = logging::init_logger(&config.logging);
|
||||
|
||||
let config = Arc::new(config);
|
||||
let captcha_solutions = Arc::new(captcha_solutions::SharedCaptchaSolutions::new());
|
||||
|
||||
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("/success", get(routes::success));
|
||||
|
||||
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))
|
||||
.layer(Extension(tera));
|
||||
.layer(Extension(captcha_solutions));
|
||||
|
||||
let app = Router::new().nest(&path_prefix, routes);
|
||||
|
||||
Server::bind(&([127, 0, 0, 1], 8080).into())
|
||||
Server::bind(&socket_address)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
Ok(tracing_worker_gurad)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init().await.unwrap_or_else(|e| {
|
||||
eprintln!("{e}");
|
||||
let _tracing_worker_gurad = init().await.unwrap_or_else(|e| {
|
||||
eprintln!("{e:?}");
|
||||
process::exit(1);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,30 +1,12 @@
|
|||
use anyhow::{Context, Result};
|
||||
use askama_axum::IntoResponse;
|
||||
use axum::extract::{Extension, Form, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::response::Response;
|
||||
use serde::Deserialize;
|
||||
use std::mem;
|
||||
use std::sync::Arc;
|
||||
use tera::Tera;
|
||||
|
||||
use crate::{captcha_solutions, config, context, forms, mailer};
|
||||
|
||||
pub struct AppError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
StatusCode::BAD_REQUEST.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for AppError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(err: E) -> Self {
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
||||
use crate::{captcha_solutions, config, errors, forms, mailer, templates};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IndexParams {
|
||||
|
@ -37,16 +19,15 @@ pub struct IndexParams {
|
|||
|
||||
pub async fn index(
|
||||
Query(params): Query<IndexParams>,
|
||||
engine: Extension<Arc<Tera>>,
|
||||
config: Extension<Arc<config::Config>>,
|
||||
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
|
||||
) -> Result<Response, AppError> {
|
||||
) -> Result<Response, errors::AppError> {
|
||||
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 form_context = context::ContactForm {
|
||||
let template = templates::ContactForm {
|
||||
path_prefix: &config.path_prefix,
|
||||
was_validated: params.was_validated.unwrap_or(false),
|
||||
id,
|
||||
|
@ -57,20 +38,14 @@ pub async fn index(
|
|||
captcha: &captcha_base64,
|
||||
};
|
||||
|
||||
let html = engine.render(
|
||||
"form.html.tera",
|
||||
&tera::Context::from_serialize(&form_context)?,
|
||||
)?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
Ok(template.into_response())
|
||||
}
|
||||
|
||||
async fn back_to_index(
|
||||
mut form: forms::ContactForm,
|
||||
engine: Extension<Arc<Tera>>,
|
||||
config: Extension<Arc<config::Config>>,
|
||||
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
|
||||
) -> Result<Response, AppError> {
|
||||
) -> Result<Response, errors::AppError> {
|
||||
let name = mem::take(&mut form.name);
|
||||
let email = mem::take(&mut form.email);
|
||||
let telefon = mem::take(&mut form.telefon);
|
||||
|
@ -84,32 +59,35 @@ async fn back_to_index(
|
|||
message: Some(message),
|
||||
});
|
||||
|
||||
index(params, engine, config, captcha_solutions).await
|
||||
index(params, config, captcha_solutions).await
|
||||
}
|
||||
|
||||
pub async fn submit(
|
||||
Form(form): Form<forms::ContactForm>,
|
||||
engine: Extension<Arc<Tera>>,
|
||||
config: Extension<Arc<config::Config>>,
|
||||
captcha_solutions: Extension<Arc<captcha_solutions::SharedCaptchaSolutions>>,
|
||||
mailer: Extension<Arc<mailer::Mailer>>,
|
||||
) -> Result<Response, AppError> {
|
||||
) -> Result<Response, errors::AppError> {
|
||||
if !captcha_solutions.check_answer(form.id, &form.captcha_answer) {
|
||||
return back_to_index(form, engine, config, captcha_solutions).await;
|
||||
return back_to_index(form, config, captcha_solutions).await;
|
||||
}
|
||||
|
||||
match mailer.send(&form.name, &form.email, &form.telefon, &form.message) {
|
||||
Ok(_) => (),
|
||||
Err(_) => {
|
||||
return back_to_index(form, engine, config, captcha_solutions).await;
|
||||
return back_to_index(form, config, captcha_solutions).await;
|
||||
}
|
||||
}
|
||||
|
||||
success(engine).await
|
||||
success(config).await
|
||||
}
|
||||
|
||||
pub async fn success(engine: Extension<Arc<Tera>>) -> Result<Response, AppError> {
|
||||
let html = engine.render("success.html.tera", &tera::Context::new())?;
|
||||
pub async fn success(config: Extension<Arc<config::Config>>) -> Result<Response, errors::AppError> {
|
||||
let template = templates::Success {
|
||||
path_prefix: &config.path_prefix,
|
||||
message:
|
||||
"Ihre Anfrage wurde erfolgreich übermittelt! Wir melden uns bald bei Ihnen zurück.",
|
||||
};
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
Ok(template.into_response())
|
||||
}
|
||||
|
|
21
src/templates.rs
Normal file
21
src/templates.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use askama::Template;
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "success.askama.html")]
|
||||
pub struct Success<'a> {
|
||||
pub path_prefix: &'a str,
|
||||
pub message: &'a str,
|
||||
}
|
7
static/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
7
static/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/bootstrap/bootstrap.bundle.min.js.map
Normal file
1
static/bootstrap/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
7
static/bootstrap/bootstrap.min.css
vendored
Normal file
7
static/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/bootstrap/bootstrap.min.css.map
Normal file
1
static/bootstrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
20
templates/base.askama.html
Normal file
20
templates/base.askama.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Contact form</title>
|
||||
|
||||
{% block styles %}{% endblock %}
|
||||
|
||||
<link href="{{ path_prefix }}/static/bootstrap/bootstrap.min.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<script src="{{ path_prefix }}/static/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,20 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>Contact form</title>
|
||||
|
||||
{% block styles %}{% endblock %}
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +1,4 @@
|
|||
{% extends "base.html.tera" %}
|
||||
{% extends "base.askama.html" %}
|
||||
|
||||
{% block body %}
|
||||
<p>
|
7
templates/success.askama.html
Normal file
7
templates/success.askama.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "base.askama.html" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,7 +0,0 @@
|
|||
{% extends "base.html.tera" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
Ihre Anfrage wurde erfolgreich übermittelt! Wir melden uns bald bei Ihnen zurück.
|
||||
</div>
|
||||
{% endblock %}
|
7
update_deps.sh
Executable file
7
update_deps.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
npm update
|
||||
|
||||
cp -v node_modules/bootstrap/dist/{css/bootstrap.min.css{,.map},js/bootstrap.bundle.min.js{,.map}} static/bootstrap
|
Loading…
Reference in a new issue