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
|
|
|
}
|