Use fix routes for user profile edit handles to make it easier when user decides to change his username
305 lines
8.8 KiB
Rust
305 lines
8.8 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 user_edit_form(
|
|
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
|
State(AppState {
|
|
connection_pool, ..
|
|
}): State<AppState>,
|
|
) -> Result<Response, AppError> {
|
|
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 {
|
|
user_id: Uuid,
|
|
username: String,
|
|
full_name: String,
|
|
bio: String,
|
|
}
|
|
|
|
#[tracing::instrument(name = "Updating user profile", skip_all, fields(user_id = %form.user_id))]
|
|
pub async fn update_user(
|
|
State(AppState {
|
|
connection_pool, ..
|
|
}): State<AppState>,
|
|
session: TypedSession,
|
|
Extension(AuthenticatedUser {
|
|
user_id: session_user_id,
|
|
username: session_username,
|
|
..
|
|
}): Extension<AuthenticatedUser>,
|
|
Form(form): Form<EditProfileForm>,
|
|
) -> Result<Response, AppError> {
|
|
if form.user_id != session_user_id {
|
|
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,
|
|
form.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
|
|
}
|