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, Extension(AuthenticatedUser { user_id, username: session_username, .. }): Extension, State(AppState { connection_pool, .. }): State, ) -> Result { 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, session: TypedSession, Extension(AuthenticatedUser { user_id, username: session_username, .. }): Extension, Path(username): Path, Form(form): Form, ) -> Result { 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, 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, } struct NewUser { username: String, password_hash: SecretString, role: Role, } impl TryFrom for NewUser { type Error = anyhow::Error; fn try_from(value: CreateUserForm) -> Result { 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, Form(form): Form, ) -> Result { 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 { 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, Path(SubscriberPathParams { user_id }): Path, ) -> Result { 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, Path(username): Path, ) -> Result { 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, 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, 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 }