237 lines
5.9 KiB
Rust
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()
|
|
}
|
|
}
|