Error handling refactor and 500 page/message templates
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,7 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
routes::{AdminError, AppError},
|
routes::AdminError, session_state::TypedSession, telemetry::spawn_blocking_with_tracing,
|
||||||
session_state::TypedSession,
|
|
||||||
telemetry::spawn_blocking_with_tracing,
|
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use argon2::{
|
use argon2::{
|
||||||
@@ -24,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))]
|
||||||
@@ -36,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(),
|
||||||
@@ -75,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(
|
||||||
@@ -101,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()
|
||||||
@@ -110,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
|
||||||
@@ -127,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)
|
||||||
}
|
}
|
||||||
@@ -137,7 +138,7 @@ pub async fn require_auth(
|
|||||||
session: TypedSession,
|
session: TypedSession,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AdminError> {
|
||||||
let user_id = session
|
let user_id = session
|
||||||
.get_user_id()
|
.get_user_id()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,18 +20,50 @@ use reqwest::StatusCode;
|
|||||||
pub use subscriptions::*;
|
pub use subscriptions::*;
|
||||||
pub use subscriptions_confirm::*;
|
pub use subscriptions_confirm::*;
|
||||||
|
|
||||||
use crate::templates::MessageTemplate;
|
use crate::{
|
||||||
|
authentication::AuthError,
|
||||||
|
templates::{InternalErrorTemplate, MessageTemplate},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(thiserror::Error)]
|
#[derive(thiserror::Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
#[error("An unexpected error was encountered.")]
|
#[error("An unexpected error was encountered.")]
|
||||||
UnexpectedError(#[from] anyhow::Error),
|
UnexpectedError {
|
||||||
|
#[source]
|
||||||
|
error: anyhow::Error,
|
||||||
|
full_page: bool,
|
||||||
|
},
|
||||||
#[error("A validation error happened.")]
|
#[error("A validation error happened.")]
|
||||||
ValidationError(#[source] anyhow::Error),
|
FormError(#[source] anyhow::Error),
|
||||||
#[error("An authentication is required.")]
|
#[error("Authentication is required.")]
|
||||||
NotAuthenticated,
|
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 {
|
impl std::fmt::Debug for AppError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
error_chain_fmt(self, f)
|
error_chain_fmt(self, f)
|
||||||
@@ -43,13 +75,21 @@ impl IntoResponse for AppError {
|
|||||||
tracing::error!("{:?}", self);
|
tracing::error!("{:?}", self);
|
||||||
|
|
||||||
match &self {
|
match &self {
|
||||||
AppError::UnexpectedError(_) => {
|
AppError::UnexpectedError {
|
||||||
let template = MessageTemplate::Error {
|
error: _,
|
||||||
message: "An internal server error occured.".into(),
|
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())
|
||||||
};
|
};
|
||||||
Html(template.render().unwrap()).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, html).into_response()
|
||||||
}
|
}
|
||||||
AppError::ValidationError(error) => {
|
AppError::FormError(error) => {
|
||||||
let template = MessageTemplate::Error {
|
let template = MessageTemplate::Error {
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
};
|
};
|
||||||
@@ -58,7 +98,7 @@ impl IntoResponse for AppError {
|
|||||||
AppError::NotAuthenticated => {
|
AppError::NotAuthenticated => {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("HX-Redirect", "/login".parse().unwrap());
|
headers.insert("HX-Redirect", "/login".parse().unwrap());
|
||||||
(StatusCode::UNAUTHORIZED, headers).into_response()
|
(StatusCode::OK, headers).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,11 +107,25 @@ impl IntoResponse for AppError {
|
|||||||
impl From<AdminError> for AppError {
|
impl From<AdminError> for AppError {
|
||||||
fn from(value: AdminError) -> Self {
|
fn from(value: AdminError) -> Self {
|
||||||
match value {
|
match value {
|
||||||
AdminError::UnexpectedError(error) => AppError::UnexpectedError(error),
|
AdminError::UnexpectedError(error) => AppError::unexpected_message(error),
|
||||||
AdminError::NotAuthenticated => AppError::NotAuthenticated,
|
AdminError::NotAuthenticated => AppError::NotAuthenticated,
|
||||||
AdminError::ChangePassword(s) => AppError::ValidationError(anyhow::anyhow!(s)),
|
AdminError::ChangePassword(s) => AppError::FormError(anyhow::anyhow!(s)),
|
||||||
AdminError::Publish(e) => AppError::ValidationError(e),
|
AdminError::Publish(e) => AppError::FormError(e),
|
||||||
AdminError::Idempotency(s) => AppError::UnexpectedError(anyhow::anyhow!(s)),
|
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."))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ mod logout;
|
|||||||
mod newsletters;
|
mod newsletters;
|
||||||
mod posts;
|
mod posts;
|
||||||
|
|
||||||
use crate::routes::error_chain_fmt;
|
use crate::{
|
||||||
|
authentication::AuthenticatedUser,
|
||||||
|
routes::{AppError, error_chain_fmt},
|
||||||
|
session_state::TypedSession,
|
||||||
|
};
|
||||||
|
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::*;
|
||||||
@@ -30,3 +35,28 @@ impl std::fmt::Debug for AdminError {
|
|||||||
error_chain_fmt(self, f)
|
error_chain_fmt(self, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn require_auth(
|
||||||
|
session: TypedSession,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> 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."
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
request
|
||||||
|
.extensions_mut()
|
||||||
|
.insert(AuthenticatedUser { user_id, username });
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::{self, AuthenticatedUser, Credentials, validate_credentials},
|
authentication::{self, AuthError, AuthenticatedUser, Credentials, validate_credentials},
|
||||||
routes::{AdminError, AppError},
|
routes::{AdminError, AppError},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
templates::MessageTemplate,
|
templates::MessageTemplate,
|
||||||
@@ -35,11 +35,14 @@ pub async fn change_password(
|
|||||||
"You entered two different passwords - the field values must match.".to_string(),
|
"You entered two different passwords - the field values must match.".to_string(),
|
||||||
)
|
)
|
||||||
.into())
|
.into())
|
||||||
} else if validate_credentials(credentials, &connection_pool)
|
} else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
|
||||||
.await
|
match e {
|
||||||
.is_err()
|
AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
|
||||||
{
|
AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(
|
||||||
Err(AdminError::ChangePassword("The current password is incorrect.".to_string()).into())
|
"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).into())
|
Err(AdminError::ChangePassword(e).into())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -84,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#"
|
||||||
@@ -97,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
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::MessageTemplate,
|
|
||||||
};
|
};
|
||||||
|
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,47 +15,6 @@ 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 = MessageTemplate::Error {
|
|
||||||
message: e.to_string(),
|
|
||||||
};
|
|
||||||
Html(template.render().unwrap()).into_response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "../templates/login.html")]
|
#[template(path = "../templates/login.html")]
|
||||||
struct LoginTemplate;
|
struct LoginTemplate;
|
||||||
@@ -66,11 +25,11 @@ pub struct LoginFormData {
|
|||||||
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 +44,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) => {
|
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
||||||
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));
|
|
||||||
session
|
|
||||||
.renew()
|
|
||||||
.await
|
|
||||||
.map_err(|e| LoginError::UnexpectedError(e.into()))?;
|
|
||||||
session
|
|
||||||
.insert_user_id(user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LoginError::UnexpectedError(e.into()))?;
|
|
||||||
session
|
|
||||||
.insert_username(form.username)
|
|
||||||
.await
|
|
||||||
.map_err(|e| LoginError::UnexpectedError(e.into()))?;
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
session.renew().await.context("Failed to renew session.")?;
|
||||||
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
session
|
||||||
Ok((StatusCode::OK, headers).into_response())
|
.insert_user_id(user_id)
|
||||||
}
|
.await
|
||||||
}
|
.context("Failed to insert user id in session data store.")?;
|
||||||
|
session
|
||||||
|
.insert_username(form.username)
|
||||||
|
.await
|
||||||
|
.context("Failed to insert username in session data store.")?;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
||||||
|
Ok((StatusCode::OK, headers).into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use crate::startup::AppState;
|
use crate::{routes::AppError, startup::AppState};
|
||||||
|
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 chrono::{DateTime, Utc};
|
||||||
use reqwest::StatusCode;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -40,17 +40,13 @@ 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)?;
|
||||||
}
|
let template = PostsTemplate { posts };
|
||||||
Ok(posts) => {
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
let template = PostsTemplate { posts };
|
|
||||||
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 +70,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)?;
|
||||||
}
|
let template = PostTemplate { post };
|
||||||
Ok(post) => {
|
Ok(Html(template.render().unwrap()).into_response())
|
||||||
let template = PostTemplate { post };
|
|
||||||
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> {
|
||||||
|
|||||||
@@ -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::MessageTemplate,
|
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 = MessageTemplate::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,6 +81,7 @@ 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
|
||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ pub enum MessageTemplate {
|
|||||||
#[template(path = "../templates/error.html")]
|
#[template(path = "../templates/error.html")]
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "../templates/500.html")]
|
||||||
|
pub struct InternalErrorTemplate;
|
||||||
|
|||||||
37
templates/500.html
Normal file
37
templates/500.html
Normal 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 on the server. 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 %}
|
||||||
Reference in New Issue
Block a user