Compare commits
4 Commits
46a6905b12
...
40dfe1aed8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40dfe1aed8 | ||
|
|
b52b676dc0 | ||
|
|
f5cd91108a | ||
|
|
01d2add44b |
File diff suppressed because one or more lines are too long
@@ -22,8 +22,6 @@ pub enum AuthError {
|
||||
UnexpectedError(#[from] anyhow::Error),
|
||||
#[error("Invalid credentials.")]
|
||||
InvalidCredentials(#[source] anyhow::Error),
|
||||
#[error("Not authenticated.")]
|
||||
NotAuthenticated,
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Change password", skip(password, connection_pool))]
|
||||
@@ -34,7 +32,7 @@ pub async fn change_password(
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let password_hash = spawn_blocking_with_tracing(move || compute_pasword_hash(password))
|
||||
.await?
|
||||
.context("Failed to hash password")?;
|
||||
.context("Failed to hash password.")?;
|
||||
sqlx::query!(
|
||||
"UPDATE users SET password_hash = $1 WHERE user_id = $2",
|
||||
password_hash.expose_secret(),
|
||||
@@ -73,23 +71,30 @@ gZiV/M1gPc22ElAH/Jh1Hw$\
|
||||
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
if let Some((stored_user_id, stored_expected_password_hash)) =
|
||||
get_stored_credentials(&username, connection_pool)
|
||||
.await
|
||||
.context("Failed to retrieve credentials from database.")
|
||||
.map_err(AuthError::UnexpectedError)?
|
||||
{
|
||||
user_id = Some(stored_user_id);
|
||||
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
|
||||
.context("Failed to spawn blocking task.")
|
||||
.map_err(AuthError::UnexpectedError)??;
|
||||
|
||||
user_id
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
|
||||
.map_err(AuthError::UnexpectedError)?
|
||||
.map_err(AuthError::InvalidCredentials)
|
||||
.map(|_| uuid)
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
@@ -99,7 +104,7 @@ CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
|
||||
fn verify_password_hash(
|
||||
expected_password_hash: SecretString,
|
||||
password_candidate: SecretString,
|
||||
) -> Result<(), AuthError> {
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let expected_password_hash = PasswordHash::new(expected_password_hash.expose_secret())
|
||||
.context("Failed to parse hash in PHC string format.")?;
|
||||
Argon2::default()
|
||||
@@ -108,14 +113,13 @@ fn verify_password_hash(
|
||||
&expected_password_hash,
|
||||
)
|
||||
.context("Password verification failed.")
|
||||
.map_err(AuthError::InvalidCredentials)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Get stored credentials", skip(username, connection_pool))]
|
||||
async fn get_stored_credentials(
|
||||
username: &str,
|
||||
connection_pool: &PgPool,
|
||||
) -> Result<Option<(Uuid, SecretString)>, anyhow::Error> {
|
||||
) -> Result<Option<(Uuid, SecretString)>, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT user_id, password_hash
|
||||
@@ -125,8 +129,7 @@ async fn get_stored_credentials(
|
||||
username,
|
||||
)
|
||||
.fetch_optional(connection_pool)
|
||||
.await
|
||||
.context("Failed to perform a query to retrieve stored credentials.")?
|
||||
.await?
|
||||
.map(|row| (row.user_id, SecretString::from(row.password_hash)));
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ pub struct EmailClientSettings {
|
||||
}
|
||||
|
||||
impl EmailClientSettings {
|
||||
pub fn sender(&self) -> Result<SubscriberEmail, String> {
|
||||
pub fn sender(&self) -> Result<SubscriberEmail, anyhow::Error> {
|
||||
SubscriberEmail::parse(self.sender_email.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod new_subscriber;
|
||||
mod post;
|
||||
mod subscriber_email;
|
||||
|
||||
pub use new_subscriber::NewSubscriber;
|
||||
pub use post::PostEntry;
|
||||
pub use subscriber_email::SubscriberEmail;
|
||||
|
||||
17
src/domain/post.rs
Normal file
17
src/domain/post.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ pub struct 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 };
|
||||
subscriber_email
|
||||
.validate()
|
||||
.map_err(|_| format!("{} is not a valid email.", subscriber_email.email))?;
|
||||
if subscriber_email.validate().is_err() {
|
||||
anyhow::bail!("{} is not a valid email.", subscriber_email.email);
|
||||
}
|
||||
Ok(subscriber_email)
|
||||
}
|
||||
}
|
||||
|
||||
117
src/routes.rs
117
src/routes.rs
@@ -8,7 +8,10 @@ mod subscriptions_confirm;
|
||||
|
||||
pub use admin::*;
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::{
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
pub use health_check::*;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
@@ -17,9 +20,115 @@ use reqwest::StatusCode;
|
||||
pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/404.html")]
|
||||
struct NotFoundTemplate;
|
||||
use crate::{
|
||||
authentication::AuthError,
|
||||
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 {
|
||||
(
|
||||
|
||||
@@ -4,23 +4,21 @@ mod logout;
|
||||
mod newsletters;
|
||||
mod posts;
|
||||
|
||||
use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
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 dashboard::*;
|
||||
pub use logout::*;
|
||||
pub use newsletters::*;
|
||||
pub use posts::*;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[derive(thiserror::Error)]
|
||||
pub enum AdminError {
|
||||
#[error("Something went wrong.")]
|
||||
#[error("Something went wrong while performing an admin action.")]
|
||||
UnexpectedError(#[from] anyhow::Error),
|
||||
#[error("Trying to access admin dashboard without authentication.")]
|
||||
NotAuthenticated,
|
||||
@@ -38,44 +36,27 @@ impl std::fmt::Debug for AdminError {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminError {
|
||||
fn into_response(self) -> Response {
|
||||
#[derive(serde::Serialize)]
|
||||
struct ErrorResponse<'a> {
|
||||
message: &'a str,
|
||||
}
|
||||
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."
|
||||
)))?;
|
||||
|
||||
tracing::error!("{:?}", self);
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(AuthenticatedUser { user_id, username });
|
||||
|
||||
match &self {
|
||||
AdminError::UnexpectedError(_) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
message: "An internal server error occured.",
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
AdminError::NotAuthenticated => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/login".parse().unwrap());
|
||||
headers.insert("Location", "/login".parse().unwrap());
|
||||
(StatusCode::SEE_OTHER, headers).into_response()
|
||||
}
|
||||
AdminError::ChangePassword(e) => {
|
||||
let template = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::{
|
||||
authentication::{self, AuthenticatedUser, Credentials, validate_credentials},
|
||||
routes::AdminError,
|
||||
authentication::{self, AuthError, AuthenticatedUser, Credentials, validate_credentials},
|
||||
routes::{AdminError, AppError},
|
||||
startup::AppState,
|
||||
templates::SuccessTemplate,
|
||||
templates::MessageTemplate,
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
@@ -25,7 +25,7 @@ pub async fn change_password(
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Form(form): Form<PasswordFormData>,
|
||||
) -> Result<Response, AdminError> {
|
||||
) -> Result<Response, AppError> {
|
||||
let credentials = Credentials {
|
||||
username,
|
||||
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() {
|
||||
Err(AdminError::ChangePassword(
|
||||
"You entered two different passwords - the field values must match.".to_string(),
|
||||
))
|
||||
} else if validate_credentials(credentials, &connection_pool)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
Err(AdminError::ChangePassword(
|
||||
"The current password is incorrect.".to_string(),
|
||||
))
|
||||
)
|
||||
.into())
|
||||
} else if let Err(e) = validate_credentials(credentials, &connection_pool).await {
|
||||
match e {
|
||||
AuthError::UnexpectedError(error) => Err(AdminError::UnexpectedError(error).into()),
|
||||
AuthError::InvalidCredentials(_) => Err(AdminError::ChangePassword(
|
||||
"The current password is incorrect.".to_string(),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
} else if let Err(e) = verify_password(form.new_password.expose_secret()) {
|
||||
Err(AdminError::ChangePassword(e))
|
||||
Err(AdminError::ChangePassword(e).into())
|
||||
} else {
|
||||
authentication::change_password(user_id, form.new_password, &connection_pool)
|
||||
.await
|
||||
.map_err(|e| AdminError::ChangePassword(e.to_string()))?;
|
||||
let template = SuccessTemplate {
|
||||
success_message: "Your password has been changed.".to_string(),
|
||||
let template = MessageTemplate::Success {
|
||||
message: "Your password has been changed.".to_string(),
|
||||
};
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::authentication::AuthenticatedUser;
|
||||
use crate::{authentication::AuthenticatedUser, templates::DashboardTemplate};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Extension,
|
||||
@@ -6,14 +6,6 @@ use axum::{
|
||||
};
|
||||
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(
|
||||
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
|
||||
) -> Response {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::{routes::AdminError, session_state::TypedSession};
|
||||
use crate::session_state::TypedSession;
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[tracing::instrument(name = "Logging out", skip(session))]
|
||||
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
|
||||
pub async fn logout(session: TypedSession) -> Response {
|
||||
session.clear().await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/login".parse().unwrap());
|
||||
Ok((StatusCode::OK, headers).into_response())
|
||||
}
|
||||
(StatusCode::OK, headers).into_response()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::{
|
||||
authentication::AuthenticatedUser,
|
||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||
routes::AdminError,
|
||||
routes::{AdminError, AppError},
|
||||
startup::AppState,
|
||||
templates::SuccessTemplate,
|
||||
templates::MessageTemplate,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
@@ -75,7 +75,7 @@ pub async fn publish_newsletter(
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<BodyData>,
|
||||
) -> Result<Response, AdminError> {
|
||||
) -> Result<Response, AppError> {
|
||||
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||
|
||||
let idempotency_key: IdempotencyKey = form
|
||||
@@ -98,15 +98,16 @@ pub async fn publish_newsletter(
|
||||
.await
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
let success_message = format!(
|
||||
let message = format!(
|
||||
r#"The newsletter issue "{}" has been published!"#,
|
||||
form.title
|
||||
);
|
||||
let template = SuccessTemplate { success_message };
|
||||
let template = MessageTemplate::Success { message };
|
||||
let response = Html(template.render().unwrap()).into_response();
|
||||
save_response(transaction, &idempotency_key, user_id, response)
|
||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
||||
.await
|
||||
.map_err(AdminError::UnexpectedError)
|
||||
.map_err(AdminError::UnexpectedError)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate_form(form: &BodyData) -> Result<(), &'static str> {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::{
|
||||
authentication::AuthenticatedUser,
|
||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||
routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue},
|
||||
routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue},
|
||||
startup::AppState,
|
||||
templates::SuccessTemplate,
|
||||
templates::MessageTemplate,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
@@ -38,7 +38,7 @@ pub async fn create_post(
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<CreatePostForm>,
|
||||
) -> Result<Response, AdminError> {
|
||||
) -> Result<Response, AppError> {
|
||||
validate_form(&form).map_err(AdminError::Publish)?;
|
||||
|
||||
let idempotency_key: IdempotencyKey = form
|
||||
@@ -65,16 +65,14 @@ pub async fn create_post(
|
||||
.await
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
// Send emails with unique identifiers that contains link to blog post with special param
|
||||
// Get handpoint that returns the post and mark the email as opened
|
||||
|
||||
let template = SuccessTemplate {
|
||||
success_message: "Your new post has been saved. Subscribers will be notified.".into(),
|
||||
let template = MessageTemplate::Success {
|
||||
message: "Your new post has been saved. Subscribers will be notified.".into(),
|
||||
};
|
||||
let response = Html(template.render().unwrap()).into_response();
|
||||
save_response(transaction, &idempotency_key, user_id, response)
|
||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
||||
.await
|
||||
.map_err(AdminError::UnexpectedError)
|
||||
.map_err(AdminError::UnexpectedError)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
@@ -86,7 +84,7 @@ pub async fn insert_post(
|
||||
title: &str,
|
||||
content: &str,
|
||||
author: &Uuid,
|
||||
) -> Result<Uuid, AdminError> {
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let post_id = Uuid::new_v4();
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
@@ -99,10 +97,7 @@ pub async fn insert_post(
|
||||
content,
|
||||
Utc::now()
|
||||
);
|
||||
transaction
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?;
|
||||
transaction.execute(query).await?;
|
||||
Ok(post_id)
|
||||
}
|
||||
|
||||
@@ -116,5 +111,6 @@ pub async fn create_newsletter(
|
||||
content: &str,
|
||||
_post_id: &Uuid,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use askama::Template;
|
||||
use axum::response::Html;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/home.html")]
|
||||
struct HomeTemplate;
|
||||
use crate::templates::HomeTemplate;
|
||||
|
||||
pub async fn home() -> Html<String> {
|
||||
Html(HomeTemplate.render().unwrap())
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::{
|
||||
authentication::{AuthError, Credentials, validate_credentials},
|
||||
routes::error_chain_fmt,
|
||||
authentication::{Credentials, validate_credentials},
|
||||
routes::AppError,
|
||||
session_state::TypedSession,
|
||||
startup::AppState,
|
||||
templates::ErrorTemplate,
|
||||
templates::LoginTemplate,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Form, Json,
|
||||
Form,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
@@ -15,62 +16,17 @@ use axum::{
|
||||
use axum::{http::StatusCode, response::Redirect};
|
||||
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)]
|
||||
pub struct LoginFormData {
|
||||
username: String,
|
||||
password: SecretString,
|
||||
}
|
||||
|
||||
pub async fn get_login(session: TypedSession) -> Result<Response, LoginError> {
|
||||
pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
|
||||
if session
|
||||
.get_user_id()
|
||||
.await
|
||||
.map_err(|e| LoginError::UnexpectedError(e.into()))?
|
||||
.context("Failed to retrieve user id from data store.")?
|
||||
.is_some()
|
||||
{
|
||||
Ok(Redirect::to("/admin/dashboard").into_response())
|
||||
@@ -85,39 +41,26 @@ pub async fn post_login(
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Form(form): Form<LoginFormData>,
|
||||
) -> Result<Response, LoginError> {
|
||||
) -> Result<Response, AppError> {
|
||||
let credentials = Credentials {
|
||||
username: form.username.clone(),
|
||||
password: form.password,
|
||||
};
|
||||
tracing::Span::current().record("username", tracing::field::display(&credentials.username));
|
||||
match 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));
|
||||
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 user_id = validate_credentials(credentials, &connection_pool).await?;
|
||||
tracing::Span::current().record("user_id", tracing::field::display(&user_id));
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
||||
Ok((StatusCode::OK, headers).into_response())
|
||||
}
|
||||
}
|
||||
session.renew().await.context("Failed to renew session.")?;
|
||||
session
|
||||
.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,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 axum::{
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::StatusCode;
|
||||
use sqlx::PgPool;
|
||||
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(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
) -> Response {
|
||||
match get_latest_posts(&connection_pool, 5).await {
|
||||
Err(e) => {
|
||||
tracing::error!("Could not fetch latest posts: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
Ok(posts) => {
|
||||
let template = PostsTemplate { posts };
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
}
|
||||
) -> Result<Response, AppError> {
|
||||
let posts = get_latest_posts(&connection_pool, 5)
|
||||
.await
|
||||
.context("Could not fetch latest posts")
|
||||
.map_err(AppError::unexpected_page)?;
|
||||
let template = PostsTemplate { posts };
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
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, ..
|
||||
}): State<AppState>,
|
||||
Path(post_id): Path<Uuid>,
|
||||
) -> Response {
|
||||
match get_post(&connection_pool, post_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!("Could not fetch post #{}: {}", post_id, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
Ok(post) => {
|
||||
let template = PostTemplate { post };
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
}
|
||||
) -> Result<Response, AppError> {
|
||||
let post = get_post(&connection_pool, post_id)
|
||||
.await
|
||||
.context(format!("Failed to fetch post #{}", post_id))
|
||||
.map_err(AppError::unexpected_page)?;
|
||||
let template = PostTemplate { post };
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
|
||||
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use crate::{
|
||||
domain::{NewSubscriber, SubscriberEmail},
|
||||
email_client::EmailClient,
|
||||
routes::AppError,
|
||||
startup::AppState,
|
||||
templates::{ErrorTemplate, SuccessTemplate},
|
||||
templates::MessageTemplate,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Form, Json,
|
||||
Form,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
@@ -42,45 +42,6 @@ pub fn error_chain_fmt(
|
||||
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(
|
||||
name = "Adding a new subscriber",
|
||||
skip(connection_pool, email_client, base_url, form),
|
||||
@@ -96,13 +57,11 @@ pub async fn subscribe(
|
||||
..
|
||||
}): State<AppState>,
|
||||
Form(form): Form<SubscriptionFormData>,
|
||||
) -> Result<Response, SubscribeError> {
|
||||
let new_subscriber = match form.try_into() {
|
||||
Ok(new_sub) => new_sub,
|
||||
Err(e) => {
|
||||
return Err(SubscribeError::ValidationError(e));
|
||||
}
|
||||
};
|
||||
) -> Result<Response, AppError> {
|
||||
let new_subscriber = form
|
||||
.try_into()
|
||||
.context("Failed to parse subscription form data.")
|
||||
.map_err(AppError::FormError)?;
|
||||
let mut transaction = connection_pool
|
||||
.begin()
|
||||
.await
|
||||
@@ -122,12 +81,13 @@ pub async fn subscribe(
|
||||
)
|
||||
.await
|
||||
.context("Failed to send a confirmation email.")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.context("Failed to commit the database transaction to store a new subscriber.")?;
|
||||
let template = SuccessTemplate {
|
||||
success_message: "A confirmation email has been sent.".to_string(),
|
||||
let template = MessageTemplate::Success {
|
||||
message: "A confirmation email has been sent.".to_string(),
|
||||
};
|
||||
Ok(Html(template.render().unwrap()).into_response())
|
||||
}
|
||||
@@ -214,7 +174,7 @@ pub struct SubscriptionFormData {
|
||||
}
|
||||
|
||||
impl TryFrom<SubscriptionFormData> for NewSubscriber {
|
||||
type Error = String;
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: SubscriptionFormData) -> Result<Self, Self::Error> {
|
||||
let email = SubscriberEmail::parse(value.email)?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::startup::AppState;
|
||||
use crate::{startup::AppState, templates::ConfirmTemplate};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
@@ -9,10 +9,6 @@ use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/confirm.html")]
|
||||
struct ConfirmTemplate;
|
||||
|
||||
#[tracing::instrument(name = "Confirming new subscriber", skip(params))]
|
||||
pub async fn confirm(
|
||||
State(AppState {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use crate::{
|
||||
authentication::require_auth, configuration::Settings, email_client::EmailClient, routes::*,
|
||||
};
|
||||
use crate::{configuration::Settings, email_client::EmailClient, routes::require_auth, routes::*};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::MatchedPath,
|
||||
|
||||
@@ -1,13 +1,51 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::domain::PostEntry;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/success.html")]
|
||||
pub struct SuccessTemplate {
|
||||
pub success_message: String,
|
||||
pub enum MessageTemplate {
|
||||
#[template(path = "../templates/success.html")]
|
||||
Success { message: String },
|
||||
#[template(path = "../templates/error.html")]
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/error.html")]
|
||||
pub struct ErrorTemplate {
|
||||
pub error_message: String,
|
||||
#[template(path = "../templates/500.html")]
|
||||
pub struct InternalErrorTemplate;
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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>
|
||||
<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>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a href="/"
|
||||
|
||||
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. 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 %}
|
||||
@@ -16,27 +16,26 @@
|
||||
<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="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-6">
|
||||
<a href="/"
|
||||
class="flex items-center space-x-2 text-blue-600 hover:text-blue-700 transition-colors">
|
||||
<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-5 h-5 text-white"
|
||||
<div class="flex items-center space-x-4 text-blue-600">
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="w-6 h-6 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"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xl font-bold">zero2prod</span>
|
||||
</a>
|
||||
<span class="text-sm font-bold">zero2prod</span>
|
||||
</div>
|
||||
<nav class="flex items-center space-x-2">
|
||||
<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">
|
||||
Home
|
||||
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
|
||||
</a>
|
||||
<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">
|
||||
Posts
|
||||
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
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -44,7 +43,7 @@
|
||||
<a href="/admin/dashboard"
|
||||
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">
|
||||
Dashboard
|
||||
dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
</svg>
|
||||
<span class="font-medium">{{ error_message }}</span>
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,83 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<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="max-w-3xl">
|
||||
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
|
||||
<p class="text-xl text-blue-100 mb-6">
|
||||
Welcome to my blog! Stay updated on my latest projects and
|
||||
thoughts. Subscribe (and unsubscribe) at any time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<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">
|
||||
<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="max-w-3xl">
|
||||
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
|
||||
<p class="text-xl text-blue-100 mb-6">
|
||||
Welcome to my blog! Stay updated on my latest projects and
|
||||
thoughts. Subscribe (and unsubscribe) at any time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<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">
|
||||
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
|
||||
</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>
|
||||
</button>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
<div id="subscribe-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="subscribe-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login - zero2prod{% endblock %}
|
||||
{% 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="text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900">Login</h2>
|
||||
|
||||
@@ -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>
|
||||
</svg>
|
||||
<span class="font-medium">{{ success_message }}</span>
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user