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) => { 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())
} }

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)?;
} 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 +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)?;
} 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> {

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)]
#[template(path = "../templates/success.html")] pub enum MessageTemplate {
pub struct SuccessTemplate { #[template(path = "../templates/success.html")]
pub success_message: String, 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,83 +1,81 @@
{% 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"> <h1 class="text-4xl font-bold mb-4">zero2prod</h1>
<h1 class="text-4xl font-bold mb-4">zero2prod</h1> <p class="text-xl text-blue-100 mb-6">
<p class="text-xl text-blue-100 mb-6"> Welcome to my blog! Stay updated on my latest projects and
Welcome to my blog! Stay updated on my latest projects and thoughts. Subscribe (and unsubscribe) at any time.
thoughts. Subscribe (and unsubscribe) at any time. </p>
</p> <div class="flex flex-col sm:flex-row gap-4">
<div class="flex flex-col sm:flex-row gap-4"> <a href="#newsletter-signup"
<a href="#newsletter-signup" class="bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors text-center">
class="bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors text-center"> Subscribe
</a>
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="border border-white text-white hover:bg-white hover:text-blue-600 font-semibold py-3 px-6 rounded-md transition-colors text-center">
View code
</a>
</div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy first</h3>
<p class="text-gray-600 text-sm">
Zero spam, zero tracking, zero data sharing. Your email stays private
and secure. Unsubscribe at any time.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Quality content</h3>
<p class="text-gray-600 text-sm">
Curated insights on Rust backend development, performance tips, and
production war stories.
</p>
</div>
</div>
<div id="newsletter-signup"
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
<form hx-post="/subscriptions"
hx-target="#subscribe-messages"
hx-swap="innerHTML"
class="max-w-md mx-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input type="email"
name="email"
placeholder="you@example.com"
required
class="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<button type="submit"
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Subscribe Subscribe
</a> </button>
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
target="_blank"
class="border border-white text-white hover:bg-white hover:text-blue-600 font-semibold py-3 px-6 rounded-md transition-colors text-center">
View code
</a>
</div> </div>
</div> <div id="subscribe-messages" class="mt-4"></div>
</div> </form>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Privacy first</h3>
<p class="text-gray-600 text-sm">
Zero spam, zero tracking, zero data sharing. Your email stays private
and secure. Unsubscribe at any time.
</p>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Quality content</h3>
<p class="text-gray-600 text-sm">
Curated insights on Rust backend development, performance tips, and
production war stories.
</p>
</div>
</div>
<div id="newsletter-signup"
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
<div class="max-w-2xl mx-auto text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
<p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
<form hx-post="/subscriptions"
hx-target="#subscribe-messages"
hx-swap="innerHTML"
class="max-w-md mx-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input type="email"
name="email"
placeholder="you@example.com"
required
class="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
<button type="submit"
class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
Subscribe
</button>
</div>
<div id="subscribe-messages" class="mt-4"></div>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

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>