337 lines
10 KiB
Rust
337 lines
10 KiB
Rust
use crate::authentication::AuthenticatedUser;
|
|
use crate::routes::{join_error_messages, verify_password};
|
|
use crate::session_state::TypedSession;
|
|
use crate::templates::{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;
|
|
use validator::Validate;
|
|
|
|
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(Debug, Validate, serde::Deserialize)]
|
|
pub struct EditProfileForm {
|
|
user_id: Uuid,
|
|
#[validate(length(min = 3, message = "Username must be at least 3 characters."))]
|
|
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 let Err(e) = form.validate().map_err(join_error_messages) {
|
|
let template = HtmlTemplate(MessageTemplate::error(e));
|
|
return Ok(template.into_response());
|
|
}
|
|
if form.user_id != session_user_id {
|
|
let template = HtmlTemplate(MessageTemplate::error(
|
|
"You are not authorized. Refresh the page and try again.".into(),
|
|
));
|
|
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(
|
|
"This username is already taken.".into(),
|
|
));
|
|
return Ok(template.into_response());
|
|
}
|
|
let updated_full_name = form.full_name.trim();
|
|
let bio = {
|
|
let bio = form.bio.trim();
|
|
if bio.is_empty() { None } else { Some(bio) }
|
|
};
|
|
|
|
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_user_id = session
|
|
.get_user_id()
|
|
.await
|
|
.context("Could not fetch session username.")?;
|
|
let profile_user_id =
|
|
sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
|
.fetch_one(&connection_pool)
|
|
.await
|
|
.context("Could not fetch profile user id.")?
|
|
.user_id;
|
|
let last_seen = sqlx::query!(
|
|
"
|
|
SELECT login_time FROM user_logins
|
|
WHERE user_id = $1
|
|
ORDER BY login_time DESC
|
|
",
|
|
profile_user_id
|
|
)
|
|
.fetch_optional(&connection_pool)
|
|
.await
|
|
.context("Failed to fetch last user login")?
|
|
.map(|r| r.login_time);
|
|
let template = HtmlTemplate(UserTemplate {
|
|
user,
|
|
session_user_id,
|
|
last_seen,
|
|
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 p.author_id, u.username as author,
|
|
p.post_id, p.title, p.content, p.published_at, p.last_modified
|
|
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
|
|
}
|