zero-to-axum/src/server/routes/subscriptions/mod.rs
azdle 3e15b7b0c9 persist sessions to db
Also switches from `chrono` to `time` because `tower-sessions-sqlx-store`
forces it.
2025-07-24 10:43:29 -05:00

237 lines
5.9 KiB
Rust

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<AppState> {
Router::new()
.route("/", post(subscribe))
.route("/confirm/", get(confirm))
.route("/publish/", post(publish))
}
#[derive(Deserialize)]
pub struct SubscribeForm {
name: Option<String>,
email: String,
}
pub async fn subscribe(
State(AppState {
db,
email_client,
conf,
..
}): State<AppState>,
Form(form): Form<SubscribeForm>,
) -> 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<AppState>,
Query(query): Query<ConfirmQuery>,
) -> 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<AppState>,
// Query(query): Query<PublishQuery>,
Form(PublishForm { subject, body }): Form<PublishForm>,
) -> 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()
}
}