/** * Comments generator * POST /comment - submits a comment * * GET /form - generate html comment form * GET /comment - gets html fragment with comments * both accept a query parameter ?page= */ use askama::Template; use axum::{ extract::{Query, Form, State}, response::{Redirect,Html}, http::StatusCode, routing::get, Router, }; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, transport::smtp::authentication::Credentials }; use serde::Deserialize; use sqlx::{ postgres::{PgPool, PgPoolOptions}, types::{ time::OffsetDateTime, uuid::Uuid, }, }; use std::{net::SocketAddr, time::Duration}; // Useful global thingies #[derive(Clone)] struct Ctx { pool: PgPool, base_path: String, mail_opts: Option, } #[derive(Clone)] struct MailCtx { notification_address: String, smtp_server: String, smtp_credentials: Credentials, } #[tokio::main] async fn main() { let base_path = std::env::var("BASE_PATH") .unwrap_or_else(|_| "/api".to_string()); let db_connection_str = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://postgres:password@localhost".to_string()); let notification_address = std::env::var("NOTIFICATION_ADDRESS").ok(); let mail_opts = notification_address.map(|na| { let smtp_server = std::env::var("SMTP_SERVER").expect("SMTP_SERVER not supplied!"); let smtp_username = std::env::var("SMTP_USERNAME").expect("SMTP_USERNAME not supplied!"); let smtp_password = std::env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD not supplied!"); MailCtx{ smtp_server: smtp_server, smtp_credentials: Credentials::new(smtp_username, smtp_password), notification_address: na, } }); let pool = PgPoolOptions::new() .max_connections(5) .connect_timeout(Duration::from_secs(3)) .connect(&db_connection_str) .await .expect("can't connect to database"); sqlx::migrate!() .run(&pool) .await .expect("failed to run migrations"); let ctx = Ctx {pool, base_path: base_path.clone(), mail_opts}; let app = Router::new() .nest(&base_path, Router::new() .route( "/form", get(get_form), ) .route( "/comment", get(get_comments).post(post_comments), ) .with_state(ctx)); let addr = SocketAddr::from(([127, 0, 0, 1], 5678)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); } #[derive(Deserialize)] struct UrlQuery { url: String } #[derive(Template)] #[template(path = "form.html")] struct CommentForm { url: String, capcha_question: String, capcha_id: Uuid, base_path: String, } async fn get_form( State(ctx): State, Query(uq): Query ) -> Result, (StatusCode, String)> { let capcha = sqlx::query!("select id, question from capchas order by random() limit 1") .fetch_one(&ctx.pool) .await .map_err(internal_error)?; let c = CommentForm{url: uq.url, capcha_question: capcha.question, capcha_id: capcha.id, base_path: ctx.base_path}; let res = c.render().map_err(internal_error)?; Ok(Html(res)) } #[derive(Template)] #[template(path = "comments.html")] struct Comments { comments: Vec, } struct Comment { author: String, comment: String, ts: OffsetDateTime, } async fn get_comments( State(ctx): State, Query(uq): Query) -> Result, (StatusCode,String)> { let comments = sqlx::query!("select author,comment,ts from comments where url = $1 order by ts", uq.url) .fetch_all(&ctx.pool) .await .map_err(internal_error)?; let render_comments: Vec = comments.into_iter().map(|comment| { Comment { author: comment.author, comment: comment.comment, ts: comment.ts, } }).collect(); let c = Comments{comments: render_comments}; let res = c.render().map_err(internal_error)?; Ok(Html(res)) } #[derive(Deserialize)] struct PostComment { url: String, author: String, comment: String, capcha_id: String, capcha_answer: String, } struct CapchaAnswer { answer:String } #[derive(Template)] #[template(path = "notification.txt")] struct Notification<'a> { url: &'a str, comment: &'a str, author: &'a str, } async fn post_comments( State(ctx): State, Form(post_comment): Form) -> Result { let capcha_id: Uuid = post_comment.capcha_id.parse() .map_err(|_| {(StatusCode::BAD_REQUEST, "Invalid capcha_id".to_string())})?; let ans: CapchaAnswer = sqlx::query_as!(CapchaAnswer, "select answer from capchas where id = $1", capcha_id) .fetch_one(&ctx.pool) .await .map_err(internal_error)?; if post_comment.capcha_answer != ans.answer { return Err((StatusCode::BAD_REQUEST, "Capcha was wrong!".to_string())); } sqlx::query!("insert into comments(url,author,comment) values($1, $2, $3)", post_comment.url, post_comment.author, post_comment.comment) .execute(&ctx.pool) .await .map_err(internal_error)?; if let Some(mail_opts) = ctx.mail_opts { let mail_body = Notification{ url: &post_comment.url, author: &post_comment.author, comment: &post_comment.comment }.render().unwrap(); let email = Message::builder() // TODO should be a config value .from("Comments Service ".parse().unwrap()) .to(mail_opts.notification_address.parse().unwrap()) .subject(format!("New comment on {}", post_comment.url)) .body(mail_body) .unwrap(); let mailer: AsyncSmtpTransport = AsyncSmtpTransport::::relay(&mail_opts.smtp_server) .unwrap() .credentials(mail_opts.smtp_credentials) .build(); match mailer.send(email).await { Ok(_) => (), Err(e) => eprintln!("Could not send email: {:?}", e), } }; Ok(Redirect::temporary(&post_comment.url)) } fn internal_error(err: E) -> (StatusCode, String) where E: std::error::Error, { (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) }