use crate::database::models; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; use crate::models::projects::{ DonationLink, License, ProjectId, ProjectStatus, SideType, VersionId, }; use crate::models::users::UserId; use crate::routes::version_creation::InitialVersionData; use crate::search::indexing::IndexingError; use crate::util::auth::{get_user_from_headers, AuthenticationError}; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix::fut::ready; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::sync::Arc; use thiserror::Error; use validator::Validate; #[derive(Error, Debug)] pub enum CreateError { #[error("Environment Error")] EnvError(#[from] dotenv::Error), #[error("An unknown database error occurred")] SqlxDatabaseError(#[from] sqlx::Error), #[error("Database Error: {0}")] DatabaseError(#[from] models::DatabaseError), #[error("Indexing Error: {0}")] IndexingError(#[from] IndexingError), #[error("Error while parsing multipart payload")] MultipartError(actix_multipart::MultipartError), #[error("Error while parsing JSON: {0}")] SerDeError(#[from] serde_json::Error), #[error("Error while validating input: {0}")] ValidationError(String), #[error("Error while uploading file")] FileHostingError(#[from] FileHostingError), #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), #[error("{}", .0)] MissingValueError(String), #[error("Invalid format for image: {0}")] InvalidIconFormat(String), #[error("Error with multipart data: {0}")] InvalidInput(String), #[error("Invalid game version: {0}")] InvalidGameVersion(String), #[error("Invalid loader: {0}")] InvalidLoader(String), #[error("Invalid category: {0}")] InvalidCategory(String), #[error("Invalid file type for version file: {0}")] InvalidFileType(String), #[error("Slug collides with other project's id!")] SlugCollision, #[error("Authentication Error: {0}")] Unauthorized(#[from] AuthenticationError), #[error("Authentication Error: {0}")] CustomAuthenticationError(String), } impl actix_web::ResponseError for CreateError { fn status_code(&self) -> StatusCode { match self { CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR, CreateError::SqlxDatabaseError(..) => { StatusCode::INTERNAL_SERVER_ERROR } CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR, CreateError::FileHostingError(..) => { StatusCode::INTERNAL_SERVER_ERROR } CreateError::SerDeError(..) => StatusCode::BAD_REQUEST, CreateError::MultipartError(..) => StatusCode::BAD_REQUEST, CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST, CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST, CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST, CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST, CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST, CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, CreateError::CustomAuthenticationError(..) => { StatusCode::UNAUTHORIZED } CreateError::SlugCollision => StatusCode::BAD_REQUEST, CreateError::ValidationError(..) => StatusCode::BAD_REQUEST, CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST, } } fn error_response(&self) -> HttpResponse { HttpResponse::build(self.status_code()).json(ApiError { error: match self { CreateError::EnvError(..) => "environment_error", CreateError::SqlxDatabaseError(..) => "database_error", CreateError::DatabaseError(..) => "database_error", CreateError::IndexingError(..) => "indexing_error", CreateError::FileHostingError(..) => "file_hosting_error", CreateError::SerDeError(..) => "invalid_input", CreateError::MultipartError(..) => "invalid_input", CreateError::MissingValueError(..) => "invalid_input", CreateError::InvalidIconFormat(..) => "invalid_input", CreateError::InvalidInput(..) => "invalid_input", CreateError::InvalidGameVersion(..) => "invalid_input", CreateError::InvalidLoader(..) => "invalid_input", CreateError::InvalidCategory(..) => "invalid_input", CreateError::InvalidFileType(..) => "invalid_input", CreateError::Unauthorized(..) => "unauthorized", CreateError::CustomAuthenticationError(..) => "unauthorized", CreateError::SlugCollision => "invalid_input", CreateError::ValidationError(..) => "invalid_input", CreateError::FileValidationError(..) => "invalid_input", }, description: &self.to_string(), }) } } fn default_project_type() -> String { "mod".to_string() } #[derive(Serialize, Deserialize, Validate, Clone)] struct ProjectCreateData { #[validate(length(min = 3, max = 256))] #[serde(alias = "mod_name")] /// The title or name of the project. pub title: String, #[validate(length(min = 1, max = 64))] #[serde(default = "default_project_type")] /// The project type of this mod pub project_type: String, #[validate( length(min = 3, max = 64), regex = "crate::util::validate::RE_URL_SAFE" )] #[serde(alias = "mod_slug")] /// The slug of a project, used for vanity URLs pub slug: String, #[validate(length(min = 3, max = 2048))] #[serde(alias = "mod_description")] /// A short description of the project. pub description: String, #[validate(length(max = 65536))] #[serde(alias = "mod_body")] /// A long description of the project, in markdown. pub body: String, /// The support range for the client project pub client_side: SideType, /// The support range for the server project pub server_side: SideType, #[validate(length(max = 32))] #[validate] /// A list of initial versions to upload with the created project pub initial_versions: Vec, #[validate(length(max = 3))] /// A list of the categories that the project is in. pub categories: Vec, #[validate(length(max = 256))] #[serde(default = "Vec::new")] /// A list of the categories that the project is in. pub additional_categories: Vec, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 2048) )] /// An optional link to where to submit bugs or issues with the project. pub issues_url: Option, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 2048) )] /// An optional link to the source code for the project. pub source_url: Option, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 2048) )] /// An optional link to the project's wiki page or other relevant information. pub wiki_url: Option, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 2048) )] /// An optional link to the project's license page pub license_url: Option, #[validate( custom(function = "crate::util::validate::validate_url"), length(max = 2048) )] /// An optional link to the project's discord. pub discord_url: Option, /// An optional list of all donation links the project has\ #[validate] pub donation_urls: Option>, /// An optional boolean. If true, the project will be created as a draft. pub is_draft: Option, /// The license id that the project follows pub license_id: String, #[validate(length(max = 64))] #[validate] /// The multipart names of the gallery items to upload pub gallery_items: Option>, } #[derive(Serialize, Deserialize, Validate, Clone)] pub struct NewGalleryItem { /// The name of the multipart item where the gallery media is located pub item: String, /// Whether the gallery item should show in search or not pub featured: bool, #[validate(length(min = 1, max = 2048))] /// The title of the gallery item pub title: Option, #[validate(length(min = 1, max = 2048))] /// The description of the gallery item pub description: Option, } pub struct UploadedFile { pub file_id: String, pub file_name: String, } pub async fn undo_uploads( file_host: &dyn FileHost, uploaded_files: &[UploadedFile], ) -> Result<(), CreateError> { for file in uploaded_files { file_host .delete_file_version(&file.file_id, &file.file_name) .await?; } Ok(()) } #[post("project")] pub async fn project_create( req: HttpRequest, mut payload: Multipart, client: Data, file_host: Data>, ) -> Result { let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); let result = project_create_inner( req, &mut payload, &mut transaction, &***file_host, &mut uploaded_files, ) .await; if result.is_err() { let undo_result = undo_uploads(&***file_host, &uploaded_files).await; let rollback_result = transaction.rollback().await; // fix multipart error bug: payload.for_each(|_| ready(())).await; if let Err(e) = undo_result { return Err(e); } if let Err(e) = rollback_result { return Err(e.into()); } } else { transaction.commit().await?; } result } /* Project Creation Steps: Get logged in user Must match the author in the version creation 1. Data - Gets "data" field from multipart form; must be first - Verification: string lengths - Create versions - Some shared logic with version creation - Create list of VersionBuilders - Create ProjectBuilder 2. Upload - Icon: check file format & size - Upload to backblaze & record URL - Project files - Check for matching version - File size limits? - Check file type - Eventually, malware scan - Upload to backblaze & create VersionFileBuilder - 3. Creation - Database stuff - Add project data to indexing queue */ pub async fn project_create_inner( req: HttpRequest, payload: &mut Multipart, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, file_host: &dyn FileHost, uploaded_files: &mut Vec, ) -> Result { // The base URL for files uploaded to backblaze let cdn_url = dotenv::var("CDN_URL")?; // The currently logged in user let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); let project_create_data; let mut versions; let mut versions_map = std::collections::HashMap::new(); let mut gallery_urls = Vec::new(); let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?; let all_loaders = models::categories::Loader::list(&mut *transaction).await?; { // The first multipart field must be named "data" and contain a // JSON `ProjectCreateData` object. let mut field = payload .next() .await .map(|m| m.map_err(CreateError::MultipartError)) .unwrap_or_else(|| { Err(CreateError::MissingValueError(String::from( "No `data` field in multipart upload", ))) })?; let content_disposition = field.content_disposition(); let name = content_disposition.get_name().ok_or_else(|| { CreateError::MissingValueError(String::from("Missing content name")) })?; if name != "data" { return Err(CreateError::InvalidInput(String::from( "`data` field must come before file fields", ))); } let mut data = Vec::new(); while let Some(chunk) = field.next().await { data.extend_from_slice( &chunk.map_err(CreateError::MultipartError)?, ); } let create_data: ProjectCreateData = serde_json::from_slice(&data)?; create_data.validate().map_err(|err| { CreateError::InvalidInput(validation_errors_to_string(err, None)) })?; let slug_project_id_option: Option = serde_json::from_str(&*format!("\"{}\"", create_data.slug)).ok(); if let Some(slug_project_id) = slug_project_id_option { let slug_project_id: models::ids::ProjectId = slug_project_id.into(); let results = sqlx::query!( " SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) ", slug_project_id as models::ids::ProjectId ) .fetch_one(&mut *transaction) .await .map_err(|e| CreateError::DatabaseError(e.into()))?; if results.exists.unwrap_or(false) { return Err(CreateError::SlugCollision); } } { let results = sqlx::query!( " SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) ", create_data.slug ) .fetch_one(&mut *transaction) .await .map_err(|e| CreateError::DatabaseError(e.into()))?; if results.exists.unwrap_or(false) { return Err(CreateError::SlugCollision); } } // Create VersionBuilders for the versions specified in `initial_versions` versions = Vec::with_capacity(create_data.initial_versions.len()); for (i, data) in create_data.initial_versions.iter().enumerate() { // Create a map of multipart field names to version indices for name in &data.file_parts { if versions_map.insert(name.to_owned(), i).is_some() { // If the name is already used return Err(CreateError::InvalidInput(String::from( "Duplicate multipart field name", ))); } } versions.push( create_initial_version( data, project_id, current_user.id, &all_game_versions, &all_loaders, &create_data.project_type, transaction, ) .await?, ); } project_create_data = create_data; } let project_type_id = models::ProjectTypeId::get_id( project_create_data.project_type.clone(), &mut *transaction, ) .await? .ok_or_else(|| { CreateError::InvalidInput(format!( "Project Type {} does not exist.", project_create_data.project_type.clone() )) })?; let mut icon_url = None; while let Some(item) = payload.next().await { let mut field: Field = item.map_err(CreateError::MultipartError)?; let content_disposition = field.content_disposition().clone(); let name = content_disposition.get_name().ok_or_else(|| { CreateError::MissingValueError("Missing content name".to_string()) })?; let (file_name, file_extension) = super::version_creation::get_name_ext(&content_disposition)?; if name == "icon" { if icon_url.is_some() { return Err(CreateError::InvalidInput(String::from( "Projects can only have one icon", ))); } // Upload the icon to the cdn icon_url = Some( process_icon_upload( uploaded_files, project_id, file_extension, file_host, field, &cdn_url, ) .await?, ); continue; } if let Some(gallery_items) = &project_create_data.gallery_items { if gallery_items.iter().filter(|a| a.featured).count() > 1 { return Err(CreateError::InvalidInput(String::from( "Only one gallery image can be featured.", ))); } if let Some(item) = gallery_items.iter().find(|x| x.item == name) { let data = read_from_field( &mut field, 5 * (1 << 20), "Gallery image exceeds the maximum of 5MiB.", ) .await?; let hash = sha1::Sha1::from(&data).hexdigest(); let (_, file_extension) = super::version_creation::get_name_ext( &content_disposition, )?; let content_type = crate::util::ext::get_image_content_type(file_extension) .ok_or_else(|| { CreateError::InvalidIconFormat( file_extension.to_string(), ) })?; let url = format!( "data/{}/images/{}.{}", project_id, hash, file_extension ); let upload_data = file_host .upload_file(content_type, &url, data.freeze()) .await?; uploaded_files.push(UploadedFile { file_id: upload_data.file_id, file_name: upload_data.file_name.clone(), }); gallery_urls.push(crate::models::projects::GalleryItem { url: format!("{}/{}", cdn_url, url), featured: item.featured, title: item.title.clone(), description: item.description.clone(), created: Utc::now(), }); continue; } } let index = if let Some(i) = versions_map.get(name) { *i } else { return Err(CreateError::InvalidInput(format!( "File `{}` (field {}) isn't specified in the versions data", file_name, name ))); }; // `index` is always valid for these lists let created_version = versions.get_mut(index).unwrap(); let version_data = project_create_data.initial_versions.get(index).unwrap(); // Upload the new jar file super::version_creation::upload_file( &mut field, file_host, uploaded_files, &mut created_version.files, &mut created_version.dependencies, &cdn_url, &content_disposition, project_id, created_version.version_id.into(), &*project_create_data.project_type, version_data.loaders.clone(), version_data.game_versions.clone(), all_game_versions.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), transaction, ) .await?; } { // Check to make sure that all specified files were uploaded for (version_data, builder) in project_create_data .initial_versions .iter() .zip(versions.iter()) { if version_data.file_parts.len() != builder.files.len() { return Err(CreateError::InvalidInput(String::from( "Some files were specified in initial_versions but not uploaded", ))); } } // Convert the list of category names to actual categories let mut categories = Vec::with_capacity(project_create_data.categories.len()); for category in &project_create_data.categories { let id = models::categories::Category::get_id_project( category, project_type_id, &mut *transaction, ) .await? .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; categories.push(id); } let mut additional_categories = Vec::with_capacity(project_create_data.additional_categories.len()); for category in &project_create_data.additional_categories { let id = models::categories::Category::get_id_project( category, project_type_id, &mut *transaction, ) .await? .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; additional_categories.push(id); } let team = models::team_item::TeamBuilder { members: vec![models::team_item::TeamMemberBuilder { user_id: current_user.id.into(), role: crate::models::teams::OWNER_ROLE.to_owned(), permissions: crate::models::teams::Permissions::ALL, accepted: true, }], }; let team_id = team.insert(&mut *transaction).await?; let status; if project_create_data.is_draft.unwrap_or(false) { status = ProjectStatus::Draft; } else { status = ProjectStatus::Processing; if project_create_data.initial_versions.is_empty() { return Err(CreateError::InvalidInput(String::from( "Project submitted for review with no initial versions", ))); } } let status_id = models::StatusId::get_id(&status, &mut *transaction) .await? .ok_or_else(|| { CreateError::InvalidInput(format!( "Status {} does not exist.", status.clone() )) })?; let client_side_id = models::SideTypeId::get_id( &project_create_data.client_side, &mut *transaction, ) .await? .ok_or_else(|| { CreateError::InvalidInput( "Client side type specified does not exist.".to_string(), ) })?; let server_side_id = models::SideTypeId::get_id( &project_create_data.server_side, &mut *transaction, ) .await? .ok_or_else(|| { CreateError::InvalidInput( "Server side type specified does not exist.".to_string(), ) })?; let license_id = models::categories::License::get_id( &project_create_data.license_id, &mut *transaction, ) .await? .ok_or_else(|| { CreateError::InvalidInput( "License specified does not exist.".to_string(), ) })?; let mut donation_urls = vec![]; if let Some(urls) = &project_create_data.donation_urls { for url in urls { let platform_id = models::DonationPlatformId::get_id( &url.id, &mut *transaction, ) .await? .ok_or_else(|| { CreateError::InvalidInput(format!( "Donation platform {} does not exist.", url.id.clone() )) })?; donation_urls.push(models::project_item::DonationUrl { project_id: project_id.into(), platform_id, platform_short: "".to_string(), platform_name: "".to_string(), url: url.url.clone(), }) } } let project_builder = models::project_item::ProjectBuilder { project_id: project_id.into(), project_type_id, team_id, title: project_create_data.title, description: project_create_data.description, body: project_create_data.body, icon_url, issues_url: project_create_data.issues_url, source_url: project_create_data.source_url, wiki_url: project_create_data.wiki_url, license_url: project_create_data.license_url, discord_url: project_create_data.discord_url, categories, additional_categories, initial_versions: versions, status: status_id, client_side: client_side_id, server_side: server_side_id, license: license_id, slug: Some(project_create_data.slug), donation_urls, gallery_items: gallery_urls .iter() .map(|x| models::project_item::GalleryItem { project_id: project_id.into(), image_url: x.url.clone(), featured: x.featured, title: x.title.clone(), description: x.description.clone(), created: x.created, }) .collect(), }; let now = Utc::now(); let response = crate::models::projects::Project { id: project_id, slug: project_builder.slug.clone(), project_type: project_create_data.project_type.clone(), team: team_id.into(), title: project_builder.title.clone(), description: project_builder.description.clone(), body: project_builder.body.clone(), body_url: None, published: now, updated: now, approved: None, status: status.clone(), moderator_message: None, license: License { id: project_create_data.license_id.clone(), name: "".to_string(), url: project_builder.license_url.clone(), }, client_side: project_create_data.client_side, server_side: project_create_data.server_side, downloads: 0, followers: 0, categories: project_create_data.categories, additional_categories: project_create_data.additional_categories, versions: project_builder .initial_versions .iter() .map(|v| v.version_id.into()) .collect::>(), icon_url: project_builder.icon_url.clone(), issues_url: project_builder.issues_url.clone(), source_url: project_builder.source_url.clone(), wiki_url: project_builder.wiki_url.clone(), discord_url: project_builder.discord_url.clone(), donation_urls: project_create_data.donation_urls.clone(), gallery: gallery_urls, }; let _project_id = project_builder.insert(&mut *transaction).await?; if status == ProjectStatus::Processing { if let Ok(webhook_url) = dotenv::var("MODERATION_DISCORD_WEBHOOK") { crate::util::webhook::send_discord_webhook( response.clone(), webhook_url, ) .await .ok(); } } Ok(HttpResponse::Ok().json(response)) } } async fn create_initial_version( version_data: &InitialVersionData, project_id: ProjectId, author: UserId, all_game_versions: &[models::categories::GameVersion], all_loaders: &[models::categories::Loader], project_type: &str, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { if version_data.project_id.is_some() { return Err(CreateError::InvalidInput(String::from( "Found project id in initial version for new project", ))); } version_data.validate().map_err(|err| { CreateError::ValidationError(validation_errors_to_string(err, None)) })?; // Randomly generate a new id to be used for the version let version_id: VersionId = models::generate_version_id(transaction).await?.into(); let game_versions = version_data .game_versions .iter() .map(|x| { all_game_versions .iter() .find(|y| y.version == x.0) .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) .map(|y| y.id) }) .collect::, CreateError>>()?; let loaders = version_data .loaders .iter() .map(|x| { all_loaders .iter() .find(|y| { y.loader == x.0 && y.supported_project_types .contains(&project_type.to_string()) }) .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) .map(|y| y.id) }) .collect::, CreateError>>()?; let dependencies = version_data .dependencies .iter() .map(|d| models::version_item::DependencyBuilder { version_id: d.version_id.map(|x| x.into()), project_id: d.project_id.map(|x| x.into()), dependency_type: d.dependency_type.to_string(), file_name: None, }) .collect::>(); let version = models::version_item::VersionBuilder { version_id: version_id.into(), project_id: project_id.into(), author_id: author.into(), name: version_data.version_title.clone(), version_number: version_data.version_number.clone(), changelog: version_data .version_body .clone() .unwrap_or_else(|| "".to_string()), files: Vec::new(), dependencies, game_versions, loaders, featured: version_data.featured, version_type: version_data.release_channel.to_string(), }; Ok(version) } async fn process_icon_upload( uploaded_files: &mut Vec, project_id: ProjectId, file_extension: &str, file_host: &dyn FileHost, mut field: actix_multipart::Field, cdn_url: &str, ) -> Result { if let Some(content_type) = crate::util::ext::get_image_content_type(file_extension) { let data = read_from_field( &mut field, 262144, "Icons must be smaller than 256KiB", ) .await?; let hash = sha1::Sha1::from(&data).hexdigest(); let upload_data = file_host .upload_file( content_type, &format!("data/{}/{}.{}", project_id, hash, file_extension), data.freeze(), ) .await?; uploaded_files.push(UploadedFile { file_id: upload_data.file_id, file_name: upload_data.file_name.clone(), }); Ok(format!("{}/{}", cdn_url, upload_data.file_name)) } else { Err(CreateError::InvalidIconFormat(file_extension.to_string())) } }