Files
zero2prod/src/routes/users.rs
Alphonse Paix b252216709
All checks were successful
Rust / Test (push) Successful in 6m6s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m36s
Rust / Code coverage (push) Successful in 4m47s
Edit profile and templates update
2025-10-06 19:23:57 +02:00

313 lines
8.9 KiB
Rust

use crate::authentication::AuthenticatedUser;
use crate::routes::verify_password;
use crate::session_state::TypedSession;
use crate::templates::{ErrorTemplate, MessageTemplate, UserEditTemplate};
use crate::{
authentication::Role,
domain::{PostEntry, UserEntry},
routes::{AppError, not_found_html},
startup::AppState,
templates::{HtmlTemplate, UserTemplate},
};
use anyhow::Context;
use axum::{
Extension, Form,
extract::{Path, State},
response::{IntoResponse, Response},
};
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool;
use uuid::Uuid;
pub async fn get_user_edit(
Path(username): Path<String>,
Extension(AuthenticatedUser {
user_id,
username: session_username,
..
}): Extension<AuthenticatedUser>,
State(AppState {
connection_pool, ..
}): State<AppState>,
) -> Result<Response, AppError> {
if username != session_username {
let template = HtmlTemplate(ErrorTemplate::Forbidden);
return Ok(template.into_response());
}
let user = sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
FROM users
WHERE user_id = $1
"#,
user_id
)
.fetch_one(&connection_pool)
.await
.context("Could not fetch user in database.")?;
let template = HtmlTemplate(UserEditTemplate { user });
Ok(template.into_response())
}
#[derive(serde::Deserialize)]
pub struct EditProfileForm {
username: String,
full_name: String,
bio: String,
}
pub async fn put_user_edit(
State(AppState {
connection_pool, ..
}): State<AppState>,
session: TypedSession,
Extension(AuthenticatedUser {
user_id,
username: session_username,
..
}): Extension<AuthenticatedUser>,
Path(username): Path<String>,
Form(form): Form<EditProfileForm>,
) -> Result<Response, AppError> {
if username != session_username {
let template = HtmlTemplate(ErrorTemplate::Forbidden);
return Ok(template.into_response());
}
let updated_username = form.username.trim();
if updated_username != session_username
&& sqlx::query!(
"SELECT user_id FROM users WHERE username = $1",
updated_username
)
.fetch_optional(&connection_pool)
.await
.context("Could not fetch users table.")?
.is_some()
{
let template = HtmlTemplate(MessageTemplate::error(
"The username is already taken.".into(),
));
return Ok(template.into_response());
}
let updated_full_name = form.full_name.trim();
let bio = form.bio.trim();
sqlx::query!(
"
UPDATE users
SET username = $1, full_name = $2, bio = $3
WHERE user_id = $4
",
updated_username,
updated_full_name,
bio,
user_id
)
.execute(&connection_pool)
.await
.context("Failed to apply changes.")
.map_err(AppError::FormError)?;
session
.insert_username(updated_username.to_owned())
.await
.context("Could not update session username.")?;
let template = HtmlTemplate(MessageTemplate::success(
"Your profile has been updated.".into(),
));
Ok(template.into_response())
}
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
pub async fn get_users(connection_pool: &PgPool) -> Result<Vec<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
FROM users
ORDER BY member_since DESC
"#
)
.fetch_all(connection_pool)
.await
}
#[derive(Debug, serde::Deserialize)]
pub struct CreateUserForm {
username: String,
password: SecretString,
password_check: SecretString,
admin: Option<bool>,
}
struct NewUser {
username: String,
password_hash: SecretString,
role: Role,
}
impl TryFrom<CreateUserForm> for NewUser {
type Error = anyhow::Error;
fn try_from(value: CreateUserForm) -> Result<Self, Self::Error> {
if value.username.trim().is_empty() {
anyhow::bail!("Username cannot be empty.");
}
verify_password(value.password.expose_secret())?;
if value.password.expose_secret() != value.password_check.expose_secret() {
anyhow::bail!("Password mismatch.");
}
let role = match value.admin {
Some(true) => Role::Admin,
_ => Role::Writer,
};
let password_hash = crate::authentication::compute_pasword_hash(value.password)
.context("Failed to hash password.")?;
Ok(Self {
username: value.username,
password_hash,
role,
})
}
}
#[tracing::instrument(name = "Creating new user", skip_all, fields(username = %form.username))]
pub async fn create_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<CreateUserForm>,
) -> Result<Response, AppError> {
let new_user: NewUser = match form.try_into().map_err(|e: anyhow::Error| e.to_string()) {
Err(e) => {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
Ok(new_user) => new_user,
};
insert_user(&connection_pool, new_user)
.await
.context("Could not insert user in database.")?;
let template = HtmlTemplate(MessageTemplate::success(
"The new user has been created.".into(),
));
Ok(template.into_response())
}
async fn insert_user(connection_pool: &PgPool, new_user: NewUser) -> Result<Uuid, sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
r#"
INSERT INTO users (user_id, username, password_hash, role)
VALUES ($1, $2, $3, $4)
"#,
user_id,
new_user.username,
new_user.password_hash.expose_secret(),
new_user.role as _
)
.execute(connection_pool)
.await?;
Ok(user_id)
}
#[derive(serde::Deserialize)]
pub struct SubscriberPathParams {
pub user_id: Uuid,
}
#[tracing::instrument(name = "Delete user from database", skip(connection_pool))]
pub async fn delete_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(SubscriberPathParams { user_id }): Path<SubscriberPathParams>,
) -> Result<Response, AppError> {
let result = sqlx::query!("DELETE FROM users WHERE user_id = $1", user_id)
.execute(&connection_pool)
.await
.context("Failed to delete user from database.")?;
let template = if result.rows_affected() == 0 {
HtmlTemplate(MessageTemplate::error(
"The user could not be deleted.".into(),
))
} else {
HtmlTemplate(MessageTemplate::success(
"The user has been deleted.".into(),
))
};
Ok(template.into_response())
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool, session))]
pub async fn user_profile(
session: TypedSession,
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(username): Path<String>,
) -> Result<Response, AppError> {
match fetch_user_data(&connection_pool, &username)
.await
.context("Failed to fetch user data.")?
{
Some(user) => {
let posts = fetch_user_posts(&connection_pool, &user.user_id)
.await
.context("Could not fetch user posts.")?;
let session_username = session
.get_username()
.await
.context("Could not fetch session username.")?;
let template = HtmlTemplate(UserTemplate {
user,
session_username,
posts,
});
Ok(template.into_response())
}
None => {
tracing::error!(username = %username, "user not found");
Ok(not_found_html().into_response())
}
}
}
#[tracing::instrument(name = "Fetching user profile", skip_all)]
async fn fetch_user_data(
connection_pool: &PgPool,
username: &str,
) -> Result<Option<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, full_name, role as "role: Role", member_since, bio
FROM users
WHERE username = $1
"#,
username
)
.fetch_optional(connection_pool)
.await
}
#[tracing::instrument(name = "Fetching user posts", skip_all)]
async fn fetch_user_posts(
connection_pool: &PgPool,
user_id: &Uuid,
) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at
FROM posts p
INNER JOIN users u ON p.author_id = u.user_id
WHERE p.author_id = $1
ORDER BY p.published_at DESC
"#,
user_id
)
.fetch_all(connection_pool)
.await
}