diff options
| author | Santo Cariotti <santo@dcariotti.me> | 2022-10-17 20:08:09 +0000 |
|---|---|---|
| committer | Santo Cariotti <santo@dcariotti.me> | 2022-10-17 20:08:09 +0000 |
| commit | 74976dab57887a4d7e29b426cdf7422722fa58ee (patch) | |
| tree | 5f1bbed3dbcf3ba520866cb4eb060aaf5a771915 /src/model | |
| parent | 611293122213f83e82d851cd8dc83fd1e4f79dcd (diff) | |
Refactoring of mods
Diffstat (limited to 'src/model')
| -rw-r--r-- | src/model/mod.rs | 2 | ||||
| -rw-r--r-- | src/model/models.rs | 479 | ||||
| -rw-r--r-- | src/model/routes.rs | 265 |
3 files changed, 746 insertions, 0 deletions
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<String>, + duration: f64, + height: f64, + weight: f64, + printer: Option<String>, + material: Option<String>, + author_id: i32, + created: NaiveDateTime, + updated: NaiveDateTime, +} + +/// Payload used for model creation +#[derive(Deserialize)] +pub struct ModelCreate { + pub name: String, + pub description: Option<String>, + #[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<String>, + pub material: Option<String>, +} + +/// 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<String>, + duration: f64, + height: f64, + weight: f64, + printer: Option<String>, + material: Option<String>, + author_id: i32, + created: NaiveDateTime, + updated: NaiveDateTime, + author: Option<JsonValue>, + uploads: Option<JsonValue>, + likes: Option<JsonValue>, +} + +#[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<String>, + duration: f64, + height: f64, + weight: f64, + printer: Option<String>, + material: Option<String>, + 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<Model, AppError> { + 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<Model, AppError> { + 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<ModelUser, AppError> { + 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<Vec<ModelUser>, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec<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) + 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<Vec<ModelUser>, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec<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.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<Vec<ModelUser>, AppError> { + let pool = unsafe { get_client() }; + let rows: Vec<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.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<i64, AppError> { + 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<i64, AppError> { + 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<i64, AppError> { + 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<Vec<String>> { + // 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::<Vec<String>>(); + + 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<ModelUpload, AppError> { + 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<Vec<ModelUpload>, AppError> { + let pool = unsafe { get_client() }; + + let rec: Vec<ModelUpload> = 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<ModelUpload, AppError> { + 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<Pagination>) -> Result<Json<ModelPagination>, 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<ModelCreate>, + claims: Claims, +) -> Result<JsonCreate<Model>, 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<i32>) -> Result<Json<ModelUser>, 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<i32>) -> Result<StatusCode, 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?; + + let uploads: Vec<String> = 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<ModelCreate>, + claims: Claims, + Path(model_id): Path<i32>, +) -> Result<Json<ModelUser>, 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<i32>, + ContentLengthLimit(multipart): ContentLengthLimit<Multipart, { 1024 * 1024 * 40 }>, +) -> Result<Json<ModelUpload>, 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<StatusCode, 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 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<i32>) -> Result<StatusCode, 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?; + + 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<i32>) -> Result<StatusCode, 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?; + + 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<Pagination>, + Json(payload): Json<ModelFilter>, +) -> Result<Json<ModelPagination>, 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 })) +} |
