Skip to content

Commit

Permalink
feat: add admin newsletter publish form
Browse files Browse the repository at this point in the history
Also moved POST /newsletters to POST /admin/newsletters.
  • Loading branch information
migueloller committed Feb 11, 2024
1 parent 88cf9dd commit f3bd6dd
Show file tree
Hide file tree
Showing 9 changed files with 123 additions and 173 deletions.
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ actix-web-flash-messages = { version = "0.4", features = ["cookies"] }
actix-web-lab = "0.18"
anyhow = "1"
argon2 = { version = "0.4", features = ["std"] }
base64 = "0.21"
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
config = "0.13"
rand = { version = "0.8", features = ["std_rng"] }
Expand Down
49 changes: 47 additions & 2 deletions src/routes/admin/newsletters/get.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::HttpResponse;
use actix_web_flash_messages::IncomingFlashMessages;
use std::fmt::Write;

pub async fn submit_newsletter_form() -> HttpResponse {
HttpResponse::Ok().finish()
pub async fn publish_newsletter_form(flash_messages: IncomingFlashMessages) -> HttpResponse {
let mut msg_html = String::new();
for m in flash_messages.iter() {
write!(msg_html, "<p><i>{}</i></p>", m.content()).unwrap();
}

let html_body = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Submit Newsletter</title>
</head>
<body>
{msg_html}
<form action="/admin/newsletters" method="post">
<label>
Title
<br />
<input type="text" name="title" placeholder="Newsletter title" />
</label>
<br />
<label>
HTML Content
<br />
<textarea name="html_content" placeholder="Newsletter content as HTML"></textarea>
</label>
<br />
<label>
Text Content
<br />
<textarea name="text_content" placeholder="Newsletter content as plain text"></textarea>
</label>
<br />
<button type="submit">Submit newsletter</button>
</form>
</body>
</html>"#
);

HttpResponse::build(StatusCode::OK)
.content_type(ContentType::html())
.body(html_body)
}
4 changes: 3 additions & 1 deletion src/routes/admin/newsletters/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
mod get;
mod post;

pub use get::*;
pub use get::publish_newsletter_form;
pub use post::publish_newsletter;
73 changes: 14 additions & 59 deletions src/routes/newsletters.rs → src/routes/admin/newsletters/post.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
use crate::authentication::{validate_credentials, AuthError, Credentials};
use crate::authentication::UserId;
use crate::domain::SubscriberEmail;
use crate::email_client::EmailClient;
use crate::routes::admin::dashboard::get_username;
use crate::routes::error_chain_fmt;
use actix_web::http::header::HeaderMap;
use actix_web::http::header::HeaderValue;
use actix_web::http::{header, StatusCode};
use actix_web::{web, HttpRequest, HttpResponse, ResponseError};
use actix_web::{web, HttpResponse, ResponseError};
use anyhow::Context;
use base64::Engine;
use secrecy::Secret;
use sqlx::PgPool;

#[derive(serde::Deserialize)]
pub struct BodyData {
pub struct FormData {
title: String,
content: Content,
}

#[derive(serde::Deserialize)]
pub struct Content {
html: String,
text: String,
html_content: String,
text_content: String,
}

#[derive(thiserror::Error)]
Expand Down Expand Up @@ -59,26 +52,19 @@ impl ResponseError for PublishError {

#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body, pool, email_client, request),
skip(body, pool, email_client),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn publish_newsletter(
body: web::Json<BodyData>,
body: web::Form<FormData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
request: HttpRequest,
user_id: web::ReqData<UserId>,
) -> Result<HttpResponse, PublishError> {
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()),
})?;
let user_id = user_id.into_inner();
let username = get_username(*user_id, &pool).await?;

tracing::Span::current().record("username", &tracing::field::display(&username));
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));

let subscribers = get_confirmed_subscribers(&pool).await?;
Expand All @@ -90,8 +76,8 @@ pub async fn publish_newsletter(
.send_email(
&subscriber.email,
&body.title,
&body.content.html,
&body.content.text,
&body.html_content,
&body.text_content,
)
.await
.with_context(|| {
Expand All @@ -111,37 +97,6 @@ pub async fn publish_newsletter(
Ok(HttpResponse::Ok().finish())
}

fn basic_authentication(headers: &HeaderMap) -> Result<Credentials, anyhow::Error> {
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_segment = header_value
.strip_prefix("Basic ")
.context("The authorization scheme was not 'Basic'.")?;
let decoded_bytes = base64::engine::general_purpose::STANDARD
.decode(base64encoded_segment)
.context("Failed to base64-decode 'Basic' credentials.")?;
let decoded_credentials = String::from_utf8(decoded_bytes)
.context("The decoded credential string is not 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),
})
}

struct ConfirmedSubscriber {
email: SubscriberEmail,
}
Expand Down
2 changes: 0 additions & 2 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ mod admin;
mod health_check;
mod home;
mod login;
mod newsletters;
mod subscriptions;
mod subscriptions_confirm;

pub use admin::*;
pub use health_check::*;
pub use home::*;
pub use login::*;
pub use newsletters::*;
pub use subscriptions::*;
pub use subscriptions_confirm::*;
6 changes: 3 additions & 3 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
email_client::EmailClient,
routes::{
admin_dashboard, change_password, change_password_form, confirm, health_check, home,
log_out, login, login_form, publish_newsletter, submit_newsletter_form, subscribe,
log_out, login, login_form, publish_newsletter, publish_newsletter_form, subscribe,
},
};
use actix_session::storage::RedisSessionStore;
Expand Down Expand Up @@ -101,14 +101,14 @@ async fn run(
.route("/login", web::get().to(login_form))
.route("/login", web::post().to(login))
.route("/health_check", web::get().to(health_check))
.route("/newsletters", web::post().to(publish_newsletter))
.route("/subscriptions", web::post().to(subscribe))
.route("/subscriptions/confirm", web::get().to(confirm))
.service(
web::scope("/admin")
.wrap(from_fn(reject_anonymous_users))
.route("/dashboard", web::get().to(admin_dashboard))
.route("/newsletters", web::get().to(submit_newsletter_form))
.route("/newsletters", web::get().to(publish_newsletter_form))
.route("/newsletters", web::post().to(publish_newsletter))
.route("/password", web::get().to(change_password_form))
.route("/password", web::post().to(change_password))
.route("/logout", web::post().to(log_out)),
Expand Down
10 changes: 6 additions & 4 deletions tests/api/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,13 @@ impl TestApp {
.expect("Failed to execute request.")
}

pub async fn post_newsletters(&self, body: serde_json::Value) -> reqwest::Response {
pub async fn post_newsletters<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
self.api_client
.post(&format!("{}/newsletters", &self.address))
.basic_auth(&self.test_user.username, Some(&self.test_user.password))
.json(&body)
.post(&format!("{}/admin/newsletters", &self.address))
.form(&body)
.send()
.await
.expect("Failed to execute request.")
Expand Down
Loading

0 comments on commit f3bd6dd

Please sign in to comment.