diff --git a/src/authentication.rs b/src/authentication.rs index d1582ca..666bdba 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,5 +1,7 @@ use crate::{ - routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing, + routes::{AdminError, AppError}, + session_state::TypedSession, + telemetry::spawn_blocking_with_tracing, }; use anyhow::Context; use argon2::{ @@ -135,7 +137,7 @@ pub async fn require_auth( session: TypedSession, mut request: Request, next: Next, -) -> Result { +) -> Result { let user_id = session .get_user_id() .await diff --git a/src/routes.rs b/src/routes.rs index fe054f4..0174ee4 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -8,7 +8,10 @@ mod subscriptions_confirm; pub use admin::*; use askama::Template; -use axum::response::{Html, IntoResponse, Response}; +use axum::{ + http::HeaderMap, + response::{Html, IntoResponse, Response}, +}; pub use health_check::*; pub use home::*; pub use login::*; @@ -17,6 +20,62 @@ use reqwest::StatusCode; pub use subscriptions::*; pub use subscriptions_confirm::*; +use crate::templates::MessageTemplate; + +#[derive(thiserror::Error)] +pub enum AppError { + #[error("An unexpected error was encountered.")] + UnexpectedError(#[from] anyhow::Error), + #[error("A validation error happened.")] + ValidationError(#[source] anyhow::Error), + #[error("An authentication is required.")] + NotAuthenticated, +} + +impl std::fmt::Debug for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + error_chain_fmt(self, f) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("{:?}", self); + + match &self { + AppError::UnexpectedError(_) => { + let template = MessageTemplate::Error { + message: "An internal server error occured.".into(), + }; + Html(template.render().unwrap()).into_response() + } + AppError::ValidationError(error) => { + let template = MessageTemplate::Error { + message: error.to_string(), + }; + Html(template.render().unwrap()).into_response() + } + AppError::NotAuthenticated => { + let mut headers = HeaderMap::new(); + headers.insert("HX-Redirect", "/login".parse().unwrap()); + (StatusCode::UNAUTHORIZED, headers).into_response() + } + } + } +} + +impl From for AppError { + fn from(value: AdminError) -> Self { + match value { + AdminError::UnexpectedError(error) => AppError::UnexpectedError(error), + AdminError::NotAuthenticated => AppError::NotAuthenticated, + AdminError::ChangePassword(s) => AppError::ValidationError(anyhow::anyhow!(s)), + AdminError::Publish(e) => AppError::ValidationError(e), + AdminError::Idempotency(s) => AppError::UnexpectedError(anyhow::anyhow!(s)), + } + } +} + #[derive(Template)] #[template(path = "../templates/404.html")] struct NotFoundTemplate; diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 793f8b0..79c44e1 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -4,19 +4,12 @@ mod logout; mod newsletters; mod posts; -use crate::{routes::error_chain_fmt, templates::MessageTemplate}; -use askama::Template; -use axum::{ - Json, - http::HeaderMap, - response::{Html, IntoResponse, Response}, -}; +use crate::routes::error_chain_fmt; pub use change_password::*; pub use dashboard::*; pub use logout::*; pub use newsletters::*; pub use posts::*; -use reqwest::StatusCode; #[derive(thiserror::Error)] pub enum AdminError { @@ -37,45 +30,3 @@ impl std::fmt::Debug for AdminError { error_chain_fmt(self, f) } } - -impl IntoResponse for AdminError { - fn into_response(self) -> Response { - #[derive(serde::Serialize)] - struct ErrorResponse<'a> { - message: &'a str, - } - - tracing::error!("{:?}", self); - - match &self { - AdminError::UnexpectedError(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - message: "An internal server error occured.", - }), - ) - .into_response(), - AdminError::NotAuthenticated => { - let mut headers = HeaderMap::new(); - headers.insert("HX-Redirect", "/login".parse().unwrap()); - headers.insert("Location", "/login".parse().unwrap()); - (StatusCode::SEE_OTHER, headers).into_response() - } - AdminError::ChangePassword(e) => { - let template = MessageTemplate::Error { - message: e.to_owned(), - }; - Html(template.render().unwrap()).into_response() - } - AdminError::Publish(e) => { - let template = MessageTemplate::Error { - message: e.to_string(), - }; - Html(template.render().unwrap()).into_response() - } - AdminError::Idempotency(e) => { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response() - } - } - } -} diff --git a/src/routes/admin/change_password.rs b/src/routes/admin/change_password.rs index d4ff9cd..9a45d4f 100644 --- a/src/routes/admin/change_password.rs +++ b/src/routes/admin/change_password.rs @@ -1,6 +1,6 @@ use crate::{ authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, - routes::AdminError, + routes::{AdminError, AppError}, startup::AppState, templates::MessageTemplate, }; @@ -25,7 +25,7 @@ pub async fn change_password( connection_pool, .. }): State, Form(form): Form, -) -> Result { +) -> Result { let credentials = Credentials { username, password: form.current_password, @@ -33,16 +33,15 @@ pub async fn change_password( if form.new_password.expose_secret() != form.new_password_check.expose_secret() { Err(AdminError::ChangePassword( "You entered two different passwords - the field values must match.".to_string(), - )) + ) + .into()) } else if validate_credentials(credentials, &connection_pool) .await .is_err() { - Err(AdminError::ChangePassword( - "The current password is incorrect.".to_string(), - )) + Err(AdminError::ChangePassword("The current password is incorrect.".to_string()).into()) } else if let Err(e) = verify_password(form.new_password.expose_secret()) { - Err(AdminError::ChangePassword(e)) + Err(AdminError::ChangePassword(e).into()) } else { authentication::change_password(user_id, form.new_password, &connection_pool) .await diff --git a/src/routes/admin/logout.rs b/src/routes/admin/logout.rs index 2f02292..4dbca69 100644 --- a/src/routes/admin/logout.rs +++ b/src/routes/admin/logout.rs @@ -1,13 +1,13 @@ -use crate::{routes::AdminError, session_state::TypedSession}; +use crate::session_state::TypedSession; use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; #[tracing::instrument(name = "Logging out", skip(session))] -pub async fn logout(session: TypedSession) -> Result { +pub async fn logout(session: TypedSession) -> Response { session.clear().await; let mut headers = HeaderMap::new(); headers.insert("HX-Redirect", "/login".parse().unwrap()); - Ok((StatusCode::OK, headers).into_response()) -} \ No newline at end of file + (StatusCode::OK, headers).into_response() +} diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index 62190f7..ce45515 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -1,7 +1,7 @@ use crate::{ authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, - routes::AdminError, + routes::{AdminError, AppError}, startup::AppState, templates::MessageTemplate, }; @@ -75,7 +75,7 @@ pub async fn publish_newsletter( }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, -) -> Result { +) -> Result { validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; let idempotency_key: IdempotencyKey = form @@ -104,9 +104,10 @@ pub async fn publish_newsletter( ); let template = MessageTemplate::Success { message }; let response = Html(template.render().unwrap()).into_response(); - save_response(transaction, &idempotency_key, user_id, response) + let response = save_response(transaction, &idempotency_key, user_id, response) .await - .map_err(AdminError::UnexpectedError) + .map_err(AdminError::UnexpectedError)?; + Ok(response) } fn validate_form(form: &BodyData) -> Result<(), &'static str> { diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs index 549376d..3ad6bad 100644 --- a/src/routes/admin/posts.rs +++ b/src/routes/admin/posts.rs @@ -1,7 +1,7 @@ use crate::{ authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, - routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue}, + routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue}, startup::AppState, templates::MessageTemplate, }; @@ -38,7 +38,7 @@ pub async fn create_post( }): State, Extension(AuthenticatedUser { user_id, .. }): Extension, Form(form): Form, -) -> Result { +) -> Result { validate_form(&form).map_err(AdminError::Publish)?; let idempotency_key: IdempotencyKey = form @@ -65,16 +65,14 @@ pub async fn create_post( .await .context("Failed to enqueue delivery tasks.")?; - // Send emails with unique identifiers that contains link to blog post with special param - // Get handpoint that returns the post and mark the email as opened - let template = MessageTemplate::Success { message: "Your new post has been saved. Subscribers will be notified.".into(), }; let response = Html(template.render().unwrap()).into_response(); - save_response(transaction, &idempotency_key, user_id, response) + let response = save_response(transaction, &idempotency_key, user_id, response) .await - .map_err(AdminError::UnexpectedError) + .map_err(AdminError::UnexpectedError)?; + Ok(response) } #[tracing::instrument( @@ -116,5 +114,6 @@ pub async fn create_newsletter( content: &str, _post_id: &Uuid, ) -> Result { + // We need to send a special link with a unique ID to determine if the user clicked it or not. insert_newsletter_issue(transaction, title, content, content).await }