From 74976dab57887a4d7e29b426cdf7422722fa58ee Mon Sep 17 00:00:00 2001 From: Santo Cariotti Date: Mon, 17 Oct 2022 22:08:09 +0200 Subject: Refactoring of mods --- src/auth/mod.rs | 2 + src/auth/models.rs | 122 +++++++++++++ src/auth/routes.rs | 66 +++++++ src/likes/mod.rs | 1 + src/likes/models.rs | 91 ++++++++++ src/main.rs | 14 +- src/model/mod.rs | 2 + src/model/models.rs | 479 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/model/routes.rs | 265 ++++++++++++++++++++++++++++ src/models/auth.rs | 122 ------------- src/models/likes.rs | 91 ---------- src/models/mod.rs | 5 - src/models/model.rs | 479 -------------------------------------------------- src/models/user.rs | 250 -------------------------- src/models/warning.rs | 382 ---------------------------------------- src/pagination.rs | 4 +- src/routes.rs | 23 +++ src/routes/auth.rs | 68 ------- src/routes/mod.rs | 28 --- src/routes/model.rs | 267 ---------------------------- src/routes/user.rs | 210 ---------------------- src/routes/warning.rs | 177 ------------------- src/user/mod.rs | 2 + src/user/models.rs | 250 ++++++++++++++++++++++++++ src/user/routes.rs | 208 ++++++++++++++++++++++ src/warning/mod.rs | 2 + src/warning/models.rs | 382 ++++++++++++++++++++++++++++++++++++++++ src/warning/routes.rs | 175 ++++++++++++++++++ 28 files changed, 2082 insertions(+), 2085 deletions(-) create mode 100644 src/auth/mod.rs create mode 100644 src/auth/models.rs create mode 100644 src/auth/routes.rs create mode 100644 src/likes/mod.rs create mode 100644 src/likes/models.rs create mode 100644 src/model/mod.rs create mode 100644 src/model/models.rs create mode 100644 src/model/routes.rs delete mode 100644 src/models/auth.rs delete mode 100644 src/models/likes.rs delete mode 100644 src/models/mod.rs delete mode 100644 src/models/model.rs delete mode 100644 src/models/user.rs delete mode 100644 src/models/warning.rs create mode 100644 src/routes.rs delete mode 100644 src/routes/auth.rs delete mode 100644 src/routes/mod.rs delete mode 100644 src/routes/model.rs delete mode 100644 src/routes/user.rs delete mode 100644 src/routes/warning.rs create mode 100644 src/user/mod.rs create mode 100644 src/user/models.rs create mode 100644 src/user/routes.rs create mode 100644 src/warning/mod.rs create mode 100644 src/warning/models.rs create mode 100644 src/warning/routes.rs diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..a0e1883 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod routes; diff --git a/src/auth/models.rs b/src/auth/models.rs new file mode 100644 index 0000000..8a673dd --- /dev/null +++ b/src/auth/models.rs @@ -0,0 +1,122 @@ +use crate::errors::AppError; +use axum::{ + async_trait, + extract::{FromRequest, RequestParts, TypedHeader}, + headers::{authorization::Bearer, Authorization}, +}; +use chrono::{Duration, Local}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; + +struct Keys { + encoding: EncodingKey, + decoding: DecodingKey, +} + +/// Claims struct +#[derive(Serialize, Deserialize)] +pub struct Claims { + /// ID from the user model + pub user_id: i32, + /// Expiration timestamp + exp: usize, +} + +/// Body used as response to login +#[derive(Serialize)] +pub struct AuthBody { + /// Access token string + access_token: String, + /// "Bearer" string + token_type: String, +} + +/// Payload used for login +#[derive(Deserialize)] +pub struct LoginCredentials { + pub username: String, + pub password: String, +} + +/// Paylod used for user creation +#[derive(Deserialize)] +pub struct SignUpForm { + pub name: String, + pub email: String, + pub username: String, + pub password1: String, + pub password2: String, +} + +static KEYS: Lazy = Lazy::new(|| { + let secret = &crate::config::CONFIG.jwt_secret; + Keys::new(secret.as_bytes()) +}); + +impl Keys { + fn new(secret: &[u8]) -> Self { + Self { + encoding: EncodingKey::from_secret(secret), + decoding: DecodingKey::from_secret(secret), + } + } +} + +impl Claims { + /// Create a new Claim using the `user_id` and the current timestamp + 2 days + pub fn new(user_id: i32) -> Self { + let expiration = Local::now() + Duration::days(1); + + Self { + user_id, + exp: expiration.timestamp() as usize, + } + } + + /// Returns the token as a string. If a token is not encoded, raises an + /// `AppError::TokenCreation` + pub fn get_token(&self) -> Result { + let token = encode(&Header::default(), &self, &KEYS.encoding) + .map_err(|_| AppError::TokenCreation)?; + + Ok(token) + } +} + +impl AuthBody { + pub fn new(access_token: String) -> Self { + Self { + access_token, + token_type: "Bearer".to_string(), + } + } +} + +/// Parse a request to get the Authorization header and then decode it checking its validation +#[async_trait] +impl FromRequest for Claims +where + B: Send, +{ + type Rejection = AppError; + + async fn from_request(req: &mut RequestParts) -> Result { + // Extract the token from the authorization header + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request(req) + .await + .map_err(|_| AppError::InvalidToken)?; + // Decode the user data + let token_data = decode::(bearer.token(), &KEYS.decoding, &Validation::default()) + .map_err(|_| AppError::InvalidToken)?; + + let now = Local::now().timestamp() as usize; + + if token_data.claims.exp < now { + return Err(AppError::InvalidToken); + } + + Ok(token_data.claims) + } +} diff --git a/src/auth/routes.rs b/src/auth/routes.rs new file mode 100644 index 0000000..65a5cb5 --- /dev/null +++ b/src/auth/routes.rs @@ -0,0 +1,66 @@ +use crate::{ + errors::AppError, + auth::models::{AuthBody, Claims, LoginCredentials, SignUpForm}, + user::models::User, + routes::JsonCreate, +}; +use axum::{routing::post, Json, Router}; + +/// Create routes for `/v1/auth/` namespace +pub fn create_route() -> Router { + Router::new() + .route("/login", post(make_login)) + .route("/signup", post(signup)) +} + +/// Make login. Check if a user with the email and password passed in request body exists into the +/// database +async fn make_login(Json(payload): Json) -> Result, AppError> { + let user = User::new( + String::new(), + String::new(), + payload.username, + payload.password, + ); + match User::find(user).await { + Ok(user) => { + let claims = Claims::new(user.id); + let token = claims.get_token()?; + Ok(Json(AuthBody::new(token))) + } + Err(_) => Err(AppError::NotFound("User not found".to_string())), + } +} + +/// Create a new user +async fn signup(Json(payload): Json) -> Result, AppError> { + if payload.password1 != payload.password2 { + return Err(AppError::BadRequest( + "The inserted passwords do not match".to_string(), + )); + } + + if User::email_has_taken(&payload.email).await? { + return Err(AppError::BadRequest( + "An user with this email already exists".to_string(), + )); + } + + if User::username_has_taken(&payload.username).await? { + return Err(AppError::BadRequest( + "An user with this username already exists".to_string(), + )); + } + + let user = User::new( + payload.name, + payload.email, + payload.username, + payload.password1, + ); + let user = User::create(user).await?; + + let claims = Claims::new(user.id); + let token = claims.get_token()?; + Ok(JsonCreate(AuthBody::new(token))) +} diff --git a/src/likes/mod.rs b/src/likes/mod.rs new file mode 100644 index 0000000..c446ac8 --- /dev/null +++ b/src/likes/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/src/likes/models.rs b/src/likes/models.rs new file mode 100644 index 0000000..56001d9 --- /dev/null +++ b/src/likes/models.rs @@ -0,0 +1,91 @@ +use crate::{db::get_client, errors::AppError}; +use chrono::{Local, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; + +/// Likes model +#[derive(Serialize, Deserialize, sqlx::FromRow)] +pub struct Like { + id: i32, + user_id: i32, + model_id: i32, + created: NaiveDateTime, +} + +impl Like { + /// Create a new like + pub fn new(user_id: i32, model_id: i32) -> Self { + let now = Local::now().naive_utc(); + Self { + id: 0, + user_id, + model_id, + created: now, + } + } + + /// Returns `true` if an user has already assigned a like to a model + pub async fn exists(&self) -> Result { + let pool = unsafe { get_client() }; + let cursor = sqlx::query( + r#" + SELECT COUNT(id) as count FROM likes WHERE user_id = $1 AND model_id = $2 + "#, + ) + .bind(self.user_id) + .bind(self.model_id) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + + Ok(count > 0) + } + + /// Save new like into db + pub async fn save(&self) -> Result { + let pool = unsafe { get_client() }; + + if self.exists().await? { + return Err(AppError::BadRequest( + "This user already likes this model".to_string(), + )); + } + + let rec: Like = sqlx::query_as( + r#" + INSERT INTO likes (user_id, model_id, created) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(self.user_id) + .bind(self.model_id) + .bind(self.created) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Remove a like + pub async fn remove(&self) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + if !self.exists().await? { + return Err(AppError::NotFound("Like not found".to_string())); + } + + sqlx::query( + r#" + DELETE FROM likes WHERE user_id = $1 AND model_id = $2 + "#, + ) + .bind(self.user_id) + .bind(self.model_id) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 19003f3..369b555 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,16 @@ +mod auth; mod config; mod db; mod errors; mod files; mod json; +mod likes; mod logger; -mod models; +mod model; mod pagination; mod routes; +mod user; +mod warning; use crate::config::{CONFIG, SENTRY}; use axum::{ @@ -57,10 +61,10 @@ async fn create_app() -> Router { let _ = db::setup().await; let api_routes = Router::new() - .nest("/users", routes::user::create_route()) - .nest("/auth", routes::auth::create_route()) - .nest("/models", routes::model::create_route()) - .nest("/warnings", routes::warning::create_route()); + .nest("/users", user::routes::create_route()) + .nest("/auth", auth::routes::create_route()) + .nest("/models", model::routes::create_route()) + .nest("/warnings", warning::routes::create_route()); Router::new() .route( diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..a0e1883 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod routes; diff --git a/src/model/models.rs b/src/model/models.rs new file mode 100644 index 0000000..87e1997 --- /dev/null +++ b/src/model/models.rs @@ -0,0 +1,479 @@ +use crate::{config::CONFIG, db::get_client, errors::AppError, json::number_from_string}; +use serde_json::json; +use sqlx::types::JsonValue; +use sqlx::Row; + +use chrono::{Local, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +/// Model for models. +#[derive(Deserialize, Serialize, Validate, sqlx::FromRow)] +pub struct Model { + id: i32, + #[validate(length(min = 2, message = "Can not be empty"))] + name: String, + description: Option, + duration: f64, + height: f64, + weight: f64, + printer: Option, + material: Option, + author_id: i32, + created: NaiveDateTime, + updated: NaiveDateTime, +} + +/// Payload used for model creation +#[derive(Deserialize)] +pub struct ModelCreate { + pub name: String, + pub description: Option, + #[serde(deserialize_with = "number_from_string")] + pub duration: f64, + #[serde(deserialize_with = "number_from_string")] + pub height: f64, + #[serde(deserialize_with = "number_from_string")] + pub weight: f64, + pub printer: Option, + pub material: Option, +} + +/// Payload used for model searching +#[derive(Deserialize)] +pub struct ModelFilter { + /// Stands for "query" + pub q: String, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct ModelUser { + pub id: i32, + name: String, + description: Option, + duration: f64, + height: f64, + weight: f64, + printer: Option, + material: Option, + author_id: i32, + created: NaiveDateTime, + updated: NaiveDateTime, + author: Option, + uploads: Option, + likes: Option, +} + +#[derive(Deserialize, Serialize, sqlx::FromRow)] +pub struct ModelUpload { + id: i32, + pub model_id: i32, + pub filepath: String, + created: NaiveDateTime, +} + +impl Model { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + description: Option, + duration: f64, + height: f64, + weight: f64, + printer: Option, + material: Option, + author_id: i32, + ) -> Self { + let now = Local::now().naive_utc(); + Self { + id: 0, + name, + description, + duration, + height, + weight, + printer, + material, + author_id, + created: now, + updated: now, + } + } + + /// Create a new model + pub async fn create(model: Model) -> Result { + let pool = unsafe { get_client() }; + + model + .validate() + .map_err(|error| AppError::BadRequest(error.to_string()))?; + + let rec: Model = sqlx::query_as( + r#" + INSERT INTO models (name, description, duration, height, weight, printer, material, author_id, created, updated) + VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + "#) + .bind(model.name) + .bind(model.description) + .bind(model.duration) + .bind(model.height) + .bind(model.weight) + .bind(model.printer) + .bind(model.material) + .bind(model.author_id) + .bind(model.created) + .bind(model.updated) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Edit a model + pub async fn edit(id: i32, model: Model) -> Result { + let pool = unsafe { get_client() }; + + model + .validate() + .map_err(|error| AppError::BadRequest(error.to_string()))?; + + let rec: Model = sqlx::query_as( + r#" + UPDATE models SET name = $1, description = $2, duration = $3, height = $4, weight = $5, printer = $6, material = $7, updated = $8 + WHERE id = $9 + RETURNING * + "#) + .bind(model.name) + .bind(model.description) + .bind(model.duration) + .bind(model.height) + .bind(model.weight) + .bind(model.printer) + .bind(model.material) + .bind(model.updated) + .bind(id) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Returns the model with id = `model_id` + pub async fn find_by_id(model_id: i32) -> Result { + let pool = unsafe { get_client() }; + + let rec: ModelUser = sqlx::query_as( + r#" + WITH model_uploads AS ( + SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads + FROM models + LEFT JOIN uploads ON uploads.model_id = models.id + GROUP BY models.id + ), + model_likes AS ( + SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes + FROM models + LEFT JOIN likes ON likes.model_id = models.id + GROUP BY models.id + ), + model_author AS ( + SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author + FROM models + JOIN users ON users.id = models.author_id + ) + SELECT models.*, author, uploads, likes + FROM models + INNER JOIN model_author using (id) + INNER JOIN model_uploads using (id) + INNER JOIN model_likes using (id) + WHERE models.id = $1 + "#) + .bind(model_id) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// List all models + pub async fn list(page: i64) -> Result, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec = sqlx::query_as( + r#" + WITH model_uploads AS ( + SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads + FROM models + LEFT JOIN uploads ON uploads.model_id = models.id + GROUP BY models.id + ), + model_likes AS ( + SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes + FROM models + LEFT JOIN likes ON likes.model_id = models.id + GROUP BY models.id + ), + model_author AS ( + SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author + FROM models + JOIN users ON users.id = models.author_id + ) + SELECT models.*, author, uploads, likes + FROM models + INNER JOIN model_author using (id) + INNER JOIN model_uploads using (id) + INNER JOIN model_likes using (id) + ORDER BY id DESC + LIMIT $1 OFFSET $2 + "#) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await?; + + Ok(rows) + } + + /// Filter models by some cols + pub async fn filter(page: i64, query: String) -> Result, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec = sqlx::query_as( + r#" + WITH model_uploads AS ( + SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads + FROM models + LEFT JOIN uploads ON uploads.model_id = models.id + GROUP BY models.id + ), + model_likes AS ( + SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes + FROM models + LEFT JOIN likes ON likes.model_id = models.id + GROUP BY models.id + ), + model_author AS ( + SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author + FROM models + JOIN users ON users.id = models.author_id + ) + SELECT models.*, author, uploads, likes + FROM models + INNER JOIN model_author using (id) + INNER JOIN model_uploads using (id) + INNER JOIN model_likes using (id) + WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1 + ORDER BY id DESC + LIMIT $2 OFFSET $3 + "#) + .bind(format!("%{}%", query)) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await?; + + Ok(rows) + } + + /// List author's models + pub async fn list_from_author(page: i64, author: i32) -> Result, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec = sqlx::query_as( + r#" + WITH model_uploads AS ( + SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads + FROM models + LEFT JOIN uploads ON uploads.model_id = models.id + GROUP BY models.id + ), + model_likes AS ( + SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes + FROM models + LEFT JOIN likes ON likes.model_id = models.id + GROUP BY models.id + ), + model_author AS ( + SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author + FROM models + JOIN users ON users.id = models.author_id + ) + SELECT models.*, author, uploads, likes + FROM models + INNER JOIN model_author using (id) + INNER JOIN model_uploads using (id) + INNER JOIN model_likes using (id) + WHERE models.author_id = $1 + ORDER BY id DESC + LIMIT $2 OFFSET $3 + "#) + .bind(author) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await?; + + Ok(rows) + } + + /// Delete a model + pub async fn delete(model_id: i32) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + sqlx::query( + r#" + DELETE FROM models WHERE id = $1 + "#, + ) + .bind(model_id) + .execute(pool) + .await?; + + Ok(()) + } + + /// Return the number of models. + pub async fn count() -> Result { + let pool = unsafe { get_client() }; + let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models"#) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } + + /// Return the number of author models + pub async fn count_filter_by_author(author: i32) -> Result { + let pool = unsafe { get_client() }; + let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models WHERE author_id = $1"#) + .bind(author) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } + + /// Return the number of models filtered by query + pub async fn count_filter(query: String) -> Result { + let pool = unsafe { get_client() }; + let cursor = sqlx::query( + r#" + SELECT COUNT(id) as count FROM models + WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1 + "# + ) + .bind(format!("%{}%", query)) + .fetch_one(pool) + .await?; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } +} + +impl ModelUser { + /// Returns the author id from the `JsonValue` + pub fn author_id(&self) -> JsonValue { + match &self.author { + Some(json) => json.get("id").unwrap().clone(), + None => json!(0), + } + } + + /// Returns a vec of string made by all the filepaths from the model + pub async fn list_upload_filepaths(&self) -> Option> { + // Raise a `None` if `self.uploads` is `None` + self.uploads.as_ref()?; + + let uploads = ModelUpload::find_by_model(self.id) + .await + .unwrap_or_default(); + + let paths = uploads + .iter() + .map(|x| x.filepath.clone()) + .collect::>(); + + Some(paths) + } +} + +impl ModelUpload { + pub fn new(filepath: String, model_id: i32) -> Self { + let now = Local::now().naive_utc(); + Self { + id: 0, + filepath, + model_id, + created: now, + } + } + + /// Create a new upload for model + pub async fn create(file: ModelUpload) -> Result { + let pool = unsafe { get_client() }; + + let rec: ModelUpload = sqlx::query_as( + r#" + INSERT INTO uploads (filepath, model_id, created) + VALUES ( $1, $2, $3) + RETURNING * + "#, + ) + .bind(file.filepath) + .bind(file.model_id) + .bind(file.created) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Find all paths of a model + pub async fn find_by_model(model_id: i32) -> Result, AppError> { + let pool = unsafe { get_client() }; + + let rec: Vec = sqlx::query_as( + r#" + SELECT * FROM uploads WHERE model_id = $1 + "#, + ) + .bind(model_id) + .fetch_all(pool) + .await?; + + Ok(rec) + } + + /// Returns the model upload with id = `upload_id` + pub async fn find_by_id(id: i32) -> Result { + let pool = unsafe { get_client() }; + + let rec: ModelUpload = sqlx::query_as( + r#" + SELECT * FROM uploads WHERE id = $1 + "#, + ) + .bind(id) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Delete a model upload + pub async fn delete(upload_id: i32) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + sqlx::query( + r#" + DELETE FROM uploads WHERE id = $1 + "#, + ) + .bind(upload_id) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/src/model/routes.rs b/src/model/routes.rs new file mode 100644 index 0000000..26d1bb5 --- /dev/null +++ b/src/model/routes.rs @@ -0,0 +1,265 @@ +use crate::{ + auth::models::Claims, + errors::AppError, + files::{delete_upload, upload}, + likes::models::Like, + model::models::{Model, ModelCreate, ModelFilter, ModelUpload, ModelUser}, + pagination::{ModelPagination, Pagination}, + routes::JsonCreate, + user::models::User, +}; +use axum::{ + extract::{ContentLengthLimit, Multipart, Path, Query}, + http::StatusCode, + routing::{delete, get, post}, + Json, Router, +}; + +/// Create routes for `/v1/models/` namespace +pub fn create_route() -> Router { + Router::new() + .route("/", get(list_models).post(create_model)) + .route("/filter", post(filter_models)) + .route("/:id", get(get_model).delete(delete_model).put(edit_model)) + .route("/:id/like", post(add_like).delete(delete_like)) + .route("/:id/upload", post(upload_model_file)) + .route("/:id/upload/:uid", delete(delete_model_file)) +} + +/// List models. +async fn list_models(pagination: Query) -> Result, AppError> { + let page = pagination.0.page.unwrap_or_default(); + let results = Model::list(page).await?; + let count = Model::count().await?; + + Ok(Json(ModelPagination { count, results })) +} + +/// Create a model. Checks Authorization token +async fn create_model( + Json(payload): Json, + claims: Claims, +) -> Result, AppError> { + let model = Model::new( + payload.name, + payload.description, + payload.duration, + payload.height, + payload.weight, + payload.printer, + payload.material, + claims.user_id, + ); + + let model_new = Model::create(model).await?; + + Ok(JsonCreate(model_new)) +} + +/// Get a model with id = `model_id` +async fn get_model(Path(model_id): Path) -> Result, AppError> { + match Model::find_by_id(model_id).await { + Ok(model) => Ok(Json(model)), + Err(_) => Err(AppError::NotFound("Model not found".to_string())), + } +} + +/// The owner or a staffer can delete a model +async fn delete_model(claims: Claims, Path(model_id): Path) -> Result { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + let uploads: Vec = model.list_upload_filepaths().await.unwrap_or_default(); + + if !(model.author_id() == user.id || user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + // If the model has been deleted, remove all old uploads from the file system + if Model::delete(model_id).await.is_ok() { + uploads + .iter() + .for_each(|path: &String| delete_upload(path).unwrap_or_default()); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// The owner or a staffer can edit a model +async fn edit_model( + Json(payload): Json, + claims: Claims, + Path(model_id): Path, +) -> Result, AppError> { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + if !(model.author_id() == user.id || user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + let model_body = Model::new( + payload.name, + payload.description, + payload.duration, + payload.height, + payload.weight, + payload.printer, + payload.material, + claims.user_id, + ); + + // NOTE: can we edit this as same as `user.edit_avatar()`? + Model::edit(model.id, model_body).await?; + Ok(Json(model)) +} + +/// Upload a file for a model +async fn upload_model_file( + claims: Claims, + Path(model_id): Path, + ContentLengthLimit(multipart): ContentLengthLimit, +) -> Result, AppError> { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + if !(model.author_id() == user.id || user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + let allowed_extensions = vec![ + "stl", + "obj", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "blend", + "octet-stream", + "sla", + ]; + + match upload(multipart, allowed_extensions, None).await { + Ok(saved_file) => { + let model_file = ModelUpload::create(ModelUpload::new(saved_file, model_id)).await?; + + Ok(Json(model_file)) + } + Err(e) => Err(e), + } +} + +/// The owner or a staffer can delete a model upload +async fn delete_model_file( + claims: Claims, + Path((model_id, upload_id)): Path<(i32, i32)>, +) -> Result { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + if !(model.author_id() == user.id || user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + let upload = match ModelUpload::find_by_id(upload_id).await { + Ok(upload) => upload, + Err(_) => { + return Err(AppError::NotFound("Upload not found".to_string())); + } + }; + + if upload.model_id != model.id { + return Err(AppError::NotFound("Upload not found".to_string())); + } + + let filepath = upload.filepath.clone(); + + match ModelUpload::delete(upload_id).await { + Ok(_) => { + delete_upload(&filepath)?; + + Ok(StatusCode::NO_CONTENT) + } + Err(e) => Err(e), + } +} + +/// Assign a like to a model from the Authorization user +async fn add_like(claims: Claims, Path(model_id): Path) -> Result { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + let like = Like::new(user.id, model.id); + + match like.save().await { + Ok(_) => Ok(StatusCode::CREATED), + Err(e) => { + return Err(e); + } + } +} + +/// Remove a like from a model and an Authorization user +async fn delete_like(claims: Claims, Path(model_id): Path) -> Result { + let model = match Model::find_by_id(model_id).await { + Ok(model) => model, + Err(_) => { + return Err(AppError::NotFound("Model not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + let like = Like::new(user.id, model.id); + + match like.remove().await { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) => { + return Err(e); + } + } +} + +/// Filter models +async fn filter_models( + pagination: Query, + Json(payload): Json, +) -> Result, AppError> { + let page = pagination.0.page.unwrap_or_default(); + + let results = Model::filter(page, payload.q.clone()).await?; + let count = Model::count_filter(payload.q).await?; + + Ok(Json(ModelPagination { count, results })) +} diff --git a/src/models/auth.rs b/src/models/auth.rs deleted file mode 100644 index 8a673dd..0000000 --- a/src/models/auth.rs +++ /dev/null @@ -1,122 +0,0 @@ -use crate::errors::AppError; -use axum::{ - async_trait, - extract::{FromRequest, RequestParts, TypedHeader}, - headers::{authorization::Bearer, Authorization}, -}; -use chrono::{Duration, Local}; -use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; - -struct Keys { - encoding: EncodingKey, - decoding: DecodingKey, -} - -/// Claims struct -#[derive(Serialize, Deserialize)] -pub struct Claims { - /// ID from the user model - pub user_id: i32, - /// Expiration timestamp - exp: usize, -} - -/// Body used as response to login -#[derive(Serialize)] -pub struct AuthBody { - /// Access token string - access_token: String, - /// "Bearer" string - token_type: String, -} - -/// Payload used for login -#[derive(Deserialize)] -pub struct LoginCredentials { - pub username: String, - pub password: String, -} - -/// Paylod used for user creation -#[derive(Deserialize)] -pub struct SignUpForm { - pub name: String, - pub email: String, - pub username: String, - pub password1: String, - pub password2: String, -} - -static KEYS: Lazy = Lazy::new(|| { - let secret = &crate::config::CONFIG.jwt_secret; - Keys::new(secret.as_bytes()) -}); - -impl Keys { - fn new(secret: &[u8]) -> Self { - Self { - encoding: EncodingKey::from_secret(secret), - decoding: DecodingKey::from_secret(secret), - } - } -} - -impl Claims { - /// Create a new Claim using the `user_id` and the current timestamp + 2 days - pub fn new(user_id: i32) -> Self { - let expiration = Local::now() + Duration::days(1); - - Self { - user_id, - exp: expiration.timestamp() as usize, - } - } - - /// Returns the token as a string. If a token is not encoded, raises an - /// `AppError::TokenCreation` - pub fn get_token(&self) -> Result { - let token = encode(&Header::default(), &self, &KEYS.encoding) - .map_err(|_| AppError::TokenCreation)?; - - Ok(token) - } -} - -impl AuthBody { - pub fn new(access_token: String) -> Self { - Self { - access_token, - token_type: "Bearer".to_string(), - } - } -} - -/// Parse a request to get the Authorization header and then decode it checking its validation -#[async_trait] -impl FromRequest for Claims -where - B: Send, -{ - type Rejection = AppError; - - async fn from_request(req: &mut RequestParts) -> Result { - // Extract the token from the authorization header - let TypedHeader(Authorization(bearer)) = - TypedHeader::>::from_request(req) - .await - .map_err(|_| AppError::InvalidToken)?; - // Decode the user data - let token_data = decode::(bearer.token(), &KEYS.decoding, &Validation::default()) - .map_err(|_| AppError::InvalidToken)?; - - let now = Local::now().timestamp() as usize; - - if token_data.claims.exp < now { - return Err(AppError::InvalidToken); - } - - Ok(token_data.claims) - } -} diff --git a/src/models/likes.rs b/src/models/likes.rs deleted file mode 100644 index 56001d9..0000000 --- a/src/models/likes.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::{db::get_client, errors::AppError}; -use chrono::{Local, NaiveDateTime}; -use serde::{Deserialize, Serialize}; -use sqlx::Row; - -/// Likes model -#[derive(Serialize, Deserialize, sqlx::FromRow)] -pub struct Like { - id: i32, - user_id: i32, - model_id: i32, - created: NaiveDateTime, -} - -impl Like { - /// Create a new like - pub fn new(user_id: i32, model_id: i32) -> Self { - let now = Local::now().naive_utc(); - Self { - id: 0, - user_id, - model_id, - created: now, - } - } - - /// Returns `true` if an user has already assigned a like to a model - pub async fn exists(&self) -> Result { - let pool = unsafe { get_client() }; - let cursor = sqlx::query( - r#" - SELECT COUNT(id) as count FROM likes WHERE user_id = $1 AND model_id = $2 - "#, - ) - .bind(self.user_id) - .bind(self.model_id) - .fetch_one(pool) - .await?; - - let count: i64 = cursor.try_get(0).unwrap(); - - Ok(count > 0) - } - - /// Save new like into db - pub async fn save(&self) -> Result { - let pool = unsafe { get_client() }; - - if self.exists().await? { - return Err(AppError::BadRequest( - "This user already likes this model".to_string(), - )); - } - - let rec: Like = sqlx::query_as( - r#" - INSERT INTO likes (user_id, model_id, created) - VALUES ($1, $2, $3) - RETURNING * - "#, - ) - .bind(self.user_id) - .bind(self.model_id) - .bind(self.created) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Remove a like - pub async fn remove(&self) -> Result<(), AppError> { - let pool = unsafe { get_client() }; - - if !self.exists().await? { - return Err(AppError::NotFound("Like not found".to_string())); - } - - sqlx::query( - r#" - DELETE FROM likes WHERE user_id = $1 AND model_id = $2 - "#, - ) - .bind(self.user_id) - .bind(self.model_id) - .execute(pool) - .await?; - - Ok(()) - } -} diff --git a/src/models/mod.rs b/src/models/mod.rs deleted file mode 100644 index 8062fe3..0000000 --- a/src/models/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod auth; -pub mod likes; -pub mod model; -pub mod user; -pub mod warning; diff --git a/src/models/model.rs b/src/models/model.rs deleted file mode 100644 index 87e1997..0000000 --- a/src/models/model.rs +++ /dev/null @@ -1,479 +0,0 @@ -use crate::{config::CONFIG, db::get_client, errors::AppError, json::number_from_string}; -use serde_json::json; -use sqlx::types::JsonValue; -use sqlx::Row; - -use chrono::{Local, NaiveDateTime}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -/// Model for models. -#[derive(Deserialize, Serialize, Validate, sqlx::FromRow)] -pub struct Model { - id: i32, - #[validate(length(min = 2, message = "Can not be empty"))] - name: String, - description: Option, - duration: f64, - height: f64, - weight: f64, - printer: Option, - material: Option, - author_id: i32, - created: NaiveDateTime, - updated: NaiveDateTime, -} - -/// Payload used for model creation -#[derive(Deserialize)] -pub struct ModelCreate { - pub name: String, - pub description: Option, - #[serde(deserialize_with = "number_from_string")] - pub duration: f64, - #[serde(deserialize_with = "number_from_string")] - pub height: f64, - #[serde(deserialize_with = "number_from_string")] - pub weight: f64, - pub printer: Option, - pub material: Option, -} - -/// Payload used for model searching -#[derive(Deserialize)] -pub struct ModelFilter { - /// Stands for "query" - pub q: String, -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct ModelUser { - pub id: i32, - name: String, - description: Option, - duration: f64, - height: f64, - weight: f64, - printer: Option, - material: Option, - author_id: i32, - created: NaiveDateTime, - updated: NaiveDateTime, - author: Option, - uploads: Option, - likes: Option, -} - -#[derive(Deserialize, Serialize, sqlx::FromRow)] -pub struct ModelUpload { - id: i32, - pub model_id: i32, - pub filepath: String, - created: NaiveDateTime, -} - -impl Model { - #[allow(clippy::too_many_arguments)] - pub fn new( - name: String, - description: Option, - duration: f64, - height: f64, - weight: f64, - printer: Option, - material: Option, - author_id: i32, - ) -> Self { - let now = Local::now().naive_utc(); - Self { - id: 0, - name, - description, - duration, - height, - weight, - printer, - material, - author_id, - created: now, - updated: now, - } - } - - /// Create a new model - pub async fn create(model: Model) -> Result { - let pool = unsafe { get_client() }; - - model - .validate() - .map_err(|error| AppError::BadRequest(error.to_string()))?; - - let rec: Model = sqlx::query_as( - r#" - INSERT INTO models (name, description, duration, height, weight, printer, material, author_id, created, updated) - VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING * - "#) - .bind(model.name) - .bind(model.description) - .bind(model.duration) - .bind(model.height) - .bind(model.weight) - .bind(model.printer) - .bind(model.material) - .bind(model.author_id) - .bind(model.created) - .bind(model.updated) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Edit a model - pub async fn edit(id: i32, model: Model) -> Result { - let pool = unsafe { get_client() }; - - model - .validate() - .map_err(|error| AppError::BadRequest(error.to_string()))?; - - let rec: Model = sqlx::query_as( - r#" - UPDATE models SET name = $1, description = $2, duration = $3, height = $4, weight = $5, printer = $6, material = $7, updated = $8 - WHERE id = $9 - RETURNING * - "#) - .bind(model.name) - .bind(model.description) - .bind(model.duration) - .bind(model.height) - .bind(model.weight) - .bind(model.printer) - .bind(model.material) - .bind(model.updated) - .bind(id) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Returns the model with id = `model_id` - pub async fn find_by_id(model_id: i32) -> Result { - let pool = unsafe { get_client() }; - - let rec: ModelUser = sqlx::query_as( - r#" - WITH model_uploads AS ( - SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads - FROM models - LEFT JOIN uploads ON uploads.model_id = models.id - GROUP BY models.id - ), - model_likes AS ( - SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes - FROM models - LEFT JOIN likes ON likes.model_id = models.id - GROUP BY models.id - ), - model_author AS ( - SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author - FROM models - JOIN users ON users.id = models.author_id - ) - SELECT models.*, author, uploads, likes - FROM models - INNER JOIN model_author using (id) - INNER JOIN model_uploads using (id) - INNER JOIN model_likes using (id) - WHERE models.id = $1 - "#) - .bind(model_id) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// List all models - pub async fn list(page: i64) -> Result, AppError> { - let pool = unsafe { get_client() }; - let rows: Vec = sqlx::query_as( - r#" - WITH model_uploads AS ( - SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads - FROM models - LEFT JOIN uploads ON uploads.model_id = models.id - GROUP BY models.id - ), - model_likes AS ( - SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes - FROM models - LEFT JOIN likes ON likes.model_id = models.id - GROUP BY models.id - ), - model_author AS ( - SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author - FROM models - JOIN users ON users.id = models.author_id - ) - SELECT models.*, author, uploads, likes - FROM models - INNER JOIN model_author using (id) - INNER JOIN model_uploads using (id) - INNER JOIN model_likes using (id) - ORDER BY id DESC - LIMIT $1 OFFSET $2 - "#) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await?; - - Ok(rows) - } - - /// Filter models by some cols - pub async fn filter(page: i64, query: String) -> Result, AppError> { - let pool = unsafe { get_client() }; - let rows: Vec = sqlx::query_as( - r#" - WITH model_uploads AS ( - SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads - FROM models - LEFT JOIN uploads ON uploads.model_id = models.id - GROUP BY models.id - ), - model_likes AS ( - SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes - FROM models - LEFT JOIN likes ON likes.model_id = models.id - GROUP BY models.id - ), - model_author AS ( - SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author - FROM models - JOIN users ON users.id = models.author_id - ) - SELECT models.*, author, uploads, likes - FROM models - INNER JOIN model_author using (id) - INNER JOIN model_uploads using (id) - INNER JOIN model_likes using (id) - WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1 - ORDER BY id DESC - LIMIT $2 OFFSET $3 - "#) - .bind(format!("%{}%", query)) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await?; - - Ok(rows) - } - - /// List author's models - pub async fn list_from_author(page: i64, author: i32) -> Result, AppError> { - let pool = unsafe { get_client() }; - let rows: Vec = sqlx::query_as( - r#" - WITH model_uploads AS ( - SELECT models.id, json_agg(uploads.*) filter(WHERE uploads.* IS NOT NULL) AS uploads - FROM models - LEFT JOIN uploads ON uploads.model_id = models.id - GROUP BY models.id - ), - model_likes AS ( - SELECT models.id, json_agg(likes.*) filter(WHERE likes.* IS NOT NULL) AS likes - FROM models - LEFT JOIN likes ON likes.model_id = models.id - GROUP BY models.id - ), - model_author AS ( - SELECT models.id, json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as author - FROM models - JOIN users ON users.id = models.author_id - ) - SELECT models.*, author, uploads, likes - FROM models - INNER JOIN model_author using (id) - INNER JOIN model_uploads using (id) - INNER JOIN model_likes using (id) - WHERE models.author_id = $1 - ORDER BY id DESC - LIMIT $2 OFFSET $3 - "#) - .bind(author) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await?; - - Ok(rows) - } - - /// Delete a model - pub async fn delete(model_id: i32) -> Result<(), AppError> { - let pool = unsafe { get_client() }; - - sqlx::query( - r#" - DELETE FROM models WHERE id = $1 - "#, - ) - .bind(model_id) - .execute(pool) - .await?; - - Ok(()) - } - - /// Return the number of models. - pub async fn count() -> Result { - let pool = unsafe { get_client() }; - let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models"#) - .fetch_one(pool) - .await?; - - let count: i64 = cursor.try_get(0).unwrap(); - Ok(count) - } - - /// Return the number of author models - pub async fn count_filter_by_author(author: i32) -> Result { - let pool = unsafe { get_client() }; - let cursor = sqlx::query(r#"SELECT COUNT(id) as count FROM models WHERE author_id = $1"#) - .bind(author) - .fetch_one(pool) - .await?; - - let count: i64 = cursor.try_get(0).unwrap(); - Ok(count) - } - - /// Return the number of models filtered by query - pub async fn count_filter(query: String) -> Result { - let pool = unsafe { get_client() }; - let cursor = sqlx::query( - r#" - SELECT COUNT(id) as count FROM models - WHERE models.name ILIKE $1 OR description ILIKE $1 OR printer ILIKE $1 OR material ILIKE $1 - "# - ) - .bind(format!("%{}%", query)) - .fetch_one(pool) - .await?; - - let count: i64 = cursor.try_get(0).unwrap(); - Ok(count) - } -} - -impl ModelUser { - /// Returns the author id from the `JsonValue` - pub fn author_id(&self) -> JsonValue { - match &self.author { - Some(json) => json.get("id").unwrap().clone(), - None => json!(0), - } - } - - /// Returns a vec of string made by all the filepaths from the model - pub async fn list_upload_filepaths(&self) -> Option> { - // Raise a `None` if `self.uploads` is `None` - self.uploads.as_ref()?; - - let uploads = ModelUpload::find_by_model(self.id) - .await - .unwrap_or_default(); - - let paths = uploads - .iter() - .map(|x| x.filepath.clone()) - .collect::>(); - - Some(paths) - } -} - -impl ModelUpload { - pub fn new(filepath: String, model_id: i32) -> Self { - let now = Local::now().naive_utc(); - Self { - id: 0, - filepath, - model_id, - created: now, - } - } - - /// Create a new upload for model - pub async fn create(file: ModelUpload) -> Result { - let pool = unsafe { get_client() }; - - let rec: ModelUpload = sqlx::query_as( - r#" - INSERT INTO uploads (filepath, model_id, created) - VALUES ( $1, $2, $3) - RETURNING * - "#, - ) - .bind(file.filepath) - .bind(file.model_id) - .bind(file.created) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Find all paths of a model - pub async fn find_by_model(model_id: i32) -> Result, AppError> { - let pool = unsafe { get_client() }; - - let rec: Vec = sqlx::query_as( - r#" - SELECT * FROM uploads WHERE model_id = $1 - "#, - ) - .bind(model_id) - .fetch_all(pool) - .await?; - - Ok(rec) - } - - /// Returns the model upload with id = `upload_id` - pub async fn find_by_id(id: i32) -> Result { - let pool = unsafe { get_client() }; - - let rec: ModelUpload = sqlx::query_as( - r#" - SELECT * FROM uploads WHERE id = $1 - "#, - ) - .bind(id) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Delete a model upload - pub async fn delete(upload_id: i32) -> Result<(), AppError> { - let pool = unsafe { get_client() }; - - sqlx::query( - r#" - DELETE FROM uploads WHERE id = $1 - "#, - ) - .bind(upload_id) - .execute(pool) - .await?; - - Ok(()) - } -} diff --git a/src/models/user.rs b/src/models/user.rs deleted file mode 100644 index d09394b..0000000 --- a/src/models/user.rs +++ /dev/null @@ -1,250 +0,0 @@ -use crate::{ - config::CONFIG, - db::get_client, - errors::AppError, - models::model::{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, - avatar: Option, -} - -/// Paylod used for user editing -#[derive(Deserialize)] -pub struct UserEdit { - pub name: String, - pub email: String, - pub username: String, - pub is_staff: Option, -} - -/// 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, - #[serde_as(as = "NoneAsEmptyString")] - pub avatar: Option, -} - -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 { - 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 { - 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 { - 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, AppError> { - let pool = unsafe { get_client() }; - let rows: Vec = 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 { - 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 { - 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 { - 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) -> 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, AppError> { - Model::list_from_author(page, self.id).await - } - - /// Returns the number of models for an user - pub async fn count_models(&self) -> Result { - Model::count_filter_by_author(self.id).await - } -} diff --git a/src/models/warning.rs b/src/models/warning.rs deleted file mode 100644 index c420dd0..0000000 --- a/src/models/warning.rs +++ /dev/null @@ -1,382 +0,0 @@ -use crate::{config::CONFIG, db::get_client, errors::AppError}; -use chrono::{Local, NaiveDateTime}; -use serde::{Deserialize, Serialize}; -use sqlx::types::JsonValue; -use sqlx::Row; -use std::convert::From; - -/// Model for warnings. -#[derive(Deserialize, Serialize, sqlx::FromRow)] -pub struct Warning { - pub id: i32, - pub user_id: Option, - pub model_id: Option, - pub resolved_by: Option, - pub note: String, - pub admin_note: String, - pub created: NaiveDateTime, - pub updated: NaiveDateTime, -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct WarningUser { - pub id: i32, - pub user_id: Option, - pub model_id: Option, - pub resolved_by: Option, - pub note: String, - pub admin_note: String, - pub created: NaiveDateTime, - pub updated: NaiveDateTime, - user: Option, - resolved: Option, -} - -/// Impl conversion from `WarningUser` to `Warning` -impl From for Warning { - fn from(item: WarningUser) -> Self { - Self { - id: item.id, - user_id: item.user_id, - model_id: item.model_id, - resolved_by: item.resolved_by, - note: item.note, - admin_note: item.admin_note, - created: item.created, - updated: item.created, - } - } -} - -/// Payload used to create a new warning -#[derive(Deserialize)] -pub struct WarningCreate { - pub model_id: i32, - pub note: String, -} - -/// Payload used to edit a warning -#[derive(Deserialize)] -pub struct WarningEdit { - pub admin_note: String, -} - -/// Payload used for warning filtering -#[derive(Deserialize)] -pub struct WarningFilterPayload { - pub model_id: Option, - pub resolved_by: Option, -} - -/// Struct used as argument for filtering by the backend -#[derive(Debug)] -pub struct WarningFilter { - pub model_id: Option, - pub resolved_by: Option, - pub user_id: Option, -} - -impl Warning { - /// Create a warning means create an object which has an `user_id` (creator of the warning), a - /// `model_id` (suspect model) and a `note` - pub fn new(user_id: i32, model_id: i32, note: String) -> Self { - let now = Local::now().naive_utc(); - Self { - id: 0, - user_id: Some(user_id), - model_id: Some(model_id), - resolved_by: None, - note, - admin_note: String::new(), - created: now, - updated: now, - } - } - - /// Delete a report - pub async fn delete(warning_id: i32) -> Result<(), AppError> { - let pool = unsafe { get_client() }; - - sqlx::query( - r#" - DELETE FROM warnings WHERE id = $1 - "#, - ) - .bind(warning_id) - .execute(pool) - .await?; - - Ok(()) - } - - /// List all warnings. A staffer can see all the warnings, a user cannot - pub async fn list(page: i64, user_id: Option) -> Result, AppError> { - let pool = unsafe { get_client() }; - let query = r#" - SELECT - warnings.*, - json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, - coalesce(r.data, '{}'::json) as resolved - FROM warnings - JOIN users ON users.id = warnings.user_id - LEFT JOIN ( - SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data - FROM users r - ) r ON r.id = warnings.resolved_by - "#; - - let rows: Vec = match user_id { - Some(id) => { - sqlx::query_as(&format!( - r#"{} WHERE user_id = $1 ORDER BY id DESC LIMIT $2 OFFSET $3"#, - query - )) - .bind(id) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await? - } - None => { - sqlx::query_as(&format!(r#"{} ORDER BY id DESC LIMIT $1 OFFSET $2"#, query)) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await? - } - }; - - Ok(rows) - } - - /// Returns the warning with id = `warning_id` - pub async fn find_by_id(warning_id: i32) -> Result { - let pool = unsafe { get_client() }; - - let rec: WarningUser = sqlx::query_as( - r#" - SELECT - warnings.*, - json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, - coalesce(r.data, '{}'::json) as resolved - FROM warnings - JOIN users ON users.id = warnings.user_id - LEFT JOIN ( - SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data - FROM users r - ) r ON r.id = warnings.resolved_by - WHERE warnings.id = $1 - "#) - .bind(warning_id) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Return the number of warnings. - pub async fn count(user_id: Option) -> Result { - let pool = unsafe { get_client() }; - - let cursor = match user_id { - Some(id) => { - sqlx::query(r#"SELECT COUNT(id) as count FROM warnings WHERE user_id = $1"#) - .bind(id) - .fetch_one(pool) - .await? - } - None => { - sqlx::query(r#"SELECT COUNT(id) as count FROM warnings"#) - .fetch_one(pool) - .await? - } - }; - - let count: i64 = cursor.try_get(0).unwrap(); - Ok(count) - } - - /// Create a new upload for model - pub async fn create(warning: Warning) -> Result { - let pool = unsafe { get_client() }; - - let rec: Warning = sqlx::query_as( - r#" - INSERT INTO warnings (user_id, model_id, resolved_by, note, admin_note, created, updated) - VALUES ( $1, $2, $3, $4, $5, $6, $7) - RETURNING * - "#, - ) - .bind(warning.user_id) - .bind(warning.model_id) - .bind(warning.resolved_by) - .bind(warning.note) - .bind(warning.admin_note) - .bind(warning.created) - .bind(warning.updated) - .fetch_one(pool) - .await?; - - Ok(rec) - } - - /// Filter warnings. Pass a `WarningFilter` argument. You can filter only by model_id or (not - /// both) resolved by - pub async fn filter(page: i64, args: WarningFilter) -> Result, AppError> { - let pool = unsafe { get_client() }; - - let mut query = r#" - SELECT - warnings.*, - json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, - coalesce(r.data, '{}'::json) as resolved - FROM warnings - JOIN users ON users.id = warnings.user_id - LEFT JOIN ( - SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data - FROM users r - ) r ON r.id = warnings.resolved_by - "#.to_string(); - - if args.model_id.is_some() { - query += r#"WHERE model_id = $1"#; - } else { - match args.resolved_by { - Some(_) => { - query += r#" WHERE warnings.resolved_by = $1"#; - } - None => { - query += r#" WHERE warnings.resolved_by IS NULL"#; - } - }; - } - - let rows: Vec = match args.user_id { - Some(id) => { - let q = if args.model_id.is_some() { - query = format!( - r#"{} AND user_id = $2 ORDER BY id DESC LIMIT $3 OFFSET $4"#, - query - ); - sqlx::query_as(&query).bind(args.model_id.unwrap()) - } else if args.resolved_by.is_some() { - query = format!( - r#"{} AND user_id = $2 ORDER BY id DESC LIMIT $3 OFFSET $4"#, - query - ); - sqlx::query_as(&query).bind(args.resolved_by.unwrap()) - } else { - query = format!( - r#"{} AND user_id = $1 ORDER BY id DESC LIMIT $2 OFFSET $3"#, - query - ); - sqlx::query_as(&query) - }; - - q.bind(id) - .bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await? - } - None => { - let q = if args.model_id.is_some() { - query = format!(r#"{} ORDER BY id DESC LIMIT $2 OFFSET $3"#, query); - sqlx::query_as(&query).bind(args.model_id.unwrap()) - } else if args.resolved_by.is_some() { - query = format!(r#"{} ORDER BY id DESC LIMIT $2 OFFSET $3"#, query); - sqlx::query_as(&query).bind(args.resolved_by.unwrap()) - } else { - query = format!(r#"{} ORDER BY id DESC LIMIT $1 OFFSET $2"#, query); - sqlx::query_as(&query) - }; - - q.bind(CONFIG.page_limit) - .bind(CONFIG.page_limit * page) - .fetch_all(pool) - .await? - } - }; - - Ok(rows) - } - - /// Return the number of filtered warnings. - pub async fn count_by_model_id(args: WarningFilter) -> Result { - let pool = unsafe { get_client() }; - - let mut query = r#" - SELECT COUNT(id) as count FROM warnings - "# - .to_string(); - - if args.model_id.is_some() { - query += r#" WHERE model_id = $1"#; - } else { - match args.resolved_by { - Some(_) => { - query += r#" WHERE warnings.resolved_by = $1"#; - } - None => { - query += r#" WHERE warnings.resolved_by IS NULL"#; - } - }; - } - - let cursor = match args.user_id { - Some(id) => { - let q = if args.model_id.is_some() { - query = format!(r#"{} AND user_id = $2"#, query); - sqlx::query(&query).bind(args.model_id.unwrap()) - } else if args.resolved_by.is_some() { - query = format!(r#"{} AND user_id = $2"#, query); - sqlx::query(&query).bind(args.resolved_by.unwrap()) - } else { - query = format!(r#"{} AND user_id = $1"#, query); - sqlx::query(&query) - }; - - q.bind(id).fetch_one(pool).await? - } - None => { - let q = if args.model_id.is_some() { - sqlx::query(&query).bind(args.model_id.unwrap()) - } else if args.resolved_by.is_some() { - sqlx::query(&query).bind(args.resolved_by.unwrap()) - } else { - sqlx::query(&query) - }; - - q.fetch_one(pool).await? - } - }; - - let count: i64 = cursor.try_get(0).unwrap(); - Ok(count) - } - - /// Edit a warning - pub async fn edit(&mut self, resolver: i32, payload: WarningEdit) -> Result<(), AppError> { - let pool = unsafe { get_client() }; - - let now = Local::now().naive_utc(); - - sqlx::query( - r#" - UPDATE warnings SET admin_note = $1, resolved_by = $2, updated = $3 WHERE id = $4 - "#, - ) - .bind(&payload.admin_note) - .bind(resolver) - .bind(now) - .bind(self.id) - .execute(pool) - .await?; - - self.admin_note = payload.admin_note; - self.resolved_by = Some(resolver); - self.updated = now; - - Ok(()) - } -} diff --git a/src/pagination.rs b/src/pagination.rs index 2525f06..8e61114 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -1,4 +1,6 @@ -use crate::models::{model::ModelUser, user::UserList, warning::WarningUser}; +use crate::model::models::ModelUser; +use crate::user::models::UserList; +use crate::warning::models::WarningUser; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..a269bc4 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,23 @@ +use crate::errors::AppError; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; + +pub async fn page_404() -> impl IntoResponse { + AppError::NotFound("Route not found".to_string()) +} + +/// Extension of `Json` which returns the CREATED status code +pub struct JsonCreate(pub T); + +impl IntoResponse for JsonCreate +where + T: Serialize, +{ + fn into_response(self) -> Response { + (StatusCode::CREATED, Json(self.0)).into_response() + } +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs deleted file mode 100644 index 0c459f5..0000000 --- a/src/routes/auth.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::{ - errors::AppError, - models::{ - auth::{AuthBody, Claims, LoginCredentials, SignUpForm}, - user::User, - }, - routes::JsonCreate, -}; -use axum::{routing::post, Json, Router}; - -/// Create routes for `/v1/auth/` namespace -pub fn create_route() -> Router { - Router::new() - .route("/login", post(make_login)) - .route("/signup", post(signup)) -} - -/// Make login. Check if a user with the email and password passed in request body exists into the -/// database -async fn make_login(Json(payload): Json) -> Result, AppError> { - let user = User::new( - String::new(), - String::new(), - payload.username, - payload.password, - ); - match User::find(user).await { - Ok(user) => { - let claims = Claims::new(user.id); - let token = claims.get_token()?; - Ok(Json(AuthBody::new(token))) - } - Err(_) => Err(AppError::NotFound("User not found".to_string())), - } -} - -/// Create a new user -async fn signup(Json(payload): Json) -> Result, AppError> { - if payload.password1 != payload.password2 { - return Err(AppError::BadRequest( - "The inserted passwords do not match".to_string(), - )); - } - - if User::email_has_taken(&payload.email).await? { - return Err(AppError::BadRequest( - "An user with this email already exists".to_string(), - )); - } - - if User::username_has_taken(&payload.username).await? { - return Err(AppError::BadRequest( - "An user with this username already exists".to_string(), - )); - } - - let user = User::new( - payload.name, - payload.email, - payload.username, - payload.password1, - ); - let user = User::create(user).await?; - - let claims = Claims::new(user.id); - let token = claims.get_token()?; - Ok(JsonCreate(AuthBody::new(token))) -} diff --git a/src/routes/mod.rs b/src/routes/mod.rs deleted file mode 100644 index a0e8031..0000000 --- a/src/routes/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -pub mod auth; -pub mod model; -pub mod user; -pub mod warning; - -use crate::errors::AppError; -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::Serialize; - -pub async fn page_404() -> impl IntoResponse { - AppError::NotFound("Route not found".to_string()) -} - -/// Extension of `Json` which returns the CREATED status code -pub struct JsonCreate(pub T); - -impl IntoResponse for JsonCreate -where - T: Serialize, -{ - fn into_response(self) -> Response { - (StatusCode::CREATED, Json(self.0)).into_response() - } -} diff --git a/src/routes/model.rs b/src/routes/model.rs deleted file mode 100644 index 58ec732..0000000 --- a/src/routes/model.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crate::{ - errors::AppError, - files::{delete_upload, upload}, - models::{ - auth::Claims, - likes::Like, - model::{Model, ModelCreate, ModelFilter, ModelUpload, ModelUser}, - user::User, - }, - pagination::{ModelPagination, Pagination}, - routes::JsonCreate, -}; -use axum::{ - extract::{ContentLengthLimit, Multipart, Path, Query}, - http::StatusCode, - routing::{delete, get, post}, - Json, Router, -}; - -/// Create routes for `/v1/models/` namespace -pub fn create_route() -> Router { - Router::new() - .route("/", get(list_models).post(create_model)) - .route("/filter", post(filter_models)) - .route("/:id", get(get_model).delete(delete_model).put(edit_model)) - .route("/:id/like", post(add_like).delete(delete_like)) - .route("/:id/upload", post(upload_model_file)) - .route("/:id/upload/:uid", delete(delete_model_file)) -} - -/// List models. -async fn list_models(pagination: Query) -> Result, AppError> { - let page = pagination.0.page.unwrap_or_default(); - let results = Model::list(page).await?; - let count = Model::count().await?; - - Ok(Json(ModelPagination { count, results })) -} - -/// Create a model. Checks Authorization token -async fn create_model( - Json(payload): Json, - claims: Claims, -) -> Result, AppError> { - let model = Model::new( - payload.name, - payload.description, - payload.duration, - payload.height, - payload.weight, - payload.printer, - payload.material, - claims.user_id, - ); - - let model_new = Model::create(model).await?; - - Ok(JsonCreate(model_new)) -} - -/// Get a model with id = `model_id` -async fn get_model(Path(model_id): Path) -> Result, AppError> { - match Model::find_by_id(model_id).await { - Ok(model) => Ok(Json(model)), - Err(_) => Err(AppError::NotFound("Model not found".to_string())), - } -} - -/// The owner or a staffer can delete a model -async fn delete_model(claims: Claims, Path(model_id): Path) -> Result { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - let uploads: Vec = model.list_upload_filepaths().await.unwrap_or_default(); - - if !(model.author_id() == user.id || user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - // If the model has been deleted, remove all old uploads from the file system - if Model::delete(model_id).await.is_ok() { - uploads - .iter() - .for_each(|path: &String| delete_upload(path).unwrap_or_default()); - } - - Ok(StatusCode::NO_CONTENT) -} - -/// The owner or a staffer can edit a model -async fn edit_model( - Json(payload): Json, - claims: Claims, - Path(model_id): Path, -) -> Result, AppError> { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - if !(model.author_id() == user.id || user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - let model_body = Model::new( - payload.name, - payload.description, - payload.duration, - payload.height, - payload.weight, - payload.printer, - payload.material, - claims.user_id, - ); - - // NOTE: can we edit this as same as `user.edit_avatar()`? - Model::edit(model.id, model_body).await?; - Ok(Json(model)) -} - -/// Upload a file for a model -async fn upload_model_file( - claims: Claims, - Path(model_id): Path, - ContentLengthLimit(multipart): ContentLengthLimit, -) -> Result, AppError> { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - if !(model.author_id() == user.id || user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - let allowed_extensions = vec![ - "stl", - "obj", - "png", - "jpg", - "jpeg", - "gif", - "webp", - "blend", - "octet-stream", - "sla", - ]; - - match upload(multipart, allowed_extensions, None).await { - Ok(saved_file) => { - let model_file = ModelUpload::create(ModelUpload::new(saved_file, model_id)).await?; - - Ok(Json(model_file)) - } - Err(e) => Err(e), - } -} - -/// The owner or a staffer can delete a model upload -async fn delete_model_file( - claims: Claims, - Path((model_id, upload_id)): Path<(i32, i32)>, -) -> Result { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - if !(model.author_id() == user.id || user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - let upload = match ModelUpload::find_by_id(upload_id).await { - Ok(upload) => upload, - Err(_) => { - return Err(AppError::NotFound("Upload not found".to_string())); - } - }; - - if upload.model_id != model.id { - return Err(AppError::NotFound("Upload not found".to_string())); - } - - let filepath = upload.filepath.clone(); - - match ModelUpload::delete(upload_id).await { - Ok(_) => { - delete_upload(&filepath)?; - - Ok(StatusCode::NO_CONTENT) - } - Err(e) => Err(e), - } -} - -/// Assign a like to a model from the Authorization user -async fn add_like(claims: Claims, Path(model_id): Path) -> Result { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - let like = Like::new(user.id, model.id); - - match like.save().await { - Ok(_) => Ok(StatusCode::CREATED), - Err(e) => { - return Err(e); - } - } -} - -/// Remove a like from a model and an Authorization user -async fn delete_like(claims: Claims, Path(model_id): Path) -> Result { - let model = match Model::find_by_id(model_id).await { - Ok(model) => model, - Err(_) => { - return Err(AppError::NotFound("Model not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - let like = Like::new(user.id, model.id); - - match like.remove().await { - Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(e) => { - return Err(e); - } - } -} - -/// Filter models -async fn filter_models( - pagination: Query, - Json(payload): Json, -) -> Result, AppError> { - let page = pagination.0.page.unwrap_or_default(); - - let results = Model::filter(page, payload.q.clone()).await?; - let count = Model::count_filter(payload.q).await?; - - Ok(Json(ModelPagination { count, results })) -} diff --git a/src/routes/user.rs b/src/routes/user.rs deleted file mode 100644 index 31366a0..0000000 --- a/src/routes/user.rs +++ /dev/null @@ -1,210 +0,0 @@ -use crate::{ - errors::AppError, - files::{delete_upload, upload}, - models::{ - auth::Claims, - user::{User, UserEdit, UserList}, - }, - pagination::{ModelPagination, Pagination, UserPagination}, -}; -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, -) -> Result, 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, 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, -) -> Result, 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, - claims: Claims, -) -> Result, 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, 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) -> Result, 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, - Json(mut payload): Json, - claims: Claims, -) -> Result, 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, - pagination: Query, -) -> Result, 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 })) -} diff --git a/src/routes/warning.rs b/src/routes/warning.rs deleted file mode 100644 index 384218a..0000000 --- a/src/routes/warning.rs +++ /dev/null @@ -1,177 +0,0 @@ -use crate::{ - errors::AppError, - models::{ - auth::Claims, - model::Model, - user::User, - warning::{Warning, WarningCreate, WarningEdit, WarningFilter, WarningFilterPayload}, - }, - pagination::{Pagination, WarningPagination}, - routes::JsonCreate, -}; -use axum::{ - extract::{Path, Query}, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; - -/// Create routes for `/v1/warnings/` namespace -pub fn create_route() -> Router { - Router::new() - .route("/", get(list_warnings).post(create_warning)) - .route( - "/:id", - get(get_warning).put(edit_warning).delete(delete_warning), - ) - .route("/filter", post(filter_warnings)) -} - -/// List warnings. A staffer can see everything. -async fn list_warnings( - pagination: Query, - claims: Claims, -) -> Result, AppError> { - let page = pagination.0.page.unwrap_or_default(); - - let user = User::find_by_id(claims.user_id).await?; - - let (results, count) = match user.is_staff.unwrap() { - true => ( - Warning::list(page, None).await?, - Warning::count(None).await?, - ), - false => ( - Warning::list(page, Some(user.id)).await?, - Warning::count(Some(user.id)).await?, - ), - }; - - Ok(Json(WarningPagination { count, results })) -} - -/// Get a warning with id = `model_id` -async fn get_warning( - Path(warning_id): Path, - claims: Claims, -) -> Result, AppError> { - let user = User::find_by_id(claims.user_id).await?; - - if !(user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - match Warning::find_by_id(warning_id).await { - Ok(warning) => Ok(Json(warning.into())), - Err(_) => Err(AppError::NotFound("Warning not found".to_string())), - } -} - -/// Create a warning. Checks Authorization token -async fn create_warning( - Json(payload): Json, - claims: Claims, -) -> Result, AppError> { - let model = match Model::find_by_id(payload.model_id).await { - Ok(model) => model, - Err(_) => return Err(AppError::NotFound("Report not found".to_string())), - }; - - let warning = Warning::new(claims.user_id, model.id, payload.note); - - let warning_new = Warning::create(warning).await?; - - Ok(JsonCreate(warning_new)) -} - -/// Staffers can edit a warning -async fn edit_warning( - Json(payload): Json, - claims: Claims, - Path(warning_id): Path, -) -> Result, AppError> { - let mut warning: Warning = match Warning::find_by_id(warning_id).await { - Ok(warning) => warning.into(), - Err(_) => { - return Err(AppError::NotFound("Report not found".to_string())); - } - }; - - let user = User::find_by_id(claims.user_id).await?; - - if !(user.is_staff.unwrap()) { - return Err(AppError::Unauthorized); - } - - warning.edit(user.id, payload).await?; - - Ok(Json(warning)) -} - -/// A staffer can delete a warning -async fn delete_warning( - claims: Claims, - Path(warning_id): Path, -) -> Result { - let user = User::find_by_id(claims.user_id).await?; - - if !user.is_staff.unwrap() { - return Err(AppError::Unauthorized); - } - - if Warning::delete(warning_id).await.is_ok() { - Ok(StatusCode::NO_CONTENT) - } else { - Ok(StatusCode::BAD_REQUEST) - } -} - -/// Apply a filter to warnings list -async fn filter_warnings( - Json(payload): Json, - pagination: Query, - claims: Claims, -) -> Result, AppError> { - let page = pagination.0.page.unwrap_or_default(); - - let user = User::find_by_id(claims.user_id).await?; - - let (results, count) = match user.is_staff.unwrap() { - true => ( - Warning::filter( - page, - WarningFilter { - model_id: payload.model_id, - resolved_by: payload.resolved_by, - user_id: None, - }, - ) - .await?, - Warning::count_by_model_id(WarningFilter { - model_id: payload.model_id, - resolved_by: payload.resolved_by, - user_id: None, - }) - .await?, - ), - false => ( - Warning::filter( - page, - WarningFilter { - model_id: payload.model_id, - resolved_by: payload.resolved_by, - user_id: Some(user.id), - }, - ) - .await?, - Warning::count_by_model_id(WarningFilter { - model_id: payload.model_id, - resolved_by: payload.resolved_by, - user_id: Some(user.id), - }) - .await?, - ), - }; - - Ok(Json(WarningPagination { count, results })) -} 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, + avatar: Option, +} + +/// Paylod used for user editing +#[derive(Deserialize)] +pub struct UserEdit { + pub name: String, + pub email: String, + pub username: String, + pub is_staff: Option, +} + +/// 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, + #[serde_as(as = "NoneAsEmptyString")] + pub avatar: Option, +} + +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 { + 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 { + 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 { + 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, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec = 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 { + 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 { + 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 { + 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) -> 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, AppError> { + Model::list_from_author(page, self.id).await + } + + /// Returns the number of models for an user + pub async fn count_models(&self) -> Result { + 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, +) -> Result, 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, 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, +) -> Result, 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, + claims: Claims, +) -> Result, 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, 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) -> Result, 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, + Json(mut payload): Json, + claims: Claims, +) -> Result, 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, + pagination: Query, +) -> Result, 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 })) +} diff --git a/src/warning/mod.rs b/src/warning/mod.rs new file mode 100644 index 0000000..a0e1883 --- /dev/null +++ b/src/warning/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod routes; diff --git a/src/warning/models.rs b/src/warning/models.rs new file mode 100644 index 0000000..c420dd0 --- /dev/null +++ b/src/warning/models.rs @@ -0,0 +1,382 @@ +use crate::{config::CONFIG, db::get_client, errors::AppError}; +use chrono::{Local, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use sqlx::types::JsonValue; +use sqlx::Row; +use std::convert::From; + +/// Model for warnings. +#[derive(Deserialize, Serialize, sqlx::FromRow)] +pub struct Warning { + pub id: i32, + pub user_id: Option, + pub model_id: Option, + pub resolved_by: Option, + pub note: String, + pub admin_note: String, + pub created: NaiveDateTime, + pub updated: NaiveDateTime, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct WarningUser { + pub id: i32, + pub user_id: Option, + pub model_id: Option, + pub resolved_by: Option, + pub note: String, + pub admin_note: String, + pub created: NaiveDateTime, + pub updated: NaiveDateTime, + user: Option, + resolved: Option, +} + +/// Impl conversion from `WarningUser` to `Warning` +impl From for Warning { + fn from(item: WarningUser) -> Self { + Self { + id: item.id, + user_id: item.user_id, + model_id: item.model_id, + resolved_by: item.resolved_by, + note: item.note, + admin_note: item.admin_note, + created: item.created, + updated: item.created, + } + } +} + +/// Payload used to create a new warning +#[derive(Deserialize)] +pub struct WarningCreate { + pub model_id: i32, + pub note: String, +} + +/// Payload used to edit a warning +#[derive(Deserialize)] +pub struct WarningEdit { + pub admin_note: String, +} + +/// Payload used for warning filtering +#[derive(Deserialize)] +pub struct WarningFilterPayload { + pub model_id: Option, + pub resolved_by: Option, +} + +/// Struct used as argument for filtering by the backend +#[derive(Debug)] +pub struct WarningFilter { + pub model_id: Option, + pub resolved_by: Option, + pub user_id: Option, +} + +impl Warning { + /// Create a warning means create an object which has an `user_id` (creator of the warning), a + /// `model_id` (suspect model) and a `note` + pub fn new(user_id: i32, model_id: i32, note: String) -> Self { + let now = Local::now().naive_utc(); + Self { + id: 0, + user_id: Some(user_id), + model_id: Some(model_id), + resolved_by: None, + note, + admin_note: String::new(), + created: now, + updated: now, + } + } + + /// Delete a report + pub async fn delete(warning_id: i32) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + sqlx::query( + r#" + DELETE FROM warnings WHERE id = $1 + "#, + ) + .bind(warning_id) + .execute(pool) + .await?; + + Ok(()) + } + + /// List all warnings. A staffer can see all the warnings, a user cannot + pub async fn list(page: i64, user_id: Option) -> Result, AppError> { + let pool = unsafe { get_client() }; + let query = r#" + SELECT + warnings.*, + json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, + coalesce(r.data, '{}'::json) as resolved + FROM warnings + JOIN users ON users.id = warnings.user_id + LEFT JOIN ( + SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data + FROM users r + ) r ON r.id = warnings.resolved_by + "#; + + let rows: Vec = match user_id { + Some(id) => { + sqlx::query_as(&format!( + r#"{} WHERE user_id = $1 ORDER BY id DESC LIMIT $2 OFFSET $3"#, + query + )) + .bind(id) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await? + } + None => { + sqlx::query_as(&format!(r#"{} ORDER BY id DESC LIMIT $1 OFFSET $2"#, query)) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await? + } + }; + + Ok(rows) + } + + /// Returns the warning with id = `warning_id` + pub async fn find_by_id(warning_id: i32) -> Result { + let pool = unsafe { get_client() }; + + let rec: WarningUser = sqlx::query_as( + r#" + SELECT + warnings.*, + json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, + coalesce(r.data, '{}'::json) as resolved + FROM warnings + JOIN users ON users.id = warnings.user_id + LEFT JOIN ( + SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data + FROM users r + ) r ON r.id = warnings.resolved_by + WHERE warnings.id = $1 + "#) + .bind(warning_id) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Return the number of warnings. + pub async fn count(user_id: Option) -> Result { + let pool = unsafe { get_client() }; + + let cursor = match user_id { + Some(id) => { + sqlx::query(r#"SELECT COUNT(id) as count FROM warnings WHERE user_id = $1"#) + .bind(id) + .fetch_one(pool) + .await? + } + None => { + sqlx::query(r#"SELECT COUNT(id) as count FROM warnings"#) + .fetch_one(pool) + .await? + } + }; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } + + /// Create a new upload for model + pub async fn create(warning: Warning) -> Result { + let pool = unsafe { get_client() }; + + let rec: Warning = sqlx::query_as( + r#" + INSERT INTO warnings (user_id, model_id, resolved_by, note, admin_note, created, updated) + VALUES ( $1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(warning.user_id) + .bind(warning.model_id) + .bind(warning.resolved_by) + .bind(warning.note) + .bind(warning.admin_note) + .bind(warning.created) + .bind(warning.updated) + .fetch_one(pool) + .await?; + + Ok(rec) + } + + /// Filter warnings. Pass a `WarningFilter` argument. You can filter only by model_id or (not + /// both) resolved by + pub async fn filter(page: i64, args: WarningFilter) -> Result, AppError> { + let pool = unsafe { get_client() }; + + let mut query = r#" + SELECT + warnings.*, + json_build_object('id', users.id, 'name', users.name, 'email', users.email, 'username', users.username, 'is_staff', users.is_staff, 'avatar', users.avatar) as user, + coalesce(r.data, '{}'::json) as resolved + FROM warnings + JOIN users ON users.id = warnings.user_id + LEFT JOIN ( + SELECT id, json_build_object('id', r.id, 'name', r.name, 'email', r.email, 'username', r.username, 'is_staff', r.is_staff, 'avatar', r.avatar) as data + FROM users r + ) r ON r.id = warnings.resolved_by + "#.to_string(); + + if args.model_id.is_some() { + query += r#"WHERE model_id = $1"#; + } else { + match args.resolved_by { + Some(_) => { + query += r#" WHERE warnings.resolved_by = $1"#; + } + None => { + query += r#" WHERE warnings.resolved_by IS NULL"#; + } + }; + } + + let rows: Vec = match args.user_id { + Some(id) => { + let q = if args.model_id.is_some() { + query = format!( + r#"{} AND user_id = $2 ORDER BY id DESC LIMIT $3 OFFSET $4"#, + query + ); + sqlx::query_as(&query).bind(args.model_id.unwrap()) + } else if args.resolved_by.is_some() { + query = format!( + r#"{} AND user_id = $2 ORDER BY id DESC LIMIT $3 OFFSET $4"#, + query + ); + sqlx::query_as(&query).bind(args.resolved_by.unwrap()) + } else { + query = format!( + r#"{} AND user_id = $1 ORDER BY id DESC LIMIT $2 OFFSET $3"#, + query + ); + sqlx::query_as(&query) + }; + + q.bind(id) + .bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await? + } + None => { + let q = if args.model_id.is_some() { + query = format!(r#"{} ORDER BY id DESC LIMIT $2 OFFSET $3"#, query); + sqlx::query_as(&query).bind(args.model_id.unwrap()) + } else if args.resolved_by.is_some() { + query = format!(r#"{} ORDER BY id DESC LIMIT $2 OFFSET $3"#, query); + sqlx::query_as(&query).bind(args.resolved_by.unwrap()) + } else { + query = format!(r#"{} ORDER BY id DESC LIMIT $1 OFFSET $2"#, query); + sqlx::query_as(&query) + }; + + q.bind(CONFIG.page_limit) + .bind(CONFIG.page_limit * page) + .fetch_all(pool) + .await? + } + }; + + Ok(rows) + } + + /// Return the number of filtered warnings. + pub async fn count_by_model_id(args: WarningFilter) -> Result { + let pool = unsafe { get_client() }; + + let mut query = r#" + SELECT COUNT(id) as count FROM warnings + "# + .to_string(); + + if args.model_id.is_some() { + query += r#" WHERE model_id = $1"#; + } else { + match args.resolved_by { + Some(_) => { + query += r#" WHERE warnings.resolved_by = $1"#; + } + None => { + query += r#" WHERE warnings.resolved_by IS NULL"#; + } + }; + } + + let cursor = match args.user_id { + Some(id) => { + let q = if args.model_id.is_some() { + query = format!(r#"{} AND user_id = $2"#, query); + sqlx::query(&query).bind(args.model_id.unwrap()) + } else if args.resolved_by.is_some() { + query = format!(r#"{} AND user_id = $2"#, query); + sqlx::query(&query).bind(args.resolved_by.unwrap()) + } else { + query = format!(r#"{} AND user_id = $1"#, query); + sqlx::query(&query) + }; + + q.bind(id).fetch_one(pool).await? + } + None => { + let q = if args.model_id.is_some() { + sqlx::query(&query).bind(args.model_id.unwrap()) + } else if args.resolved_by.is_some() { + sqlx::query(&query).bind(args.resolved_by.unwrap()) + } else { + sqlx::query(&query) + }; + + q.fetch_one(pool).await? + } + }; + + let count: i64 = cursor.try_get(0).unwrap(); + Ok(count) + } + + /// Edit a warning + pub async fn edit(&mut self, resolver: i32, payload: WarningEdit) -> Result<(), AppError> { + let pool = unsafe { get_client() }; + + let now = Local::now().naive_utc(); + + sqlx::query( + r#" + UPDATE warnings SET admin_note = $1, resolved_by = $2, updated = $3 WHERE id = $4 + "#, + ) + .bind(&payload.admin_note) + .bind(resolver) + .bind(now) + .bind(self.id) + .execute(pool) + .await?; + + self.admin_note = payload.admin_note; + self.resolved_by = Some(resolver); + self.updated = now; + + Ok(()) + } +} diff --git a/src/warning/routes.rs b/src/warning/routes.rs new file mode 100644 index 0000000..81173e6 --- /dev/null +++ b/src/warning/routes.rs @@ -0,0 +1,175 @@ +use crate::{ + auth::models::Claims, + errors::AppError, + model::models::Model, + pagination::{Pagination, WarningPagination}, + routes::JsonCreate, + user::models::User, + warning::models::*, +}; +use axum::{ + extract::{Path, Query}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; + +/// Create routes for `/v1/warnings/` namespace +pub fn create_route() -> Router { + Router::new() + .route("/", get(list_warnings).post(create_warning)) + .route( + "/:id", + get(get_warning).put(edit_warning).delete(delete_warning), + ) + .route("/filter", post(filter_warnings)) +} + +/// List warnings. A staffer can see everything. +async fn list_warnings( + pagination: Query, + claims: Claims, +) -> Result, AppError> { + let page = pagination.0.page.unwrap_or_default(); + + let user = User::find_by_id(claims.user_id).await?; + + let (results, count) = match user.is_staff.unwrap() { + true => ( + Warning::list(page, None).await?, + Warning::count(None).await?, + ), + false => ( + Warning::list(page, Some(user.id)).await?, + Warning::count(Some(user.id)).await?, + ), + }; + + Ok(Json(WarningPagination { count, results })) +} + +/// Get a warning with id = `model_id` +async fn get_warning( + Path(warning_id): Path, + claims: Claims, +) -> Result, AppError> { + let user = User::find_by_id(claims.user_id).await?; + + if !(user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + match Warning::find_by_id(warning_id).await { + Ok(warning) => Ok(Json(warning.into())), + Err(_) => Err(AppError::NotFound("Warning not found".to_string())), + } +} + +/// Create a warning. Checks Authorization token +async fn create_warning( + Json(payload): Json, + claims: Claims, +) -> Result, AppError> { + let model = match Model::find_by_id(payload.model_id).await { + Ok(model) => model, + Err(_) => return Err(AppError::NotFound("Report not found".to_string())), + }; + + let warning = Warning::new(claims.user_id, model.id, payload.note); + + let warning_new = Warning::create(warning).await?; + + Ok(JsonCreate(warning_new)) +} + +/// Staffers can edit a warning +async fn edit_warning( + Json(payload): Json, + claims: Claims, + Path(warning_id): Path, +) -> Result, AppError> { + let mut warning: Warning = match Warning::find_by_id(warning_id).await { + Ok(warning) => warning.into(), + Err(_) => { + return Err(AppError::NotFound("Report not found".to_string())); + } + }; + + let user = User::find_by_id(claims.user_id).await?; + + if !(user.is_staff.unwrap()) { + return Err(AppError::Unauthorized); + } + + warning.edit(user.id, payload).await?; + + Ok(Json(warning)) +} + +/// A staffer can delete a warning +async fn delete_warning( + claims: Claims, + Path(warning_id): Path, +) -> Result { + let user = User::find_by_id(claims.user_id).await?; + + if !user.is_staff.unwrap() { + return Err(AppError::Unauthorized); + } + + if Warning::delete(warning_id).await.is_ok() { + Ok(StatusCode::NO_CONTENT) + } else { + Ok(StatusCode::BAD_REQUEST) + } +} + +/// Apply a filter to warnings list +async fn filter_warnings( + Json(payload): Json, + pagination: Query, + claims: Claims, +) -> Result, AppError> { + let page = pagination.0.page.unwrap_or_default(); + + let user = User::find_by_id(claims.user_id).await?; + + let (results, count) = match user.is_staff.unwrap() { + true => ( + Warning::filter( + page, + WarningFilter { + model_id: payload.model_id, + resolved_by: payload.resolved_by, + user_id: None, + }, + ) + .await?, + Warning::count_by_model_id(WarningFilter { + model_id: payload.model_id, + resolved_by: payload.resolved_by, + user_id: None, + }) + .await?, + ), + false => ( + Warning::filter( + page, + WarningFilter { + model_id: payload.model_id, + resolved_by: payload.resolved_by, + user_id: Some(user.id), + }, + ) + .await?, + Warning::count_by_model_id(WarningFilter { + model_id: payload.model_id, + resolved_by: payload.resolved_by, + user_id: Some(user.id), + }) + .await?, + ), + }; + + Ok(Json(WarningPagination { count, results })) +} -- cgit v1.2.3-71-g8e6c