use super::version_creation::{try_create_version_fields, InitialVersionData}; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::loader_fields::{Loader, LoaderField, LoaderFieldEnumValue}; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, image_item, User}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; use crate::models::ids::{ImageId, OrganizationId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, VersionId, VersionStatus, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::ThreadType; use crate::models::users::UserId; use crate::queue::session::AuthQueue; use crate::search::indexing::IndexingError; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::{self, Data}; use actix_web::{HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; use image::ImageError; use itertools::Itertools; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::sync::Arc; use thiserror::Error; use validator::Validate; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.route("project", web::post().to(project_create)); } #[derive(Error, Debug)] pub enum CreateError { #[error("Environment Error")] EnvError(#[from] dotenvy::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: {0}")] MultipartError(#[from] 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: {0}")] 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), #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), #[error("Reroute Error: {0}")] RerouteError(#[from] reqwest::Error), } 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, CreateError::ImageError(..) => StatusCode::BAD_REQUEST, CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR, } } 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", CreateError::ImageError(..) => "invalid_image", CreateError::RerouteError(..) => "reroute_error", }, description: &self.to_string(), }) } } pub fn default_project_type() -> String { "mod".to_string() } fn default_requested_status() -> ProjectStatus { ProjectStatus::Approved } #[derive(Serialize, Deserialize, Validate, Clone)] pub struct ProjectCreateData { #[validate( length(min = 3, max = 64), custom(function = "crate::util::validate::validate_name") )] #[serde(alias = "mod_name")] /// The title or name of the project. pub title: 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 = 255))] #[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, #[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>, #[serde(default = "default_requested_status")] /// The status of the mod to be set once it is approved pub requested_status: ProjectStatus, // Associations to uploaded images in body/description #[validate(length(max = 10))] #[serde(default)] pub uploaded_images: Vec, /// The id of the organization to create the project in pub organization_id: 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 ordering: i64, } 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(()) } pub async fn project_create( req: HttpRequest, mut payload: Multipart, client: Data, redis: Data, file_host: Data>, session_queue: 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, &client, &redis, &session_queue, ) .await; if result.is_err() { let undo_result = undo_uploads(&***file_host, &uploaded_files).await; let rollback_result = transaction.rollback().await; undo_result?; 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 */ #[allow(clippy::too_many_arguments)] async fn project_create_inner( req: HttpRequest, payload: &mut Multipart, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, file_host: &dyn FileHost, uploaded_files: &mut Vec, pool: &PgPool, redis: &RedisPool, session_queue: &AuthQueue, ) -> Result { // The base URL for files uploaded to backblaze let cdn_url = dotenvy::var("CDN_URL")?; // The currently logged in user let current_user = get_user_from_headers( &req, pool, redis, session_queue, Some(&[Scopes::PROJECT_CREATE]), ) .await? .1; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?; let project_create_data: ProjectCreateData; let mut versions; let mut versions_map = std::collections::HashMap::new(); let mut gallery_urls = Vec::new(); { // 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_loaders, transaction, redis, ) .await?, ); } project_create_data = create_data; } let mut icon_data = None; let mut error = None; while let Some(item) = payload.next().await { let mut field: Field = item?; if error.is_some() { continue; } let result = async { 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_data.is_some() { return Err(CreateError::InvalidInput(String::from( "Projects can only have one icon", ))); } // Upload the icon to the cdn icon_data = Some( process_icon_upload( uploaded_files, project_id.0, file_extension, file_host, field, &cdn_url, ) .await?, ); return Ok(()); } 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/{project_id}/images/{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, }); 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(), ordering: item.ordering, }); return Ok(()); } } let index = if let Some(i) = versions_map.get(name) { *i } else { return Err(CreateError::InvalidInput(format!( "File `{file_name}` (field {name}) isn't specified in the versions data" ))); }; // `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(); // TODO: maybe redundant is this calculation done elsewhere? // Upload the new jar file super::version_creation::upload_file( &mut field, file_host, version_data.file_parts.len(), uploaded_files, &mut created_version.files, &mut created_version.dependencies, &cdn_url, &content_disposition, project_id, created_version.version_id.into(), &created_version.version_fields, version_data.loaders.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), None, transaction, redis, ) .await?; Ok(()) } .await; if result.is_err() { error = result.err(); } } if let Some(error) = error { return Err(error); } { // 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 ids = models::categories::Category::get_ids(category, &mut **transaction).await?; if ids.is_empty() { return Err(CreateError::InvalidCategory(category.clone())); } // TODO: We should filter out categories that don't match the project type of any of the versions // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version categories.extend(ids.values()); } let mut additional_categories = Vec::with_capacity(project_create_data.additional_categories.len()); for category in &project_create_data.additional_categories { let ids = models::categories::Category::get_ids(category, &mut **transaction).await?; if ids.is_empty() { return Err(CreateError::InvalidCategory(category.clone())); } // TODO: We should filter out categories that don't match the project type of any of the versions // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version additional_categories.extend(ids.values()); } 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(), // Allow all permissions for project creator, even if attached to a project permissions: ProjectPermissions::all(), organization_permissions: None, accepted: true, payouts_split: Decimal::ONE_HUNDRED, ordering: 0, }], }; 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 license_id = spdx::Expression::parse(&project_create_data.license_id).map_err(|err| { CreateError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) })?; let mut donation_urls = vec![]; if let Some(urls) = &project_create_data.donation_urls { for url in urls { let platform_id = models::categories::DonationPlatform::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 { platform_id, platform_short: "".to_string(), platform_name: "".to_string(), url: url.url.clone(), }) } } let project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), team_id, organization_id: project_create_data.organization_id.map(|x| x.into()), title: project_create_data.title, description: project_create_data.description, body: project_create_data.body, icon_url: icon_data.clone().map(|x| x.0), 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, requested_status: Some(project_create_data.requested_status), license: license_id.to_string(), slug: Some(project_create_data.slug), donation_urls, gallery_items: gallery_urls .iter() .map(|x| models::project_item::GalleryItem { image_url: x.url.clone(), featured: x.featured, title: x.title.clone(), description: x.description.clone(), created: x.created, ordering: x.ordering, }) .collect(), color: icon_data.and_then(|x| x.1), monetization_status: MonetizationStatus::Monetized, }; let project_builder = project_builder_actual.clone(); let now = Utc::now(); let id = project_builder_actual.insert(&mut *transaction).await?; User::clear_project_cache(&[current_user.id.into()], redis).await?; for image_id in project_create_data.uploaded_images { if let Some(db_image) = image_item::Image::get(image_id.into(), &mut **transaction, redis).await? { let image: Image = db_image.into(); if !matches!(image.context, ImageContext::Project { .. }) || image.context.inner_id().is_some() { return Err(CreateError::InvalidInput(format!( "Image {} is not unused and in the 'project' context", image_id ))); } sqlx::query!( " UPDATE uploaded_images SET mod_id = $1 WHERE id = $2 ", id as models::ids::ProjectId, image_id.0 as i64 ) .execute(&mut **transaction) .await?; image_item::Image::clear_cache(image.id.into(), redis).await?; } else { return Err(CreateError::InvalidInput(format!( "Image {} does not exist", image_id ))); } } let thread_id = ThreadBuilder { type_: ThreadType::Project, members: vec![], project_id: Some(id), report_id: None, } .insert(&mut *transaction) .await?; let loaders = project_builder .initial_versions .iter() .flat_map(|v| v.loaders.clone()) .unique() .collect::>(); let (project_types, games) = Loader::list(&mut **transaction, redis) .await? .into_iter() .fold( (Vec::new(), Vec::new()), |(mut project_types, mut games), loader| { if loaders.contains(&loader.id) { project_types.extend(loader.supported_project_types); games.extend(loader.supported_games); } (project_types, games) }, ); let response = crate::models::projects::Project { id: project_id, slug: project_builder.slug.clone(), project_types, games, team: team_id.into(), organization: project_create_data.organization_id, title: project_builder.title.clone(), description: project_builder.description.clone(), body: project_builder.body.clone(), body_url: None, published: now, updated: now, approved: None, queued: None, status, requested_status: project_builder.requested_status, moderator_message: None, license: License { id: project_create_data.license_id.clone(), name: "".to_string(), url: project_builder.license_url.clone(), }, downloads: 0, followers: 0, categories: project_create_data.categories, additional_categories: project_create_data.additional_categories, loaders: vec![], 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, color: project_builder.color, thread_id: thread_id.into(), monetization_status: MonetizationStatus::Monetized, }; Ok(HttpResponse::Ok().json(response)) } } async fn create_initial_version( version_data: &InitialVersionData, project_id: ProjectId, author: UserId, all_loaders: &[models::loader_fields::Loader], transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> 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 loaders = version_data .loaders .iter() .map(|x| { all_loaders .iter() .find(|y| y.loader == x.0) .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) .map(|y| y.id) }) .collect::, CreateError>>()?; let loader_fields = LoaderField::get_fields(&loaders, &mut **transaction, redis).await?; let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut **transaction, redis) .await?; let version_fields = try_create_version_fields( version_id, &version_data.fields, &loader_fields, &mut loader_field_enum_values, )?; 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_default(), files: Vec::new(), dependencies, loaders, version_fields, featured: version_data.featured, status: VersionStatus::Listed, version_type: version_data.release_channel.to_string(), requested_status: None, ordering: version_data.ordering, }; Ok(version) } async fn process_icon_upload( uploaded_files: &mut Vec, id: u64, file_extension: &str, file_host: &dyn FileHost, mut field: Field, cdn_url: &str, ) -> Result<(String, Option), CreateError> { 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 color = crate::util::img::get_color_from_img(&data)?; let hash = sha1::Sha1::from(&data).hexdigest(); let upload_data = file_host .upload_file( content_type, &format!("data/{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), color)) } else { Err(CreateError::InvalidIconFormat(file_extension.to_string())) } }