diff --git a/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json b/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json index 4b7060f6f1..4bf6c6baa3 100644 --- a/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json +++ b/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json @@ -70,14 +70,14 @@ ] }, "nullable": [ + false, + false, true, true, - true, - true, - true, - true, - true, - true, + false, + false, + false, + false, true, true, null, diff --git a/apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json b/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json similarity index 79% rename from apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json rename to apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json index 53b0853208..f7cb840845 100644 --- a/apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json +++ b/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", + "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", "describe": { "columns": [ { @@ -125,11 +125,16 @@ }, { "ordinal": 24, + "name": "side_types_migration_review_status", + "type_info": "Varchar" + }, + { + "ordinal": 25, "name": "categories", "type_info": "VarcharArray" }, { - "ordinal": 25, + "ordinal": 26, "name": "additional_categories", "type_info": "VarcharArray" } @@ -165,9 +170,10 @@ true, false, false, + false, null, null ] }, - "hash": "5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763" + "hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62" } diff --git a/apps/labrinth/.sqlx/query-89162149ed1bb870b9602bc9c974839d87c2641c47c8b3b1555cd7120ab3e30a.json b/apps/labrinth/.sqlx/query-89162149ed1bb870b9602bc9c974839d87c2641c47c8b3b1555cd7120ab3e30a.json new file mode 100644 index 0000000000..ef46ac7235 --- /dev/null +++ b/apps/labrinth/.sqlx/query-89162149ed1bb870b9602bc9c974839d87c2641c47c8b3b1555cd7120ab3e30a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET side_types_migration_review_status = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "89162149ed1bb870b9602bc9c974839d87c2641c47c8b3b1555cd7120ab3e30a" +} diff --git a/apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json b/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json similarity index 60% rename from apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json rename to apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json index 1de75299c2..af9bf42e59 100644 --- a/apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json +++ b/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ", + "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ", "describe": { "columns": [], "parameters": { @@ -21,10 +21,11 @@ "Text", "Int4", "Varchar", - "Int8" + "Int8", + "Varchar" ] }, "nullable": [] }, - "hash": "bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38" + "hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702" } diff --git a/apps/labrinth/migrations/20250523174544_project-versions-environments.sql b/apps/labrinth/migrations/20250523174544_project-versions-environments.sql new file mode 100644 index 0000000000..80b862a7de --- /dev/null +++ b/apps/labrinth/migrations/20250523174544_project-versions-environments.sql @@ -0,0 +1,122 @@ +DO LANGUAGE plpgsql $$ +DECLARE + VAR_env_field_id INT; + VAR_env_field_enum_id INT := 4; -- Known available ID for a new enum type +BEGIN + +-- Define a new loader field for environment +INSERT INTO loader_field_enums (id, enum_name, ordering, hidable) + VALUES (VAR_env_field_enum_id, 'environment', NULL, TRUE); + +INSERT INTO loader_field_enum_values (enum_id, value, ordering, created, metadata) + VALUES + -- Must be installed on both client and (integrated) server + (VAR_env_field_enum_id, 'client_and_server', NULL, NOW(), NULL), + -- Must be installed only on the client + (VAR_env_field_enum_id, 'client_only', NULL, NOW(), NULL), + -- Must be installed on the client, may be installed on a (integrated) server. To be displayed as a + -- client mod + (VAR_env_field_enum_id, 'client_only_server_optional', NULL, NOW(), NULL), + -- Must be installed only on the integrated singleplayer server. To be displayed as a server mod for + -- singleplayer exclusively + (VAR_env_field_enum_id, 'singleplayer_only', NULL, NOW(), NULL), + -- Must be installed only on a (integrated) server + (VAR_env_field_enum_id, 'server_only', NULL, NOW(), NULL), + -- Must be installed on the server, may be installed on the client. To be displayed as a + -- singleplayer-compatible server mod + (VAR_env_field_enum_id, 'server_only_client_optional', NULL, NOW(), NULL), + -- Must be installed only on a dedicated multiplayer server (not the integrated singleplayer server). + -- To be displayed as an server mod for multiplayer exclusively + (VAR_env_field_enum_id, 'dedicated_server_only', NULL, NOW(), NULL), + -- Can be installed on both client and server, with no strong preference for either. To be displayed + -- as both a client and server mod + (VAR_env_field_enum_id, 'client_or_server', NULL, NOW(), NULL), + -- Can be installed on both client and server, with a preference for being installed on both. To be + -- displayed as a client and server mod + (VAR_env_field_enum_id, 'client_or_server_prefers_both', NULL, NOW(), NULL), + (VAR_env_field_enum_id, 'unknown', NULL, NOW(), NULL); + +INSERT INTO loader_fields (field, field_type, enum_type, optional) + VALUES ('environment', 'enum', VAR_env_field_enum_id, FALSE) + RETURNING id INTO VAR_env_field_id; + +-- Update version_fields to have the new environment field, initializing it from the +-- values of the previous fields +INSERT INTO version_fields (version_id, field_id, enum_value) + SELECT vf.version_id, VAR_env_field_id, ( + SELECT id + FROM loader_field_enum_values + WHERE enum_id = VAR_env_field_enum_id + AND value = ( + CASE jsonb_object_agg(lf.field, vf.int_value) + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only' + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server' + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'singleplayer_only' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both' + ELSE 'unknown' + END + ) + ) + FROM version_fields vf + JOIN loader_fields lf ON vf.field_id = lf.id + WHERE lf.field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + GROUP BY vf.version_id + HAVING COUNT(DISTINCT lf.field) = 4; + +-- Clean up old fields from the project versions +DELETE FROM version_fields + WHERE field_id IN ( + SELECT id + FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + ); + +-- Switch loader fields definitions on the available loaders to use the new environment field +ALTER TABLE loader_fields_loaders DROP CONSTRAINT unique_loader_field; +ALTER TABLE loader_fields_loaders DROP CONSTRAINT loader_fields_loaders_pkey; + +UPDATE loader_fields_loaders + SET loader_field_id = VAR_env_field_id + WHERE loader_field_id IN ( + SELECT id + FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + ); + +-- Remove duplicate (loader_id, loader_field_id) pairs that may have been created due to several +-- old fields being converted to a single new field +DELETE FROM loader_fields_loaders + WHERE ctid NOT IN ( + SELECT MIN(ctid) + FROM loader_fields_loaders + GROUP BY loader_id, loader_field_id + ); + +-- Having both a PK and UNIQUE constraint for the same columns is redundant, so only restore the PK +ALTER TABLE loader_fields_loaders ADD PRIMARY KEY (loader_id, loader_field_id); + +-- Finally, remove the old loader fields +DELETE FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only'); + +-- Add a field to the projects table to track whether the new environment field value has been +-- reviewed to be appropriate after automated migration +ALTER TABLE mods + ADD COLUMN side_types_migration_review_status VARCHAR(64) NOT NULL DEFAULT 'reviewed' + CHECK (side_types_migration_review_status IN ('reviewed', 'pending')); + +UPDATE mods SET side_types_migration_review_status = 'pending'; + +END; +$$ diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index b22c4cb543..a87bcb5097 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -6,7 +6,9 @@ use super::{DBUser, ids::*}; use crate::database::models; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; -use crate::models::projects::{MonetizationStatus, ProjectStatus}; +use crate::models::projects::{ + MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, +}; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -210,6 +212,8 @@ impl ProjectBuilder { webhook_sent: false, color: self.color, monetization_status: self.monetization_status, + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], }; project_struct.insert(&mut *transaction).await?; @@ -288,6 +292,7 @@ pub struct DBProject { pub webhook_sent: bool, pub color: Option, pub monetization_status: MonetizationStatus, + pub side_types_migration_review_status: SideTypesMigrationReviewStatus, pub loaders: Vec, } @@ -302,13 +307,15 @@ impl DBProject { id, team_id, name, summary, description, published, downloads, icon_url, raw_icon_url, status, requested_status, license_url, license, - slug, color, monetization_status, organization_id + slug, color, monetization_status, organization_id, + side_types_migration_review_status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - LOWER($14), $15, $16, $17 + LOWER($14), $15, $16, $17, + $18 ) ", self.id as DBProjectId, @@ -328,6 +335,7 @@ impl DBProject { self.color.map(|x| x as i32), self.monetization_status.as_str(), self.organization_id.map(|x| x.0 as i64), + self.side_types_migration_review_status.as_str() ) .execute(&mut **transaction) .await?; @@ -770,6 +778,7 @@ impl DBProject { m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, m.webhook_sent, m.color, t.id thread_id, m.monetization_status monetization_status, + m.side_types_migration_review_status side_types_migration_review_status, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories FROM mods m @@ -835,6 +844,9 @@ impl DBProject { monetization_status: MonetizationStatus::from_string( &m.monetization_status, ), + side_types_migration_review_status: SideTypesMigrationReviewStatus::from_string( + &m.side_types_migration_review_status, + ), loaders, }, categories: m.categories.unwrap_or_default(), diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs index 35a340f0be..0af1e53fe1 100644 --- a/apps/labrinth/src/models/v2/projects.rs +++ b/apps/labrinth/src/models/v2/projects.rs @@ -127,7 +127,7 @@ impl LegacyProject { .collect(); if let Some(versions_item) = versions_item { - // Extract side types from remaining fields (singleplayer, client_only, etc) + // Extract side types from remaining fields let fields = versions_item .version_fields .iter() @@ -135,10 +135,11 @@ impl LegacyProject { (f.field_name.clone(), f.value.clone().serialize_internal()) }) .collect::>(); - (client_side, server_side) = v2_reroute::convert_side_types_v2( - &fields, - Some(&*og_project_type), - ); + (client_side, server_side) = + v2_reroute::convert_v3_side_types_to_v2_side_types( + &fields, + Some(&*og_project_type), + ); // - if loader is mrpack, this is a modpack // the loaders are whatever the corresponding loader fields are diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs index dfc9356b7f..1aabaca146 100644 --- a/apps/labrinth/src/models/v2/search.rs +++ b/apps/labrinth/src/models/v2/search.rs @@ -102,28 +102,20 @@ impl LegacyResultSearchProject { let project_loader_fields = result_search_project.project_loader_fields.clone(); - let get_one_bool_loader_field = |key: &str| { + let get_one_string_loader_field = |key: &str| { project_loader_fields .get(key) - .cloned() - .unwrap_or_default() + .map_or(&[][..], |values| values.as_slice()) .first() - .and_then(|s| s.as_bool()) + .and_then(|s| s.as_str()) }; - let singleplayer = get_one_bool_loader_field("singleplayer"); - let client_only = - get_one_bool_loader_field("client_only").unwrap_or(false); - let server_only = - get_one_bool_loader_field("server_only").unwrap_or(false); - let client_and_server = get_one_bool_loader_field("client_and_server"); + let environment = + get_one_string_loader_field("environment").unwrap_or("unknown"); let (client_side, server_side) = - v2_reroute::convert_side_types_v2_bools( - singleplayer, - client_only, - server_only, - client_and_server, + v2_reroute::convert_v3_environment_to_v2_side_types( + environment, Some(&*og_project_type), ); let client_side = client_side.to_string(); diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 6e9f17cf29..0644a2e520 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -92,6 +92,9 @@ pub struct Project { /// The monetization status of this project pub monetization_status: MonetizationStatus, + /// The status of the manual review of the migration of side types of this project + pub side_types_migration_review_status: SideTypesMigrationReviewStatus, + /// Aggregated loader-fields across its myriad of versions #[serde(flatten)] pub fields: HashMap>, @@ -206,6 +209,8 @@ impl From for Project { color: m.color, thread_id: data.thread_id.into(), monetization_status: m.monetization_status, + side_types_migration_review_status: m + .side_types_migration_review_status, fields, } } @@ -585,6 +590,35 @@ impl MonetizationStatus { } } +/// Represents the status of the manual review of the migration of side types of this +/// project to the new environment field. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum SideTypesMigrationReviewStatus { + /// The project has been reviewed to use the new environment side types appropriately. + Reviewed, + /// The project has been automatically migrated to the new environment side types, but + /// the appropriateness of such migration has not been reviewed. + Pending, +} + +impl SideTypesMigrationReviewStatus { + pub fn as_str(&self) -> &'static str { + match self { + SideTypesMigrationReviewStatus::Reviewed => "reviewed", + SideTypesMigrationReviewStatus::Pending => "pending", + } + } + + pub fn from_string(string: &str) -> SideTypesMigrationReviewStatus { + match string { + "reviewed" => SideTypesMigrationReviewStatus::Reviewed, + "pending" => SideTypesMigrationReviewStatus::Pending, + _ => SideTypesMigrationReviewStatus::Reviewed, + } + } +} + /// A specific version of a project #[derive(Serialize, Deserialize, Clone)] pub struct Version { @@ -843,7 +877,6 @@ 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", diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 49c2e38c22..3aaa932fca 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -242,7 +242,7 @@ impl AutomatedModerationQueue { version_specific: HashMap::new(), }; - if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| ["server_only", "client_only", "client_and_server", "singleplayer"].contains(&&*x.field_name)) { + if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") { mod_messages.messages.push(ModerationMessage::NoSideTypes); } diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 540b3a6e1a..9a45624309 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -158,10 +158,12 @@ pub async fn project_create( .into_iter() .map(|v| { let mut fields = HashMap::new(); - fields.extend(v2_reroute::convert_side_types_v3( - client_side, - server_side, - )); + fields.extend( + v2_reroute::convert_v2_side_types_to_v3_side_types( + client_side, + server_side, + ), + ); fields.insert( "game_versions".to_string(), json!(v.game_versions), diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 4915b3ad02..b8599f30e6 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -511,6 +511,7 @@ pub async fn project_edit( moderation_message: v2_new_project.moderation_message, moderation_message_body: v2_new_project.moderation_message_body, monetization_status: v2_new_project.monetization_status, + side_types_migration_review_status: None, // Not to be exposed in v2 }; // This returns 204 or failure so we don't need to do anything with it @@ -547,10 +548,12 @@ pub async fn project_edit( let version = Version::from(version); let mut fields = version.fields; let (current_client_side, current_server_side) = - v2_reroute::convert_side_types_v2(&fields, None); + v2_reroute::convert_v3_side_types_to_v2_side_types( + &fields, None, + ); let client_side = client_side.unwrap_or(current_client_side); let server_side = server_side.unwrap_or(current_server_side); - fields.extend(v2_reroute::convert_side_types_v3( + fields.extend(v2_reroute::convert_v2_side_types_to_v3_side_types( client_side, server_side, )); diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 093f1736b5..24f893f225 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -105,7 +105,7 @@ pub async fn version_create( json!(legacy_create.game_versions), ); - // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc. + // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply side types let loaders = match v3::tags::loader_list(client.clone(), redis.clone()) .await @@ -136,53 +136,32 @@ pub async fn version_create( .collect::>(); // Copies side types of another version of the project. - // If no version exists, defaults to all false. + // If no version exists, defaults to an unknown side type. // This is inherently lossy, but not much can be done about it, as side types are no longer associated with projects, - // so the 'missing' ones can't be easily accessed, and versions do need to have these fields explicitly set. - let side_type_loader_field_names = [ - "singleplayer", - "client_and_server", - "client_only", - "server_only", - ]; + // so the 'missing' ones can't be easily accessed, and versions do need to have that field explicitly set. - // Check if loader_fields_aggregate contains any of these side types + // Check if loader_fields_aggregate contains the side types // We assume these four fields are linked together. if loader_fields_aggregate .iter() - .any(|f| side_type_loader_field_names.contains(&f.as_str())) + .any(|field| field == "environment") { - // If so, we get the fields of the example version of the project, and set the side types to match. - fields.extend( - side_type_loader_field_names - .iter() - .map(|f| (f.to_string(), json!(false))), - ); - if let Some(example_version_fields) = + // If so, we get the field of an example version of the project, and set the side types to match. + fields.insert( + "environment".into(), get_example_version_fields( legacy_create.project_id, client, &redis, ) .await? - { - fields.extend( - example_version_fields.into_iter().filter_map( - |f| { - if side_type_loader_field_names - .contains(&f.field_name.as_str()) - { - Some(( - f.field_name, - f.value.serialize_internal(), - )) - } else { - None - } - }, - ), - ); - } + .into_iter() + .flatten() + .find(|f| f.field_name == "environment") + .map_or(json!("unknown"), |f| { + f.value.serialize_internal() + }), + ); } // Handle project type via file extension prediction let mut project_type = None; diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index b6a1937578..0a99b0796c 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -164,69 +164,46 @@ where Ok(new_multipart) } -// Converts a "client_side" and "server_side" pair into the new v3 corresponding fields -pub fn convert_side_types_v3( +/// Converts V2 side types to V3 side types. +pub fn convert_v2_side_types_to_v3_side_types( client_side: LegacySideType, server_side: LegacySideType, ) -> HashMap { - use LegacySideType::{Optional, Required}; - - let singleplayer = client_side == Required - || client_side == Optional - || server_side == Required - || server_side == Optional; - let client_and_server = singleplayer; - let client_only = (client_side == Required || client_side == Optional) - && server_side != Required; - let server_only = (server_side == Required || server_side == Optional) - && client_side != Required; + use LegacySideType::{Optional, Required, Unsupported}; + + let environment = match (client_side, server_side) { + (Required, Required) => "client_and_server", // Or "singleplayer_only" + (Required, Unsupported) => "client_only", + (Required, Optional) => "client_only_server_optional", + (Unsupported, Required) => "server_only", // Or "dedicated_server_only" + (Optional, Required) => "server_only_client_optional", + (Optional, Optional) => "client_or_server", // Or "client_or_server_prefers_both" + _ => "unknown", + }; - let mut fields = HashMap::new(); - fields.insert("singleplayer".to_string(), json!(singleplayer)); - fields.insert("client_and_server".to_string(), json!(client_and_server)); - fields.insert("client_only".to_string(), json!(client_only)); - fields.insert("server_only".to_string(), json!(server_only)); - fields + [("environment".to_string(), json!(environment))] + .into_iter() + .collect() } -// Convert search facets from V3 back to v2 -// this is not lossless. (See tests) -pub fn convert_side_types_v2( +/// Converts a V3 side types map into the corresponding V2 side types. +pub fn convert_v3_side_types_to_v2_side_types( side_types: &HashMap, project_type: Option<&str>, ) -> (LegacySideType, LegacySideType) { - let client_and_server = side_types - .get("client_and_server") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - let singleplayer = side_types - .get("singleplayer") - .and_then(|x| x.as_bool()) - .unwrap_or(client_and_server); - let client_only = side_types - .get("client_only") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - let server_only = side_types - .get("server_only") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - - convert_side_types_v2_bools( - Some(singleplayer), - client_only, - server_only, - Some(client_and_server), + convert_v3_environment_to_v2_side_types( + side_types + .get("environment") + .and_then(|x| x.as_str()) + .unwrap_or("unknown"), project_type, ) } -// Client side, server side -pub fn convert_side_types_v2_bools( - singleplayer: Option, - client_only: bool, - server_only: bool, - client_and_server: Option, +/// Converts a V3 environment and project type into the corresponding V2 side types. +/// The first side type is for the client, the second is for the server. +pub fn convert_v3_environment_to_v2_side_types( + environment: &str, project_type: Option<&str>, ) -> (LegacySideType, LegacySideType) { use LegacySideType::{Optional, Required, Unknown, Unsupported}; @@ -236,30 +213,18 @@ pub fn convert_side_types_v2_bools( Some("datapack") => (Optional, Required), Some("shader") => (Required, Unsupported), Some("resourcepack") => (Required, Unsupported), - _ => { - let singleplayer = - singleplayer.or(client_and_server).unwrap_or(false); - - match (singleplayer, client_only, server_only) { - // Only singleplayer - (true, false, false) => (Required, Required), - - // Client only and not server only - (false, true, false) => (Required, Unsupported), - (true, true, false) => (Required, Unsupported), - - // Server only and not client only - (false, false, true) => (Unsupported, Required), - (true, false, true) => (Unsupported, Required), - - // Both server only and client only - (true, true, true) => (Optional, Optional), - (false, true, true) => (Optional, Optional), - - // Bad type - (false, false, false) => (Unknown, Unknown), - } - } + _ => match environment { + "client_and_server" => (Required, Required), + "client_only" => (Required, Unsupported), + "client_only_server_optional" => (Required, Optional), + "singleplayer_only" => (Required, Required), + "server_only" => (Unsupported, Required), + "server_only_client_optional" => (Optional, Required), + "dedicated_server_only" => (Unsupported, Required), + "client_or_server" => (Optional, Optional), + "client_or_server_prefers_both" => (Optional, Optional), + _ => (Unknown, Unknown), // "unknown" + }, } } @@ -279,13 +244,14 @@ mod tests { }; #[test] - fn convert_types() { - // Converting types from V2 to V3 and back should be idempotent- for certain pairs + fn v2_v3_side_type_conversion() { + // Only nonsensical V2 side types cannot be round-tripped from V2 to V3 and back. + // When converting from V3 to V2, only additional information about the + // singleplayer-only, multiplayer-only, or install on both sides nature of the + // project is lost. let lossy_pairs = [ (Optional, Unsupported), (Unsupported, Optional), - (Required, Optional), - (Optional, Required), (Unsupported, Unsupported), ]; @@ -294,10 +260,13 @@ mod tests { if lossy_pairs.contains(&(client_side, server_side)) { continue; } - let side_types = - convert_side_types_v3(client_side, server_side); + let side_types = convert_v2_side_types_to_v3_side_types( + client_side, + server_side, + ); let (client_side2, server_side2) = - convert_side_types_v2(&side_types, None); + convert_v3_side_types_to_v2_side_types(&side_types, None); + assert_eq!(client_side, client_side2); assert_eq!(server_side, server_side2); } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index d1fffe4a43..84abcfd85c 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -12,7 +12,8 @@ use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ - License, Link, MonetizationStatus, ProjectStatus, VersionStatus, + License, Link, MonetizationStatus, ProjectStatus, + SideTypesMigrationReviewStatus, VersionStatus, }; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; @@ -903,6 +904,9 @@ async fn project_create_inner( color: project_builder.color, thread_id: thread_id.into(), monetization_status: MonetizationStatus::Monetized, + // New projects are considered reviewed with respect to side types migrations + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, fields: HashMap::new(), // Fields instantiate to empty }; diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index b78885343b..b59253b4bb 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -17,6 +17,7 @@ use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ MonetizationStatus, Project, ProjectStatus, SearchRequest, + SideTypesMigrationReviewStatus, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; @@ -240,6 +241,8 @@ pub struct EditProject { #[validate(length(max = 65536))] pub moderation_message_body: Option>, pub monetization_status: Option, + pub side_types_migration_review_status: + Option, } #[allow(clippy::too_many_arguments)] @@ -863,6 +866,29 @@ pub async fn project_edit( .await?; } + if let Some(side_types_migration_review_status) = + &new_project.side_types_migration_review_status + { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the side types migration review status of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET side_types_migration_review_status = $1 + WHERE id = $2 + ", + side_types_migration_review_status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + // check new description and body for links to associated images // if they no longer exist in the description or body, delete them let checkable_strings: Vec<&str> = diff --git a/apps/labrinth/src/search/indexing/local_import.rs b/apps/labrinth/src/search/indexing/local_import.rs index c6d2e408c1..4a34635727 100644 --- a/apps/labrinth/src/search/indexing/local_import.rs +++ b/apps/labrinth/src/search/indexing/local_import.rs @@ -362,7 +362,7 @@ pub async fn index_local( let (_, v2_og_project_type) = LegacyProject::get_project_type(&project_types); let (client_side, server_side) = - v2_reroute::convert_side_types_v2( + v2_reroute::convert_v3_side_types_to_v2_side_types( &unvectorized_loader_fields, Some(&v2_og_project_type), ); diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs index 3f4dcdee92..6ae58eb49b 100644 --- a/apps/labrinth/src/search/indexing/mod.rs +++ b/apps/labrinth/src/search/indexing/mod.rs @@ -327,11 +327,8 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ "color", // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). // TODO: remove these- as they should be automatically populated. This is a band-aid fix. - "server_only", - "client_only", + "environment", "game_versions", - "singleplayer", - "client_and_server", "mrpack_loaders", // V2 legacy fields for logical consistency "client_side", @@ -374,11 +371,8 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[ "color", // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). // TODO: remove these- as they should be automatically populated. This is a band-aid fix. - "server_only", - "client_only", + "environment", "game_versions", - "singleplayer", - "client_and_server", "mrpack_loaders", // V2 legacy fields for logical consistency "client_side", diff --git a/apps/labrinth/tests/common/api_v3/request_data.rs b/apps/labrinth/tests/common/api_v3/request_data.rs index 3e2586f454..f1a6902cbc 100644 --- a/apps/labrinth/tests/common/api_v3/request_data.rs +++ b/apps/labrinth/tests/common/api_v3/request_data.rs @@ -74,10 +74,7 @@ pub fn get_public_version_creation_data_json( // Loader fields "game_versions": ["1.20.1"], - "singleplayer": true, - "client_and_server": true, - "client_only": true, - "server_only": false, + "environment": "client_only_server_optional", }); if is_modpack { j["mrpack_loaders"] = json!(["fabric"]); diff --git a/apps/labrinth/tests/common/search.rs b/apps/labrinth/tests/common/search.rs index daf7a9eb51..0f7559b299 100644 --- a/apps/labrinth/tests/common/search.rs +++ b/apps/labrinth/tests/common/search.rs @@ -65,7 +65,7 @@ pub async fn setup_search_projects( let id = 0; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[4..6] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) .unwrap(); @@ -80,7 +80,7 @@ pub async fn setup_search_projects( let id = 1; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, ])) .unwrap(); project_creation_futures.push(create_async_future( @@ -94,7 +94,7 @@ pub async fn setup_search_projects( let id = 2; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/name", "value": "Mysterious Project" }, ])) .unwrap(); @@ -109,7 +109,7 @@ pub async fn setup_search_projects( let id = 3; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.4"] }, { "op": "add", "path": "/name", "value": "Mysterious Project" }, { "op": "add", "path": "/license_id", "value": "LicenseRef-All-Rights-Reserved" }, @@ -126,7 +126,7 @@ pub async fn setup_search_projects( let id = 4; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, ])) .unwrap(); @@ -141,7 +141,7 @@ pub async fn setup_search_projects( let id = 5; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) @@ -157,8 +157,7 @@ pub async fn setup_search_projects( let id = 6; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) .unwrap(); @@ -175,8 +174,7 @@ pub async fn setup_search_projects( let id = 7; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, { "op": "add", "path": "/initial_versions/0/loaders", "value": ["forge"] }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.2"] }, diff --git a/apps/labrinth/tests/files/dummy_data.sql b/apps/labrinth/tests/files/dummy_data.sql index f3fb1e47dc..9866a9d45d 100644 --- a/apps/labrinth/tests/files/dummy_data.sql +++ b/apps/labrinth/tests/files/dummy_data.sql @@ -67,8 +67,8 @@ VALUES (2, 'Ordering_Negative1', '{"type":"release","major":false}', -1); INSERT INTO loader_field_enum_values(enum_id, value, metadata, ordering) VALUES (2, 'Ordering_Positive100', '{"type":"release","major":false}', 100); -INSERT INTO loader_fields_loaders(loader_id, loader_field_id) -SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','singleplayer', 'client_and_server', 'client_only', 'server_only') ON CONFLICT DO NOTHING; +INSERT INTO loader_fields_loaders(loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','environment') ON CONFLICT DO NOTHING; INSERT INTO categories (id, category, project_type) VALUES (51, 'combat', 1), @@ -108,6 +108,6 @@ VALUES ( INSERT INTO oauth_client_redirect_uris (id, client_id, uri) VALUES (1, 1, 'https://modrinth.com/oauth_callback'); -- Create dummy data table to mark that this file has been run -CREATE TABLE dummy_data ( +CREATE TABLE dummy_data ( update_id bigint PRIMARY KEY ); diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index 7caf8beb49..4b9ac1e2e6 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -115,7 +115,7 @@ async fn creating_loader_fields() { Some( serde_json::from_value(json!([{ "op": "remove", - "path": "/singleplayer" + "path": "/environment" }])) .unwrap(), ), @@ -274,12 +274,8 @@ async fn creating_loader_fields() { "value": ["1.20.1", "1.20.2"] }, { "op": "add", - "path": "/singleplayer", - "value": false - }, { - "op": "add", - "path": "/server_only", - "value": true + "path": "/environment", + "value": "client_or_server_prefers_both" }])) .unwrap(), ), @@ -290,16 +286,17 @@ async fn creating_loader_fields() { v.fields.get("game_versions").unwrap(), &json!(["1.20.1", "1.20.2"]) ); - assert_eq!(v.fields.get("singleplayer").unwrap(), &json!(false)); - assert_eq!(v.fields.get("server_only").unwrap(), &json!(true)); + assert_eq!( + v.fields.get("environment").unwrap(), + &json!("client_or_server_prefers_both") + ); // - Patch let resp = api .edit_version( alpha_version_id, json!({ "game_versions": ["1.20.1", "1.20.2"], - "singleplayer": false, - "server_only": true + "environment": "client_or_server_prefers_both" }), USER_USER_PAT, ) @@ -327,8 +324,8 @@ async fn creating_loader_fields() { "value": ["1.20.5"] }, { "op": "add", - "path": "/singleplayer", - "value": false + "path": "/environment", + "value": "client_or_server" }])) .unwrap(), ), @@ -367,16 +364,16 @@ async fn creating_loader_fields() { assert!( project .fields - .get("singleplayer") + .get("environment") .unwrap() - .contains(&json!(false)) + .contains(&json!("client_or_server")) ); assert!( project .fields - .get("singleplayer") + .get("environment") .unwrap() - .contains(&json!(true)) + .contains(&json!("client_or_server_prefers_both")) ); }) .await @@ -440,10 +437,7 @@ async fn get_available_loader_fields() { fabric_loader_fields, [ "game_versions", - "singleplayer", - "client_and_server", - "client_only", - "server_only", + "environment", "test_fabric_optional" // exists for testing ] .iter() @@ -463,10 +457,7 @@ async fn get_available_loader_fields() { mrpack_loader_fields, [ "game_versions", - "singleplayer", - "client_and_server", - "client_only", - "server_only", + "environment", // mrpack has all the general fields as well as this "mrpack_loaders" ] diff --git a/apps/labrinth/tests/search.rs b/apps/labrinth/tests/search.rs index e8562f5c74..ad5f6aebc9 100644 --- a/apps/labrinth/tests/search.rs +++ b/apps/labrinth/tests/search.rs @@ -52,8 +52,11 @@ async fn search_projects() { vec![1, 2, 3, 4], ), (json!([["project_types:modpack"]]), vec![4]), - (json!([["client_only:true"]]), vec![0, 2, 3, 7, 9]), - (json!([["server_only:true"]]), vec![0, 2, 3, 6, 7]), + (json!([["environment:server_only"]]), vec![0, 2, 3]), + ( + json!([["environment:client_or_server_prefers_both"]]), + vec![6, 7], + ), (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6, 7, 9]), (json!([["license:MIT"]]), vec![1, 2, 4, 9]), (json!([[r#"name:'Mysterious Project'"#]]), vec![2, 3]),