Compare commits

..

4 Commits

Author SHA1 Message Date
Alphonse Paix
40dfe1aed8 Templates refactoring
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
2025-09-20 04:43:55 +02:00
Alphonse Paix
b52b676dc0 Error handling refactor and 500 page/message templates 2025-09-20 04:06:48 +02:00
Alphonse Paix
f5cd91108a Refactor admin routes to use new AppError struct in responses 2025-09-20 01:08:05 +02:00
Alphonse Paix
01d2add44b Askama message template 2025-09-20 00:51:46 +02:00
27 changed files with 450 additions and 411 deletions

File diff suppressed because one or more lines are too long

View File

@@ -22,8 +22,6 @@ pub enum AuthError {
UnexpectedError(#[from] anyhow::Error), UnexpectedError(#[from] anyhow::Error),
#[error("Invalid credentials.")] #[error("Invalid credentials.")]
InvalidCredentials(#[source] anyhow::Error), InvalidCredentials(#[source] anyhow::Error),
#[error("Not authenticated.")]
NotAuthenticated,
} }
#[tracing::instrument(name = "Change password", skip(password, connection_pool))] #[tracing::instrument(name = "Change password", skip(password, connection_pool))]
@@ -34,7 +32,7 @@ pub async fn change_password(
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password)) let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password))
.await? .await?
.context("Failed to hash password")?; .context("Failed to hash password.")?;
sqlx::query!( sqlx::query!(
"UPDATE users SET password_hash = $1 WHERE user_id = $2", "UPDATE users SET password_hash = $1 WHERE user_id = $2",
password_hash.expose_secret(), password_hash.expose_secret(),
@@ -73,23 +71,30 @@ gZiV/M1gPc22ElAH/Jh1Hw$\
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno" CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.to_string(), .to_string(),
); );
if let Some((stored_user_id, stored_expected_password_hash)) = if let Some((stored_user_id, stored_expected_password_hash)) =
get_stored_credentials(&username, connection_pool) get_stored_credentials(&username, connection_pool)
.await .await
.context("Failed to retrieve credentials from database.")
.map_err(AuthError::UnexpectedError)? .map_err(AuthError::UnexpectedError)?
{ {
user_id = Some(stored_user_id); user_id = Some(stored_user_id);
expected_password_hash = stored_expected_password_hash; expected_password_hash = stored_expected_password_hash;
} }
spawn_blocking_with_tracing(|| verify_password_hash(expected_password_hash, password)) let handle =
spawn_blocking_with_tracing(|| verify_password_hash(expected_password_hash, password));
let uuid = user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)?;
handle
.await .await
.context("Failed to spawn blocking task.") .context("Failed to spawn blocking task.")
.map_err(AuthError::UnexpectedError)??; .map_err(AuthError::UnexpectedError)?
user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials) .map_err(AuthError::InvalidCredentials)
.map(|_| uuid)
} }
#[tracing::instrument( #[tracing::instrument(
@@ -99,7 +104,7 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
fn verify_password_hash( fn verify_password_hash(
expected_password_hash: SecretString, expected_password_hash: SecretString,
password_candidate: SecretString, password_candidate: SecretString,
) -> Result<(), AuthError> { ) -> Result<(), anyhow::Error> {
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret()) let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
.context("Failed to parse hash in PHC string format.")?; .context("Failed to parse hash in PHC string format.")?;
Argon2::default() Argon2::default()
@@ -108,14 +113,13 @@ fn verify_password_hash(
&expected_password_hash, &expected_password_hash,
) )
.context("Password verification failed.") .context("Password verification failed.")
.map_err(AuthError::InvalidCredentials)
} }
#[tracing::instrument(name = "Get stored credentials", skip(username, connection_pool))] #[tracing::instrument(name = "Get stored credentials", skip(username, connection_pool))]
async fn get_stored_credentials( async fn get_stored_credentials(
username: &str, username: &str,
connection_pool: &PgPool, connection_pool: &PgPool,
) -> Result<Option<(Uuid, SecretString)>, anyhow::Error> { ) -> Result<Option<(Uuid, SecretString)>, sqlx::Error> {
let row = sqlx::query!( let row = sqlx::query!(
r#" r#"
SELECT user_id, password_hash SELECT user_id, password_hash
@@ -125,8 +129,7 @@ async fn get_stored_credentials(
username, username,
) )
.fetch_optional(connection_pool) .fetch_optional(connection_pool)
.await .await?
.context("Failed to perform a query to retrieve stored credentials.")?
.map(|row| (row.user_id, SecretString::from(row.password_hash))); .map(|row| (row.user_id, SecretString::from(row.password_hash)));
Ok(row) Ok(row)
} }

View File

@@ -81,7 +81,7 @@ pub struct EmailClientSettings {
} }
impl EmailClientSettings { impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, String> { pub fn sender(&self) -> Result<SubscriberEmail, anyhow::Error> {
SubscriberEmail::parse(self.sender_email.clone()) SubscriberEmail::parse(self.sender_email.clone())
} }

View File

@@ -1,5 +1,7 @@
mod new_subscriber; mod new_subscriber;
mod post;
mod subscriber_email; mod subscriber_email;
pub use new_subscriber::NewSubscriber; pub use new_subscriber::NewSubscriber;
pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail; pub use subscriber_email::SubscriberEmail;

17
src/domain/post.rs Normal file
View File

@@ -0,0 +1,17 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub struct PostEntry {
pub post_id: Uuid,
pub author: Option<String>,
pub title: String,
pub content: String,
pub published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
}

View File

@@ -7,11 +7,11 @@ pub struct SubscriberEmail {
} }
impl SubscriberEmail { impl SubscriberEmail {
pub fn parse(email: String) -> Result<Self, String> { pub fn parse(email: String) -> Result<Self, anyhow::Error> {
let subscriber_email = SubscriberEmail { email }; let subscriber_email = SubscriberEmail { email };
subscriber_email if subscriber_email.validate().is_err() {
.validate() anyhow::bail!("{} is not a valid email.", subscriber_email.email);
.map_err(|_| format!("{} is not a valid email.", subscriber_email.email))?; }
Ok(subscriber_email) Ok(subscriber_email)
} }
} }

View File

@@ -8,7 +8,10 @@ mod subscriptions_confirm;
pub use admin::*; pub use admin::*;
use askama::Template; use askama::Template;
use axum::response::{Html, IntoResponse, Response}; use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
pub use health_check::*; pub use health_check::*;
pub use home::*; pub use home::*;
pub use login::*; pub use login::*;
@@ -17,9 +20,115 @@ use reqwest::StatusCode;
pub use subscriptions::*; pub use subscriptions::*;
pub use subscriptions_confirm::*; pub use subscriptions_confirm::*;
#[derive(Template)] use crate::{
#[template(path = "../templates/404.html")] authentication::AuthError,
struct NotFoundTemplate; templates::{InternalErrorTemplate, MessageTemplate, NotFoundTemplate},
};
#[derive(thiserror::Error)]
pub enum AppError {
#[error("An unexpected error was encountered.")]
UnexpectedError {
#[source]
error: anyhow::Error,
full_page: bool,
},
#[error("A validation error happened.")]
FormError(#[source] anyhow::Error),
#[error("Authentication is required.")]
NotAuthenticated,
}
impl From<anyhow::Error> for AppError {
fn from(value: anyhow::Error) -> Self {
Self::UnexpectedError {
error: value,
full_page: false,
}
}
}
impl AppError {
pub fn unexpected_page(error: anyhow::Error) -> Self {
Self::UnexpectedError {
error,
full_page: true,
}
}
pub fn unexpected_message(error: anyhow::Error) -> Self {
Self::UnexpectedError {
error,
full_page: false,
}
}
}
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 {
error: _,
full_page,
} => {
let html = if *full_page {
Html(InternalErrorTemplate.render().unwrap())
} else {
let template = MessageTemplate::Error {
message: "An internal server error occured.".into(),
};
Html(template.render().unwrap())
};
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
}
AppError::FormError(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::OK, headers).into_response()
}
}
}
}
impl From<AdminError> for AppError {
fn from(value: AdminError) -> Self {
match value {
AdminError::UnexpectedError(error) => AppError::unexpected_message(error),
AdminError::NotAuthenticated => AppError::NotAuthenticated,
AdminError::ChangePassword(s) => AppError::FormError(anyhow::anyhow!(s)),
AdminError::Publish(e) => AppError::FormError(e),
AdminError::Idempotency(s) => AppError::UnexpectedError {
error: anyhow::anyhow!(s),
full_page: false,
},
}
}
}
impl From<AuthError> for AppError {
fn from(value: AuthError) -> Self {
match value {
AuthError::UnexpectedError(error) => AppError::unexpected_message(error),
AuthError::InvalidCredentials(error) => {
AppError::FormError(error.context("Invalid credentials."))
}
}
}
}
pub async fn not_found() -> Response { pub async fn not_found() -> Response {
( (

View File

@@ -4,23 +4,21 @@ mod logout;
mod newsletters; mod newsletters;
mod posts; mod posts;
use crate::{routes::error_chain_fmt, templates::ErrorTemplate}; use crate::{
use askama::Template; authentication::AuthenticatedUser,
use axum::{ routes::{AppError, error_chain_fmt},
Json, session_state::TypedSession,
http::HeaderMap,
response::{Html, IntoResponse, Response},
}; };
use axum::{extract::Request, middleware::Next, response::Response};
pub use change_password::*; pub use change_password::*;
pub use dashboard::*; pub use dashboard::*;
pub use logout::*; pub use logout::*;
pub use newsletters::*; pub use newsletters::*;
pub use posts::*; pub use posts::*;
use reqwest::StatusCode;
#[derive(thiserror::Error)] #[derive(thiserror::Error)]
pub enum AdminError { pub enum AdminError {
#[error("Something went wrong.")] #[error("Something went wrong while performing an admin action.")]
UnexpectedError(#[from] anyhow::Error), UnexpectedError(#[from] anyhow::Error),
#[error("Trying to access admin dashboard without authentication.")] #[error("Trying to access admin dashboard without authentication.")]
NotAuthenticated, NotAuthenticated,
@@ -38,44 +36,27 @@ impl std::fmt::Debug for AdminError {
} }
} }
impl IntoResponse for AdminError { pub async fn require_auth(
fn into_response(self) -> Response { session: TypedSession,
#[derive(serde::Serialize)] mut request: Request,
struct ErrorResponse<'a> { next: Next,
message: &'a str, ) -> Result<Response, AppError> {
} let user_id = session
.get_user_id()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::NotAuthenticated)?;
let username = session
.get_username()
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?
.ok_or(AdminError::UnexpectedError(anyhow::anyhow!(
"Could not find username in session."
)))?;
tracing::error!("{:?}", self); request
.extensions_mut()
.insert(AuthenticatedUser { user_id, username });
match &self { Ok(next.run(request).await)
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 = ErrorTemplate {
error_message: e.to_owned(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Publish(e) => {
let template = ErrorTemplate {
error_message: e.to_string(),
};
Html(template.render().unwrap()).into_response()
}
AdminError::Idempotency(e) => {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { message: e })).into_response()
}
}
}
} }

View File

@@ -1,8 +1,8 @@
use crate::{ use crate::{
authentication::{self, AuthenticatedUser, Credentials, validate_credentials}, authentication::{self, AuthError, AuthenticatedUser, Credentials, validate_credentials},
routes::AdminError, routes::{AdminError, AppError},
startup::AppState, startup::AppState,
templates::SuccessTemplate, templates::MessageTemplate,
}; };
use askama::Template; use askama::Template;
use axum::{ use axum::{
@@ -25,7 +25,7 @@ pub async fn change_password(
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Form(form): Form<PasswordFormData>, Form(form): Form<PasswordFormData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
let credentials = Credentials { let credentials = Credentials {
username, username,
password: form.current_password, password: form.current_password,
@@ -33,22 +33,24 @@ pub async fn change_password(
if form.new_password.expose_secret() != form.new_password_check.expose_secret() { if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
Err(AdminError::ChangePassword( Err(AdminError::ChangePassword(
"You entered two different passwords - the field values must match.".to_string(), "You entered two different passwords - the field values must match.".to_string(),
)) )
} else if validate_credentials(credentials, &connection_pool) .into())
.await } else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
.is_err() match e {
{ AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
Err(AdminError::ChangePassword( AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(
"The current password is incorrect.".to_string(), "The current password is incorrect.".to_string(),
)) )
.into()),
}
} else if let Err(e) = verify_password(form.new_password.expose_secret()) { } else if let Err(e) = verify_password(form.new_password.expose_secret()) {
Err(AdminError::ChangePassword(e)) Err(AdminError::ChangePassword(e).into())
} else { } else {
authentication::change_password(user_id, form.new_password, &connection_pool) authentication::change_password(user_id, form.new_password, &connection_pool)
.await .await
.map_err(|e| AdminError::ChangePassword(e.to_string()))?; .map_err(|e| AdminError::ChangePassword(e.to_string()))?;
let template = SuccessTemplate { let template = MessageTemplate::Success {
success_message: "Your password has been changed.".to_string(), message: "Your password has been changed.".to_string(),
}; };
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }

View File

@@ -1,4 +1,4 @@
use crate::authentication::AuthenticatedUser; use crate::{authentication::AuthenticatedUser, templates::DashboardTemplate};
use askama::Template; use askama::Template;
use axum::{ use axum::{
Extension, Extension,
@@ -6,14 +6,6 @@ use axum::{
}; };
use uuid::Uuid; use uuid::Uuid;
#[derive(Template)]
#[template(path = "../templates/dashboard.html")]
struct DashboardTemplate {
username: String,
idempotency_key_1: String,
idempotency_key_2: String,
}
pub async fn admin_dashboard( pub async fn admin_dashboard(
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
) -> Response { ) -> Response {

View File

@@ -1,13 +1,13 @@
use crate::{routes::AdminError, session_state::TypedSession}; use crate::session_state::TypedSession;
use axum::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
#[tracing::instrument(name = "Logging out", skip(session))] #[tracing::instrument(name = "Logging out", skip(session))]
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> { pub async fn logout(session: TypedSession) -> Response {
session.clear().await; session.clear().await;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/login".parse().unwrap()); headers.insert("HX-Redirect", "/login".parse().unwrap());
Ok((StatusCode::OK, headers).into_response()) (StatusCode::OK, headers).into_response()
} }

View File

@@ -1,9 +1,9 @@
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing}, idempotency::{IdempotencyKey, save_response, try_processing},
routes::AdminError, routes::{AdminError, AppError},
startup::AppState, startup::AppState,
templates::SuccessTemplate, templates::MessageTemplate,
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
@@ -75,7 +75,7 @@ pub async fn publish_newsletter(
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<BodyData>, Form(form): Form<BodyData>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?; validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
let idempotency_key: IdempotencyKey = form let idempotency_key: IdempotencyKey = form
@@ -98,15 +98,16 @@ pub async fn publish_newsletter(
.await .await
.context("Failed to enqueue delivery tasks.")?; .context("Failed to enqueue delivery tasks.")?;
let success_message = format!( let message = format!(
r#"The newsletter issue "{}" has been published!"#, r#"The newsletter issue "{}" has been published!"#,
form.title form.title
); );
let template = SuccessTemplate { success_message }; let template = MessageTemplate::Success { message };
let response = Html(template.render().unwrap()).into_response(); 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 .await
.map_err(AdminError::UnexpectedError) .map_err(AdminError::UnexpectedError)?;
Ok(response)
} }
fn validate_form(form: &BodyData) -> Result<(), &'static str> { fn validate_form(form: &BodyData) -> Result<(), &'static str> {

View File

@@ -1,9 +1,9 @@
use crate::{ use crate::{
authentication::AuthenticatedUser, authentication::AuthenticatedUser,
idempotency::{IdempotencyKey, save_response, try_processing}, 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, startup::AppState,
templates::SuccessTemplate, templates::MessageTemplate,
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
@@ -38,7 +38,7 @@ pub async fn create_post(
}): State<AppState>, }): State<AppState>,
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>, Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
Form(form): Form<CreatePostForm>, Form(form): Form<CreatePostForm>,
) -> Result<Response, AdminError> { ) -> Result<Response, AppError> {
validate_form(&form).map_err(AdminError::Publish)?; validate_form(&form).map_err(AdminError::Publish)?;
let idempotency_key: IdempotencyKey = form let idempotency_key: IdempotencyKey = form
@@ -65,16 +65,14 @@ pub async fn create_post(
.await .await
.context("Failed to enqueue delivery tasks.")?; .context("Failed to enqueue delivery tasks.")?;
// Send emails with unique identifiers that contains link to blog post with special param let template = MessageTemplate::Success {
// Get handpoint that returns the post and mark the email as opened message: "Your new post has been saved. Subscribers will be notified.".into(),
let template = SuccessTemplate {
success_message: "Your new post has been saved. Subscribers will be notified.".into(),
}; };
let response = Html(template.render().unwrap()).into_response(); 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 .await
.map_err(AdminError::UnexpectedError) .map_err(AdminError::UnexpectedError)?;
Ok(response)
} }
#[tracing::instrument( #[tracing::instrument(
@@ -86,7 +84,7 @@ pub async fn insert_post(
title: &str, title: &str,
content: &str, content: &str,
author: &Uuid, author: &Uuid,
) -> Result<Uuid, AdminError> { ) -> Result<Uuid, sqlx::Error> {
let post_id = Uuid::new_v4(); let post_id = Uuid::new_v4();
let query = sqlx::query!( let query = sqlx::query!(
r#" r#"
@@ -99,10 +97,7 @@ pub async fn insert_post(
content, content,
Utc::now() Utc::now()
); );
transaction transaction.execute(query).await?;
.execute(query)
.await
.map_err(|e| AdminError::UnexpectedError(e.into()))?;
Ok(post_id) Ok(post_id)
} }
@@ -116,5 +111,6 @@ pub async fn create_newsletter(
content: &str, content: &str,
_post_id: &Uuid, _post_id: &Uuid,
) -> Result<Uuid, sqlx::Error> { ) -> Result<Uuid, sqlx::Error> {
// 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 insert_newsletter_issue(transaction, title, content, content).await
} }

View File

@@ -1,9 +1,7 @@
use askama::Template; use askama::Template;
use axum::response::Html; use axum::response::Html;
#[derive(Template)] use crate::templates::HomeTemplate;
#[template(path = "../templates/home.html")]
struct HomeTemplate;
pub async fn home() -> Html<String> { pub async fn home() -> Html<String> {
Html(HomeTemplate.render().unwrap()) Html(HomeTemplate.render().unwrap())

View File

@@ -1,13 +1,14 @@
use crate::{ use crate::{
authentication::{AuthError, Credentials, validate_credentials}, authentication::{Credentials, validate_credentials},
routes::error_chain_fmt, routes::AppError,
session_state::TypedSession, session_state::TypedSession,
startup::AppState, startup::AppState,
templates::ErrorTemplate, templates::LoginTemplate,
}; };
use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Form, Json, Form,
extract::State, extract::State,
http::HeaderMap, http::HeaderMap,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
@@ -15,62 +16,17 @@ use axum::{
use axum::{http::StatusCode, response::Redirect}; use axum::{http::StatusCode, response::Redirect};
use secrecy::SecretString; use secrecy::SecretString;
#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Something went wrong.")]
UnexpectedError(#[from] anyhow::Error),
#[error("Authentication failed.")]
AuthError(#[source] anyhow::Error),
}
impl std::fmt::Debug for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl IntoResponse for LoginError {
fn into_response(self) -> Response {
#[derive(serde::Serialize)]
struct ErrorResponse<'a> {
message: &'a str,
}
tracing::error!("{:?}", self);
match &self {
LoginError::UnexpectedError(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
message: "An internal server error occured.",
}),
)
.into_response(),
LoginError::AuthError(e) => {
let template = ErrorTemplate {
error_message: e.to_string(),
};
Html(template.render().unwrap()).into_response()
}
}
}
}
#[derive(Template)]
#[template(path = "../templates/login.html")]
struct LoginTemplate;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct LoginFormData { pub struct LoginFormData {
username: String, username: String,
password: SecretString, password: SecretString,
} }
pub async fn get_login(session: TypedSession) -> Result<Response, LoginError> { pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
if session if session
.get_user_id() .get_user_id()
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))? .context("Failed to retrieve user id from data store.")?
.is_some() .is_some()
{ {
Ok(Redirect::to("/admin/dashboard").into_response()) Ok(Redirect::to("/admin/dashboard").into_response())
@@ -85,39 +41,26 @@ pub async fn post_login(
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Form(form): Form<LoginFormData>, Form(form): Form<LoginFormData>,
) -> Result<Response, LoginError> { ) -> Result<Response, AppError> {
let credentials = Credentials { let credentials = Credentials {
username: form.username.clone(), username: form.username.clone(),
password: form.password, password: form.password,
}; };
tracing::Span::current().record("username", tracing::field::display(&credentials.username)); tracing::Span::current().record("username", tracing::field::display(&credentials.username));
match validate_credentials(credentials, &connection_pool).await { let user_id = validate_credentials(credentials, &connection_pool).await?;
Err(e) => {
let e = match e {
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::NotAuthenticated => unreachable!(),
};
Err(e)
}
Ok(user_id) => {
tracing::Span::current().record("user_id", tracing::field::display(&user_id)); tracing::Span::current().record("user_id", tracing::field::display(&user_id));
session
.renew() session.renew().await.context("Failed to renew session.")?;
.await
.map_err(|e| LoginError::UnexpectedError(e.into()))?;
session session
.insert_user_id(user_id) .insert_user_id(user_id)
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))?; .context("Failed to insert user id in session data store.")?;
session session
.insert_username(form.username) .insert_username(form.username)
.await .await
.map_err(|e| LoginError::UnexpectedError(e.into()))?; .context("Failed to insert username in session data store.")?;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap()); headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
Ok((StatusCode::OK, headers).into_response()) Ok((StatusCode::OK, headers).into_response())
} }
}
}

View File

@@ -1,56 +1,29 @@
use crate::startup::AppState; use crate::{
domain::PostEntry,
routes::AppError,
startup::AppState,
templates::{PostTemplate, PostsTemplate},
};
use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::{DateTime, Utc};
use reqwest::StatusCode;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
struct PostEntry {
post_id: Uuid,
author: Option<String>,
title: String,
content: String,
published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}
}
#[derive(Template)]
#[template(path = "../templates/posts.html")]
struct PostsTemplate {
posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "../templates/post.html")]
struct PostTemplate {
post: PostEntry,
}
pub async fn list_posts( pub async fn list_posts(
State(AppState { State(AppState {
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
) -> Response { ) -> Result<Response, AppError> {
match get_latest_posts(&connection_pool, 5).await { let posts = get_latest_posts(&connection_pool, 5)
Err(e) => { .await
tracing::error!("Could not fetch latest posts: {}", e); .context("Could not fetch latest posts")
StatusCode::INTERNAL_SERVER_ERROR.into_response() .map_err(AppError::unexpected_page)?;
}
Ok(posts) => {
let template = PostsTemplate { posts }; let template = PostsTemplate { posts };
Html(template.render().unwrap()).into_response() Ok(Html(template.render().unwrap()).into_response())
}
}
} }
async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> { async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> {
@@ -74,17 +47,13 @@ pub async fn see_post(
connection_pool, .. connection_pool, ..
}): State<AppState>, }): State<AppState>,
Path(post_id): Path<Uuid>, Path(post_id): Path<Uuid>,
) -> Response { ) -> Result<Response, AppError> {
match get_post(&connection_pool, post_id).await { let post = get_post(&connection_pool, post_id)
Err(e) => { .await
tracing::error!("Could not fetch post #{}: {}", post_id, e); .context(format!("Failed to fetch post #{}", post_id))
StatusCode::INTERNAL_SERVER_ERROR.into_response() .map_err(AppError::unexpected_page)?;
}
Ok(post) => {
let template = PostTemplate { post }; let template = PostTemplate { post };
Html(template.render().unwrap()).into_response() Ok(Html(template.render().unwrap()).into_response())
}
}
} }
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> { async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {

View File

@@ -1,15 +1,15 @@
use crate::{ use crate::{
domain::{NewSubscriber, SubscriberEmail}, domain::{NewSubscriber, SubscriberEmail},
email_client::EmailClient, email_client::EmailClient,
routes::AppError,
startup::AppState, startup::AppState,
templates::{ErrorTemplate, SuccessTemplate}, templates::MessageTemplate,
}; };
use anyhow::Context; use anyhow::Context;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Form, Json, Form,
extract::State, extract::State,
http::StatusCode,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc; use chrono::Utc;
@@ -42,45 +42,6 @@ pub fn error_chain_fmt(
Ok(()) Ok(())
} }
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
#[error("{0}")]
ValidationError(String),
}
impl std::fmt::Debug for SubscribeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
impl IntoResponse for SubscribeError {
fn into_response(self) -> Response {
#[derive(serde::Serialize)]
struct ErrorResponse<'a> {
message: &'a str,
}
tracing::error!("{:?}", self);
match self {
SubscribeError::UnexpectedError(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
message: "An internal server error occured.",
}),
)
.into_response(),
SubscribeError::ValidationError(e) => {
let template = ErrorTemplate { error_message: e };
Html(template.render().unwrap()).into_response()
}
}
}
}
#[tracing::instrument( #[tracing::instrument(
name = "Adding a new subscriber", name = "Adding a new subscriber",
skip(connection_pool, email_client, base_url, form), skip(connection_pool, email_client, base_url, form),
@@ -96,13 +57,11 @@ pub async fn subscribe(
.. ..
}): State<AppState>, }): State<AppState>,
Form(form): Form<SubscriptionFormData>, Form(form): Form<SubscriptionFormData>,
) -> Result<Response, SubscribeError> { ) -> Result<Response, AppError> {
let new_subscriber = match form.try_into() { let new_subscriber = form
Ok(new_sub) => new_sub, .try_into()
Err(e) => { .context("Failed to parse subscription form data.")
return Err(SubscribeError::ValidationError(e)); .map_err(AppError::FormError)?;
}
};
let mut transaction = connection_pool let mut transaction = connection_pool
.begin() .begin()
.await .await
@@ -122,12 +81,13 @@ pub async fn subscribe(
) )
.await .await
.context("Failed to send a confirmation email.")?; .context("Failed to send a confirmation email.")?;
transaction transaction
.commit() .commit()
.await .await
.context("Failed to commit the database transaction to store a new subscriber.")?; .context("Failed to commit the database transaction to store a new subscriber.")?;
let template = SuccessTemplate { let template = MessageTemplate::Success {
success_message: "A confirmation email has been sent.".to_string(), message: "A confirmation email has been sent.".to_string(),
}; };
Ok(Html(template.render().unwrap()).into_response()) Ok(Html(template.render().unwrap()).into_response())
} }
@@ -214,7 +174,7 @@ pub struct SubscriptionFormData {
} }
impl TryFrom<SubscriptionFormData> for NewSubscriber { impl TryFrom<SubscriptionFormData> for NewSubscriber {
type Error = String; type Error = anyhow::Error;
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> { fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
let email = SubscriberEmail::parse(value.email)?; let email = SubscriberEmail::parse(value.email)?;

View File

@@ -1,4 +1,4 @@
use crate::startup::AppState; use crate::{startup::AppState, templates::ConfirmTemplate};
use askama::Template; use askama::Template;
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
@@ -9,10 +9,6 @@ use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
#[derive(Template)]
#[template(path = "../templates/confirm.html")]
struct ConfirmTemplate;
#[tracing::instrument(name = "Confirming new subscriber", skip(params))] #[tracing::instrument(name = "Confirming new subscriber", skip(params))]
pub async fn confirm( pub async fn confirm(
State(AppState { State(AppState {

View File

@@ -1,6 +1,4 @@
use crate::{ use crate::{configuration::Settings, email_client::EmailClient, routes::require_auth, routes::*};
authentication::require_auth, configuration::Settings, email_client::EmailClient, routes::*,
};
use axum::{ use axum::{
Router, Router,
extract::MatchedPath, extract::MatchedPath,

View File

@@ -1,13 +1,51 @@
use askama::Template; use askama::Template;
use crate::domain::PostEntry;
#[derive(Template)] #[derive(Template)]
pub enum MessageTemplate {
#[template(path = "../templates/success.html")] #[template(path = "../templates/success.html")]
pub struct SuccessTemplate { Success { message: String },
pub success_message: String, #[template(path = "../templates/error.html")]
Error { message: String },
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "../templates/error.html")] #[template(path = "../templates/500.html")]
pub struct ErrorTemplate { pub struct InternalErrorTemplate;
pub error_message: String,
#[derive(Template)]
#[template(path = "../templates/login.html")]
pub struct LoginTemplate;
#[derive(Template)]
#[template(path = "../templates/dashboard.html")]
pub struct DashboardTemplate {
pub username: String,
pub idempotency_key_1: String,
pub idempotency_key_2: String,
} }
#[derive(Template)]
#[template(path = "../templates/home.html")]
pub struct HomeTemplate;
#[derive(Template)]
#[template(path = "../templates/posts.html")]
pub struct PostsTemplate {
pub posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "../templates/post.html")]
pub struct PostTemplate {
pub post: PostEntry,
}
#[derive(Template)]
#[template(path = "../templates/confirm.html")]
pub struct ConfirmTemplate;
#[derive(Template)]
#[template(path = "../templates/404.html")]
pub struct NotFoundTemplate;

View File

@@ -7,7 +7,7 @@
<h1 class="text-4xl font-semibold text-gray-700 mb-4">404</h1> <h1 class="text-4xl font-semibold text-gray-700 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Not Found</h2> <h2 class="text-2xl font-semibold text-gray-500 mb-6">Not Found</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto"> <p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Sorry, we couldn't find the page you're looking for. The page may have been moved, deleted, or the URL might be incorrect. Sorry, I couldn't find the page you're looking for. The page may have been moved, deleted, or the URL might be incorrect.
</p> </p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center"> <div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/" <a href="/"

37
templates/500.html Normal file
View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Internal Server Error{% endblock %}
{% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto text-center">
<div class="mb-8">
<h1 class="text-4xl font-semibold text-red-600 mb-4">500</h1>
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Internal Server Error</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
Something went wrong. Please try again in a few minutes or contact me if the problem persists.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href="/"
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Home
</a>
<button onclick="window.location.reload()"
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
<svg class="w-4 h-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Try again
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -16,27 +16,26 @@
<header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40"> <header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-4 text-blue-600">
<a href="/" <div class="flex space-x-2 items-center">
class="flex items-center space-x-2 text-blue-600 hover:text-blue-700 transition-colors"> <div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center"> <svg class="w-4 h-4 text-white"
<svg class="w-5 h-5 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
<span class="text-xl font-bold">zero2prod</span> <span class="text-sm font-bold">zero2prod</span>
</a> </div>
<nav class="flex items-center space-x-2"> <nav class="flex items-center space-x-2">
<a href="/" <a href="/"
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors"> class="flex items-center text-gray-700 hover:underline underline-offset-2 decoration-1 decoration-blue-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Home home
</a> </a>
<a href="/posts" <a href="/posts"
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors"> class="flex items-center text-gray-700 hover:underline underline-offset-2 decoration-1 decoration-blue-600 hover:text-blue-600 px-3 py-2 rounded-md text-sm font-medium transition-colors">
Posts posts
</a> </a>
</nav> </nav>
</div> </div>
@@ -44,7 +43,7 @@
<a href="/admin/dashboard" <a href="/admin/dashboard"
hx-boost="true" hx-boost="true"
class="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-md text-sm font-medium transition-colors"> class="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-md text-sm font-medium transition-colors">
Dashboard dashboard
</a> </a>
</nav> </nav>
</div> </div>

View File

@@ -4,6 +4,6 @@
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd">
</path> </path>
</svg> </svg>
<span class="font-medium">{{ error_message }}</span> <span class="font-medium">{{ message }}</span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content %} {% block content %}
<div class="flex-1 flex items-center justify-center">
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8"> <div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8">
<div class="max-w-3xl"> <div class="max-w-3xl">
@@ -80,5 +79,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Login - zero2prod{% endblock %} {% block title %}Login - zero2prod{% endblock %}
{% block content %} {% block content %}
<div class="min-h-[60vh] flex items-center justify-center"> <div class="flex-1 flex items-center justify-center">
<div class="max-w-md w-full space-y-8"> <div class="max-w-md w-full space-y-8">
<div class="text-center"> <div class="text-center">
<h2 class="text-3xl font-bold text-gray-900">Login</h2> <h2 class="text-3xl font-bold text-gray-900">Login</h2>

View File

@@ -4,6 +4,6 @@
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd">
</path> </path>
</svg> </svg>
<span class="font-medium">{{ success_message }}</span> <span class="font-medium">{{ message }}</span>
</div> </div>
</div> </div>