use crate::authentication::{validate_credentials, Credentials}; use crate::email_client::EmailClient; use crate::routes::error_chain_fmt; use crate::{authentication::AuthError, domain::SubscriberEmail}; use actix_web::http::{ header::{HeaderMap, HeaderValue}, StatusCode, }; use actix_web::{web, HttpRequest, HttpResponse, ResponseError}; use anyhow::Context; use secrecy::Secret; use sqlx::PgPool; #[derive(serde::Deserialize)] pub struct BodyData { title: String, content: Content, } #[derive(serde::Deserialize)] pub struct Content { html: String, text: String, } pub fn basic_authentication(headers: &HeaderMap) -> Result { // 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: Secret::new(password), }) } #[derive(thiserror::Error)] pub enum PublishError { #[error("Authentication failed.")] AuthError(#[source] anyhow::Error), #[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 { fn error_response(&self) -> HttpResponse { match self { 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 } } } } #[tracing::instrument( name = "Publish a newsletter issue", skip(body, pool, email_client, request), fields(username=tracing::field::Empty, user_id=tracing::field::Empty) )] pub async fn publish_newsletter( body: web::Json, pool: web::Data, email_client: web::Data, request: HttpRequest, ) -> Result { let credentials = basic_authentication(request.headers()).map_err(PublishError::AuthError)?; tracing::Span::current().record("username", &tracing::field::display(&credentials.username)); let user_id = validate_credentials(credentials, &pool) .await .map_err(|e| match e { AuthError::InvalidCredentials(_) => PublishError::AuthError(e.into()), AuthError::UnexpectedError(_) => PublishError::UnexpectedError(e.into()), })?; tracing::Span::current().record("user_id", &tracing::field::display(&user_id)); let subscribers = get_confirmed_subscribers(&pool).await?; for subscriber in subscribers { 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", ); } } } Ok(HttpResponse::Ok().finish()) } struct ConfirmedSubscriber { email: SubscriberEmail, } #[tracing::instrument(name = "Get confirmed subscribers", skip(pool))] async fn get_confirmed_subscribers( pool: &PgPool, ) -> Result>, anyhow::Error> { let confirmed_subscribers = sqlx::query!( r#" SELECT email FROM subscriptions WHERE status = 'confirmed' "#, ) .fetch_all(pool) .await? .into_iter() .map(|r| match SubscriberEmail::parse(r.email) { Ok(email) => Ok(ConfirmedSubscriber { email }), Err(error) => Err(anyhow::anyhow!(error)), }) .collect(); Ok(confirmed_subscribers) }