use std::collections::HashMap; use super::ApiError; use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; use crate::database::models::loader_fields::{ self, LoaderField, LoaderFieldEnumValue, VersionField, }; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{skip_nulls, Loader}; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::search::indexing::remove_documents; use crate::search::SearchConfig; use crate::util::img; use crate::util::validate::validation_errors_to_string; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { cfg.route( "version", web::post().to(super::version_creation::version_create), ); cfg.route("versions", web::get().to(versions_get)); cfg.service( web::scope("version") .route("{id}", web::get().to(version_get)) .route("{id}", web::patch().to(version_edit)) .route("{id}", web::delete().to(version_delete)) .route("{id}/schedule", web::post().to(version_schedule)) .route( "{version_id}/file", web::post().to(super::version_creation::upload_file_to_version), ), ); } // Given a project ID/slug and a version slug pub async fn version_project_get( req: HttpRequest, info: web::Path<(String, String)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let info = info.into_inner(); version_project_get_helper(req, info, pool, redis, session_queue).await } pub async fn version_project_get_helper( req: HttpRequest, id: (String, String), pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let result = database::models::Project::get(&id.0, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), ) .await .map(|x| x.1) .ok(); if let Some(project) = result { if !is_authorized(&project.inner, &user_option, &pool).await? { return Err(ApiError::NotFound); } let versions = database::models::Version::get_many(&project.versions, &**pool, &redis).await?; let id_opt = parse_base62(&id.1).ok(); let version = versions .into_iter() .find(|x| Some(x.inner.id.0 as u64) == id_opt || x.inner.version_number == id.1); if let Some(version) = version { if is_authorized_version(&version.inner, &user_option, &pool).await? { return Ok(HttpResponse::Ok().json(models::projects::Version::from(version))); } } } Err(ApiError::NotFound) } #[derive(Serialize, Deserialize)] pub struct VersionIds { pub ids: String, } pub async fn versions_get( req: HttpRequest, web::Query(ids): web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let version_ids = serde_json::from_str::>(&ids.ids)? .into_iter() .map(|x| x.into()) .collect::>(); let versions_data = database::models::Version::get_many(&version_ids, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::VERSION_READ]), ) .await .map(|x| x.1) .ok(); let versions = filter_authorized_versions(versions_data, &user_option, &pool).await?; Ok(HttpResponse::Ok().json(versions)) } pub async fn version_get( req: HttpRequest, info: web::Path<(models::ids::VersionId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let id = info.into_inner().0; version_get_helper(req, id, pool, redis, session_queue).await } pub async fn version_get_helper( req: HttpRequest, id: models::ids::VersionId, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let version_data = database::models::Version::get(id.into(), &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::VERSION_READ]), ) .await .map(|x| x.1) .ok(); if let Some(data) = version_data { if is_authorized_version(&data.inner, &user_option, &pool).await? { return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); } } Err(ApiError::NotFound) } #[derive(Serialize, Deserialize, Validate, Default, Debug)] pub struct EditVersion { #[validate( length(min = 1, max = 64), custom(function = "crate::util::validate::validate_name") )] pub name: Option, #[validate( length(min = 1, max = 32), regex = "crate::util::validate::RE_URL_SAFE" )] pub version_number: Option, #[validate(length(max = 65536))] pub changelog: Option, pub version_type: Option, #[validate( length(min = 0, max = 4096), custom(function = "crate::util::validate::validate_deps") )] pub dependencies: Option>, pub loaders: Option>, pub featured: Option, pub primary_file: Option<(String, String)>, pub downloads: Option, pub status: Option, pub file_types: Option>, pub ordering: Option>, //TODO: How do you actually pass this in json? // Flattened loader fields // All other fields are loader-specific VersionFields // These are flattened during serialization #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, } #[derive(Serialize, Deserialize, Debug)] pub struct EditVersionFileType { pub algorithm: String, pub hash: String, pub file_type: Option, } // TODO: Avoid this 'helper' pattern here and similar fnunctoins- a macro might be the best bet here to ensure it's callable from both v2 and v3 // (web::Path can't be recreated naturally) pub async fn version_edit( req: HttpRequest, info: web::Path<(VersionId,)>, pool: web::Data, redis: web::Data, new_version: web::Json, session_queue: web::Data, ) -> Result { let new_version: EditVersion = serde_json::from_value(new_version.into_inner())?; version_edit_helper( req, info.into_inner(), pool, redis, new_version, session_queue, ) .await } pub async fn version_edit_helper( req: HttpRequest, info: (VersionId,), pool: web::Data, redis: web::Data, new_version: EditVersion, session_queue: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::VERSION_WRITE]), ) .await? .1; new_version .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; let version_id = info.0; let id = version_id.into(); let result = database::models::Version::get(id, &**pool, &redis).await?; if let Some(version_item) = result { let team_member = database::models::TeamMember::get_from_user_id_project( version_item.inner.project_id, user.id.into(), &**pool, ) .await?; let organization = Organization::get_associated_organization_project_id( version_item.inner.project_id, &**pool, ) .await?; let organization_team_member = if let Some(organization) = &organization { database::models::TeamMember::get_from_user_id( organization.team_id, user.id.into(), &**pool, ) .await? } else { None }; let permissions = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ); if let Some(perms) = permissions { if !perms.contains(ProjectPermissions::UPLOAD_VERSION) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit this version!".to_string(), )); } let mut transaction = pool.begin().await?; if let Some(name) = &new_version.name { sqlx::query!( " UPDATE versions SET name = $1 WHERE (id = $2) ", name.trim(), id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(number) = &new_version.version_number { sqlx::query!( " UPDATE versions SET version_number = $1 WHERE (id = $2) ", number, id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(version_type) = &new_version.version_type { sqlx::query!( " UPDATE versions SET version_type = $1 WHERE (id = $2) ", version_type.as_str(), id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(dependencies) = &new_version.dependencies { // TODO: Re-add this exclusions when modpack also has separate dependency retrieval that was removed from validators // if let Some(project) = project_item { // if project.project_type != "modpack" { sqlx::query!( " DELETE FROM dependencies WHERE dependent_id = $1 ", id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; let builders = dependencies .iter() .map(|x| database::models::version_item::DependencyBuilder { project_id: x.project_id.map(|x| x.into()), version_id: x.version_id.map(|x| x.into()), file_name: x.file_name.clone(), dependency_type: x.dependency_type.to_string(), }) .collect::>(); DependencyBuilder::insert_many(builders, version_item.inner.id, &mut transaction) .await?; // } // } } if !new_version.fields.is_empty() { let version_fields_names = new_version .fields .keys() .map(|x| x.to_string()) .collect::>(); let all_loaders = loader_fields::Loader::list(&mut *transaction, &redis).await?; let loader_ids = version_item .loaders .iter() .filter_map(|x| all_loaders.iter().find(|y| &y.loader == x).map(|y| y.id)) .collect_vec(); let loader_fields = LoaderField::get_fields(&loader_ids, &mut *transaction, &redis) .await? .into_iter() .filter(|lf| version_fields_names.contains(&lf.field)) .collect::>(); let loader_field_ids = loader_fields.iter().map(|lf| lf.id.0).collect::>(); sqlx::query!( " DELETE FROM version_fields WHERE version_id = $1 AND field_id = ANY($2) ", id as database::models::ids::VersionId, &loader_field_ids ) .execute(&mut *transaction) .await?; let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( &loader_fields, &mut *transaction, &redis, ) .await?; let mut version_fields = Vec::new(); for (vf_name, vf_value) in new_version.fields { let loader_field = loader_fields .iter() .find(|lf| lf.field == vf_name) .ok_or_else(|| { ApiError::InvalidInput(format!( "Loader field '{vf_name}' does not exist for any loaders supplied." )) })?; let enum_variants = loader_field_enum_values .remove(&loader_field.id) .unwrap_or_default(); let vf: VersionField = VersionField::check_parse( version_id.into(), loader_field.clone(), vf_value.clone(), enum_variants, ) .map_err(ApiError::InvalidInput)?; version_fields.push(vf); } VersionField::insert_many(version_fields, &mut transaction).await?; } if let Some(loaders) = &new_version.loaders { sqlx::query!( " DELETE FROM loaders_versions WHERE version_id = $1 ", id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; let mut loader_versions = Vec::new(); for loader in loaders { let loader_id = database::models::loader_fields::Loader::get_id( &loader.0, &mut *transaction, &redis, ) .await? .ok_or_else(|| { ApiError::InvalidInput("No database entry for loader provided.".to_string()) })?; loader_versions.push(LoaderVersion::new(loader_id, id)); } LoaderVersion::insert_many(loader_versions, &mut transaction).await?; crate::database::models::Project::clear_cache( version_item.inner.project_id, None, None, &redis, ) .await?; } if let Some(featured) = &new_version.featured { sqlx::query!( " UPDATE versions SET featured = $1 WHERE (id = $2) ", featured, id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(primary_file) = &new_version.primary_file { let result = sqlx::query!( " SELECT f.id id FROM hashes h INNER JOIN files f ON h.file_id = f.id WHERE h.algorithm = $2 AND h.hash = $1 ", primary_file.1.as_bytes(), primary_file.0 ) .fetch_optional(&**pool) .await? .ok_or_else(|| { ApiError::InvalidInput(format!( "Specified file with hash {} does not exist.", primary_file.1.clone() )) })?; sqlx::query!( " UPDATE files SET is_primary = FALSE WHERE (version_id = $1) ", id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; sqlx::query!( " UPDATE files SET is_primary = TRUE WHERE (id = $1) ", result.id, ) .execute(&mut *transaction) .await?; } if let Some(body) = &new_version.changelog { sqlx::query!( " UPDATE versions SET changelog = $1 WHERE (id = $2) ", body, id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(downloads) = &new_version.downloads { if !user.role.is_mod() { return Err(ApiError::CustomAuthentication( "You don't have permission to set the downloads of this mod".to_string(), )); } sqlx::query!( " UPDATE versions SET downloads = $1 WHERE (id = $2) ", *downloads as i32, id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; let diff = *downloads - (version_item.inner.downloads as u32); sqlx::query!( " UPDATE mods SET downloads = downloads + $1 WHERE (id = $2) ", diff as i32, version_item.inner.project_id as database::models::ids::ProjectId, ) .execute(&mut *transaction) .await?; } if let Some(status) = &new_version.status { if !status.can_be_requested() { return Err(ApiError::InvalidInput( "The requested status cannot be set!".to_string(), )); } sqlx::query!( " UPDATE versions SET status = $1 WHERE (id = $2) ", status.as_str(), id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } if let Some(file_types) = &new_version.file_types { for file_type in file_types { let result = sqlx::query!( " SELECT f.id id FROM hashes h INNER JOIN files f ON h.file_id = f.id WHERE h.algorithm = $2 AND h.hash = $1 ", file_type.hash.as_bytes(), file_type.algorithm ) .fetch_optional(&**pool) .await? .ok_or_else(|| { ApiError::InvalidInput(format!( "Specified file with hash {} does not exist.", file_type.algorithm.clone() )) })?; sqlx::query!( " UPDATE files SET file_type = $2 WHERE (id = $1) ", result.id, file_type.file_type.as_ref().map(|x| x.as_str()), ) .execute(&mut *transaction) .await?; } } if let Some(ordering) = &new_version.ordering { sqlx::query!( " UPDATE versions SET ordering = $1 WHERE (id = $2) ", ordering.to_owned() as Option, id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; } // delete any images no longer in the changelog let checkable_strings: Vec<&str> = vec![&new_version.changelog] .into_iter() .filter_map(|x| x.as_ref().map(|y| y.as_str())) .collect(); let context = ImageContext::Version { version_id: Some(version_item.inner.id.into()), }; img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; database::models::Version::clear_cache(&version_item, &redis).await?; database::models::Project::clear_cache( version_item.inner.project_id, None, Some(true), &redis, ) .await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::CustomAuthentication( "You do not have permission to edit this version!".to_string(), )) } } else { Err(ApiError::NotFound) } } #[derive(Serialize, Deserialize)] pub struct VersionListFilters { pub loaders: Option, pub featured: Option, pub version_type: Option, pub limit: Option, pub offset: Option, /* Loader fields to filter with: "game_versions": ["1.16.5", "1.17"] Returns if it matches any of the values */ pub loader_fields: Option, } pub async fn version_list( req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { let string = info.into_inner().0; let result = database::models::Project::get(&string, &**pool, &redis).await?; let user_option = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), ) .await .map(|x| x.1) .ok(); if let Some(project) = result { if !is_authorized(&project.inner, &user_option, &pool).await? { return Err(ApiError::NotFound); } let loader_field_filters = filters.loader_fields.as_ref().map(|x| { serde_json::from_str::>>(x).unwrap_or_default() }); let loader_filters = filters .loaders .as_ref() .map(|x| serde_json::from_str::>(x).unwrap_or_default()); let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis) .await? .into_iter() .skip(filters.offset.unwrap_or(0)) .take(filters.limit.unwrap_or(usize::MAX)) .filter(|x| { let mut bool = true; if let Some(version_type) = filters.version_type { bool &= &*x.inner.version_type == version_type.as_str(); } if let Some(loaders) = &loader_filters { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } if let Some(loader_fields) = &loader_field_filters { for (key, values) in loader_fields { bool &= if let Some(x_vf) = x.version_fields.iter().find(|y| y.field_name == *key) { values.iter().any(|v| x_vf.value.contains_json_value(v)) } else { true }; } } bool }) .collect::>(); let mut response = versions .iter() .filter(|version| { filters .featured .map(|featured| featured == version.inner.featured) .unwrap_or(true) }) .cloned() .collect::>(); versions.sort(); // Attempt to populate versions with "auto featured" versions if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { // TODO: Re-implement this // let (loaders, game_versions) = futures::future::try_join( // database::models::loader_fields::Loader::list(&**pool, &redis), // database::models::loader_fields::GameVersion::list_filter( // None, // Some(true), // &**pool, // &redis, // ), // ) // .await?; // let mut joined_filters = Vec::new(); // for game_version in &game_versions { // for loader in &loaders { // joined_filters.push((game_version, loader)) // } // } // joined_filters.into_iter().for_each(|filter| { // versions // .iter() // .find(|version| { // // version.game_versions.contains(&filter.0.version) // // && // version.loaders.contains(&filter.1.loader) // }) // .map(|version| response.push(version.clone())) // .unwrap_or(()); // }); if response.is_empty() { versions .into_iter() .for_each(|version| response.push(version)); } } response.sort(); response.dedup_by(|a, b| a.inner.id == b.inner.id); let response = filter_authorized_versions(response, &user_option, &pool).await?; Ok(HttpResponse::Ok().json(response)) } else { Err(ApiError::NotFound) } } #[derive(Deserialize)] pub struct SchedulingData { pub time: DateTime, pub requested_status: VersionStatus, } pub async fn version_schedule( req: HttpRequest, info: web::Path<(models::ids::VersionId,)>, pool: web::Data, redis: web::Data, scheduling_data: web::Json, session_queue: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::VERSION_WRITE]), ) .await? .1; if scheduling_data.time < Utc::now() { return Err(ApiError::InvalidInput( "You cannot schedule a version to be released in the past!".to_string(), )); } if !scheduling_data.requested_status.can_be_requested() { return Err(ApiError::InvalidInput( "Specified requested status cannot be requested!".to_string(), )); } let string = info.into_inner().0; let result = database::models::Version::get(string.into(), &**pool, &redis).await?; if let Some(version_item) = result { let team_member = database::models::TeamMember::get_from_user_id_project( version_item.inner.project_id, user.id.into(), &**pool, ) .await?; let organization_item = database::models::Organization::get_associated_organization_project_id( version_item.inner.project_id, &**pool, ) .await .map_err(ApiError::Database)?; let organization_team_member = if let Some(organization) = &organization_item { database::models::TeamMember::get_from_user_id( organization.team_id, user.id.into(), &**pool, ) .await? } else { None }; let permissions = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ) .unwrap_or_default(); if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have permission to edit this version's scheduling data!".to_string(), )); } let mut transaction = pool.begin().await?; sqlx::query!( " UPDATE versions SET status = $1, date_published = $2 WHERE (id = $3) ", VersionStatus::Scheduled.as_str(), scheduling_data.time, version_item.inner.id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; database::models::Version::clear_cache(&version_item, &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::NotFound) } } pub async fn version_delete( req: HttpRequest, info: web::Path<(models::ids::VersionId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, search_config: web::Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::VERSION_DELETE]), ) .await? .1; let id = info.into_inner().0; let version = database::models::Version::get(id.into(), &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput("The specified version does not exist!".to_string()) })?; if !user.role.is_admin() { let team_member = database::models::TeamMember::get_from_user_id_project( version.inner.project_id, user.id.into(), &**pool, ) .await .map_err(ApiError::Database)?; let organization = Organization::get_associated_organization_project_id(version.inner.project_id, &**pool) .await?; let organization_team_member = if let Some(organization) = &organization { database::models::TeamMember::get_from_user_id( organization.team_id, user.id.into(), &**pool, ) .await? } else { None }; let permissions = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ) .unwrap_or_default(); if !permissions.contains(ProjectPermissions::DELETE_VERSION) { return Err(ApiError::CustomAuthentication( "You do not have permission to delete versions in this team".to_string(), )); } } let mut transaction = pool.begin().await?; let context = ImageContext::Version { version_id: Some(version.inner.id.into()), }; let uploaded_images = database::models::Image::get_many_contexted(context, &mut transaction).await?; for image in uploaded_images { image_item::Image::remove(image.id, &mut transaction, &redis).await?; } let result = database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?; remove_documents(&[version.inner.id.into()], &search_config).await?; database::models::Project::clear_cache(version.inner.project_id, None, Some(true), &redis) .await?; transaction.commit().await?; if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::NotFound) } }