use crate::auth::checks::{filter_authorized_collections, is_authorized_collection}; use crate::auth::get_user_from_headers; use crate::database::models::{collection_item, generate_collection_id, project_item}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::collections::{Collection, CollectionStatus}; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::ApiError; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { cfg.route("collections", web::get().to(collections_get)); cfg.route("collection", web::post().to(collection_create)); cfg.service( web::scope("collection") .route("{id}", web::get().to(collection_get)) .route("{id}", web::delete().to(collection_delete)) .route("{id}", web::patch().to(collection_edit)) .route("{id}/icon", web::patch().to(collection_icon_edit)) .route("{id}/icon", web::delete().to(delete_collection_icon)), ); } #[derive(Serialize, Deserialize, Validate, Clone)] pub struct CollectionCreateData { #[validate( length(min = 3, max = 64), custom(function = "crate::util::validate::validate_name") )] /// The title or name of the project. pub name: String, #[validate(length(min = 3, max = 255))] /// A short description of the collection. pub description: String, #[validate(length(max = 32))] #[serde(default = "Vec::new")] /// A list of initial projects to use with the created collection pub projects: Vec, } pub async fn collection_create( req: HttpRequest, collection_create_data: web::Json, client: Data, redis: Data, session_queue: Data, ) -> Result { let collection_create_data = collection_create_data.into_inner(); // The currently logged in user let current_user = get_user_from_headers( &req, &**client, &redis, &session_queue, Some(&[Scopes::COLLECTION_CREATE]), ) .await? .1; collection_create_data .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; let mut transaction = client.begin().await?; let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into(); let initial_project_ids = project_item::Project::get_many( &collection_create_data.projects, &mut *transaction, &redis, ) .await? .into_iter() .map(|x| x.inner.id.into()) .collect::>(); let collection_builder_actual = collection_item::CollectionBuilder { collection_id: collection_id.into(), user_id: current_user.id.into(), name: collection_create_data.name, description: collection_create_data.description, status: CollectionStatus::Listed, projects: initial_project_ids .iter() .copied() .map(|x| x.into()) .collect(), }; let collection_builder = collection_builder_actual.clone(); let now = Utc::now(); collection_builder_actual.insert(&mut transaction).await?; let response = crate::models::collections::Collection { id: collection_id, user: collection_builder.user_id.into(), name: collection_builder.name.clone(), description: collection_builder.description.clone(), created: now, updated: now, icon_url: None, color: None, status: collection_builder.status, projects: initial_project_ids, }; transaction.commit().await?; Ok(HttpResponse::Ok().json(response)) } #[derive(Serialize, Deserialize)] pub struct CollectionIds { pub ids: String, } pub async fn collections_get( req: HttpRequest, web::Query(ids): web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let ids = serde_json::from_str::>(&ids.ids)?; let ids = ids .into_iter() .map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64))) .collect::, _>>()?; let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_READ]), ) .await .map(|x| x.1) .ok(); let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?; Ok(HttpResponse::Ok().json(collections)) } pub async fn collection_get( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let collection_data = database::models::Collection::get(id, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_READ]), ) .await .map(|x| x.1) .ok(); if let Some(data) = collection_data { if is_authorized_collection(&data, &user_option).await? { return Ok(HttpResponse::Ok().json(Collection::from(data))); } } Err(ApiError::NotFound) } #[derive(Deserialize, Validate)] pub struct EditCollection { #[validate( length(min = 3, max = 64), custom(function = "crate::util::validate::validate_name") )] pub name: Option, #[validate(length(min = 3, max = 256))] pub description: Option, pub status: Option, #[validate(length(max = 64))] pub new_projects: Option>, } pub async fn collection_edit( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, new_collection: web::Json, redis: web::Data, session_queue: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) .await? .1; new_collection .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let result = database::models::Collection::get(id, &**pool, &redis).await?; if let Some(collection_item) = result { if !can_modify_collection(&collection_item, &user) { return Ok(HttpResponse::Unauthorized().body("")); } let id = collection_item.id; let mut transaction = pool.begin().await?; if let Some(name) = &new_collection.name { sqlx::query!( " UPDATE collections SET name = $1 WHERE (id = $2) ", name.trim(), id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; } if let Some(description) = &new_collection.description { sqlx::query!( " UPDATE collections SET description = $1 WHERE (id = $2) ", description, id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; } if let Some(status) = &new_collection.status { if !(user.role.is_mod() || collection_item.status.is_approved() && status.can_be_requested()) { return Err(ApiError::CustomAuthentication( "You don't have permission to set this status!".to_string(), )); } sqlx::query!( " UPDATE collections SET status = $1 WHERE (id = $2) ", status.to_string(), id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; } if let Some(new_project_ids) = &new_collection.new_projects { // Delete all existing projects sqlx::query!( " DELETE FROM collections_mods WHERE collection_id = $1 ", collection_item.id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; let collection_item_ids = new_project_ids .iter() .map(|_| collection_item.id.0) .collect_vec(); let mut validated_project_ids = Vec::new(); for project_id in new_project_ids { let project = database::models::Project::get(project_id, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput(format!( "The specified project {project_id} does not exist!" )) })?; validated_project_ids.push(project.inner.id.0); } // Insert- don't throw an error if it already exists sqlx::query!( " INSERT INTO collections_mods (collection_id, mod_id) SELECT * FROM UNNEST ($1::int8[], $2::int8[]) ON CONFLICT DO NOTHING ", &collection_item_ids[..], &validated_project_ids[..], ) .execute(&mut *transaction) .await?; } database::models::Collection::clear_cache(collection_item.id, &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::NotFound) } } #[derive(Serialize, Deserialize)] pub struct Extension { pub ext: String, } #[allow(clippy::too_many_arguments)] pub async fn collection_icon_edit( web::Query(ext): web::Query, req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, file_host: web::Data>, mut payload: web::Payload, session_queue: web::Data, ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { let cdn_url = dotenvy::var("CDN_URL")?; let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) .await? .1; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let collection_item = database::models::Collection::get(id, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; if !can_modify_collection(&collection_item, &user) { return Ok(HttpResponse::Unauthorized().body("")); } if let Some(icon) = collection_item.icon_url { let name = icon.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { file_host.delete_file_version("", icon_path).await?; } } let bytes = read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; let color = crate::util::img::get_color_from_img(&bytes)?; let hash = sha1::Sha1::from(&bytes).hexdigest(); let collection_id: CollectionId = collection_item.id.into(); let upload_data = file_host .upload_file( content_type, &format!("data/{}/{}.{}", collection_id, hash, ext.ext), bytes.freeze(), ) .await?; let mut transaction = pool.begin().await?; sqlx::query!( " UPDATE collections SET icon_url = $1, color = $2 WHERE (id = $3) ", format!("{}/{}", cdn_url, upload_data.file_name), color.map(|x| x as i32), collection_item.id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; database::models::Collection::clear_cache(collection_item.id, &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::InvalidInput(format!( "Invalid format for collection icon: {}", ext.ext ))) } } pub async fn delete_collection_icon( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, file_host: web::Data>, session_queue: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_WRITE]), ) .await? .1; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let collection_item = database::models::Collection::get(id, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; if !can_modify_collection(&collection_item, &user) { return Ok(HttpResponse::Unauthorized().body("")); } let cdn_url = dotenvy::var("CDN_URL")?; if let Some(icon) = collection_item.icon_url { let name = icon.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { file_host.delete_file_version("", icon_path).await?; } } let mut transaction = pool.begin().await?; sqlx::query!( " UPDATE collections SET icon_url = NULL, color = NULL WHERE (id = $1) ", collection_item.id as database::models::ids::CollectionId, ) .execute(&mut *transaction) .await?; database::models::Collection::clear_cache(collection_item.id, &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } pub async fn collection_delete( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::COLLECTION_DELETE]), ) .await? .1; let string = info.into_inner().0; let id = database::models::CollectionId(parse_base62(&string)? as i64); let collection = database::models::Collection::get(id, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput("The specified collection does not exist!".to_string()) })?; if !can_modify_collection(&collection, &user) { return Ok(HttpResponse::Unauthorized().body("")); } let mut transaction = pool.begin().await?; let result = database::models::Collection::remove(collection.id, &mut transaction, &redis).await?; database::models::Collection::clear_cache(collection.id, &redis).await?; transaction.commit().await?; if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::NotFound) } } fn can_modify_collection( collection: &database::models::Collection, user: &models::users::User, ) -> bool { collection.user_id == user.id.into() || user.role.is_mod() }