diff options
| author | Santo Cariotti <santo@dcariotti.me> | 2022-10-17 20:08:09 +0000 |
|---|---|---|
| committer | Santo Cariotti <santo@dcariotti.me> | 2022-10-17 20:08:09 +0000 |
| commit | 74976dab57887a4d7e29b426cdf7422722fa58ee (patch) | |
| tree | 5f1bbed3dbcf3ba520866cb4eb060aaf5a771915 /src/user | |
| parent | 611293122213f83e82d851cd8dc83fd1e4f79dcd (diff) | |
Refactoring of mods
Diffstat (limited to 'src/user')
| -rw-r--r-- | src/user/mod.rs | 2 | ||||
| -rw-r--r-- | src/user/models.rs | 250 | ||||
| -rw-r--r-- | src/user/routes.rs | 208 |
3 files changed, 460 insertions, 0 deletions
diff --git a/src/user/mod.rs b/src/user/mod.rs new file mode 100644 index 0000000..a0e1883 --- /dev/null +++ b/src/user/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod routes; diff --git a/src/user/models.rs b/src/user/models.rs new file mode 100644 index 0000000..2ee6a06 --- /dev/null +++ b/src/user/models.rs @@ -0,0 +1,250 @@ +use crate::{ + config::CONFIG, + db::get_client, + errors::AppError, + model::models::{Model, ModelUser}, +}; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, NoneAsEmptyString}; +use sqlx::Row; +use validator::Validate; + +/// User model +#[derive(Deserialize, Serialize, Validate)] +pub struct User { + id: i32, + name: String, + #[validate(length(min = 4, message = "Can not be empty"))] + email: String, + #[validate(length(min = 2, message = "Can not be empty"))] + username: String, + #[validate(length(min = 8, message = "Must be min 8 chars length"))] + password: String, + is_staff: Option<bool>, + avatar: Option<String>, +} + +/// Paylod used for user editing +#[derive(Deserialize)] +pub struct UserEdit { + pub name: String, + pub email: String, + pub username: String, + pub is_staff: Option<bool>, +} + +/// Response used to print a user (or a users list) +#[serde_as] +#[derive(Deserialize, Serialize, sqlx::FromRow, Validate)] +pub struct UserList { + pub id: i32, + pub name: String, + #[validate(length(min = 4, message = "Can not be empty"))] + pub email: String, + #[validate(length(min = 2, message = "Can not be empty"))] + pub username: String, + pub is_staff: Option<bool>, + #[serde_as(as = "NoneAsEmptyString")] + pub avatar: Option<String>, +} + +impl User { + /// By default an user has id = 0. It is not created yet + pub fn new(name: String, email: String, username: String, password: String) -> Self { + Self { + id: 0, + name, + email, + username, + password, + is_staff: Some(false), + avatar: None, + } + } + + /// Create a new user from the model using a SHA256 crypted password + pub async fn create(user: User) -> Result<UserList, AppError> { + let pool = unsafe { get_client() }; + + user.validate() + .map_err(|error| AppError::BadRequest(error.to_string()))?; + + let crypted_password = sha256::digest(user.password); + + let rec: UserList = sqlx::query_as( + r#" + INSERT INTO users (name, email, username, password) + VALUES ( $1, $2, $3, $4) + RETURNING id, name, email, username, is_staff, avatar + "#, + ) + .bind(user.name) + .bind(user.email) + .bind(user.username) + .bind(crypted_password) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Find a user using the model. It used for login + pub async fn find(user: User) -> Result<UserList, AppError> { + let pool = unsafe { get_client() }; + + let crypted_password = sha256::digest(user.password); + + let rec: UserList = sqlx::query_as( + r#" + SELECT id, name, email, username, is_staff, avatar FROM "users" + WHERE username = $1 AND password = $2 + "#, + ) + .bind(user.username) + .bind(crypted_password) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Returns the user with id = `user_id` + pub async fn find_by_id(user_id: i32) -> Result<UserList, AppError> { + let pool = unsafe { get_client() }; + + let rec: UserList = sqlx::query_as( + r#" + SELECT id, name, email, username, is_staff, avatar FROM "users" + WHERE id = $1 + "#, + ) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// List all users + pub async fn list(page: i64) -> Result<Vec<UserList>, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec<UserList> = sqlx::query_as( + r#"SELECT id, name, email, username, is_staff, avatar FROM users + ORDER BY id DESC + LIMIT $1 OFFSET $2 + "#, + ) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await?; + + Ok(rows) + } + + /// Return the number of users. + pub async fn count() -> Result<i64, AppError> { + let pool = unsafe { get_client() }; + let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM users"#) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } + + /// Prevent the "uniquess" Postgres fields check. Check if username has been taken + pub async fn username_has_taken(username: &String) -> Result<bool, AppError> { + let pool = unsafe { get_client() }; + let cursor = sqlx::query( + r#" + SELECT COUNT(id) as count FROM users WHERE username = $1 + "#, + ) + .bind(username) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + + Ok(count > 0) + } + + /// Prevent the "uniquess" Postgres fields check. Check if email has been taken + pub async fn email_has_taken(email: &String) -> Result<bool, AppError> { + let pool = unsafe { get_client() }; + let cursor = sqlx::query( + r#" + SELECT COUNT(id) as count FROM users WHERE email = $1 + "#, + ) + .bind(email) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + + Ok(count > 0) + } +} + +impl UserList { + /// Edit an user avatar + pub async fn edit_avatar(&mut self, avatar: Option<String>) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + sqlx::query( + r#" + UPDATE users SET avatar = $1 WHERE id = $2 + "#, + ) + .bind(&avatar) + .bind(self.id) + .execute(pool) + .await?; + + self.avatar = avatar; + + Ok(()) + } + + /// Edit an user + pub async fn edit(&mut self, payload: UserEdit) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + // Make assignments before the `sqlx::query()` so to perform validation. + // If the `AppError::BadRequest` is raised, the query (and then the update) will be skipped + self.name = payload.name.clone(); + self.username = payload.username.clone(); + self.email = payload.email.clone(); + self.is_staff = payload.is_staff; + + self.validate() + .map_err(|error| AppError::BadRequest(error.to_string()))?; + + sqlx::query( + r#" + UPDATE users SET name = $1, username = $2, email = $3, is_staff = $4 WHERE id = $5 + "#, + ) + .bind(&payload.name) + .bind(&payload.username) + .bind(&payload.email) + .bind(payload.is_staff.unwrap_or_default()) + .bind(self.id) + .execute(pool) + .await?; + + Ok(()) + } + + /// Get all models created by an user + pub async fn get_models(&self, page: i64) -> Result<Vec<ModelUser>, AppError> { + Model::list_from_author(page, self.id).await + } + + /// Returns the number of models for an user + pub async fn count_models(&self) -> Result<i64, AppError> { + Model::count_filter_by_author(self.id).await + } +} diff --git a/src/user/routes.rs b/src/user/routes.rs new file mode 100644 index 0000000..59b81a4 --- /dev/null +++ b/src/user/routes.rs @@ -0,0 +1,208 @@ +use crate::{ + auth::models::Claims, + errors::AppError, + files::{delete_upload, upload}, + pagination::{ModelPagination, Pagination, UserPagination}, + user::models::{User, UserEdit, UserList}, +}; +use axum::{ + extract::{ContentLengthLimit, Multipart, Path, Query}, + routing::{delete, get, put}, + Json, Router, +}; + +/// Create routes for `/v1/users/` namespace +pub fn create_route() -> Router { + Router::new() + .route("/", get(list_users)) + .route("/me", get(get_me)) + .route("/me/avatar", put(edit_my_avatar).delete(delete_my_avatar)) + .route("/:id", get(get_user).put(edit_user)) + .route("/:id/avatar", delete(delete_avatar)) + .route("/:id/models", get(get_user_models)) +} + +/// List users. Checks Authorization token +async fn list_users( + _: Claims, + pagination: Query<Pagination>, +) -> Result<Json<UserPagination>, AppError> { + let page = pagination.0.page.unwrap_or_default(); + let results = User::list(page).await?; + let count = User::count().await?; + + Ok(Json(UserPagination { count, results })) +} + +/// Get info about me +async fn get_me(claims: Claims) -> Result<Json<UserList>, AppError> { + match User::find_by_id(claims.user_id).await { + Ok(user) => Ok(Json(user)), + Err(_) => Err(AppError::NotFound("User not found".to_string())), + } +} + +/// Edit the avatar of the user linked to the claims +async fn edit_my_avatar( + claims: Claims, + ContentLengthLimit(multipart): ContentLengthLimit<Multipart, { 1024 * 1024 * 5 }>, +) -> Result<Json<UserList>, AppError> { + let mut user = match User::find_by_id(claims.user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + if user.avatar.is_some() { + let avatar_url = user.avatar.as_ref().unwrap(); + delete_upload(avatar_url)?; + } + + match upload( + multipart, + vec!["jpg", "jpeg", "png", "webp"], + Some(format!("avatar-{}", user.id)), + ) + .await + { + Ok(saved_file) => { + user.edit_avatar(Some(saved_file)).await?; + + Ok(Json(user)) + } + Err(e) => Err(e), + } +} + +/// A staffer can delete an user `id`'s avatar +async fn delete_avatar( + Path(user_id): Path<i32>, + claims: Claims, +) -> Result<Json<UserList>, AppError> { + let mut user = match User::find_by_id(user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + // If the user of the access token is different than the user they want to edit, checks if the + // first user is an admin + if claims.user_id != user.id { + match User::find_by_id(claims.user_id).await { + Ok(user) => { + if !(user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + } + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + } + + if user.avatar.is_some() { + let avatar_url = user.avatar.as_ref().unwrap(); + delete_upload(avatar_url)?; + } + + user.edit_avatar(None).await?; + + Ok(Json(user)) +} + +/// Delete the avatar of the user linked to the claims +async fn delete_my_avatar(claims: Claims) -> Result<Json<UserList>, AppError> { + let mut user = match User::find_by_id(claims.user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + if user.avatar.is_some() { + let avatar_url = user.avatar.as_ref().unwrap(); + delete_upload(avatar_url)?; + } + + user.edit_avatar(None).await?; + + Ok(Json(user)) +} + +/// Get an user with id = `user_id` +async fn get_user(Path(user_id): Path<i32>) -> Result<Json<UserList>, AppError> { + match User::find_by_id(user_id).await { + Ok(user) => Ok(Json(user)), + Err(_) => Err(AppError::NotFound("User not found".to_string())), + } +} + +/// Edit an user with id = `user_id`. Only staffers and owner of that account can perform this +/// action. +/// Only staffers can update the user `is_staff` value +async fn edit_user( + Path(user_id): Path<i32>, + Json(mut payload): Json<UserEdit>, + claims: Claims, +) -> Result<Json<UserList>, AppError> { + let mut user = match User::find_by_id(user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + let claimed = match User::find_by_id(claims.user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + if user.id != claimed.id { + if !(claimed.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + } + + if !claimed.is_staff.unwrap() && user.is_staff != payload.is_staff { + payload.is_staff = user.is_staff; + } + + if user.email != payload.email && User::email_has_taken(&payload.email).await? { + return Err(AppError::BadRequest( + "An user with this email already exists".to_string(), + )); + } + + if user.username != payload.username && User::username_has_taken(&payload.username).await? { + return Err(AppError::BadRequest( + "An user with this username already exists".to_string(), + )); + } + + user.edit(payload).await?; + + Ok(Json(user)) +} + +/// Get user models list +async fn get_user_models( + Path(user_id): Path<i32>, + pagination: Query<Pagination>, +) -> Result<Json<ModelPagination>, AppError> { + let user = match User::find_by_id(user_id).await { + Ok(user) => user, + Err(_) => { + return Err(AppError::NotFound("User not found".to_string())); + } + }; + + let page = pagination.0.page.unwrap_or_default(); + let results = user.get_models(page).await?; + let count = user.count_models().await?; + + Ok(Json(ModelPagination { count, results })) +} |
