use anyhow::Context; use axum::{ extract::{Query, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Form, Router, }; use cookie::time::OffsetDateTime; use rand::{distr::Alphanumeric, rng, Rng as _}; use serde::Deserialize; use tracing::{debug, error, info}; use uuid::Uuid; use crate::server::AppState; pub fn build() -> Router { Router::new() .route("/", post(subscribe)) .route("/confirm/", get(confirm)) .route("/publish/", post(publish)) } #[derive(Deserialize)] pub struct SubscribeForm { name: Option, email: String, } pub async fn subscribe( State(AppState { db, email_client, conf, .. }): State, Form(form): Form, ) -> Result<(), SubscribeError> { info!(form.name, form.email, "subscribe attempt"); if form.email.is_empty() { return Err(SubscribeError::InvalidEmail); } let mut txn = db.begin().await.context("start database transaction")?; let subscriber_id = Uuid::new_v4(); sqlx::query!( r#" INSERT INTO subscriptions (id, email, name, subscribed_at, status) VALUES ($1, $2, $3, $4, 'pending_confirmation') "#, subscriber_id, form.email, form.name, OffsetDateTime::now_utc() ) .execute(&mut *txn) .await .context("insert subscription into database")?; let token: String = { let mut rng = rng(); std::iter::repeat_with(|| rng.sample(Alphanumeric)) .map(char::from) .take(25) .collect() }; sqlx::query!( r#" INSERT INTO subscription_tokens (subscriber, token) VALUES ($1, $2) "#, subscriber_id, token ) .execute(&mut *txn) .await .context("insert subscription token into database")?; email_client .send_email( form.email .parse() .map_err(|_| SubscribeError::InvalidEmail)?, "Test".to_owned(), format!( "Please confirm your subscription: {}subscriptions/confirm/?token={token}", conf.app.public_url ), ) .await?; debug!("email sent"); txn.commit().await.context("commit transaction")?; Ok(()) } #[derive(thiserror::Error, Debug)] pub enum SubscribeError { #[error("Invalid Email Address")] InvalidEmail, #[error("Unknown Error: {0}")] Unknown(#[from] anyhow::Error), } impl IntoResponse for SubscribeError { fn into_response(self) -> axum::response::Response { match self { SubscribeError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email Address"), SubscribeError::Unknown(e) => { error!(?e, "returning INTERNAL SERVER ERROR"); (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") } } .into_response() } } #[derive(Deserialize)] pub struct ConfirmQuery { token: String, } pub async fn confirm( State(AppState { db, .. }): State, Query(query): Query, ) -> Result<(), ConfirmError> { info!(query.token, "confirm attempt"); if query.token.is_empty() { return Err(ConfirmError::InvalidToken); } let rows = sqlx::query!( r#" UPDATE subscriptions SET status = 'confirmed' FROM subscription_tokens WHERE status = 'pending_confirmation' AND subscription_tokens.subscriber = id AND subscription_tokens.token = $1; "#, query.token ) .execute(&db) .await .context("insert subscription into database")?.rows_affected(); if rows < 1 { return Err(ConfirmError::InvalidToken); } Ok(()) } #[derive(thiserror::Error, Debug)] pub enum ConfirmError { #[error("Invalid Confirmation Token")] InvalidToken, #[error("Unknown Error: {0}")] Unknown(#[from] anyhow::Error), } impl IntoResponse for ConfirmError { fn into_response(self) -> axum::response::Response { match self { ConfirmError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid Confirmation Token"), ConfirmError::Unknown(e) => { error!(?e, "returning INTERNAL SERVER ERROR"); (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") } } .into_response() } } #[derive(Deserialize)] pub struct PublishForm { subject: String, body: String, } pub async fn publish( State(AppState { db, .. }): State, // Query(query): Query, Form(PublishForm { subject, body }): Form, ) -> Result<(), PublishError> { info!("publish"); let mut txn = db.begin().await.context("start database transaction")?; let newsletter_issue_id = Uuid::new_v4(); sqlx::query!( r#" INSERT INTO newsletter_issue (id, subject, body) VALUES ($1, $2, $3); "#, newsletter_issue_id, subject, body ) .execute(&mut *txn) .await .context("create newsletter")?; sqlx::query!( r#" INSERT INTO issue_delivery_queue (newsletter_issue_id, subscriber_email) SELECT $1, email FROM subscriptions WHERE status = 'confirmed'; "#, newsletter_issue_id ) .execute(&mut *txn) .await .context("enqueue newsletter sends")?; txn.commit().await.context("commit transaction")?; Ok(()) } #[derive(thiserror::Error, Debug)] pub enum PublishError { #[error("Unknown Error: {0}")] Unknown(#[from] anyhow::Error), } impl IntoResponse for PublishError { fn into_response(self) -> axum::response::Response { match self { PublishError::Unknown(e) => { error!(?e, "returning INTERNAL SERVER ERROR"); (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") } } .into_response() } }