summaryrefslogtreecommitdiffstats
path: root/src/user
diff options
context:
space:
mode:
Diffstat (limited to 'src/user')
-rw-r--r--src/user/mod.rs2
-rw-r--r--src/user/models.rs250
-rw-r--r--src/user/routes.rs208
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 }))
+}