diff --git a/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json similarity index 59% rename from apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json rename to apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json index 10cba08014..460f5477c3 100644 --- a/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json +++ b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ", + "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ", "describe": { "columns": [], "parameters": { @@ -16,10 +16,11 @@ "Varchar", "Bool", "Varchar", - "Int4" + "Int4", + "Jsonb" ] }, "nullable": [] }, - "hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce" + "hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c" } diff --git a/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json b/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json new file mode 100644 index 0000000000..940c72dcde --- /dev/null +++ b/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e" +} diff --git a/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json similarity index 85% rename from apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json rename to apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json index f7cb840845..ce8b181e6f 100644 --- a/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json +++ b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.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 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 ", + "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 m.components AS \"components: sqlx::types::Json\"\n\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\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ", "describe": { "columns": [ { @@ -137,6 +137,11 @@ "ordinal": 26, "name": "additional_categories", "type_info": "VarcharArray" + }, + { + "ordinal": 27, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" } ], "parameters": { @@ -172,8 +177,9 @@ false, false, null, - null + null, + false ] }, - "hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62" + "hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377" } diff --git a/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json similarity index 81% rename from apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json rename to apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json index 5fc3bd90c9..c67f73498d 100644 --- a/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json +++ b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ", + "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json\"\n FROM versions v\n WHERE v.id = ANY($1);\n ", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "ordinal": 12, "name": "ordering", "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" } ], "parameters": { @@ -87,8 +92,9 @@ false, false, true, - true + true, + false ] }, - "hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6" + "hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860" } diff --git a/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json new file mode 100644 index 0000000000..a58ac0f232 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66" +} diff --git a/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json similarity index 63% rename from apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json rename to apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json index af9bf42e59..0b5f5d4ffb 100644 --- a/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json +++ b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.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 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 ", + "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 components\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 $19\n )\n ", "describe": { "columns": [], "parameters": { @@ -22,10 +22,11 @@ "Int4", "Varchar", "Int8", - "Varchar" + "Varchar", + "Jsonb" ] }, "nullable": [] }, - "hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702" + "hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192" } diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql new file mode 100644 index 0000000000..7de4b975ac --- /dev/null +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -0,0 +1,2 @@ +ALTER TABLE mods +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/labrinth/migrations/20260207153522_version_components.sql b/apps/labrinth/migrations/20260207153522_version_components.sql new file mode 100644 index 0000000000..f6c3185264 --- /dev/null +++ b/apps/labrinth/migrations/20260207153522_version_components.sql @@ -0,0 +1,8 @@ +ALTER TABLE versions +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; + +-- extra metadata for the `minecraft_java_server` version component +CREATE TABLE minecraft_java_server_versions ( + id bigint PRIMARY KEY REFERENCES versions(id), + modpack_id bigint REFERENCES versions(id) +); diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index b4db9530f2..19949b49d2 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -6,6 +6,7 @@ use super::{DBUser, ids::*}; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; use crate::database::{PgTransaction, models}; +use crate::models::exp; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; @@ -176,6 +177,7 @@ pub struct ProjectBuilder { pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, + pub components: exp::ProjectCreate, } impl ProjectBuilder { @@ -215,6 +217,7 @@ impl ProjectBuilder { side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], + components: self.components.into_db(), }; project_struct.insert(&mut *transaction).await?; @@ -294,6 +297,7 @@ pub struct DBProject { pub monetization_status: MonetizationStatus, pub side_types_migration_review_status: SideTypesMigrationReviewStatus, pub loaders: Vec, + pub components: exp::ProjectSerial, } impl DBProject { @@ -308,14 +312,16 @@ impl DBProject { published, downloads, icon_url, raw_icon_url, status, requested_status, license_url, license, slug, color, monetization_status, organization_id, - side_types_migration_review_status + side_types_migration_review_status, + components ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, LOWER($14), $15, $16, $17, - $18 + $18, + $19 ) ", self.id as DBProjectId, @@ -335,7 +341,8 @@ 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() + self.side_types_migration_review_status.as_str(), + serde_json::to_value(&self.components).expect("serialization shouldn't fail"), ) .execute(&mut *transaction) .await?; @@ -767,7 +774,7 @@ impl DBProject { .await?; let projects = sqlx::query!( - " + r#" SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published, m.approved approved, m.queued, m.status status, m.requested_status requested_status, @@ -777,14 +784,17 @@ impl DBProject { 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 + ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories, + m.components AS "components: sqlx::types::Json" + FROM mods m INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id LEFT JOIN categories c ON mc.joining_category_id = c.id + WHERE m.id = ANY($1) OR m.slug = ANY($2) - GROUP BY t.id, m.id; - ", + GROUP BY t.id, m.id + "#, &project_ids_parsed, &slugs, ) @@ -845,6 +855,7 @@ impl DBProject { &m.side_types_migration_review_status, ), loaders, + components: exp::ProjectSerial::default(), }, categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), @@ -858,6 +869,21 @@ impl DBProject { urls, aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), thread_id: DBThreadId(m.thread_id), + minecraft_server: m + .components + .0 + .minecraft_server + .map(exp::component::Component::from_db), + minecraft_java_server: m + .components + .0 + .minecraft_java_server + .map(exp::component::Component::from_db), + minecraft_bedrock_server: m + .components + .0 + .minecraft_bedrock_server + .map(exp::component::Component::from_db), }; acc.insert(m.id, (m.slug, project)); @@ -983,4 +1009,7 @@ pub struct ProjectQueryResult { pub gallery_items: Vec, pub thread_id: DBThreadId, pub aggregate_version_fields: Vec, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index b6f18ee0e4..429c5ea011 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{ QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, }; use crate::database::redis::RedisPool; +use crate::models::exp; use crate::models::projects::{FileType, VersionStatus}; use crate::routes::internal::delphi::DelphiRunParameters; use chrono::{DateTime, Utc}; @@ -37,6 +38,7 @@ pub struct VersionBuilder { pub status: VersionStatus, pub requested_status: Option, pub ordering: Option, + pub components: exp::VersionCreate, } #[derive(Clone)] @@ -206,6 +208,7 @@ impl VersionBuilder { status: self.status, requested_status: self.requested_status, ordering: self.ordering, + components: self.components.into_db(), }; version.insert(transaction).await?; @@ -285,7 +288,7 @@ impl DBLoaderVersion { } } -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, Serialize)] pub struct DBVersion { pub id: DBVersionId, pub project_id: DBProjectId, @@ -300,8 +303,17 @@ pub struct DBVersion { pub status: VersionStatus, pub requested_status: Option, pub ordering: Option, + pub components: exp::VersionSerial, } +impl PartialEq for DBVersion { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for DBVersion {} + impl DBVersion { pub async fn insert( &self, @@ -312,12 +324,14 @@ impl DBVersion { INSERT INTO versions ( id, mod_id, author_id, name, version_number, changelog, date_published, downloads, - version_type, featured, status, ordering + version_type, featured, status, ordering, + components ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, - $9, $10, $11, $12 + $9, $10, $11, $12, + $13 ) ", self.id as DBVersionId, @@ -331,7 +345,9 @@ impl DBVersion { &self.version_type, self.featured, self.status.as_str(), - self.ordering + self.ordering, + serde_json::to_value(&self.components) + .expect("serialization shouldn't fail"), ) .execute(&mut *transaction) .await?; @@ -719,13 +735,14 @@ impl DBVersion { ).await?; let res = sqlx::query!( - " + r#" SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number, v.changelog changelog, v.date_published date_published, v.downloads downloads, - v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering + v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering, + v.components AS "components: sqlx::types::Json" FROM versions v WHERE v.id = ANY($1); - ", + "#, &version_ids ) .fetch(&mut exec) @@ -762,6 +779,7 @@ impl DBVersion { requested_status: v.requested_status .map(|x| VersionStatus::from_string(&x)), ordering: v.ordering, + components: exp::VersionSerial::default(), }, files: { let mut files = files.into_iter().map(|x| { @@ -804,6 +822,11 @@ impl DBVersion { project_types, games, dependencies, + minecraft_java_server: v + .components + .0 + .minecraft_java_server + .map(exp::component::Component::from_db), }; acc.insert(v.id, query_version); @@ -937,7 +960,7 @@ impl DBVersion { } } -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, Serialize)] pub struct VersionQueryResult { pub inner: DBVersion, @@ -947,6 +970,7 @@ pub struct VersionQueryResult { pub project_types: Vec, pub games: Vec, pub dependencies: Vec, + pub minecraft_java_server: Option, } #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -993,6 +1017,14 @@ impl std::cmp::PartialOrd for VersionQueryResult { } } +impl std::cmp::PartialEq for VersionQueryResult { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl std::cmp::Eq for VersionQueryResult {} + impl std::cmp::Ord for DBVersion { fn cmp(&self, other: &Self) -> Ordering { let ordering_order = match (self.ordering, other.ordering) { @@ -1061,6 +1093,7 @@ mod tests { featured: false, status: VersionStatus::Listed, requested_status: None, + components: exp::VersionSerial::default(), } } } diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs new file mode 100644 index 0000000000..07b3903f4b --- /dev/null +++ b/apps/labrinth/src/models/exp/base.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::{ids::OrganizationId, projects::ProjectStatus}; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Project { + /// Human-readable friendly name of the project. + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: String, + /// Slug of the project, used in vanity URLs. + #[validate( + length(min = 3, max = 64), + regex(path = *crate::util::validate::RE_URL_SAFE) + )] + pub slug: String, + /// Short description of the project. + #[validate(length(min = 3, max = 255))] + pub summary: String, + /// A long description of the project, in markdown. + #[validate(length(max = 65536))] + pub description: String, + /// What status the user would like the project to be in after review. + pub requested_status: ProjectStatus, + /// What organization the project belongs to. + pub organization_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Version {} diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs new file mode 100644 index 0000000000..42f0bfc084 --- /dev/null +++ b/apps/labrinth/src/models/exp/component.rs @@ -0,0 +1,213 @@ +macro_rules! define { + () => {}; + ( + #[component($component_kind:ident :: $component_kind_variant:ident)] + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + #[base( + $($field_base_meta:meta),* + )] + #[edit( + $($field_edit_meta:meta),* + )] + $(#[$field_meta:meta])* + $field_vis:vis $field:ident: $field_ty:ty + ),* $(,)? + } + + $($rest:tt)* + ) => { paste::paste! { + $(#[$meta])* + $vis struct $name { + $( + $(#[$field_meta])* + $(#[$field_base_meta])* + $field_vis $field: $field_ty, + )* + } + + $(#[$meta])* + $vis struct [< $name Edit >] { + $( + $(#[$field_meta])* + $(#[$field_edit_meta])* + $field_vis $field: Option<$field_ty>, + )* + } + + impl $crate::models::exp::component::Component for $name { + type Serial = Self; + type Edit = [< $name Edit >]; + type Kind = $component_kind; + + fn kind() -> Self::Kind { + $component_kind::$component_kind_variant + } + + fn into_db(self) -> Self::Serial { + self + } + + fn from_db(serial: Self::Serial) -> Self { + serial + } + } + + impl $crate::models::exp::component::ComponentEdit for [< $name Edit >] { + type Component = $name; + + fn create(self) -> eyre::Result { + Ok($name { + $( + $field: eyre::OptionExt::ok_or_eyre( + self.$field, + concat!("missing field `", stringify!($field), "`") + )?, + )* + }) + } + + async fn apply_to( + self, + #[allow(unused_variables)] + component: &mut Self::Component, + ) -> eyre::Result<()> { + $( + if let Some(f) = self.$field { + component.$field = f; + } + )* + Ok(()) + } + } + + $crate::models::exp::component::define!($($rest)*); + }}; +} + +macro_rules! relations { + ($vis:vis static $name:ident: $component_kind:ty = $expr:block) => { + $vis static $name: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { + #[allow(unused_imports)] + use $crate::models::exp::component::{ComponentKindExt, ComponentKindArrayExt}; + + Vec::<$crate::models::exp::component::ComponentRelation<$component_kind>>::from($expr) + }); + }; +} + +pub(crate) use define; +use eyre::Result; +pub(crate) use relations; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::{collections::HashSet, hash::Hash}; +use thiserror::Error; + +pub trait ComponentKind: + Clone + Send + Sync + PartialEq + Eq + Hash + 'static +{ +} + +pub trait Component: Sized { + type Serial: Serialize + DeserializeOwned; + + type Edit: ComponentEdit; + + type Kind; + + fn kind() -> Self::Kind; + + fn into_db(self) -> Self::Serial; + + fn from_db(serial: Self::Serial) -> Self; +} + +pub trait ComponentEdit: Sized { + type Component: Component; + + fn create(self) -> Result; + + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn apply_to(self, component: &mut Self::Component) -> Result<()>; +} + +#[derive(Debug, Clone)] +pub enum ComponentRelation { + /// If one of these components is present, then it can only be present with + /// other components from this set. + Only(HashSet), + /// If component `0` is present, then `1` must also be present. + Requires(K, K), +} + +pub trait ComponentKindExt { + fn requires(self, other: K) -> ComponentRelation; +} + +impl ComponentKindExt for K { + fn requires(self, other: K) -> ComponentRelation { + ComponentRelation::Requires(self, other) + } +} + +pub trait ComponentKindArrayExt { + fn only(self) -> ComponentRelation; +} + +impl ComponentKindArrayExt for [K; N] { + fn only(self) -> ComponentRelation { + ComponentRelation::Only(self.iter().cloned().collect()) + } +} + +#[derive(Debug, Clone, Error, Serialize, Deserialize)] +pub enum ComponentRelationError { + #[error("no components")] + NoComponents, + #[error("component `{target:?}` is missing")] + Missing { target: K }, + #[error( + "only components {only:?} can be together, found extra components {extra:?}" + )] + Only { only: HashSet, extra: HashSet }, + #[error("component `{target:?}` requires `{requires:?}`")] + Requires { target: K, requires: K }, +} + +pub fn kinds_valid( + kinds: &HashSet, + relations: &[ComponentRelation], +) -> Result<(), ComponentRelationError> { + if kinds.is_empty() { + return Err(ComponentRelationError::NoComponents); + } + + for relation in relations { + match relation { + ComponentRelation::Only(set) => { + if kinds.iter().any(|k| set.contains(k)) { + let extra: HashSet<_> = + kinds.difference(set).cloned().collect(); + if !extra.is_empty() { + return Err(ComponentRelationError::Only { + only: set.clone(), + extra, + }); + } + } + } + ComponentRelation::Requires(a, b) => { + if kinds.contains(a) && !kinds.contains(b) { + return Err(ComponentRelationError::Requires { + target: a.clone(), + requires: b.clone(), + }); + } + } + } + } + + Ok(()) +} diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs new file mode 100644 index 0000000000..746be25d7d --- /dev/null +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::{ + exp::{ProjectComponentKind, VersionComponentKind, component}, + ids::VersionId, +}; + +component::define! { + #[component(ProjectComponentKind::MinecraftMod)] + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ModProject {} + + #[component(ProjectComponentKind::MinecraftServer)] + /// Listing for a Minecraft server. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ServerProject { + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + /// Maximum number of players allowed on the server. + pub max_players: Option, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + /// Country which this server is hosted in. + #[validate(length(min = 2, max = 2))] + pub country: Option, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + /// Which version of the listing this server is currently using. + pub active_version: Option, + } + + #[component(ProjectComponentKind::MinecraftJavaServer)] + /// Listing for a Minecraft Java server. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServerProject { + #[base()] + #[edit(serde(default))] + /// Address (IP or domain name) of the Java server, excluding port. + #[validate(length(max = 255))] + pub address: String, + #[base()] + #[edit(serde(default))] + /// Port which the server runs on. + pub port: u16, + #[base(serde(default))] + #[edit(serde(default))] + /// What game content this server is using. + pub content: ServerContent, + } + + #[component(VersionComponentKind::MinecraftJavaServer)] + /// Version of a Minecraft Java server listing. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServerVersion {} + + #[component(ProjectComponentKind::MinecraftBedrockServer)] + /// Listing for a Minecraft Bedrock server. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct BedrockServerProject { + #[base()] + #[edit(serde(default))] + /// Address (IP or domain name) of the Bedrock server, excluding port. + #[validate(length(max = 255))] + pub address: String, + #[base()] + #[edit(serde(default))] + /// Port which the server runs on. + pub port: u16, + } +} + +/// What game content a [`JavaServerProject`] is using. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ServerContent { + /// Server runs modded content with a modpack found on the Modrinth platform. + Modpack { + /// Version ID of the modpack which the server runs. + /// + /// This version may or may not belong to the server project, since + /// server projects may also be treated as modpacks. + version_id: VersionId, + }, + /// Server is a vanilla Minecraft server. + Vanilla { + /// List of supported Minecraft Java client versions which can join this + /// server. + supported_game_versions: Vec, + /// Recommended Minecraft Java client version to use to join this server. + recommended_game_version: Option, + }, +} + +impl Default for ServerContent { + fn default() -> Self { + ServerContent::Vanilla { + supported_game_versions: Vec::new(), + recommended_game_version: None, + } + } +} + +component::relations! { + pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + use ProjectComponentKind::*; + + [ + [MinecraftMod].only(), + [MinecraftServer, MinecraftJavaServer, MinecraftBedrockServer].only(), + MinecraftJavaServer.requires(MinecraftServer), + MinecraftBedrockServer.requires(MinecraftServer), + ] + } +} diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs new file mode 100644 index 0000000000..8272499379 --- /dev/null +++ b/apps/labrinth/src/models/exp/mod.rs @@ -0,0 +1,233 @@ +//! Highly experimental and unstable API endpoint models. +//! +//! These are used for testing new API patterns and exploring future endpoints, +//! which may or may not make it into an official release. +//! +//! # Projects and versions +//! +//! Projects and versions work in an ECS-like architecture, where each project +//! is an entity (project ID), and components can be attached to that project to +//! determine the project's type, like a Minecraft mod, data pack, etc. Project +//! components *may* store extra data (like a server listing which stores the +//! server address), but typically, the version will store this data in *version +//! components*. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +pub mod base; +pub mod component; +pub mod minecraft; + +macro_rules! define_project_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + // kinds + + #[expect(dead_code, reason = "static check so $ty implements `Component`")] + const _: () = { + fn assert_implements_component() + where + T: component::Component, + {} + + fn assert_components_implement_trait() { + $(assert_implements_component::<$ty>();)* + } + }; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ProjectComponentKind { + $($variant_name,)* + } + + impl component::ComponentKind for ProjectComponentKind {} + + // structs + + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Project { + #[validate(nested)] + pub base: base::Project, + $( + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, + )* + } + + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + pub struct ProjectSerial { + $( + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + )* + } + + impl ProjectSerial { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $( + if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + } + )* + kinds + } + } + + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ProjectCreate { + #[validate(nested)] + pub base: Option, + $( + #[validate(nested)] + pub $field_name: Option<$ty>, + )* + } + + impl ProjectCreate { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $( + if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + } + )* + kinds + } + } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct ProjectEdit { + $( + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>, + )* + } + + // logic + + impl ProjectCreate { + pub fn into_db(self) -> ProjectSerial { + ProjectSerial { + $( + $field_name: self.$field_name.map(component::Component::into_db), + )* + } + } + } + }; +} + +macro_rules! define_version_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + // kinds + + #[expect(dead_code, reason = "static check so $ty implements `Component`")] + const _: () = { + fn assert_implements_component() + where + T: component::Component, + {} + + fn assert_components_implement_trait() { + $(assert_implements_component::<$ty>();)* + } + }; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum VersionComponentKind { + $($variant_name,)* + } + + impl component::ComponentKind for VersionComponentKind {} + + // structs + + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Version { + #[validate(nested)] + pub base: base::Version, + $( + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, + )* + } + + #[derive(Debug, Clone, Default, Serialize, Deserialize)] + pub struct VersionSerial { + $( + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + )* + } + + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct VersionCreate { + #[validate(nested)] + pub base: Option, + $( + #[validate(nested)] + pub $field_name: Option<$ty>, + )* + } + + impl VersionCreate { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $(if self.$field_name.is_some() { + kinds.insert(VersionComponentKind::$variant_name); + })* + kinds + } + } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct VersionEdit { + $( + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>, + )* + } + + // logic + + impl VersionCreate { + pub fn into_db(self) -> VersionSerial { + VersionSerial { + $( + $field_name: self.$field_name.map(component::Component::into_db), + )* + } + } + } + }; +} + +define_project_components![ + (minecraft_mod, MinecraftMod): minecraft::ModProject, + (minecraft_server, MinecraftServer): minecraft::ServerProject, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject, +]; + +define_version_components![ + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion, +]; + +component::relations! { + pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + minecraft::PROJECT_COMPONENT_RELATIONS.clone() + } +} diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index 8b31a04c71..13be1a318d 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod error; +pub mod exp; pub mod v2; pub mod v3; diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 0ccc193bf1..cb8db6fe79 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -4,6 +4,7 @@ use std::mem; use crate::database::models::loader_fields::VersionField; use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; +use crate::models::exp; use crate::models::ids::{ FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, }; @@ -98,6 +99,13 @@ pub struct Project { /// Aggregated loader-fields across its myriad of versions #[serde(flatten)] pub fields: HashMap>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_java_server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_bedrock_server: Option, } // This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values @@ -212,6 +220,9 @@ impl From for Project { side_types_migration_review_status: m .side_types_migration_review_status, fields, + minecraft_server: data.minecraft_server, + minecraft_java_server: data.minecraft_java_server, + minecraft_bedrock_server: data.minecraft_bedrock_server, } } } @@ -690,6 +701,9 @@ pub struct Version { #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, + + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_java_server: Option, } pub fn skip_nulls<'de, D>( @@ -761,6 +775,7 @@ impl From for Version { .into_iter() .map(|vf| (vf.field_name, vf.value.serialize_internal())) .collect(), + minecraft_java_server: data.minecraft_java_server, } } } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index f3696a3ff5..d0c5822798 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -219,9 +219,9 @@ impl ApiError { } }, description: match self { - Self::Internal(e) => format!("{e:#?}"), - Self::Request(e) => format!("{e:#?}"), - Self::Auth(e) => format!("{e:#?}"), + Self::Internal(e) => format!("{e:#}"), + Self::Request(e) => format!("{e:#}"), + Self::Auth(e) => format!("{e:#}"), _ => self.to_string(), }, details: match self { diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 543bb34876..facecac170 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -200,6 +200,7 @@ pub async fn project_create( uploaded_images: v.uploaded_images, ordering: v.ordering, fields, + minecraft_java_server: None, } }) .collect(); diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 5750cd6fd9..bce315d25a 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -214,7 +214,7 @@ pub async fn project_get( ) -> Result { // Convert V2 data to V3 data // Call V3 project creation - let response = v3::projects::project_get( + let project = match v3::projects::project_get_internal( req, info, pool.clone(), @@ -222,23 +222,21 @@ pub async fn project_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + { + Ok(resp) => resp.0, + Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(err) => return Err(err), + }; // Convert response to V2 format - match v2_reroute::extract_ok_json::(response).await { - Ok(project) => { - let version_item = match project.versions.first() { - Some(vid) => { - version_item::DBVersion::get((*vid).into(), &**pool, &redis) - .await? - } - None => None, - }; - let project = LegacyProject::from(project, version_item); - Ok(HttpResponse::Ok().json(project)) + let version_item = match project.versions.first() { + Some(vid) => { + version_item::DBVersion::get((*vid).into(), &**pool, &redis).await? } - Err(response) => Ok(response), - } + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) } //checks the validity of a project id or slug @@ -249,7 +247,7 @@ pub async fn project_get_check( redis: web::Data, ) -> Result { // Returns an id only, do not need to convert - v3::projects::project_get_check(info, pool, redis) + v3::projects::project_get_check_internal(info, pool, redis) .await .or_else(v2_reroute::flatten_404_error) } @@ -269,7 +267,7 @@ pub async fn dependency_list( session_queue: web::Data, ) -> Result { // TODO: tests, probably - let response = v3::projects::dependency_list( + let response = v3::projects::dependency_list_internal( req, info, pool.clone(), @@ -512,12 +510,16 @@ pub async fn project_edit( 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 - loader_fields: HashMap::new(), // Loader fields are not a thing in v2 + // None of the below is present in v2 + loader_fields: HashMap::new(), + minecraft_server: None, + minecraft_java_server: None, + minecraft_bedrock_server: None, }; // This returns 204 or failure so we don't need to do anything with it let project_id = info.clone().0; - let mut response = v3::projects::project_edit( + let mut response = v3::projects::project_edit_internal( req.clone(), info, pool.clone(), @@ -754,7 +756,7 @@ pub async fn project_icon_edit( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_icon_edit( + v3::projects::project_icon_edit_internal( web::Query(v3::projects::Extension { ext: ext.ext }), req, info, @@ -778,7 +780,7 @@ pub async fn delete_project_icon( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::delete_project_icon( + v3::projects::delete_project_icon_internal( req, info, pool, @@ -814,7 +816,7 @@ pub async fn add_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::add_gallery_item( + v3::projects::add_gallery_item_internal( web::Query(v3::projects::Extension { ext: ext.ext }), req, web::Query(v3::projects::GalleryCreateQuery { @@ -865,7 +867,7 @@ pub async fn edit_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::edit_gallery_item( + v3::projects::edit_gallery_item_internal( req, web::Query(v3::projects::GalleryEditQuery { url: item.url, @@ -897,7 +899,7 @@ pub async fn delete_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::delete_gallery_item( + v3::projects::delete_gallery_item_internal( req, web::Query(v3::projects::GalleryDeleteQuery { url: item.url }), pool, @@ -919,7 +921,7 @@ pub async fn project_delete( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_delete( + v3::projects::project_delete_internal( req, info, pool, @@ -940,7 +942,7 @@ pub async fn project_follow( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_follow(req, info, pool, redis, session_queue) + v3::projects::project_follow_internal(req, info, pool, redis, session_queue) .await .or_else(v2_reroute::flatten_404_error) } @@ -954,7 +956,13 @@ pub async fn project_unfollow( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_unfollow(req, info, pool, redis, session_queue) - .await - .or_else(v2_reroute::flatten_404_error) + v3::projects::project_unfollow_internal( + req, + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) } diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index 5810f436c1..3ca3942c5e 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -39,7 +39,7 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { - let response = v3::teams::team_members_get_project( + let response = v3::teams::team_members_get_project_internal( req, info, pool, diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 4fab4ccc60..3d938f7032 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -222,6 +222,7 @@ pub async fn version_create( uploaded_images: legacy_create.uploaded_images, ordering: legacy_create.ordering, fields, + minecraft_java_server: None, }) } }, diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 708f35b729..a95cf901c7 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -104,7 +104,7 @@ pub async fn version_list( include_changelog: filters.include_changelog, }; - let response = v3::versions::version_list( + let response = v3::versions::version_list_internal( req, info, web::Query(filters), @@ -211,6 +211,7 @@ pub async fn version_get( let response = v3::versions::version_get_helper(req, id, pool, redis, session_queue) .await + .map(|b| HttpResponse::Ok().json(b)) .or_else(v2_reroute::flatten_404_error)?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -277,7 +278,7 @@ pub async fn version_edit( } // Get the older version to get info from - let old_version = v3::versions::version_get_helper( + let old_version = match v3::versions::version_get_helper( req.clone(), (*info).0, pool.clone(), @@ -285,12 +286,19 @@ pub async fn version_edit( session_queue.clone(), ) .await - .or_else(v2_reroute::flatten_404_error)?; - let old_version = - match v2_reroute::extract_ok_json::(old_version).await { - Ok(version) => version, - Err(response) => return Ok(response), - }; + { + Ok(resp) => resp, + Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(err) => return Err(err), + }; + let old_version = match v2_reroute::extract_ok_json::( + HttpResponse::Ok().json(old_version.0), + ) + .await + { + Ok(version) => version, + Err(response) => return Ok(response), + }; // If this has 'mrpack_loaders' as a loader field previously, this is a modpack. // Therefore, if we are modifying the 'loader' field in this case, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 96c3aaf62d..471853fbc8 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -36,7 +36,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(images::config) .configure(notifications::config) .configure(organizations::config) - .configure(project_creation::config) .configure(projects::config) .configure(reports::config) .configure(shared_instance_version_creation::config) @@ -65,6 +64,12 @@ pub fn utoipa_config( .wrap(default_cors()) .configure(payouts::config), ); + cfg.service( + utoipa_actix_web::scope("/v3/project") + .wrap(default_cors()) + .configure(projects::utoipa_config) + .configure(project_creation::config), + ); } pub async fn hello_world() -> Result { diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 1071c124de..3e35ab361e 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -10,6 +10,7 @@ use crate::database::models::{self, DBUser, image_item}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError}; use crate::models::error::ApiError; +use crate::models::exp; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -43,8 +44,12 @@ use std::sync::Arc; use thiserror::Error; use validator::Validate; -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(project_create).service(project_create_with_id); +mod new; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(project_create) + .service(project_create_with_id) + .configure(new::config); } #[derive(Error, Debug)] @@ -262,7 +267,8 @@ pub async fn undo_uploads( Ok(()) } -#[post("/project")] +#[utoipa::path] +#[post("")] pub async fn project_create( req: HttpRequest, payload: Multipart, @@ -327,7 +333,8 @@ pub async fn project_create_internal( /// Allows creating a project with a specific ID. /// /// This is a testing endpoint only accessible behind an admin key. -#[post("/project/{id}", guard = "admin_key_guard")] +#[utoipa::path] +#[post("/{id}", guard = "admin_key_guard")] pub async fn project_create_with_id( req: HttpRequest, mut payload: Multipart, @@ -870,6 +877,7 @@ async fn project_create_inner( .collect(), color: icon_data.and_then(|x| x.2), monetization_status: MonetizationStatus::Monetized, + components: exp::ProjectCreate::default(), }; let project_builder = project_builder_actual.clone(); @@ -992,6 +1000,9 @@ async fn project_create_inner( side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, fields: HashMap::new(), // Fields instantiate to empty + minecraft_server: None, + minecraft_java_server: None, + minecraft_bedrock_server: None, }; Ok(HttpResponse::Ok().json(response)) @@ -1076,6 +1087,10 @@ async fn create_initial_version( version_type: version_data.release_channel.to_string(), requested_status: None, ordering: version_data.ordering, + components: exp::VersionCreate { + base: None, + minecraft_java_server: version_data.minecraft_java_server.clone(), + }, }; Ok(version) diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs new file mode 100644 index 0000000000..46ce5b26a1 --- /dev/null +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -0,0 +1,292 @@ +use actix_http::StatusCode; +use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; +use eyre::eyre; +use rust_decimal::Decimal; +use validator::Validate; + +use crate::{ + auth::get_user_from_headers, + database::{ + PgPool, + models::{ + self, DBOrganization, DBTeamMember, DBUser, + project_item::ProjectBuilder, thread_item::ThreadBuilder, + }, + redis::RedisPool, + }, + models::{ + exp::{self, ProjectComponentKind, component::ComponentRelationError}, + ids::ProjectId, + pats::Scopes, + projects::{MonetizationStatus, ProjectStatus}, + teams::{OrganizationPermissions, ProjectPermissions}, + threads::ThreadType, + v3::user_limits::UserLimits, + }, + queue::session::AuthQueue, + routes::ApiError, + util::{error::Context, validate::validation_errors_to_string}, +}; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(create); +} + +#[derive(Debug, thiserror::Error)] +pub enum CreateError { + #[error("project limit reached")] + LimitReached, + #[error("missing base component")] + MissingBase, + #[error("invalid component kinds")] + ComponentKinds(ComponentRelationError), + #[error("failed to validate request: {0}")] + Validation(String), + #[error("slug collision")] + SlugCollision, + #[error(transparent)] + Api(#[from] ApiError), +} + +impl CreateError { + pub fn as_api_error(&self) -> crate::models::error::ApiError<'_> { + match self { + Self::LimitReached => crate::models::error::ApiError { + error: "limit_reached", + description: self.to_string(), + details: None, + }, + Self::MissingBase => crate::models::error::ApiError { + error: "missing_base", + description: self.to_string(), + details: None, + }, + Self::ComponentKinds(err) => crate::models::error::ApiError { + error: "component_kinds", + description: format!("{self}: {err}"), + details: Some( + serde_json::to_value(err) + .expect("should never fail to serialize"), + ), + }, + Self::Validation(_) => crate::models::error::ApiError { + error: "validation", + description: self.to_string(), + details: None, + }, + Self::SlugCollision => crate::models::error::ApiError { + error: "slug_collision", + description: self.to_string(), + details: None, + }, + Self::Api(err) => err.as_api_error(), + } + } +} + +impl ResponseError for CreateError { + fn status_code(&self) -> actix_http::StatusCode { + match self { + Self::LimitReached + | Self::MissingBase + | Self::ComponentKinds(_) + | Self::Validation(_) + | Self::SlugCollision => StatusCode::BAD_REQUEST, + Self::Api(err) => err.status_code(), + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(self.as_api_error()) + } +} + +/// Creates a new project with the given components. +/// +/// Components must include `base` ([`exp::base::Project`]), and at least one +/// other component. +#[utoipa::path] +#[put("")] +pub async fn create( + req: HttpRequest, + db: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(details): web::Json, +) -> Result, CreateError> { + // check that the user can make a project + let (_, user) = get_user_from_headers( + &req, + &**db, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await + .map_err(ApiError::from)?; + + let limits = UserLimits::get_for_projects(&user, &db) + .await + .map_err(ApiError::from)?; + if limits.current >= limits.max { + return Err(CreateError::LimitReached); + } + + // check if the given details are valid + + exp::component::kinds_valid( + &details.component_kinds(), + &exp::PROJECT_COMPONENT_RELATIONS, + ) + .map_err(CreateError::ComponentKinds)?; + + details.validate().map_err(|err| { + CreateError::Validation(validation_errors_to_string(err, None)) + })?; + + // get component-specific data + // use struct destructor syntax, so we get a compile error + // if we add a new field and don't add it here + let exp::base::Project { + name, + slug, + summary, + description, + requested_status, + organization_id, + } = details.base.clone().ok_or(CreateError::MissingBase)?; + + // check if this won't conflict with an existing project + + let mut txn = db + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let same_slug_record = sqlx::query!( + "SELECT EXISTS( + SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1 + )", + slug.to_lowercase() + ) + .fetch_one(&mut txn) + .await + .wrap_internal_err("failed to query if slug already exists")?; + + if same_slug_record.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + + // create project and supporting records in db + + let team = if let Some(organization_id) = organization_id { + let org = DBOrganization::get_id(organization_id.into(), &**db, &redis) + .await + .wrap_internal_err("failed to get organization")? + .wrap_request_err("invalid organization ID")?; + + let team_member = + DBTeamMember::get_from_user_id(org.team_id, user.id.into(), &**db) + .await + .wrap_internal_err( + "failed to get team member of user for organization", + )?; + + let perms = OrganizationPermissions::get_permissions_by_role( + &user.role, + &team_member, + ); + + if !perms + .is_some_and(|p| p.contains(OrganizationPermissions::ADD_PROJECT)) + { + return Err(ApiError::Auth(eyre!( + "no permission to create projects in this organization" + )) + .into()); + } + + models::team_item::TeamBuilder { + members: Vec::new(), + } + } else { + let members = vec![models::team_item::TeamMemberBuilder { + user_id: user.id.into(), + role: crate::models::teams::DEFAULT_ROLE.to_owned(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }]; + + models::team_item::TeamBuilder { members } + }; + let team_id = team + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert team")?; + + let project_id: ProjectId = models::generate_project_id(&mut txn) + .await + .wrap_internal_err("failed to generate project ID")? + .into(); + + // TODO: special-case server projects to be unmonetized + let monetization_status = if details.minecraft_server.is_some() { + MonetizationStatus::ForceDemonetized + } else { + MonetizationStatus::Monetized + }; + + let project_builder = ProjectBuilder { + project_id: project_id.into(), + team_id, + organization_id: organization_id.map(From::from), + name: name.clone(), + summary: summary.clone(), + description: description.clone(), + icon_url: None, + raw_icon_url: None, + license_url: None, + categories: vec![], + additional_categories: vec![], + initial_versions: vec![], + status: ProjectStatus::Draft, + requested_status: Some(requested_status), + license: "LicenseRef-Unknown".into(), + slug: Some(slug.clone()), + link_urls: vec![], + gallery_items: vec![], + color: None, + monetization_status, + components: details, + }; + + project_builder + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert project")?; + DBUser::clear_project_cache(&[user.id.into()], &redis) + .await + .wrap_internal_err("failed to clear user project cache")?; + + ThreadBuilder { + type_: ThreadType::Project, + members: vec![], + project_id: Some(project_id.into()), + report_id: None, + } + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert thread")?; + + // and commit! + + txn.commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(web::Json(project_id)) +} diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 60590e8f31..b9bcfa97eb 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -1,3 +1,4 @@ +use std::any::type_name; use std::collections::HashMap; use std::sync::Arc; @@ -7,13 +8,12 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{ - DBModerationLock, DBTeamMember, ids as db_ids, image_item, + DBModerationLock, DBProjectId, DBTeamMember, ids as db_ids, image_item, }; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; use crate::database::{PgPool, PgTransaction}; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; @@ -24,17 +24,20 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; +use crate::models::{self, exp}; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::indexing::remove_documents; use crate::search::{SearchConfig, SearchError, search_for_project}; +use crate::util::error::Context; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use chrono::Utc; +use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -46,38 +49,27 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.route("projects", web::get().to(projects_get)); cfg.route("projects", web::patch().to(projects_edit)); cfg.route("projects_random", web::get().to(random_projects_get)); +} - cfg.service( - web::scope("project") - .route("{id}", web::get().to(project_get)) - .route("{id}/check", web::get().to(project_get_check)) - .route("{id}", web::delete().to(project_delete)) - .route("{id}", web::patch().to(project_edit)) - .route("{id}/icon", web::patch().to(project_icon_edit)) - .route("{id}/icon", web::delete().to(delete_project_icon)) - .route("{id}/gallery", web::post().to(add_gallery_item)) - .route("{id}/gallery", web::patch().to(edit_gallery_item)) - .route("{id}/gallery", web::delete().to(delete_gallery_item)) - .route("{id}/follow", web::post().to(project_follow)) - .route("{id}/follow", web::delete().to(project_unfollow)) - .route("{id}/organization", web::get().to(project_get_organization)) - .service( - web::scope("{project_id}") - .route( - "members", - web::get().to(super::teams::team_members_get_project), - ) - .route( - "version", - web::get().to(super::versions::version_list), - ) - .route( - "version/{slug}", - web::get().to(super::versions::version_project_get), - ) - .route("dependencies", web::get().to(dependency_list)), - ), - ); +pub fn utoipa_config( + cfg: &mut utoipa_actix_web::service_config::ServiceConfig, +) { + cfg.service(project_get) + .service(project_get_check) + .service(project_delete) + .service(project_edit) + .service(project_icon_edit) + .service(delete_project_icon) + .service(add_gallery_item) + .service(edit_gallery_item) + .service(delete_gallery_item) + .service(project_follow) + .service(project_unfollow) + .service(project_get_organization) + .service(super::teams::team_members_get_project) + .service(super::versions::version_list) + .service(super::versions::version_project_get) + .service(dependency_list); } #[derive(Deserialize, Validate)] @@ -161,13 +153,25 @@ pub async fn projects_get( Ok(HttpResponse::Ok().json(projects)) } -pub async fn project_get( +#[utoipa::path] +#[get("/{id}")] +async fn project_get( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { + project_get_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_get_internal( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { let string = info.into_inner().0; let project_data = @@ -186,12 +190,12 @@ pub async fn project_get( if let Some(data) = project_data && is_visible_project(&data.inner, &user_option, &pool, false).await? { - return Ok(HttpResponse::Ok().json(Project::from(data))); + return Ok(web::Json(Project::from(data))); } Err(ApiError::NotFound) } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct EditProject { #[validate( length(min = 3, max = 64), @@ -253,15 +257,61 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, + + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub minecraft_server: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub minecraft_java_server: + Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub minecraft_bedrock_server: + Option>, } #[allow(clippy::too_many_arguments)] -pub async fn project_edit( +#[utoipa::path] +#[patch("/{id}")] +async fn project_edit( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, search_config: web::Data, - new_project: web::Json, + web::Json(new_project): web::Json, + redis: web::Data, + session_queue: web::Data, + moderation_queue: web::Data, +) -> Result { + project_edit_internal( + req, + info, + pool, + search_config, + web::Json(new_project), + redis, + session_queue, + moderation_queue, + ) + .await +} + +pub async fn project_edit_internal( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + search_config: web::Data, + web::Json(new_project): web::Json, redis: web::Data, session_queue: web::Data, moderation_queue: web::Data, @@ -280,7 +330,7 @@ pub async fn project_edit( ApiError::Validation(validation_errors_to_string(err, None)) })?; - let Some(project_item) = + let Some(mut project_item) = db_models::DBProject::get(&info.into_inner().0, &**pool, &redis) .await? else { @@ -937,6 +987,106 @@ pub async fn project_edit( } } + // components + + async fn update( + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + edit: Option>, + mut component: &mut Option, + ) -> Result<(), ApiError> { + let Some(edit) = edit else { + // component is not specified in the input JSON - leave alone + return Ok(()); + }; + + match (&mut component, edit) { + (None, None) => {} + (Some(_), None) => { + // component is `null` in the input JSON - remove component + *component = None; + } + (None, Some(edit)) => { + // component is specified in the JSON and is non-null - create new component + *component = + Some(edit.create().wrap_request_err_with(|| { + eyre!( + "failed to create `{}` component", + type_name::() + ) + })?); + } + (Some(component), Some(edit)) => { + // edit component + edit.apply_to(component).await.wrap_internal_err_with( + || { + eyre!( + "failed to update `{}` component", + type_name::() + ) + }, + )?; + } + } + + Ok(()) + } + + update( + &mut transaction, + id, + new_project.minecraft_server, + &mut project_item.minecraft_server, + ) + .await?; + update( + &mut transaction, + id, + new_project.minecraft_java_server, + &mut project_item.minecraft_java_server, + ) + .await?; + update( + &mut transaction, + id, + new_project.minecraft_bedrock_server, + &mut project_item.minecraft_bedrock_server, + ) + .await?; + + let components_serial = exp::ProjectSerial { + minecraft_mod: None, + minecraft_server: project_item + .minecraft_server + .map(exp::component::Component::into_db), + minecraft_java_server: project_item + .minecraft_java_server + .map(exp::component::Component::into_db), + minecraft_bedrock_server: project_item + .minecraft_bedrock_server + .map(exp::component::Component::into_db), + }; + + exp::component::kinds_valid( + &components_serial.component_kinds(), + &exp::PROJECT_COMPONENT_RELATIONS, + ) + .wrap_request_err("invalid component kinds")?; + + sqlx::query!( + " + UPDATE mods + SET components = $1 + WHERE id = $2 + ", + serde_json::to_value(&components_serial) + .expect("serialization shouldn't fail"), + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to update components")?; + // 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> = @@ -1056,7 +1206,17 @@ pub async fn project_search( } //checks the validity of a project id or slug -pub async fn project_get_check( +#[utoipa::path] +#[get("/{id}/check")] +async fn project_get_check( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + project_get_check_internal(info, pool, redis).await +} + +pub async fn project_get_check_internal( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, @@ -1081,12 +1241,24 @@ pub struct DependencyInfo { pub versions: Vec, } +#[utoipa::path] +#[get("/{project_id}/dependencies")] pub async fn dependency_list( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, +) -> Result { + dependency_list_internal(req, info, pool, redis, session_queue).await +} + +pub async fn dependency_list_internal( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; @@ -1491,7 +1663,32 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] -pub async fn project_icon_edit( +#[utoipa::path] +#[patch("/{id}/icon")] +async fn project_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + project_icon_edit_internal( + web::Query(ext), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await +} + +pub async fn project_icon_edit_internal( web::Query(ext): web::Query, req: HttpRequest, info: web::Path<(String,)>, @@ -1606,7 +1803,28 @@ pub async fn project_icon_edit( Ok(HttpResponse::NoContent().body("")) } -pub async fn delete_project_icon( +#[utoipa::path] +#[delete("/{id}/icon")] +async fn delete_project_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + delete_project_icon_internal( + req, + info, + pool, + redis, + file_host, + session_queue, + ) + .await +} + +pub async fn delete_project_icon_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -1707,7 +1925,34 @@ pub struct GalleryCreateQuery { } #[allow(clippy::too_many_arguments)] +#[utoipa::path] +#[post("/{id}/gallery")] pub async fn add_gallery_item( + web::Query(ext): web::Query, + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + add_gallery_item_internal( + web::Query(ext), + req, + web::Query(item), + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await +} + +pub async fn add_gallery_item_internal( web::Query(ext): web::Query, req: HttpRequest, web::Query(item): web::Query, @@ -1874,7 +2119,26 @@ pub struct GalleryEditQuery { pub ordering: Option, } -pub async fn edit_gallery_item( +#[utoipa::path] +#[patch("/{id}/gallery")] +async fn edit_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + edit_gallery_item_internal( + req, + web::Query(item), + pool, + redis, + session_queue, + ) + .await +} + +pub async fn edit_gallery_item_internal( req: HttpRequest, web::Query(item): web::Query, pool: web::Data, @@ -2040,7 +2304,28 @@ pub struct GalleryDeleteQuery { pub url: String, } -pub async fn delete_gallery_item( +#[utoipa::path] +#[delete("/{id}/gallery")] +async fn delete_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + delete_gallery_item_internal( + req, + web::Query(item), + pool, + redis, + file_host, + session_queue, + ) + .await +} + +pub async fn delete_gallery_item_internal( req: HttpRequest, web::Query(item): web::Query, pool: web::Data, @@ -2150,7 +2435,28 @@ pub async fn delete_gallery_item( Ok(HttpResponse::NoContent().body("")) } -pub async fn project_delete( +#[utoipa::path] +#[delete("/{id}")] +async fn project_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + search_config: web::Data, + session_queue: web::Data, +) -> Result { + project_delete_internal( + req, + info, + pool, + redis, + search_config, + session_queue, + ) + .await +} + +pub async fn project_delete_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2254,7 +2560,19 @@ pub async fn project_delete( } } -pub async fn project_follow( +#[utoipa::path] +#[post("/{id}/follow")] +async fn project_follow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + project_follow_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_follow_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2334,7 +2652,19 @@ pub async fn project_follow( } } -pub async fn project_unfollow( +#[utoipa::path] +#[delete("/{id}/follow")] +async fn project_unfollow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + project_unfollow_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_unfollow_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2410,6 +2740,8 @@ pub async fn project_unfollow( } } +#[utoipa::path] +#[get("/{id}/organization")] pub async fn project_get_organization( req: HttpRequest, info: web::Path<(String,)>, diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 489d77128a..1b5d7e7968 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -12,7 +12,7 @@ use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::UserId; use eyre::eyre; use rust_decimal::Decimal; @@ -40,7 +40,20 @@ pub fn config(cfg: &mut 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 -pub async fn team_members_get_project( +#[utoipa::path] +#[get("/{project_id}/members")] +async fn team_members_get_project( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + team_members_get_project_internal(req, info, pool, redis, session_queue) + .await +} + +pub async fn team_members_get_project_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 9b91a6bf81..0a3aad8c74 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -12,6 +12,7 @@ use crate::database::models::version_item::{ use crate::database::models::{self, DBOrganization, image_item}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::exp; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -93,6 +94,10 @@ pub struct InitialVersionData { #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, + + #[serde(default)] + #[validate(nested)] + pub minecraft_java_server: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -324,6 +329,10 @@ async fn version_create_inner( status: version_create_data.status, requested_status: None, ordering: version_create_data.ordering, + components: exp::VersionCreate { + base: None, + minecraft_java_server: version_create_data.minecraft_java_server.clone(), + }, }); return Ok(()); @@ -473,6 +482,7 @@ async fn version_create_inner( dependencies: version_data.dependencies, loaders: version_data.loaders, fields: version_data.fields, + minecraft_java_server: None, }; let project_id = builder.project_id; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index c58cb7eb40..06a8b2b609 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -29,7 +29,7 @@ use crate::search::SearchConfig; use crate::search::indexing::remove_documents; use crate::util::img; use crate::util::validate::validation_errors_to_string; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::base62_impl::parse_base62; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -55,6 +55,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { } // Given a project ID/slug and a version slug +#[utoipa::path] +#[get("/{project_id}/version/{slug}")] pub async fn version_project_get( req: HttpRequest, info: web::Path<(String, String)>, @@ -175,7 +177,7 @@ pub async fn version_get( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let id = info.into_inner().0; version_get_helper(req, id, pool, redis, session_queue).await } @@ -186,7 +188,7 @@ pub async fn version_get_helper( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let version_data = database::models::DBVersion::get(id.into(), &**pool, &redis).await?; @@ -204,9 +206,7 @@ pub async fn version_get_helper( if let Some(data) = version_data && is_visible_version(&data.inner, &user_option, &pool, &redis).await? { - return Ok( - HttpResponse::Ok().json(models::projects::Version::from(data)) - ); + return Ok(web::Json(models::projects::Version::from(data))); } Err(ApiError::NotFound) @@ -731,7 +731,28 @@ pub struct VersionListFilters { pub include_changelog: bool, } -pub async fn version_list( +#[utoipa::path] +#[get("/{project_id}/version")] +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 { + version_list_internal( + req, + info, + web::Query(filters), + pool, + redis, + session_queue, + ) + .await +} + +pub async fn version_list_internal( req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query,