diff --git a/apps/labrinth/.sqlx/query-e497b7ee2bb69ec23bc091c525a69666544b850f66b01635a1674bc50704f688.json b/apps/labrinth/.sqlx/query-e497b7ee2bb69ec23bc091c525a69666544b850f66b01635a1674bc50704f688.json new file mode 100644 index 0000000000..56b42c28d8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e497b7ee2bb69ec23bc091c525a69666544b850f66b01635a1674bc50704f688.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n refs.project_ref AS \"project_ref!\",\n redirect.target_project_id AS \"target_project_id: DBProjectId\"\n FROM UNNEST($1::text[], $2::bigint[])\n AS refs(project_ref, project_id)\n INNER JOIN project_redirects redirect\n ON redirect.slug = LOWER(refs.project_ref)\n LEFT JOIN mods id_project\n ON id_project.id = refs.project_id\n LEFT JOIN mods slug_project\n ON slug_project.slug = LOWER(refs.project_ref)\n WHERE id_project.id IS NULL AND slug_project.id IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_ref!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "target_project_id: DBProjectId", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Int8Array" + ] + }, + "nullable": [ + null, + false + ] + }, + "hash": "e497b7ee2bb69ec23bc091c525a69666544b850f66b01635a1674bc50704f688" +} diff --git a/apps/labrinth/migrations/20260922120000_project_redirects.sql b/apps/labrinth/migrations/20260922120000_project_redirects.sql new file mode 100644 index 0000000000..6e09016289 --- /dev/null +++ b/apps/labrinth/migrations/20260922120000_project_redirects.sql @@ -0,0 +1,7 @@ +CREATE TABLE project_redirects ( + slug TEXT PRIMARY KEY CHECK (slug = LOWER(slug)), + target_project_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE +); + +CREATE INDEX project_redirects_target_project_id + ON project_redirects(target_project_id); diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 7ce9031f0d..8184b73db8 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -27,6 +27,86 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(version_file); } +async fn redirect_maven_ref( + req: &HttpRequest, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let Some(project_ref) = req.match_info().get("id") else { + return Ok(None); + }; + let Some(target_project_id) = + crate::routes::resolve_ref(project_ref, pool, redis).await? + else { + return Ok(None); + }; + + let Some(route_pattern) = req.match_pattern() else { + return Ok(None); + }; + let route_segments = route_pattern.split('/').collect::>(); + let Some(project_ref_index) = + route_segments.iter().position(|segment| *segment == "{id}") + else { + return Ok(None); + }; + let mut path_segments = req + .uri() + .path() + .split('/') + .map(ToOwned::to_owned) + .collect::>(); + let Some(project_ref_segment) = path_segments.get_mut(project_ref_index) + else { + return Ok(None); + }; + let target_project_ref = target_project_id.to_string(); + *project_ref_segment = target_project_ref.clone(); + + if let (Some(version), Some(filename)) = ( + req.match_info().get("versionnum"), + req.match_info().get("file"), + ) { + let source_prefix = format!("{project_ref}-{version}"); + if filename + .get(..source_prefix.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(&source_prefix)) + { + let target_prefix = format!("{target_project_ref}-{version}"); + let rewritten_filename = + format!("{target_prefix}{}", &filename[source_prefix.len()..]); + if let Some(filename_index) = route_segments + .iter() + .position(|segment| segment.starts_with("{file}")) + { + let route_suffix = route_segments[filename_index] + .strip_prefix("{file}") + .unwrap_or_default(); + if let Some(filename_segment) = + path_segments.get_mut(filename_index) + { + *filename_segment = format!( + "{}{route_suffix}", + urlencoding::encode(&rewritten_filename) + ); + } + } + } + } + + let mut location = path_segments.join("/"); + if let Some(query) = req.uri().query() { + location.push('?'); + location.push_str(query); + } + + Ok(Some( + HttpResponse::PermanentRedirect() + .append_header((actix_web::http::header::LOCATION, location)) + .finish(), + )) +} + #[derive(Default, Debug, Clone, YaSerialize)] #[yaserde(rename = "metadata")] pub struct Metadata { @@ -85,6 +165,12 @@ pub async fn maven_metadata( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + redirect_maven_ref(&req, pool.as_ref(), redis.as_ref()).await? + { + return Ok(response); + } + let project_id = params.into_inner().0; let Some(project) = database::models::DBProject::get(&project_id, &**pool, &redis) @@ -323,6 +409,12 @@ pub async fn version_file( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + redirect_maven_ref(&req, pool.as_ref(), redis.as_ref()).await? + { + return Ok(response); + } + let (project_id, vnum, file) = params.into_inner(); let Some(project) = database::models::DBProject::get(&project_id, &**pool, &redis) @@ -410,6 +502,12 @@ pub async fn version_file_sha1( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + redirect_maven_ref(&req, pool.as_ref(), redis.as_ref()).await? + { + return Ok(response); + } + let (project_id, vnum, file) = params.into_inner(); let Some(project) = database::models::DBProject::get(&project_id, &**pool, &redis) @@ -476,6 +574,12 @@ pub async fn version_file_sha512( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + redirect_maven_ref(&req, pool.as_ref(), redis.as_ref()).await? + { + return Ok(response); + } + let (project_id, vnum, file) = params.into_inner(); let Some(project) = database::models::DBProject::get(&project_id, &**pool, &redis) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 1387f9daea..da3524b656 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -1,12 +1,19 @@ +use crate::database::PgPool; +use crate::database::models::DBProjectId; use crate::env::ENV; +use crate::models::ids::ProjectId; use crate::util::cors::default_cors; +use crate::util::error::Context; use actix_cors::Cors; use actix_files::Files; -use actix_web::http::StatusCode; -use actix_web::{HttpResponse, web}; +use actix_web::http::{StatusCode, header}; +use actix_web::{HttpRequest, HttpResponse, web}; +use ariadne::ids::base62_impl::parse_base62; use futures::FutureExt; +use std::collections::{HashMap, HashSet}; use utoipa::openapi::extensions::ExtensionsBuilder; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use xredis::RedisPool; pub mod debug; pub mod internal; @@ -22,6 +29,207 @@ mod updates; pub use self::not_found::not_found; +const PROJECT_REDIRECTS_NAMESPACE: &str = "project_redirects:v1"; +const PROJECT_REDIRECT_CACHE_TTL_SECONDS: i64 = 300; + +pub async fn resolve_ref( + project_ref: &str, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let project_refs = [project_ref.to_string()]; + Ok(resolve_refs(&project_refs, pool, redis) + .await? + .into_iter() + .next() + .flatten()) +} + +pub async fn resolve_refs( + project_refs: &[String], + pool: &PgPool, + redis: &RedisPool, +) -> Result>, ApiError> { + if project_refs.is_empty() { + return Ok(Vec::new()); + } + + let mut redis = redis + .connect() + .await + .wrap_internal_err("connecting to Redis for project redirects")?; + let keys = project_refs + .iter() + .map(|project_ref| { + redis.key().entity(PROJECT_REDIRECTS_NAMESPACE, project_ref) + }) + .collect::>(); + let cached_targets = redis + .get_many_deserialized::>(&keys) + .await + .wrap_internal_err("reading cached project redirects")?; + + let missing_refs = project_refs + .iter() + .zip(&cached_targets) + .filter_map(|(project_ref, cached_target)| { + cached_target.is_none().then_some(project_ref.clone()) + }) + .collect::>() + .into_iter() + .collect::>(); + let parsed_project_ids = missing_refs + .iter() + .map(|project_ref| { + parse_base62(project_ref) + .ok() + .map(|project_id| project_id as i64) + }) + .collect::>(); + let redirects = if missing_refs.is_empty() { + HashMap::new() + } else { + sqlx::query!( + r#" + SELECT + refs.project_ref AS "project_ref!", + redirect.target_project_id AS "target_project_id: DBProjectId" + FROM UNNEST($1::text[], $2::bigint[]) + AS refs(project_ref, project_id) + INNER JOIN project_redirects redirect + ON redirect.slug = LOWER(refs.project_ref) + LEFT JOIN mods id_project + ON id_project.id = refs.project_id + LEFT JOIN mods slug_project + ON slug_project.slug = LOWER(refs.project_ref) + WHERE id_project.id IS NULL AND slug_project.id IS NULL + "#, + &missing_refs, + &parsed_project_ids as &[Option], + ) + .fetch_all(pool) + .await + .wrap_internal_err("looking up project redirects")? + .into_iter() + .map(|redirect| (redirect.project_ref, redirect.target_project_id)) + .collect::>() + }; + + let mut resolved = Vec::with_capacity(project_refs.len()); + for ((project_ref, key), cached_target) in + project_refs.iter().zip(&keys).zip(cached_targets) + { + let target_project_id = if let Some(cached_target) = cached_target { + cached_target.map(DBProjectId) + } else { + let target_project_id = redirects.get(project_ref).copied(); + redis + .set_serialized( + key, + &target_project_id.map(|project_id| project_id.0), + Some(PROJECT_REDIRECT_CACHE_TTL_SECONDS), + ) + .await + .wrap_internal_err("caching project redirect")?; + target_project_id + }; + resolved.push(target_project_id.map(ProjectId::from)); + } + + Ok(resolved) +} + +pub async fn redirect_query_refs( + req: &HttpRequest, + parameter_name: &str, + project_refs: &[String], + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let resolved_refs = resolve_refs(project_refs, pool, redis).await?; + if resolved_refs.iter().all(Option::is_none) { + return Ok(None); + } + + let canonical_refs = project_refs + .iter() + .zip(resolved_refs) + .map(|(project_ref, resolved_ref)| { + resolved_ref.map_or_else( + || project_ref.clone(), + |project_id| project_id.to_string(), + ) + }) + .collect::>(); + let canonical_refs = serde_json::to_string(&canonical_refs) + .wrap_internal_err("serializing redirected project references")?; + let mut query = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in + url::form_urlencoded::parse(req.query_string().as_bytes()) + { + if name == parameter_name { + query.append_pair(&name, &canonical_refs); + } else { + query.append_pair(&name, &value); + } + } + let location = format!("{}?{}", req.path(), query.finish()); + + Ok(Some( + HttpResponse::PermanentRedirect() + .append_header((header::LOCATION, location)) + .finish(), + )) +} + +pub async fn redirect_ref( + req: &HttpRequest, + parameter_name: &str, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let Some(project_ref) = req.match_info().get(parameter_name) else { + return Ok(None); + }; + let Some(target_project_id) = resolve_ref(project_ref, pool, redis).await? + else { + return Ok(None); + }; + + let Some(route_pattern) = req.match_pattern() else { + return Ok(None); + }; + let parameter = format!("{{{parameter_name}}}"); + let constrained_parameter = format!("{{{parameter_name}:"); + let Some(parameter_index) = route_pattern.split('/').position(|segment| { + segment == parameter || segment.starts_with(&constrained_parameter) + }) else { + return Ok(None); + }; + let mut path_segments = req + .uri() + .path() + .split('/') + .map(ToOwned::to_owned) + .collect::>(); + let Some(path_segment) = path_segments.get_mut(parameter_index) else { + return Ok(None); + }; + *path_segment = target_project_id.to_string(); + + let mut location = path_segments.join("/"); + if let Some(query) = req.uri().query() { + location.push('?'); + location.push_str(query); + } + + Ok(Some( + HttpResponse::PermanentRedirect() + .append_header((header::LOCATION, location)) + .finish(), + )) +} + // utoipa-specific struct to use a value_type for docs. /// A sha1 or sha512 hash. pub struct FileHash; diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index ceda711d6b..1c685a0779 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -165,115 +165,148 @@ pub async fn project_create( search_state: Data, ) -> Result { // Convert V2 multipart payload to V3 multipart payload + let redirect_client = client.clone(); + let redirect_redis = redis.clone(); let payload = v2_reroute::alter_actix_multipart( payload, req.headers().clone(), - |legacy_create: ProjectCreateData, _| async move { - // Side types will be applied to each version - let client_side = legacy_create.client_side; - let server_side = legacy_create.server_side; + |mut legacy_create: ProjectCreateData, _| { + let redirect_client = redirect_client.clone(); + let redirect_redis = redirect_redis.clone(); + async move { + // Side types will be applied to each version + let client_side = legacy_create.client_side; + let server_side = legacy_create.server_side; - let project_type = legacy_create.project_type; + let project_type = legacy_create.project_type; - let initial_versions = legacy_create - .initial_versions - .into_iter() - .map(|v| { - let mut fields = HashMap::new(); - fields.extend( - v2_reroute::convert_v2_side_types_to_v3_side_types( - client_side, - server_side, - ), - ); - if let Some(environment) = v.environment { - fields.insert( - "environment".to_string(), - json!(environment), - ); + let dependency_project_refs = legacy_create + .initial_versions + .iter() + .flat_map(|version| version.dependencies.iter()) + .filter_map(|dependency| dependency.project_id) + .map(|project_id| project_id.to_string()) + .collect::>(); + let resolved_dependency_project_ids = + crate::routes::resolve_refs( + &dependency_project_refs, + redirect_client.as_ref(), + redirect_redis.as_ref(), + ) + .await?; + for (dependency, resolved_project_id) in legacy_create + .initial_versions + .iter_mut() + .flat_map(|version| version.dependencies.iter_mut()) + .filter(|dependency| dependency.project_id.is_some()) + .zip(resolved_dependency_project_ids) + { + if let Some(project_id) = resolved_project_id { + dependency.project_id = Some(project_id); } - fields.insert( - "game_versions".to_string(), - json!(v.game_versions), - ); + } - // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. - // Setting of 'project_type' directly is removed, it's loader-based now. - if project_type == "modpack" { + let initial_versions = legacy_create + .initial_versions + .into_iter() + .map(|v| { + let mut fields = HashMap::new(); + fields.extend( + v2_reroute::convert_v2_side_types_to_v3_side_types( + client_side, + server_side, + ), + ); + if let Some(environment) = v.environment { + fields.insert( + "environment".to_string(), + json!(environment), + ); + } fields.insert( - "mrpack_loaders".to_string(), - json!(v.loaders), + "game_versions".to_string(), + json!(v.game_versions), ); - } - let loaders = if project_type == "modpack" { - vec![Loader("mrpack".to_string())] - } else { - v.loaders - }; + // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. + // Setting of 'project_type' directly is removed, it's loader-based now. + if project_type == "modpack" { + fields.insert( + "mrpack_loaders".to_string(), + json!(v.loaders), + ); + } - v3::version_creation::InitialVersionData { - project_id: v.project_id, - file_parts: v.file_parts, - version_number: v.version_number, - version_title: v.version_title, - version_body: v.version_body, - dependencies: v.dependencies, - release_channel: v.release_channel, - loaders, - featured: v.featured, - primary_file: v.primary_file, - status: v.status, - file_types: v.file_types, - uploaded_images: v.uploaded_images, - ordering: v.ordering, - fields, - } - }) - .collect(); + let loaders = if project_type == "modpack" { + vec![Loader("mrpack".to_string())] + } else { + v.loaders + }; - let mut link_urls = HashMap::new(); - if let Some(issue_url) = legacy_create.issues_url { - link_urls.insert("issues".to_string(), issue_url); - } - if let Some(source_url) = legacy_create.source_url { - link_urls.insert("source".to_string(), source_url); - } - if let Some(wiki_url) = legacy_create.wiki_url { - link_urls.insert("wiki".to_string(), wiki_url); - } - if let Some(discord_url) = legacy_create.discord_url { - link_urls.insert("discord".to_string(), discord_url); - } - if let Some(donation_urls) = legacy_create.donation_urls { - crate::models::v2::projects::validate_donation_platforms( - &donation_urls, - ) - .map_err(|error| { - CreateError::InvalidInput(error.to_string()) - })?; - for donation_url in donation_urls { - link_urls.insert(donation_url.id, donation_url.url); + v3::version_creation::InitialVersionData { + project_id: v.project_id, + file_parts: v.file_parts, + version_number: v.version_number, + version_title: v.version_title, + version_body: v.version_body, + dependencies: v.dependencies, + release_channel: v.release_channel, + loaders, + featured: v.featured, + primary_file: v.primary_file, + status: v.status, + file_types: v.file_types, + uploaded_images: v.uploaded_images, + ordering: v.ordering, + fields, + } + }) + .collect(); + + let mut link_urls = HashMap::new(); + if let Some(issue_url) = legacy_create.issues_url { + link_urls.insert("issues".to_string(), issue_url); } - } + if let Some(source_url) = legacy_create.source_url { + link_urls.insert("source".to_string(), source_url); + } + if let Some(wiki_url) = legacy_create.wiki_url { + link_urls.insert("wiki".to_string(), wiki_url); + } + if let Some(discord_url) = legacy_create.discord_url { + link_urls.insert("discord".to_string(), discord_url); + } + if let Some(donation_urls) = legacy_create.donation_urls { + crate::models::v2::projects::validate_donation_platforms( + &donation_urls, + ) + .map_err(|error| { + CreateError::InvalidInput(error.to_string()) + })?; - Ok(v3::project_creation::ProjectCreateData { - name: legacy_create.title, - slug: legacy_create.slug, - summary: legacy_create.description, // Description becomes summary - description: legacy_create.body, // Body becomes description - initial_versions, - categories: legacy_create.categories, - additional_categories: legacy_create.additional_categories, - license_url: legacy_create.license_url, - link_urls, - is_draft: legacy_create.is_draft, - license_id: legacy_create.license_id, - gallery_items: legacy_create.gallery_items, - requested_status: legacy_create.requested_status, - uploaded_images: legacy_create.uploaded_images, - organization_id: legacy_create.organization_id, - }) + for donation_url in donation_urls { + link_urls.insert(donation_url.id, donation_url.url); + } + } + + Ok(v3::project_creation::ProjectCreateData { + name: legacy_create.title, + slug: legacy_create.slug, + summary: legacy_create.description, // Description becomes summary + description: legacy_create.body, // Body becomes description + initial_versions, + categories: legacy_create.categories, + additional_categories: legacy_create.additional_categories, + license_url: legacy_create.license_url, + link_urls, + is_draft: legacy_create.is_draft, + license_id: legacy_create.license_id, + gallery_items: legacy_create.gallery_items, + requested_status: legacy_create.requested_status, + uploaded_images: legacy_create.uploaded_images, + organization_id: legacy_create.organization_id, + }) + } }, ) .await?; diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 6f7c9f4971..60482355af 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -241,6 +241,20 @@ pub async fn projects_get( redis: web::Data, session_queue: web::Data, ) -> Result { + let project_refs = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing project references")?; + if let Some(response) = crate::routes::redirect_query_refs( + &req, + "ids", + &project_refs, + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + // Call V3 project creation let response = v3::projects::projects_get( req, @@ -294,6 +308,13 @@ pub async fn project_get( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Convert V2 data to V3 data // Call V3 project creation let project = match v3::projects::project_get_internal( @@ -357,10 +378,18 @@ pub async fn project_get( )] #[get("/{id}/check")] pub async fn project_get_check( + req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns an id only, do not need to convert v3::projects::project_get_check_internal(info, pool, redis) .await @@ -399,6 +428,17 @@ pub async fn dependency_list( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + // TODO: tests, probably let response = v3::projects::dependency_list_internal( req, @@ -575,6 +615,13 @@ pub async fn project_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + let v2_new_project = new_project.into_inner(); let client_side = v2_new_project.client_side; let server_side = v2_new_project.server_side; @@ -839,6 +886,20 @@ pub async fn projects_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { + let project_refs = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing project references")?; + if let Some(response) = crate::routes::redirect_query_refs( + &req, + "ids", + &project_refs, + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let bulk_edit_project = bulk_edit_project.into_inner(); let mut link_urls = HashMap::new(); @@ -980,6 +1041,13 @@ pub async fn project_icon_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::project_icon_edit_internal( web::Query(v3::projects::Extension { ext: ext.ext }), @@ -1025,6 +1093,13 @@ pub async fn delete_project_icon( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::delete_project_icon_internal( req, @@ -1101,6 +1176,13 @@ pub async fn add_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::add_gallery_item_internal( web::Query(v3::projects::Extension { ext: ext.ext }), @@ -1181,6 +1263,13 @@ pub async fn edit_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::edit_gallery_item_internal( req, @@ -1235,6 +1324,13 @@ pub async fn delete_gallery_item( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::delete_gallery_item_internal( req, @@ -1277,6 +1373,13 @@ pub async fn project_delete( session_queue: web::Data, search_state: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::project_delete_internal( req, @@ -1318,6 +1421,13 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::project_follow_internal(req, info, pool, redis, session_queue) .await @@ -1351,6 +1461,13 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + // Returns NoContent, so no need to convert v3::projects::project_unfollow_internal( req, diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index cdd4e30665..fea945cecf 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -31,7 +31,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) // They can be differentiated by the "organization_permissions" field being null or not -/// Get a project's team members. +/// Get a project's team members. #[utoipa::path( context_path = "/project", tag = "teams", @@ -56,6 +56,13 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = + crate::routes::redirect_ref(&req, "id", pool.as_ref(), redis.as_ref()) + .await? + { + return Ok(response); + } + let response = v3::teams::team_members_get_project_internal( req, info, @@ -80,7 +87,7 @@ pub async fn team_members_get_project( } // Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) -/// Get a team's members. +/// Get a team's members. #[utoipa::path( context_path = "/team", tag = "teams", @@ -123,7 +130,7 @@ pub struct TeamIds { pub ids: String, } -/// Get the members of multiple teams. +/// Get the members of multiple teams. #[utoipa::path( tag = "teams", get, @@ -172,7 +179,7 @@ pub async fn teams_get( } } -/// Join a team with a pending invite. +/// Join a team with a pending invite. #[utoipa::path( context_path = "/team", tag = "teams", @@ -232,7 +239,7 @@ pub struct NewTeamMember { pub ordering: i64, } -/// Add a member to a team. +/// Add a member to a team. #[utoipa::path( context_path = "/team", tag = "teams", @@ -293,7 +300,7 @@ pub struct EditTeamMember { pub ordering: Option, } -/// Update a team member. +/// Update a team member. #[utoipa::path( context_path = "/team", tag = "teams", @@ -350,7 +357,7 @@ pub struct TransferOwnership { pub user_id: UserId, } -/// Transfer team ownership. +/// Transfer team ownership. #[utoipa::path( context_path = "/team", tag = "teams", @@ -397,7 +404,7 @@ pub async fn transfer_ownership( .or_else(v2_reroute::flatten_404_error) } -/// Remove a member from a team. +/// Remove a member from a team. #[utoipa::path( context_path = "/team", tag = "teams", diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 0e44a9b66e..3e4590543c 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -108,11 +108,46 @@ pub async fn version_create( let payload = v2_reroute::alter_actix_multipart( payload, req.headers().clone(), - |legacy_create: InitialVersionData, + |mut legacy_create: InitialVersionData, content_dispositions: Vec| { let client = client.clone(); let redis = redis.clone(); async move { + if let Some(project_id) = legacy_create.project_id { + legacy_create.project_id = Some( + crate::routes::resolve_ref( + &project_id.to_string(), + client.as_ref(), + redis.as_ref(), + ) + .await? + .unwrap_or(project_id), + ); + } + let dependency_project_refs = legacy_create + .dependencies + .iter() + .filter_map(|dependency| dependency.project_id) + .map(|project_id| project_id.to_string()) + .collect::>(); + let resolved_dependency_project_ids = + crate::routes::resolve_refs( + &dependency_project_refs, + client.as_ref(), + redis.as_ref(), + ) + .await?; + for (dependency, resolved_project_id) in legacy_create + .dependencies + .iter_mut() + .filter(|dependency| dependency.project_id.is_some()) + .zip(resolved_dependency_project_ids) + { + if let Some(project_id) = resolved_project_id { + dependency.project_id = Some(project_id); + } + } + // Convert input data to V3 format let mut fields = HashMap::new(); fields.insert( diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index ec94f169ba..b18c5582ff 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -46,7 +46,7 @@ fn default_true() -> bool { true } -/// List versions for a project. +/// List versions for a project. #[utoipa::path( context_path = "/project/{project_id}", tag = "versions", @@ -80,6 +80,17 @@ pub async fn version_list( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let loaders = if let Some(loaders) = filters.loaders { if let Ok(mut loaders) = serde_json::from_str::>(&loaders) { loaders.push("mrpack".to_string()); @@ -157,7 +168,7 @@ pub async fn version_list( } // Given a project ID/slug and a version slug -/// Get a project version by ID or version number. +/// Get a project version by ID or version number. #[utoipa::path( context_path = "/project/{project_id}", tag = "versions", @@ -184,6 +195,17 @@ pub async fn version_project_get( redis: web::Data, session_queue: web::Data, ) -> Result { + if let Some(response) = crate::routes::redirect_ref( + &req, + "project_id", + pool.as_ref(), + redis.as_ref(), + ) + .await? + { + return Ok(response); + } + let id = info.into_inner(); let response = v3::versions::version_project_get_helper( req, @@ -213,7 +235,7 @@ pub struct VersionIds { pub include_changelog: bool, } -/// Get multiple versions by ID. +/// Get multiple versions by ID. #[utoipa::path( tag = "versions", get, @@ -262,7 +284,7 @@ pub async fn versions_get( } } -/// Get a version by ID. +/// Get a version by ID. #[utoipa::path( context_path = "/version", tag = "versions", @@ -346,7 +368,7 @@ pub struct EditVersionFileType { pub file_type: Option, } -/// Update an existing version. +/// Update an existing version. #[utoipa::path( context_path = "/version", tag = "versions", @@ -380,7 +402,29 @@ pub async fn version_edit( session_queue: web::Data, search_state: web::Data, ) -> Result { - let new_version = new_version.into_inner(); + let mut new_version = new_version.into_inner(); + if let Some(dependencies) = &mut new_version.dependencies { + let dependency_project_refs = dependencies + .iter() + .filter_map(|dependency| dependency.project_id) + .map(|project_id| project_id.to_string()) + .collect::>(); + let resolved_dependency_project_ids = crate::routes::resolve_refs( + &dependency_project_refs, + pool.as_ref(), + redis.as_ref(), + ) + .await?; + for (dependency, resolved_project_id) in dependencies + .iter_mut() + .filter(|dependency| dependency.project_id.is_some()) + .zip(resolved_dependency_project_ids) + { + if let Some(project_id) = resolved_project_id { + dependency.project_id = Some(project_id); + } + } + } let mut fields = HashMap::new(); if new_version.game_versions.is_some() { @@ -471,7 +515,7 @@ pub async fn version_edit( Ok(response) } -/// Delete a version by ID. +/// Delete a version by ID. #[utoipa::path( context_path = "/version", tag = "versions",