zero-to-production/src/routes/newsletters.rs

182 lines
5.8 KiB
Rust
Raw Normal View History

2021-08-01 10:11:56 +00:00
use crate::domain::SubscriberEmail;
use crate::email_client::EmailClient;
2021-07-28 08:35:34 +00:00
use crate::routes::error_chain_fmt;
2021-08-14 21:57:03 +00:00
use actix_web::http::{HeaderMap, HeaderValue, StatusCode};
2021-08-01 10:11:56 +00:00
use actix_web::{web, HttpResponse, ResponseError};
use anyhow::Context;
2021-08-22 15:54:41 +00:00
use sha3::Digest;
2021-08-01 10:11:56 +00:00
use sqlx::PgPool;
2021-07-22 07:31:57 +00:00
2021-07-25 16:37:00 +00:00
#[derive(serde::Deserialize)]
2021-07-25 15:40:01 +00:00
pub struct BodyData {
title: String,
content: Content,
}
2021-07-25 16:37:00 +00:00
#[derive(serde::Deserialize)]
2021-07-25 15:40:01 +00:00
pub struct Content {
html: String,
text: String,
}
2021-07-28 08:35:34 +00:00
#[derive(thiserror::Error)]
pub enum PublishError {
2021-08-14 21:57:03 +00:00
#[error("Authentication failed.")]
AuthError(#[source] anyhow::Error),
2021-07-28 08:35:34 +00:00
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
impl std::fmt::Debug for PublishError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl ResponseError for PublishError {
2021-08-14 21:57:03 +00:00
fn error_response(&self) -> HttpResponse {
2021-07-28 08:35:34 +00:00
match self {
2021-08-14 21:57:03 +00:00
PublishError::UnexpectedError(_) => {
HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
}
PublishError::AuthError(_) => {
let mut response = HttpResponse::new(StatusCode::UNAUTHORIZED);
let header_value = HeaderValue::from_str(r#"Basic realm="publish""#).unwrap();
response
.headers_mut()
.insert(actix_web::http::header::WWW_AUTHENTICATE, header_value);
response
}
2021-07-28 08:35:34 +00:00
}
}
}
2021-08-14 21:57:03 +00:00
struct Credentials {
username: String,
password: String,
}
fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
// The header value, if present, must be a valid UTF8 string
let header_value = headers
.get("Authorization")
.context("The 'Authorization' header was missing")?
.to_str()
.context("The 'Authorization' header was not a valid UTF8 string.")?;
let base64encoded_credentials = header_value
.strip_prefix("Basic ")
.context("The authorization scheme was not 'Basic'.")?;
let decoded_credentials = base64::decode_config(base64encoded_credentials, base64::STANDARD)
.context("Failed to base64-decode 'Basic' credentials.")?;
let decoded_credentials = String::from_utf8(decoded_credentials)
.context("The decoded credential string is valid UTF8.")?;
let mut credentials = decoded_credentials.splitn(2, ':');
let username = credentials
.next()
.ok_or_else(|| anyhow::anyhow!("A username must be provided in 'Basic' auth."))?
.to_string();
let password = credentials
.next()
.ok_or_else(|| anyhow::anyhow!("A password must be provided in 'Basic' auth."))?
.to_string();
Ok(Credentials { username, password })
}
2021-08-15 12:26:16 +00:00
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
2021-08-22 15:54:41 +00:00
let password_hash = sha3::Sha3_256::digest(credentials.password.as_bytes());
let password_hash = format!("{:x}", password_hash);
2021-08-15 12:26:16 +00:00
let user_id: Option<_> = sqlx::query!(
r#"
SELECT user_id
FROM users
2021-08-22 15:54:41 +00:00
WHERE username = $1 AND password_hash = $2
2021-08-15 12:26:16 +00:00
"#,
credentials.username,
2021-08-22 15:54:41 +00:00
password_hash
2021-08-15 12:26:16 +00:00
)
.fetch_optional(pool)
.await
.context("Failed to performed a query to validate auth credentials.")
.map_err(PublishError::UnexpectedError)?;
user_id
.map(|row| row.user_id)
.ok_or_else(|| anyhow::anyhow!("Invalid username or password."))
.map_err(PublishError::AuthError)
}
#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body, pool, email_client, request),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
2021-07-28 08:35:34 +00:00
pub async fn publish_newsletter(
body: web::Json<BodyData>,
pool: web::Data<PgPool>,
2021-08-01 10:11:56 +00:00
email_client: web::Data<EmailClient>,
2021-08-14 21:57:03 +00:00
request: web::HttpRequest,
2021-07-28 08:35:34 +00:00
) -> Result<HttpResponse, PublishError> {
2021-08-14 21:57:03 +00:00
let credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?;
2021-08-15 12:26:16 +00:00
tracing::Span::current().record("username", &tracing::field::display(&credentials.username));
let user_id = validate_credentials(credentials, &pool).await?;
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
2021-07-28 08:35:34 +00:00
let subscribers = get_confirmed_subscribers(&pool).await?;
2021-08-01 10:11:56 +00:00
for subscriber in subscribers {
2021-08-01 13:57:02 +00:00
match subscriber {
Ok(subscriber) => {
email_client
.send_email(
&subscriber.email,
&body.title,
&body.content.html,
&body.content.text,
)
.await
.with_context(|| {
format!("Failed to send newsletter issue to {}", subscriber.email)
})?;
}
Err(error) => {
tracing::warn!(
error.cause_chain = ?error,
"Skipping a confirmed subscriber. \
Their stored contact details are invalid",
);
}
}
2021-08-01 10:11:56 +00:00
}
2021-07-28 08:35:34 +00:00
Ok(HttpResponse::Ok().finish())
2021-07-22 07:31:57 +00:00
}
2021-07-27 08:09:20 +00:00
struct ConfirmedSubscriber {
2021-08-01 10:11:56 +00:00
email: SubscriberEmail,
2021-07-27 08:09:20 +00:00
}
#[tracing::instrument(name = "Adding a new subscriber", skip(pool))]
async fn get_confirmed_subscribers(
pool: &PgPool,
2021-08-01 13:57:02 +00:00
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
2021-08-01 14:17:47 +00:00
let confirmed_subscribers = sqlx::query!(
2021-07-27 08:09:20 +00:00
r#"
2021-08-01 10:11:56 +00:00
SELECT email
2021-07-27 08:09:20 +00:00
FROM subscriptions
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
2021-08-01 14:17:47 +00:00
.await?
.into_iter()
.map(|r| match SubscriberEmail::parse(r.email) {
Ok(email) => Ok(ConfirmedSubscriber { email }),
Err(error) => Err(anyhow::anyhow!(error)),
})
.collect();
2021-08-01 10:11:56 +00:00
Ok(confirmed_subscribers)
2021-07-27 08:09:20 +00:00
}