From d0fb5c3bd5ae7a257f957142e086a0f9cf1e4514 Mon Sep 17 00:00:00 2001 From: Aeledfyr <45501007+Aeledfyr@users.noreply.github.com> Date: Sat, 17 Oct 2020 21:34:23 -0500 Subject: [PATCH] Refactor mod creation route, add more checks (#80) This also removes the `team_members` field of `InitialModData`, as team members are no longer specified at mod creation. --- sqlx-data.json | 140 +++++++ src/database/models/ids.rs | 2 +- src/database/models/mod.rs | 44 +++ src/models/mods.rs | 39 +- src/routes/mod_creation.rs | 696 +++++++++++++++++++-------------- src/routes/version_creation.rs | 171 +++++--- 6 files changed, 730 insertions(+), 362 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index 0ff8d55f..097163cb 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -59,6 +59,46 @@ "nullable": [] } }, + "0a3f99eae57c0c3d10aa0014db7fb8a33952da3e7d00949a25ade843859272cb": { + "query": "\n SELECT id\n FROM release_channels\n WHERE channel = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, + "0ca11a32b2860e4f5c3d20892a5be3cb419e084f42ba0f98e09b9995027fcc4e": { + "query": "\n SELECT id FROM statuses\n WHERE status = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "0da158263c6588a83421154342db2ede16b9abf9931827790b9fcaf71080c324": { "query": "\n SELECT u.id, u.username FROM users u\n INNER JOIN team_members tm ON tm.user_id = u.id\n WHERE tm.team_id = $2 AND tm.role = $1\n ", "describe": { @@ -86,6 +126,26 @@ ] } }, + "0ef06dd5094da2458c558b115ed272da338ade372e717d8580cdf52c0000f80c": { + "query": "\n SELECT user_id FROM team_members\n WHERE team_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, "1016a0bf55e9474357ac5ef725605ac337e82e1a2b93726ae795ec48f0d696dd": { "query": "\n SELECT v.mod_id, v.author_id, v.name, v.version_number,\n v.changelog_url, v.date_published, v.downloads,\n release_channels.channel\n FROM versions v\n INNER JOIN release_channels ON v.release_channel = release_channels.id\n WHERE v.id = $1\n ", "describe": { @@ -519,6 +579,46 @@ "nullable": [] } }, + "40597b84607e77809c13ffa9c6b0b1674bd6378a4737a8f6118e91ae2ede7e4a": { + "query": "\n SELECT id\n FROM release_channels\n WHERE channel = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, + "42e072309779598d0c213280dd8052d1b4889cb24ef5204ca13b74f693b94328": { + "query": "\n SELECT user_id FROM team_members tm\n INNER JOIN mods ON mods.team_id = tm.team_id\n WHERE mods.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, "4411f2aefd43881450da34db81e826110ac86c3a6cef9fd6a3e9e341508d1f09": { "query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ", "describe": { @@ -554,6 +654,26 @@ "nullable": [] } }, + "4c98e4441f8168d00bc7ff47951f15b44ff884cff6fc484645c74bfe3e7e7020": { + "query": "\n SELECT id\n FROM statuses\n WHERE status = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "4c99c0840159d18e88cd6094a41117258f2337346c145d926b5b610c76b5125f": { "query": "\n SELECT c.category\n FROM mods_categories mc\n INNER JOIN categories c ON mc.joining_category_id=c.id\n WHERE mc.joining_mod_id = $1\n ", "describe": { @@ -1406,6 +1526,26 @@ ] } }, + "cf031f19c7882833a8a30348ee90175a5d8b1fb7d9645c5deb2dc68c6eb33683": { + "query": "\n SELECT id FROM release_channels\n WHERE channel = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "d0172d12dce3d8ddc888893ec1cdd93ad232685e80f706e70dea22c85d96df63": { "query": "SELECT team_id FROM mods WHERE id=$1", "describe": { diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index e5b7784d..364c27c2 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -88,7 +88,7 @@ generate_ids!( UserId ); -#[derive(Copy, Clone, Debug, Type)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Type)] #[sqlx(transparent)] pub struct UserId(pub i64); diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 75f32ee4..e0f80a5b 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -31,3 +31,47 @@ pub enum DatabaseError { )] InvalidIdentifier(String), } + +impl ids::ChannelId { + pub async fn get_id<'a, E>( + channel: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM release_channels + WHERE channel = $1 + ", + channel + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| ids::ChannelId(r.id))) + } +} + +impl ids::StatusId { + pub async fn get_id<'a, E>( + status: &crate::models::mods::ModStatus, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM statuses + WHERE status = $1 + ", + status.as_str() + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| ids::StatusId(r.id))) + } +} diff --git a/src/models/mods.rs b/src/models/mods.rs index 68f83501..88274972 100644 --- a/src/models/mods.rs +++ b/src/models/mods.rs @@ -72,12 +72,12 @@ pub enum ModStatus { impl std::fmt::Display for ModStatus { fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - ModStatus::Approved => write!(fmt, "release"), - ModStatus::Rejected => write!(fmt, "beta"), - ModStatus::Draft => write!(fmt, "alpha"), + ModStatus::Approved => write!(fmt, "approved"), + ModStatus::Rejected => write!(fmt, "rejected"), + ModStatus::Draft => write!(fmt, "draft"), ModStatus::Unlisted => write!(fmt, "unlisted"), - ModStatus::Processing => write!(fmt, "Processing"), - ModStatus::Unknown => write!(fmt, "Unknown"), + ModStatus::Processing => write!(fmt, "processing"), + ModStatus::Unknown => write!(fmt, "unknown"), } } } @@ -86,13 +86,23 @@ impl ModStatus { pub fn from_str(string: &str) -> ModStatus { match string { "processing" => ModStatus::Processing, - "rejected" => ModStatus::Processing, - "approved" => ModStatus::Processing, - "draft" => ModStatus::Processing, - "unlisted" => ModStatus::Processing, + "rejected" => ModStatus::Rejected, + "approved" => ModStatus::Approved, + "draft" => ModStatus::Draft, + "unlisted" => ModStatus::Unlisted, _ => ModStatus::Unknown, } } + pub fn as_str(&self) -> &'static str { + match self { + ModStatus::Approved => "approved", + ModStatus::Rejected => "rejected", + ModStatus::Draft => "draft", + ModStatus::Unlisted => "unlisted", + ModStatus::Processing => "processing", + ModStatus::Unknown => "unknown", + } + } } /// A specific version of a mod @@ -158,6 +168,17 @@ impl std::fmt::Display for VersionType { } } +impl VersionType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + VersionType::Release => "release", + VersionType::Beta => "beta", + VersionType::Alpha => "alpha", + } + } +} + /// A specific version of Minecraft #[derive(Serialize, Deserialize, Clone)] #[serde(transparent)] diff --git a/src/routes/mod_creation.rs b/src/routes/mod_creation.rs index 397a888f..c2eda689 100644 --- a/src/routes/mod_creation.rs +++ b/src/routes/mod_creation.rs @@ -1,10 +1,8 @@ use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models; -use crate::database::models::StatusId; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; use crate::models::mods::{ModId, ModStatus, VersionId}; -use crate::models::teams::TeamMember; use crate::models::users::UserId; use crate::routes::version_creation::InitialVersionData; use crate::search::indexing::queue::CreationQueue; @@ -104,8 +102,6 @@ struct ModCreateData { pub mod_body: String, /// A list of initial versions to upload with the created mod pub initial_versions: Vec, - /// The team of people that has ownership of this mod. - pub team_members: Vec, /// A list of the categories that the mod is in. pub categories: Vec, /// An optional link to where to submit bugs or issues with the mod. @@ -171,6 +167,36 @@ pub async fn mod_create( result } +/* + +Mod 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 ModBuilder + +2. Upload + - Icon: check file format & size + - Upload to backblaze & record URL + - Mod files + - Check for matching version + - File size limits? + - Check file type + - Eventually, malware scan + - Upload to backblaze & create VersionFileBuilder + - + +3. Creation + - Database stuff + - Add mod data to indexing queue +*/ + async fn mod_create_inner( req: HttpRequest, mut payload: Multipart, @@ -179,157 +205,177 @@ async fn mod_create_inner( uploaded_files: &mut Vec, indexing_queue: &CreationQueue, ) -> 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 mod_id: ModId = models::generate_mod_id(transaction).await?.into(); - let user = get_user_from_headers(req.headers(), &mut *transaction).await?; - let mut created_versions: Vec = vec![]; + let mod_create_data; + let mut versions; + let mut versions_map = std::collections::HashMap::new(); - let mut mod_create_data: Option = None; - let mut icon_url = "".to_string(); + { + // The first multipart field must be named "data" and contain a + // JSON `ModCreateData` 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().ok_or_else(|| { + CreateError::MissingValueError(String::from("Missing 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: ModCreateData = serde_json::from_slice(&data)?; + + { + // Verify the lengths of various fields in the mod create data + /* + # ModCreateData + mod_name: 3..=256 + mod_description: 3..=2048, + mod_body: max of 64KiB?, + categories: Vec, 1..=256 + issues_url: 0..=2048, (Validate url?) + source_url: 0..=2048, + wiki_url: 0..=2048, + + initial_versions: Vec, + team_members: Vec, + + # TeamMember: + name: 3..=64 + role: 3..=64 + */ + + check_length(3..=256, "mod name", &create_data.mod_name)?; + check_length(3..=2048, "mod description", &create_data.mod_description)?; + check_length(..65536, "mod body", &create_data.mod_body)?; + + create_data + .categories + .iter() + .map(|f| check_length(1..=256, "category", f)) + .collect::>()?; + + if let Some(url) = &create_data.issues_url { + check_length(..=2048, "url", url)?; + } + if let Some(url) = &create_data.wiki_url { + check_length(..=2048, "url", url)?; + } + if let Some(url) = &create_data.source_url { + check_length(..=2048, "url", url)?; + } + + create_data + .initial_versions + .iter() + .map(|v| super::version_creation::check_version(v)) + .collect::>()?; + } + + // 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, + mod_id, + current_user.id, + &cdn_url, + transaction, + file_host, + uploaded_files, + ) + .await?, + ); + } + + mod_create_data = create_data; + } + + 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().ok_or_else(|| { CreateError::MissingValueError("Missing content disposition".to_string()) })?; + let name = content_disposition .get_name() .ok_or_else(|| CreateError::MissingValueError("Missing content name".to_string()))?; - if name == "data" { - let mut data = Vec::new(); - while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); - } - let create_data: ModCreateData = serde_json::from_slice(&data)?; - - check_length("mod_name", 3, 255, &*create_data.mod_name)?; - check_length("mod_description", 3, 2048, &*create_data.mod_description)?; - - for version_data in &create_data.initial_versions { - if version_data.mod_id.is_some() { - return Err(CreateError::InvalidInput(String::from( - "Found mod id in initial version for new mod", - ))); - } - let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - - let body_url = format!("data/{}/changelogs/{}/body.md", mod_id, version_id); - - let uploaded_text = file_host - .upload_file( - "text/plain", - &body_url, - version_data.version_body.clone().into_bytes(), - ) - .await?; - - uploaded_files.push(UploadedFile { - file_id: uploaded_text.file_id.clone(), - file_name: uploaded_text.file_name.clone(), - }); - - let release_channel = models::ChannelId( - sqlx::query!( - " - SELECT id - FROM release_channels - WHERE channel = $1 - ", - version_data.release_channel.to_string() - ) - .fetch_one(&mut *transaction) - .await? - .id, - ); - - let mut game_versions = Vec::with_capacity(version_data.game_versions.len()); - for v in &version_data.game_versions { - let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction) - .await? - .ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?; - game_versions.push(id); - } - - let mut loaders = Vec::with_capacity(version_data.loaders.len()); - for l in &version_data.loaders { - let id = models::categories::Loader::get_id(&l.0, &mut *transaction) - .await? - .ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?; - loaders.push(id); - } - - let version = models::version_item::VersionBuilder { - version_id: version_id.into(), - mod_id: mod_id.into(), - author_id: user.id.into(), - name: version_data.version_title.clone(), - version_number: version_data.version_number.clone(), - changelog_url: Some(format!("{}/{}", cdn_url, body_url)), - files: Vec::with_capacity(1), - dependencies: version_data - .dependencies - .iter() - .map(|x| (*x).into()) - .collect::>(), - game_versions, - loaders, - release_channel, - }; - - created_versions.push(version); - } - - mod_create_data = Some(create_data); - continue; - } - - let create_data = mod_create_data.as_ref().ok_or_else(|| { - CreateError::InvalidInput(String::from("`data` field must come before file fields")) - })?; - let (file_name, file_extension) = super::version_creation::get_name_ext(&content_disposition)?; if name == "icon" { - icon_url = process_icon_upload( - uploaded_files, - mod_id, - file_name, - file_extension, - file_host, - field, - &cdn_url, - ) - .await?; + if icon_url.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Mods can only have one icon", + ))); + } + // Upload the icon to the cdn + icon_url = Some( + process_icon_upload( + uploaded_files, + mod_id, + file_name, + file_extension, + file_host, + field, + &cdn_url, + ) + .await?, + ); continue; } - let (version_index, version_data) = create_data - .initial_versions - .iter() - .enumerate() - .find(|(_, x)| x.file_parts.iter().any(|n| n == name)) - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "File `{}` (field {}) isn't specified in the versions data", - file_name, name - )) - })?; - - let created_version = if let Some(created_version) = created_versions.get_mut(version_index) - { - created_version + let index = if let Some(i) = versions_map.get(name) { + *i } else { - // This shouldn't be reachable, but better safe than sorry 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 = mod_create_data.initial_versions.get(index).unwrap(); + // Upload the new jar file let file_builder = super::version_creation::upload_file( &mut field, @@ -346,179 +392,222 @@ async fn mod_create_inner( created_version.files.push(file_builder); } - let create_data = if let Some(create_data) = mod_create_data { - create_data - } else { - return Err(CreateError::MissingValueError(String::from( - "Multipart upload missing `data` field", - ))); - }; - - for (version_data, builder) in create_data - .initial_versions - .iter() - .zip(created_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", - ))); + // Check to make sure that all specified files were uploaded + for (version_data, builder) in mod_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", + ))); + } } - } - let ids: Vec = (&create_data.team_members) - .iter() - .map(|m| m.user_id) - .collect(); - if !ids.contains(&user.id) { - return Err(CreateError::InvalidInput(String::from( - "Team members must include yourself!", - ))); - } + // Convert the list of category names to actual categories + let mut categories = Vec::with_capacity(mod_create_data.categories.len()); + for category in &mod_create_data.categories { + let id = models::categories::Category::get_id(&category, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; + categories.push(id); + } - let mut categories = Vec::with_capacity(create_data.categories.len()); - for category in &create_data.categories { - let id = models::categories::Category::get_id(&category, &mut *transaction) + // Upload the mod desciption markdown to the CDN + // TODO: Should we also process and upload an html version here for SSR? + let body_path = format!("data/{}/body.md", mod_id); + { + let upload_data = file_host + .upload_file( + "text/plain", + &body_path, + mod_create_data.mod_body.into_bytes(), + ) + .await?; + + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: upload_data.file_name, + }); + } + + let team = models::team_item::TeamBuilder { + members: vec![models::team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + name: current_user.username.clone(), + role: crate::models::teams::OWNER_ROLE.to_owned(), + }], + }; + + let team_id = team.insert(&mut *transaction).await?; + + let status = ModStatus::Processing; + let status_id = models::StatusId::get_id(&status, &mut *transaction) .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - categories.push(id); - } + .expect("No database entry found for status"); - let body_url = format!("data/{}/body.md", mod_id); + let mod_builder = models::mod_item::ModBuilder { + mod_id: mod_id.into(), + team_id, + title: mod_create_data.mod_name, + description: mod_create_data.mod_description, + body_url: format!("{}/{}", cdn_url, body_path), + icon_url, + issues_url: mod_create_data.issues_url, + source_url: mod_create_data.source_url, + wiki_url: mod_create_data.wiki_url, - let upload_data = file_host - .upload_file("text/plain", &body_url, create_data.mod_body.into_bytes()) - .await?; + categories, + initial_versions: versions, + status: status_id, + }; - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id.clone(), - file_name: upload_data.file_name.clone(), - }); - - let mut author_username = None; - let mut author_id = None; - - let team = models::team_item::TeamBuilder { - members: create_data - .team_members - .into_iter() - .map(|member| { - if member.role == crate::models::teams::OWNER_ROLE { - author_id = Some(member.user_id); - author_username = Some(member.name.clone()); - } - models::team_item::TeamMemberBuilder { - user_id: member.user_id.into(), - name: member.name, - role: member.role, - } - }) - .collect(), - }; - - let (author_username, author_id) = if let (Some(u), Some(id)) = (author_username, author_id) { - (u, id) - } else { - return Err(CreateError::InvalidInput(String::from( - "A mod must have an author", - ))); - }; - - let team_id = team.insert(&mut *transaction).await?; - - let status = ModStatus::Processing; - let status_id = sqlx::query!( - " - SELECT id - FROM statuses - WHERE status = $1 - ", - status.to_string() - ) - .fetch_one(&mut *transaction) - .await? - .id; - - let mod_builder = models::mod_item::ModBuilder { - mod_id: mod_id.into(), - team_id, - title: create_data.mod_name, - description: create_data.mod_description, - body_url: format!("{}/{}", cdn_url, body_url), - icon_url: Some(icon_url), - issues_url: create_data.issues_url, - source_url: create_data.source_url, - wiki_url: create_data.wiki_url, - - categories, - initial_versions: created_versions, - status: StatusId(status_id), - }; - - let versions_list = mod_builder - .initial_versions - .iter() - .flat_map(|v| { - v.game_versions.iter().map(|id| id.0.to_string()) - // TODO: proper version identifiers, once game versions - // have been implemented - }) - .collect::>() - .into_iter() - .collect::>(); - - let now = chrono::Utc::now(); - let timestamp = now.timestamp(); - - let index_mod = crate::search::UploadSearchMod { - mod_id: format!("local-{}", mod_id), - title: mod_builder.title.clone(), - description: mod_builder.description.clone(), - categories: create_data.categories.clone(), - versions: versions_list, - page_url: format!("https://modrinth.com/mod/{}", mod_id), - icon_url: mod_builder.icon_url.clone().unwrap(), - author: author_username, - author_url: format!("https://modrinth.com/user/{}", author_id), - // TODO: latest version info - latest_version: String::new(), - downloads: 0, - date_created: now, - created_timestamp: timestamp, - date_modified: now, - modified_timestamp: timestamp, - host: Cow::Borrowed("modrinth"), - empty: Cow::Borrowed("{}{}{}"), - }; - - indexing_queue.add(index_mod); - - let response = crate::models::mods::Mod { - id: mod_id, - team: team_id.into(), - title: mod_builder.title.clone(), - description: mod_builder.description.clone(), - body_url: mod_builder.body_url.clone(), - published: now, - updated: now, - status, - downloads: 0, - categories: create_data.categories.clone(), - versions: mod_builder + let versions_list = mod_builder .initial_versions .iter() - .map(|v| v.version_id.into()) - .collect::>(), - icon_url: mod_builder.icon_url.clone(), - issues_url: mod_builder.issues_url.clone(), - source_url: mod_builder.source_url.clone(), - wiki_url: mod_builder.wiki_url.clone(), + .flat_map(|v| v.game_versions.iter().map(|id| id.0.to_string())) + .collect::>() + .into_iter() + .collect::>(); + + let now = chrono::Utc::now(); + let timestamp = now.timestamp(); + + let index_mod = crate::search::UploadSearchMod { + mod_id: format!("local-{}", mod_id), + title: mod_builder.title.clone(), + description: mod_builder.description.clone(), + categories: mod_create_data.categories.clone(), + versions: versions_list, + page_url: format!("https://modrinth.com/mod/{}", mod_id), + // This should really be optional in the index + icon_url: mod_builder.icon_url.clone().unwrap_or_else(String::new), + author: current_user.username.clone(), + author_url: format!("https://modrinth.com/user/{}", current_user.id), + // TODO: latest version info + latest_version: String::new(), + downloads: 0, + date_created: now, + created_timestamp: timestamp, + date_modified: now, + modified_timestamp: timestamp, + host: Cow::Borrowed("modrinth"), + empty: Cow::Borrowed("{}{}{}"), + }; + + indexing_queue.add(index_mod); + + let response = crate::models::mods::Mod { + id: mod_id, + team: team_id.into(), + title: mod_builder.title.clone(), + description: mod_builder.description.clone(), + body_url: mod_builder.body_url.clone(), + published: now, + updated: now, + status, + downloads: 0, + categories: mod_create_data.categories.clone(), + versions: mod_builder + .initial_versions + .iter() + .map(|v| v.version_id.into()) + .collect::>(), + icon_url: mod_builder.icon_url.clone(), + issues_url: mod_builder.issues_url.clone(), + source_url: mod_builder.source_url.clone(), + wiki_url: mod_builder.wiki_url.clone(), + }; + + let _mod_id = mod_builder.insert(&mut *transaction).await?; + + Ok(HttpResponse::Ok().json(response)) + } +} + +async fn create_initial_version( + version_data: &InitialVersionData, + mod_id: ModId, + author: UserId, + cdn_url: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, +) -> Result { + if version_data.mod_id.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Found mod id in initial version for new mod", + ))); + } + + check_length(3..=256, "version name", &version_data.version_title)?; + check_length(1..=32, "version number", &version_data.version_number)?; + + // Randomly generate a new id to be used for the version + let version_id: VersionId = models::generate_version_id(transaction).await?.into(); + + // Upload the version's changelog to the CDN + let changelog_path = if let Some(changelog) = &version_data.version_body { + let changelog_path = format!("data/{}/changelogs/{}/body.md", mod_id, version_id); + + let uploaded_text = file_host + .upload_file( + "text/plain", + &changelog_path, + changelog.clone().into_bytes(), + ) + .await?; + + uploaded_files.push(UploadedFile { + file_id: uploaded_text.file_id, + file_name: uploaded_text.file_name, + }); + Some(changelog_path) + } else { + None }; - let _mod_id = mod_builder.insert(&mut *transaction).await?; + let release_channel = + models::ChannelId::get_id(version_data.release_channel.as_str(), &mut *transaction) + .await? + .expect("Release Channel not found in database"); - // TODO: respond with the new mod info, or with just the new mod id. - Ok(HttpResponse::Ok().json(response)) + let mut game_versions = Vec::with_capacity(version_data.game_versions.len()); + for v in &version_data.game_versions { + let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?; + game_versions.push(id); + } + + let mut loaders = Vec::with_capacity(version_data.loaders.len()); + for l in &version_data.loaders { + let id = models::categories::Loader::get_id(&l.0, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?; + loaders.push(id); + } + + let dependencies = version_data + .dependencies + .iter() + .map(|x| (*x).into()) + .collect::>(); + + let version = models::version_item::VersionBuilder { + version_id: version_id.into(), + mod_id: mod_id.into(), + author_id: author.into(), + name: version_data.version_title.clone(), + version_number: version_data.version_number.clone(), + changelog_url: changelog_path.map(|path| format!("{}/{}", cdn_url, path)), + files: Vec::new(), + dependencies, + game_versions, + loaders, + release_channel, + }; + + Ok(version) } async fn process_icon_upload( @@ -536,6 +625,12 @@ async fn process_icon_upload( data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); } + if data.len() >= 16384 { + return Err(CreateError::InvalidInput(String::from( + "Icons must be smaller than 16KiB", + ))); + } + let upload_data = file_host .upload_file( content_type, @@ -574,17 +669,28 @@ fn get_image_content_type(extension: &str) -> Option<&'static str> { } } -fn check_length( - var_name: &str, - min_length: usize, - max_length: usize, - string: &str, +pub fn check_length( + range: impl std::ops::RangeBounds + std::fmt::Debug, + field_name: &str, + field: &str, ) -> Result<(), CreateError> { - let length = string.len(); - if length > max_length || length < min_length { + use std::ops::Bound; + + let length = field.len(); + if !range.contains(&length) { + let bounds = match (range.start_bound(), range.end_bound()) { + (Bound::Included(a), Bound::Included(b)) => format!("between {} and {} bytes", a, b), + (Bound::Included(a), Bound::Excluded(b)) => { + format!("between {} and {} bytes", a, b - 1) + } + (Bound::Included(a), Bound::Unbounded) => format!("more than {} bytes", a), + (Bound::Unbounded, Bound::Included(b)) => format!("less than or equal to {} bytes", b), + (Bound::Unbounded, Bound::Excluded(b)) => format!("less than {} bytes", b), + _ => format!("{:?}", range), + }; Err(CreateError::InvalidInput(format!( - "The {} must be between {} and {} characters; got {}.", - var_name, min_length, max_length, length + "The {} must be {}; got {}.", + field_name, bounds, length ))) } else { Ok(()) diff --git a/src/routes/version_creation.rs b/src/routes/version_creation.rs index fa8c36a7..05d366e2 100644 --- a/src/routes/version_creation.rs +++ b/src/routes/version_creation.rs @@ -19,7 +19,7 @@ pub struct InitialVersionData { pub file_parts: Vec, pub version_number: String, pub version_title: String, - pub version_body: String, + pub version_body: Option, pub dependencies: Vec, pub game_versions: Vec, pub release_channel: VersionType, @@ -31,6 +31,45 @@ struct InitialFileData { // TODO: hashes? } +pub fn check_version(version: &InitialVersionData) -> Result<(), CreateError> { + /* + # InitialVersionData + file_parts: Vec, 1..=256 + version_number: 1..=64, + version_title: 3..=256, + version_body: max of 64KiB, + game_versions: Vec, 1..=256 + release_channel: VersionType, + loaders: Vec, 1..=256 + */ + use super::mod_creation::check_length; + + version + .file_parts + .iter() + .map(|f| check_length(1..=256, "file part name", f)) + .collect::>()?; + + check_length(1..=64, "version number", &version.version_number)?; + check_length(3..=256, "version title", &version.version_title)?; + if let Some(body) = &version.version_body { + check_length(..65536, "version body", body)?; + } + + version + .game_versions + .iter() + .map(|v| check_length(1..=256, "game version", &v.0)) + .collect::>()?; + version + .loaders + .iter() + .map(|l| check_length(1..=256, "loader name", &l.0)) + .collect::>()?; + + Ok(()) +} + // under `/api/v1/mod/{mod_id}` #[post("version")] pub async fn version_create( @@ -105,8 +144,11 @@ async fn version_create_inner( return Err(CreateError::MissingValueError("Missing mod id".to_string())); } + check_version(version_create_data)?; + let mod_id: models::ModId = version_create_data.mod_id.unwrap().into(); + // Ensure that the mod this version is being added to exists let results = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", mod_id as models::ModId @@ -120,6 +162,8 @@ async fn version_create_inner( )); } + // Check whether there is already a version of this mod with the + // same version number let results = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))", version_create_data.version_number, @@ -134,58 +178,61 @@ async fn version_create_inner( )); } - let team_id = sqlx::query!( - "SELECT team_id FROM mods WHERE id=$1", + // Check that the user creating this version is a team member + // of the mod the version is being added to. + let member_ids = sqlx::query!( + " + SELECT user_id FROM team_members tm + INNER JOIN mods ON mods.team_id = tm.team_id + WHERE mods.id = $1 + ", mod_id as models::ModId, ) - .fetch_one(&mut *transaction) - .await? - .team_id; + .fetch_all(&mut *transaction) + .await?; - let member_ids_rows = - sqlx::query!("SELECT user_id FROM team_members WHERE team_id=$1", team_id,) - .fetch_all(&mut *transaction) - .await?; + let member_ids: Vec = member_ids + .iter() + .map(|m| models::UserId(m.user_id)) + .collect(); - let member_ids: Vec = member_ids_rows.iter().map(|m| m.user_id).collect(); - - if !member_ids.contains(&(user.id.0 as i64)) { + if !member_ids.contains(&user.id.into()) { + // TODO: Some team members may not have the permissions + // to upload mods; We need a more in depth permissions + // system. return Err(CreateError::InvalidInput("Unauthorized".to_string())); } let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - let body_url = format!( - "data/{}/changelogs/{}/body.md", - version_create_data.mod_id.unwrap(), - version_id - ); - let uploaded_text = file_host - .upload_file( - "text/plain", - &body_url, - version_create_data.version_body.clone().into_bytes(), - ) - .await?; + let body_path; - uploaded_files.push(UploadedFile { - file_id: uploaded_text.file_id.clone(), - file_name: uploaded_text.file_name.clone(), - }); + if let Some(body) = &version_create_data.version_body { + let path = format!( + "data/{}/changelogs/{}/body.md", + version_create_data.mod_id.unwrap(), + version_id + ); - let release_channel = models::ChannelId( - sqlx::query!( - " - SELECT id - FROM release_channels - WHERE channel = $1 - ", - version_create_data.release_channel.to_string() - ) - .fetch_one(&mut *transaction) - .await? - .id, - ); + let uploaded_text = file_host + .upload_file("text/plain", &path, body.clone().into_bytes()) + .await?; + + uploaded_files.push(UploadedFile { + file_id: uploaded_text.file_id.clone(), + file_name: uploaded_text.file_name.clone(), + }); + body_path = Some(path); + } else { + body_path = None; + } + + let release_channel = models::ChannelId::get_id( + version_create_data.release_channel.as_str(), + &mut *transaction, + ) + .await? + .expect("Release channel not found in database"); let mut game_versions = Vec::with_capacity(version_create_data.game_versions.len()); for v in &version_create_data.game_versions { @@ -209,8 +256,8 @@ async fn version_create_inner( author_id: user.id.into(), name: version_create_data.version_title.clone(), version_number: version_create_data.version_number.clone(), - changelog_url: Some(format!("{}/{}", cdn_url, body_url)), - files: Vec::with_capacity(1), + changelog_url: body_path.map(|path| format!("{}/{}", cdn_url, path)), + files: Vec::new(), dependencies: version_create_data .dependencies .iter() @@ -243,22 +290,22 @@ async fn version_create_inner( version.files.push(file_builder); } - let version_data_safe = initial_version_data + let version_data = initial_version_data .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; - let version_builder_safe = version_builder + let builder = version_builder .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; let response = Version { - id: version_builder_safe.version_id.into(), - mod_id: version_builder_safe.mod_id.into(), + id: builder.version_id.into(), + mod_id: builder.mod_id.into(), author_id: user.id, - name: version_builder_safe.name.clone(), - version_number: version_builder_safe.version_number.clone(), - changelog_url: version_builder_safe.changelog_url.clone(), + name: builder.name.clone(), + version_number: builder.version_number.clone(), + changelog_url: builder.changelog_url.clone(), date_published: chrono::Utc::now(), downloads: 0, - version_type: version_data_safe.release_channel, - files: version_builder_safe + version_type: version_data.release_channel, + files: builder .files .iter() .map(|file| VersionFile { @@ -280,12 +327,12 @@ async fn version_create_inner( filename: file.filename.clone(), }) .collect::>(), - dependencies: version_data_safe.dependencies, - game_versions: version_data_safe.game_versions, - loaders: version_data_safe.loaders, + dependencies: version_data.dependencies, + game_versions: version_data.game_versions, + loaders: version_data.loaders, }; - version_builder_safe.insert(transaction).await?; + builder.insert(transaction).await?; Ok(HttpResponse::Ok().json(response)) } @@ -449,6 +496,16 @@ pub async fn upload_file( data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); } + // Mod file size limit of 25MiB + const FILE_SIZE_CAP: usize = 25 * (2 << 30); + + // TODO: override file size cap for authorized users or mods + if data.len() >= FILE_SIZE_CAP { + return Err(CreateError::InvalidInput( + String::from("Mod file exceeds the maximum of 25MiB. Contact a moderator or admin to request permission to upload larger files.") + )); + } + let upload_data = file_host .upload_file( content_type,