From b497aa5c9d978e081c3de37f3051f1a16dd922a2 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 29 Jan 2026 16:43:48 +0000 Subject: [PATCH 01/17] update frontend to work with new versions list route --- apps/frontend/src/pages/[type]/[id].vue | 32 ++++--------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index c5e680927a..336e5d235a 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1057,15 +1057,11 @@ const currentGameVersion = computed(() => { }) const possibleGameVersions = computed(() => { - return versions.value - .filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value)) - .flatMap((x) => x.game_versions) + return versionsV3.value?.available_game_versions || [] }) const possiblePlatforms = computed(() => { - return versions.value - .filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value)) - .flatMap((x) => x.loaders) + return versionsV3.value?.available_loaders || [] }) const currentPlatform = computed(() => { @@ -1417,29 +1413,11 @@ const filteredVersions = computed(() => { ) }) -const filteredRelease = computed(() => { - return filteredVersions.value.find((x) => x.version_type === 'release') -}) +const filteredRelease = computed(() => versionsV3.value?.latest_versions?.release || null) -const filteredBeta = computed(() => { - return filteredVersions.value.find( - (x) => - x.version_type === 'beta' && - (!filteredRelease.value || - dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))), - ) -}) +const filteredBeta = computed(() => versionsV3.value?.latest_versions?.beta || null) -const filteredAlpha = computed(() => { - return filteredVersions.value.find( - (x) => - x.version_type === 'alpha' && - (!filteredRelease.value || - dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) && - (!filteredBeta.value || - dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))), - ) -}) +const filteredAlpha = computed(() => versionsV3.value?.latest_versions?.alpha || null) const displayCollectionsSearch = ref('') const collections = computed(() => From 0f7734fdca91fbcbe7c13ebaf748fcc33a4a291f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 14 Jan 2026 15:25:48 +0000 Subject: [PATCH 02/17] wip: server listing API --- ...20260114130019_server_listing_projects.sql | 6 + apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v67/base.rs | 24 +++ apps/labrinth/src/models/v67/minecraft.rs | 70 ++++++++ apps/labrinth/src/models/v67/mod.rs | 152 ++++++++++++++++++ .../src/routes/v3/project_creation/new.rs | 77 +++++++++ 6 files changed, 330 insertions(+) create mode 100644 apps/labrinth/migrations/20260114130019_server_listing_projects.sql create mode 100644 apps/labrinth/src/models/v67/base.rs create mode 100644 apps/labrinth/src/models/v67/minecraft.rs create mode 100644 apps/labrinth/src/models/v67/mod.rs create mode 100644 apps/labrinth/src/routes/v3/project_creation/new.rs 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..8cfa15cb4f --- /dev/null +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -0,0 +1,6 @@ +CREATE TABLE minecraft_server_projects ( + id bigint PRIMARY KEY NOT NULL REFERENCES mods(id), + java_address varchar(255) NOT NULL, + bedrock_address varchar(255) NOT NULL, + max_players int +); diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index 8b31a04c71..cb4f02a877 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod error; pub mod v2; pub mod v3; +pub mod v67; pub use v3::analytics; pub use v3::billing; diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs new file mode 100644 index 0000000000..0f54e183e5 --- /dev/null +++ b/apps/labrinth/src/models/v67/base.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Create { + /// 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, +} diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs new file mode 100644 index 0000000000..0753c07546 --- /dev/null +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -0,0 +1,70 @@ +use std::sync::LazyLock; + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::v67::{ + ComponentKindArrayExt, ComponentKindExt, ComponentRelation, + ProjectComponent, ProjectComponentKind, +}; + +pub(super) static RELATIONS: LazyLock> = + LazyLock::new(|| { + use ProjectComponentKind as C; + + vec![ + [C::MinecraftMod].only(), + [ + C::MinecraftServer, + C::MinecraftJavaServer, + C::MinecraftBedrockServer, + ] + .only(), + C::MinecraftJavaServer.requires(C::MinecraftServer), + C::MinecraftBedrockServer.requires(C::MinecraftServer), + ] + }); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModCreate {} + +impl ProjectComponent for ModCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftMod + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct ServerCreate { + pub max_players: Option, +} + +impl ProjectComponent for ServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftServer + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct JavaServerCreate { + #[validate(length(max = 255))] + pub address: String, +} + +impl ProjectComponent for JavaServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftJavaServer + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct BedrockServerCreate { + #[validate(length(max = 255))] + pub address: String, +} + +impl ProjectComponent for BedrockServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftBedrockServer + } +} diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs new file mode 100644 index 0000000000..22aeb8035d --- /dev/null +++ b/apps/labrinth/src/models/v67/mod.rs @@ -0,0 +1,152 @@ +//! Highly experimental and unstable API endpoints. +//! +//! 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, sync::LazyLock}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use validator::Validate; + +pub mod base; +pub mod minecraft; + +macro_rules! define_project_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + pub struct ProjectCreate { + pub base: base::Create, + $(pub $field_name: Option<$ty>,)* + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ProjectComponentKind { + $($variant_name,)* + } + + #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + const _: () = { + fn assert_implements_project_component() {} + + fn assert_components_implement_trait() { + $(assert_implements_project_component::<$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 + } + } + }; +} + +define_project_components! [ + (minecraft_mod, MinecraftMod): minecraft::ModCreate, + (minecraft_server, MinecraftServer): minecraft::ServerCreate, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerCreate, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerCreate, +]; + +pub trait ProjectComponent { + fn kind() -> ProjectComponentKind; +} + +#[derive(Debug, Clone)] +pub enum ComponentRelation { + /// If one of these components, 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(ProjectComponentKind, ProjectComponentKind), +} + +trait ComponentKindExt { + fn requires(self, other: ProjectComponentKind) -> ComponentRelation; +} + +impl ComponentKindExt for ProjectComponentKind { + fn requires(self, other: ProjectComponentKind) -> ComponentRelation { + ComponentRelation::Requires(self, other) + } +} + +trait ComponentKindArrayExt { + fn only(self) -> ComponentRelation; +} + +impl ComponentKindArrayExt for [ProjectComponentKind; N] { + fn only(self) -> ComponentRelation { + ComponentRelation::Only(self.iter().copied().collect()) + } +} + +#[derive(Debug, Clone, Error)] +pub enum ComponentsIncompatibleError { + #[error( + "only components {only:?} can be together, found extra components {extra:?}" + )] + Only { + only: HashSet, + extra: HashSet, + }, + #[error("component `{target:?}` requires `{requires:?}`")] + Requires { + target: ProjectComponentKind, + requires: ProjectComponentKind, + }, +} + +pub fn component_kinds_compatible( + kinds: &HashSet, +) -> Result<(), ComponentsIncompatibleError> { + static RELATIONS: LazyLock> = LazyLock::new(|| { + let mut relations = Vec::new(); + relations.extend_from_slice(minecraft::RELATIONS.as_slice()); + relations + }); + + for relation in RELATIONS.iter() { + 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(ComponentsIncompatibleError::Only { + only: set.clone(), + extra, + }); + } + } + } + ComponentRelation::Requires(a, b) => { + if kinds.contains(a) && !kinds.contains(b) { + return Err(ComponentsIncompatibleError::Requires { + target: *a, + requires: *b, + }); + } + } + } + } + + Ok(()) +} 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..4f54b053f4 --- /dev/null +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -0,0 +1,77 @@ +use actix_web::web; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use validator::Validate; + +use crate::{ + auth::get_user_from_headers, + database::models, + models::{ids::ProjectId, v3::user_limits::UserLimits, v67}, + util::error::Context, +}; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(create); +} + +#[derive(Debug, Clone, Serialize, Deserialize, Error)] +pub enum CreateError { + #[error("project limit reached")] + LimitReached, + #[error("incompatible components")] + IncompatibleComponents(v67::ComponentsIncompatibleError), +} + +#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +pub struct CreateRequest {} + +/// Creates a new project. +#[utoipa::path] +#[put("/project")] +pub async fn create( + req: HttpRequest, + db: web::Data, + redis: 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?; + + let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + if limits.current >= limits.max { + return Err(CreateError::LimitReached); + } + + // check if the given details are valid + + v67::component_kinds_compatible(&details.component_kinds()) + .map_err(CreateError::IncompatibleComponents)?; + + details.validate()?; + + // check if this won't conflict with an existing project + + let slug_project_id_option = serde_json::from_value::( + serde_json::Value::String(details.base.slug.to_lowercase()), + ) + .expect("should be able to deserialize"); + + let mut txn = db + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let project_id: ProjectId = models::generate_project_id(&mut txn) + .await + .wrap_internal_err("failed to generate project ID")? + .into(); + + Ok(()) +} From fc8cb7eac2109c0bf0d86b705e7bb4f9b15ca4df Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 19 Jan 2026 23:21:46 +0000 Subject: [PATCH 03/17] wip: v67 project creation endpoint --- ...20260114130019_server_listing_projects.sql | 22 +- apps/labrinth/src/models/v67/base.rs | 2 +- apps/labrinth/src/models/v67/minecraft.rs | 117 +++++++-- apps/labrinth/src/models/v67/mod.rs | 28 +- .../src/routes/v3/project_creation.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 240 ++++++++++++++++-- 6 files changed, 355 insertions(+), 60 deletions(-) diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index 8cfa15cb4f..b2747c23c8 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -1,6 +1,20 @@ CREATE TABLE minecraft_server_projects ( - id bigint PRIMARY KEY NOT NULL REFERENCES mods(id), - java_address varchar(255) NOT NULL, - bedrock_address varchar(255) NOT NULL, - max_players int + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + max_players int +); + +CREATE TABLE minecraft_java_server_projects ( + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + address varchar(255) NOT NULL +); + +CREATE TABLE minecraft_bedrock_server_projects ( + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + address varchar(255) NOT NULL ); diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs index 0f54e183e5..15bfee80ec 100644 --- a/apps/labrinth/src/models/v67/base.rs +++ b/apps/labrinth/src/models/v67/base.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Create { /// Human-readable friendly name of the project. #[validate( diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs index 0753c07546..2ead660a14 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -1,11 +1,15 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; +use sqlx::PgTransaction; use validator::Validate; -use crate::models::v67::{ - ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentKind, +use crate::{ + database::models::DBProjectId, + models::v67::{ + ComponentKindArrayExt, ComponentKindExt, ComponentRelation, + ProjectComponent, ProjectComponentKind, + }, }; pub(super) static RELATIONS: LazyLock> = @@ -25,46 +29,113 @@ pub(super) static RELATIONS: LazyLock> = ] }); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModCreate {} +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Mod {} -impl ProjectComponent for ModCreate { +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Server { + pub max_players: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct JavaServer { + #[validate(length(max = 255))] + pub address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct BedrockServer { + #[validate(length(max = 255))] + pub address: String, +} + +// impl + +impl ProjectComponent for Mod { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftMod } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct ServerCreate { - pub max_players: Option, + async fn upsert( + &self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + unimplemented!(); + } } -impl ProjectComponent for ServerCreate { +impl ProjectComponent for Server { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct JavaServerCreate { - #[validate(length(max = 255))] - pub address: String, + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_server_projects (id, max_players) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET max_players = $2 + ", + project_id as _, + self.max_players.map(|n| n.cast_signed()), + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } -impl ProjectComponent for JavaServerCreate { +impl ProjectComponent for JavaServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct BedrockServerCreate { - #[validate(length(max = 255))] - pub address: String, + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_java_server_projects (id, address) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET address = $2 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } -impl ProjectComponent for BedrockServerCreate { +impl ProjectComponent for BedrockServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } + + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_bedrock_server_projects (id, address) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET address = $2 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs index 22aeb8035d..b133617bbc 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/v67/mod.rs @@ -15,9 +15,12 @@ use std::{collections::HashSet, sync::LazyLock}; use serde::{Deserialize, Serialize}; +use sqlx::PgTransaction; use thiserror::Error; use validator::Validate; +use crate::database::models::DBProjectId; + pub mod base; pub mod minecraft; @@ -25,7 +28,7 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { - #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { pub base: base::Create, $(pub $field_name: Option<$ty>,)* @@ -59,20 +62,27 @@ macro_rules! define_project_components { } define_project_components! [ - (minecraft_mod, MinecraftMod): minecraft::ModCreate, - (minecraft_server, MinecraftServer): minecraft::ServerCreate, - (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerCreate, - (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerCreate, + (minecraft_mod, MinecraftMod): minecraft::Mod, + (minecraft_server, MinecraftServer): minecraft::Server, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServer, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, ]; pub trait ProjectComponent { fn kind() -> ProjectComponentKind; + + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error>; } #[derive(Debug, Clone)] pub enum ComponentRelation { - /// If one of these components, then it can only be present with other - /// components from this set. + /// 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(ProjectComponentKind, ProjectComponentKind), @@ -98,7 +108,7 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { } } -#[derive(Debug, Clone, Error)] +#[derive(Debug, Clone, Error, Serialize, Deserialize)] pub enum ComponentsIncompatibleError { #[error( "only components {only:?} can be together, found extra components {extra:?}" @@ -128,7 +138,7 @@ pub fn component_kinds_compatible( ComponentRelation::Only(set) => { if kinds.iter().any(|k| set.contains(k)) { let extra: HashSet<_> = - kinds.difference(set).cloned().collect(); + kinds.difference(set).copied().collect(); if !extra.is_empty() { return Err(ComponentsIncompatibleError::Only { only: set.clone(), diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 1071c124de..95dcaa6ece 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -43,8 +43,12 @@ use std::sync::Arc; use thiserror::Error; use validator::Validate; +mod new; + pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(project_create).service(project_create_with_id); + cfg.service(project_create) + .service(project_create_with_id) + .configure(new::config); } #[derive(Error, Debug)] diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 4f54b053f4..40146e6757 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,29 +1,105 @@ -use actix_web::web; -use serde::{Deserialize, Serialize}; -use thiserror::Error; +use std::any::type_name; + +use actix_http::StatusCode; +use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; +use eyre::eyre; +use rust_decimal::Decimal; +use sqlx::{PgPool, PgTransaction}; use validator::Validate; use crate::{ auth::get_user_from_headers, - database::models, - models::{ids::ProjectId, v3::user_limits::UserLimits, v67}, - util::error::Context, + database::{ + models::{ + self, DBUser, project_item::ProjectBuilder, + thread_item::ThreadBuilder, + }, + redis::RedisPool, + }, + models::{ + ids::ProjectId, + pats::Scopes, + projects::{MonetizationStatus, ProjectStatus}, + teams::ProjectPermissions, + threads::ThreadType, + v3::user_limits::UserLimits, + v67, + }, + queue::session::AuthQueue, + routes::ApiError, + util::{error::Context, validate::validation_errors_to_string}, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +// pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +// cfg.service(create); +// } + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(create); } -#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[derive(Debug, thiserror::Error)] pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("incompatible components")] IncompatibleComponents(v67::ComponentsIncompatibleError), + #[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::IncompatibleComponents(err) => { + crate::models::error::ApiError { + error: "incompatible_components", + description: self.to_string(), + 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(), + } + } } -#[derive(Debug, Clone, Validate, Serialize, Deserialize)] -pub struct CreateRequest {} +impl ResponseError for CreateError { + fn status_code(&self) -> actix_http::StatusCode { + match self { + Self::LimitReached => StatusCode::BAD_REQUEST, + Self::IncompatibleComponents(_) => StatusCode::BAD_REQUEST, + Self::Validation(_) => StatusCode::BAD_REQUEST, + 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. #[utoipa::path] @@ -32,19 +108,23 @@ pub async fn create( req: HttpRequest, db: web::Data, redis: web::Data, + session_queue: web::Data, web::Json(details): web::Json, -) -> Result<(), CreateError> { +) -> Result, CreateError> { // check that the user can make a project let (_, user) = get_user_from_headers( &req, - &db, + &**db, &redis, - session_queue, + &session_queue, Scopes::PROJECT_CREATE, ) - .await?; + .await + .map_err(ApiError::from)?; - let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + let limits = UserLimits::get_for_projects(&user, &db) + .await + .map_err(ApiError::from)?; if limits.current >= limits.max { return Err(CreateError::LimitReached); } @@ -54,24 +134,140 @@ pub async fn create( v67::component_kinds_compatible(&details.component_kinds()) .map_err(CreateError::IncompatibleComponents)?; - details.validate()?; + details.validate().map_err(|err| { + CreateError::Validation(validation_errors_to_string(err, None)) + })?; // check if this won't conflict with an existing project - let slug_project_id_option = serde_json::from_value::( - serde_json::Value::String(details.base.slug.to_lowercase()), - ) - .expect("should be able to deserialize"); - 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 text_id_lower = $1)", + details.base.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_id = { + // TODO organization + 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, + }]; + let team = models::team_item::TeamBuilder { members }; + 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(); - Ok(()) + let project_builder = ProjectBuilder { + project_id: project_id.into(), + team_id, + organization_id: None, // todo + name: details.base.name, + summary: details.base.summary, + description: details.base.description, + icon_url: None, + raw_icon_url: None, + license_url: None, + categories: vec![], + additional_categories: vec![], + initial_versions: vec![], + status: ProjectStatus::Draft, + requested_status: Some(ProjectStatus::Approved), + license: "LicenseRef-Unknown".into(), + slug: Some(details.base.slug), + link_urls: vec![], + gallery_items: vec![], + color: None, + // TODO: what if we don't monetize server listing projects? + monetization_status: MonetizationStatus::Monetized, + }; + + 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")?; + + // component-specific info + + async fn upsert( + txn: &mut PgTransaction<'_>, + project_id: ProjectId, + component: Option, + ) -> Result<(), CreateError> { + let Some(component) = component else { + return Ok(()); + }; + component + .upsert(txn, project_id.into()) + .await + .wrap_internal_err_with(|| { + eyre!("failed to insert `{}` component", type_name::()) + })?; + Ok(()) + } + + // use struct destructor syntax, so we get a compile error + // if we add a new field and don't add it here + let v67::ProjectCreate { + base: _, + minecraft_mod, + minecraft_server, + minecraft_java_server, + minecraft_bedrock_server, + } = details; + + if let Some(_component) = minecraft_mod { + return Err(ApiError::Request(eyre!( + "creating a mod project from this endpoint is not supported yet" + )) + .into()); + } + upsert(&mut txn, project_id, minecraft_server).await?; + upsert(&mut txn, project_id, minecraft_java_server).await?; + upsert(&mut txn, project_id, minecraft_bedrock_server).await?; + + // and commit! + + txn.commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(web::Json(project_id)) } From 669351faad95f4c9e285049a055ac7cce06fa943 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 16:51:20 +0000 Subject: [PATCH 04/17] wip: project components API --- ...20260114130019_server_listing_projects.sql | 2 +- .../src/database/models/project_item.rs | 41 +++++- apps/labrinth/src/models/v3/projects.rs | 11 ++ apps/labrinth/src/models/v67/base.rs | 42 ++++--- apps/labrinth/src/models/v67/minecraft.rs | 119 ++++++++++++++---- apps/labrinth/src/models/v67/mod.rs | 87 +++++++++++-- apps/labrinth/src/routes/v2/projects.rs | 34 ++--- .../src/routes/v3/project_creation.rs | 3 + .../src/routes/v3/project_creation/new.rs | 39 +++--- apps/labrinth/src/routes/v3/projects.rs | 49 +++++++- 10 files changed, 324 insertions(+), 103 deletions(-) diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index b2747c23c8..22c7c01ef8 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -2,7 +2,7 @@ CREATE TABLE minecraft_server_projects ( id bigint PRIMARY KEY NOT NULL REFERENCES mods(id) ON DELETE CASCADE, - max_players int + max_players int NOT NULL ); CREATE TABLE minecraft_java_server_projects ( diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index b4db9530f2..10d8c14d06 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -9,6 +9,7 @@ use crate::database::{PgTransaction, models}; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; +use crate::models::v67; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -767,7 +768,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 +778,28 @@ 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, + -- components + COUNT(c1.id) > 0 AS minecraft_server_exists, + MAX(c1.max_players) AS minecraft_server_max_players, + COUNT(c2.id) > 0 AS minecraft_java_server_exists, + MAX(c2.address) AS minecraft_java_server_address, + COUNT(c3.id) > 0 AS minecraft_bedrock_server_exists, + MAX(c3.address) AS minecraft_bedrock_server_address + 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 + + -- components + LEFT JOIN minecraft_server_projects c1 ON c1.id = m.id + LEFT JOIN minecraft_java_server_projects c2 ON c2.id = m.id + LEFT JOIN minecraft_bedrock_server_projects c3 ON c3.id = m.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, ) @@ -858,6 +873,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: if m.minecraft_server_exists.unwrap_or(false) { + Some(v67::minecraft::Server { + max_players: m.minecraft_server_max_players.map(|n| n.cast_unsigned()), + }) + } else { None }, + minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { + Some(v67::minecraft::JavaServer { + address: m.minecraft_java_server_address.unwrap(), + }) + } else { None }, + minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { + Some(v67::minecraft::BedrockServer { + address: m.minecraft_bedrock_server_address.unwrap(), + }) + } else { None }, }; acc.insert(m.id, (m.slug, project)); @@ -983,4 +1013,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/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 0ccc193bf1..4f5c5681e9 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -7,6 +7,7 @@ use crate::database::models::version_item::VersionQueryResult; use crate::models::ids::{ FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, }; +use crate::models::v67; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -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, } } } diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs index 15bfee80ec..04a6191e51 100644 --- a/apps/labrinth/src/models/v67/base.rs +++ b/apps/labrinth/src/models/v67/base.rs @@ -1,24 +1,26 @@ use serde::{Deserialize, Serialize}; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Create { - /// 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, +define! { + #[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, + } } diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs index 2ead660a14..002bdb5856 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -1,14 +1,14 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; -use sqlx::PgTransaction; +use sqlx::{PgTransaction, postgres::PgQueryResult}; use validator::Validate; use crate::{ database::models::DBProjectId, models::v67::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentKind, + ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, }; @@ -29,24 +29,26 @@ pub(super) static RELATIONS: LazyLock> = ] }); -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Mod {} +define! { + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Mod {} -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Server { - pub max_players: Option, -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Server { + pub max_players: u32, + } -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct JavaServer { - #[validate(length(max = 255))] - pub address: String, -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServer { + #[validate(length(max = 255))] + pub address: String, + } -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct BedrockServer { - #[validate(length(max = 255))] - pub address: String, + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct BedrockServer { + #[validate(length(max = 255))] + pub address: String, + } } // impl @@ -56,7 +58,7 @@ impl ProjectComponent for Mod { ProjectComponentKind::MinecraftMod } - async fn upsert( + async fn insert( &self, _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, @@ -65,12 +67,22 @@ impl ProjectComponent for Mod { } } +impl ProjectComponentEdit for ModEdit { + async fn update( + &self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + ) -> Result { + unimplemented!(); + } +} + impl ProjectComponent for Server { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -79,10 +91,9 @@ impl ProjectComponent for Server { " INSERT INTO minecraft_server_projects (id, max_players) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET max_players = $2 ", project_id as _, - self.max_players.map(|n| n.cast_signed()), + self.max_players.cast_signed(), ) .execute(&mut **txn) .await?; @@ -90,12 +101,32 @@ impl ProjectComponent for Server { } } +impl ProjectComponentEdit for ServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_server_projects + SET max_players = COALESCE($2, max_players) + WHERE id = $1 + ", + project_id as _, + self.max_players.map(|n| n.cast_signed()), + ) + .execute(&mut **txn) + .await + } +} + impl ProjectComponent for JavaServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -104,7 +135,6 @@ impl ProjectComponent for JavaServer { " INSERT INTO minecraft_java_server_projects (id, address) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET address = $2 ", project_id as _, self.address, @@ -115,12 +145,32 @@ impl ProjectComponent for JavaServer { } } +impl ProjectComponentEdit for JavaServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_java_server_projects + SET address = COALESCE($2, address) + WHERE id = $1 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await + } +} + impl ProjectComponent for BedrockServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -129,7 +179,6 @@ impl ProjectComponent for BedrockServer { " INSERT INTO minecraft_bedrock_server_projects (id, address) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET address = $2 ", project_id as _, self.address, @@ -139,3 +188,23 @@ impl ProjectComponent for BedrockServer { Ok(()) } } + +impl ProjectComponentEdit for BedrockServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_bedrock_server_projects + SET address = COALESCE($2, address) + WHERE id = $1 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await + } +} diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs index b133617bbc..40fd0355ee 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/v67/mod.rs @@ -15,12 +15,46 @@ use std::{collections::HashSet, sync::LazyLock}; use serde::{Deserialize, Serialize}; -use sqlx::PgTransaction; +use sqlx::{PgTransaction, postgres::PgQueryResult}; use thiserror::Error; use validator::Validate; use crate::database::models::DBProjectId; +macro_rules! define { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident: $ty:ty + ),* $(,)? + } + + $($rest:tt)* + ) => { paste::paste! { + $(#[$meta])* + $vis struct $name { + $( + $(#[$field_meta])* + $field_vis $field: $ty, + )* + } + + $(#[$meta])* + $vis struct [< $name Edit >] { + $( + $(#[$field_meta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + $field_vis $field: Option<$ty>, + )* + } + + define!($($rest)*); + }}; + () => {}; +} + pub mod base; pub mod minecraft; @@ -28,15 +62,29 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ProjectComponentKind { + $($variant_name,)* + } + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { - pub base: base::Create, + pub base: base::Project, $(pub $field_name: Option<$ty>,)* } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] - pub enum ProjectComponentKind { - $($variant_name,)* + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Project { + pub base: base::Project, + $( + #[serde(skip_serializing_if = "Option::is_none")] + pub $field_name: Option<$ty>, + )* + } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ProjectEdit { + pub base: base::ProjectEdit, } #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] @@ -68,17 +116,26 @@ define_project_components! [ (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, ]; -pub trait ProjectComponent { +pub trait ProjectComponent: Sized { fn kind() -> ProjectComponentKind; #[expect(async_fn_in_trait, reason = "internal trait")] - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, ) -> Result<(), sqlx::Error>; } +pub trait ProjectComponentEdit: Sized { + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result; +} + #[derive(Debug, Clone)] pub enum ComponentRelation { /// If one of these components is present, then it can only be present with @@ -109,7 +166,9 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { } #[derive(Debug, Clone, Error, Serialize, Deserialize)] -pub enum ComponentsIncompatibleError { +pub enum ComponentKindsError { + #[error("no components")] + NoComponents, #[error( "only components {only:?} can be together, found extra components {extra:?}" )] @@ -124,15 +183,19 @@ pub enum ComponentsIncompatibleError { }, } -pub fn component_kinds_compatible( +pub fn component_kinds_valid( kinds: &HashSet, -) -> Result<(), ComponentsIncompatibleError> { +) -> Result<(), ComponentKindsError> { static RELATIONS: LazyLock> = LazyLock::new(|| { let mut relations = Vec::new(); relations.extend_from_slice(minecraft::RELATIONS.as_slice()); relations }); + if kinds.is_empty() { + return Err(ComponentKindsError::NoComponents); + } + for relation in RELATIONS.iter() { match relation { ComponentRelation::Only(set) => { @@ -140,7 +203,7 @@ pub fn component_kinds_compatible( let extra: HashSet<_> = kinds.difference(set).copied().collect(); if !extra.is_empty() { - return Err(ComponentsIncompatibleError::Only { + return Err(ComponentKindsError::Only { only: set.clone(), extra, }); @@ -149,7 +212,7 @@ pub fn component_kinds_compatible( } ComponentRelation::Requires(a, b) => { if kinds.contains(a) && !kinds.contains(b) { - return Err(ComponentsIncompatibleError::Requires { + return Err(ComponentKindsError::Requires { target: *a, requires: *b, }); diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 5750cd6fd9..b853010742 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( 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 @@ -512,7 +510,11 @@ 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 diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 95dcaa6ece..9b3ae2fa4b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -996,6 +996,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)) diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 40146e6757..b61524635a 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -42,8 +42,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { pub enum CreateError { #[error("project limit reached")] LimitReached, - #[error("incompatible components")] - IncompatibleComponents(v67::ComponentsIncompatibleError), + #[error("invalid component kinds")] + ComponentKinds(v67::ComponentKindsError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -60,16 +60,14 @@ impl CreateError { description: self.to_string(), details: None, }, - Self::IncompatibleComponents(err) => { - crate::models::error::ApiError { - error: "incompatible_components", - description: self.to_string(), - details: Some( - serde_json::to_value(err) - .expect("should never fail to serialize"), - ), - } - } + 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(), @@ -89,7 +87,7 @@ impl ResponseError for CreateError { fn status_code(&self) -> actix_http::StatusCode { match self { Self::LimitReached => StatusCode::BAD_REQUEST, - Self::IncompatibleComponents(_) => StatusCode::BAD_REQUEST, + Self::ComponentKinds(_) => StatusCode::BAD_REQUEST, Self::Validation(_) => StatusCode::BAD_REQUEST, Self::SlugCollision => StatusCode::BAD_REQUEST, Self::Api(err) => err.status_code(), @@ -131,8 +129,8 @@ pub async fn create( // check if the given details are valid - v67::component_kinds_compatible(&details.component_kinds()) - .map_err(CreateError::IncompatibleComponents)?; + v67::component_kinds_valid(&details.component_kinds()) + .map_err(CreateError::ComponentKinds)?; details.validate().map_err(|err| { CreateError::Validation(validation_errors_to_string(err, None)) @@ -226,7 +224,7 @@ pub async fn create( // component-specific info - async fn upsert( + async fn insert( txn: &mut PgTransaction<'_>, project_id: ProjectId, component: Option, @@ -235,7 +233,7 @@ pub async fn create( return Ok(()); }; component - .upsert(txn, project_id.into()) + .insert(txn, project_id.into()) .await .wrap_internal_err_with(|| { eyre!("failed to insert `{}` component", type_name::()) @@ -254,14 +252,15 @@ pub async fn create( } = details; if let Some(_component) = minecraft_mod { + // todo return Err(ApiError::Request(eyre!( "creating a mod project from this endpoint is not supported yet" )) .into()); } - upsert(&mut txn, project_id, minecraft_server).await?; - upsert(&mut txn, project_id, minecraft_java_server).await?; - upsert(&mut txn, project_id, minecraft_bedrock_server).await?; + insert(&mut txn, project_id, minecraft_server).await?; + insert(&mut txn, project_id, minecraft_java_server).await?; + insert(&mut txn, project_id, minecraft_bedrock_server).await?; // and commit! diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 60590e8f31..12fb3ae214 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,13 @@ 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, DBTeamMember, ids as db_ids, + ids as db_ids, image_item, 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 +25,23 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; +use crate::models::{self, v67}; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::indexing::remove_documents; +use crate::search::{ + MeilisearchReadClient, SearchConfig, SearchError, search_for_project, +}; 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 chrono::Utc; +use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -167,7 +174,7 @@ pub async fn project_get( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let string = info.into_inner().0; let project_data = @@ -186,7 +193,7 @@ 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) } @@ -253,6 +260,9 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } #[allow(clippy::too_many_arguments)] @@ -261,7 +271,7 @@ pub async fn project_edit( 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, @@ -937,6 +947,35 @@ pub async fn project_edit( } } + // components + + async fn update( + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + component: Option, + ) -> Result<(), ApiError> { + let Some(component) = component else { + return Ok(()); + }; + let result = component + .update(txn, project_id) + .await + .wrap_internal_err_with(|| { + eyre!("failed to update `{}` component", type_name::()) + })?; + if result.rows_affected() == 0 { + return Err(ApiError::Request(eyre!( + "project does not have `{}` component", + type_name::() + ))); + } + Ok(()) + } + + update(&mut transaction, id, new_project.minecraft_server).await?; + update(&mut transaction, id, new_project.minecraft_java_server).await?; + update(&mut transaction, id, new_project.minecraft_bedrock_server).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> = From f249ce4ebdde88837d5647ac7fe80463efe5565d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 17:16:17 +0000 Subject: [PATCH 05/17] revert accidental change --- apps/frontend/src/pages/[type]/[id].vue | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 336e5d235a..c5e680927a 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1057,11 +1057,15 @@ const currentGameVersion = computed(() => { }) const possibleGameVersions = computed(() => { - return versionsV3.value?.available_game_versions || [] + return versions.value + .filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value)) + .flatMap((x) => x.game_versions) }) const possiblePlatforms = computed(() => { - return versionsV3.value?.available_loaders || [] + return versions.value + .filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value)) + .flatMap((x) => x.loaders) }) const currentPlatform = computed(() => { @@ -1413,11 +1417,29 @@ const filteredVersions = computed(() => { ) }) -const filteredRelease = computed(() => versionsV3.value?.latest_versions?.release || null) +const filteredRelease = computed(() => { + return filteredVersions.value.find((x) => x.version_type === 'release') +}) -const filteredBeta = computed(() => versionsV3.value?.latest_versions?.beta || null) +const filteredBeta = computed(() => { + return filteredVersions.value.find( + (x) => + x.version_type === 'beta' && + (!filteredRelease.value || + dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))), + ) +}) -const filteredAlpha = computed(() => versionsV3.value?.latest_versions?.alpha || null) +const filteredAlpha = computed(() => { + return filteredVersions.value.find( + (x) => + x.version_type === 'alpha' && + (!filteredRelease.value || + dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) && + (!filteredBeta.value || + dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))), + ) +}) const displayCollectionsSearch = ref('') const collections = computed(() => From 824be8ae55879d7f853e6aa27e3288ecfedb011d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 10:16:16 +0000 Subject: [PATCH 06/17] fix up rebase --- apps/labrinth/src/database/models/project_item.rs | 2 +- apps/labrinth/src/routes/v3/projects.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 10d8c14d06..99dc9a1b95 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -875,7 +875,7 @@ impl DBProject { thread_id: DBThreadId(m.thread_id), minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { Some(v67::minecraft::Server { - max_players: m.minecraft_server_max_players.map(|n| n.cast_unsigned()), + max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), }) } else { None }, minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 12fb3ae214..92d0f8dd70 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -8,8 +8,7 @@ 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, DBProjectId, DBTeamMember, DBTeamMember, ids as db_ids, - ids as db_ids, image_item, image_item, + DBModerationLock, DBProjectId, DBTeamMember, ids as db_ids, image_item, }; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; @@ -33,7 +32,6 @@ use crate::search::indexing::remove_documents; use crate::search::{ MeilisearchReadClient, SearchConfig, SearchError, search_for_project, }; -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}; From 53f12dc40e4d6fecdacda519a6c2fc3958e152c6 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 11:31:38 +0000 Subject: [PATCH 07/17] No more six seven --- .../src/database/models/project_item.rs | 13 ++++++------- apps/labrinth/src/models/{v67 => exp}/base.rs | 0 .../src/models/{v67 => exp}/minecraft.rs | 2 +- apps/labrinth/src/models/{v67 => exp}/mod.rs | 2 +- apps/labrinth/src/models/mod.rs | 2 +- apps/labrinth/src/models/v3/projects.rs | 8 ++++---- .../src/routes/v3/project_creation/new.rs | 17 ++++++++++------- apps/labrinth/src/routes/v3/projects.rs | 10 +++++----- 8 files changed, 28 insertions(+), 26 deletions(-) rename apps/labrinth/src/models/{v67 => exp}/base.rs (100%) rename apps/labrinth/src/models/{v67 => exp}/minecraft.rs (99%) rename apps/labrinth/src/models/{v67 => exp}/mod.rs (99%) diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 99dc9a1b95..d9af6760bd 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -9,7 +9,6 @@ use crate::database::{PgTransaction, models}; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; -use crate::models::v67; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -874,17 +873,17 @@ impl DBProject { aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), thread_id: DBThreadId(m.thread_id), minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { - Some(v67::minecraft::Server { + Some(exp::minecraft::Server { max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), }) } else { None }, minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { - Some(v67::minecraft::JavaServer { + Some(exp::minecraft::JavaServer { address: m.minecraft_java_server_address.unwrap(), }) } else { None }, minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { - Some(v67::minecraft::BedrockServer { + Some(exp::minecraft::BedrockServer { address: m.minecraft_bedrock_server_address.unwrap(), }) } else { None }, @@ -1013,7 +1012,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, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/exp/base.rs similarity index 100% rename from apps/labrinth/src/models/v67/base.rs rename to apps/labrinth/src/models/exp/base.rs diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs similarity index 99% rename from apps/labrinth/src/models/v67/minecraft.rs rename to apps/labrinth/src/models/exp/minecraft.rs index 002bdb5856..97c883be74 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -6,7 +6,7 @@ use validator::Validate; use crate::{ database::models::DBProjectId, - models::v67::{ + models::exp::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/exp/mod.rs similarity index 99% rename from apps/labrinth/src/models/v67/mod.rs rename to apps/labrinth/src/models/exp/mod.rs index 40fd0355ee..e1cc5af48d 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -1,4 +1,4 @@ -//! Highly experimental and unstable API endpoints. +//! 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. diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index cb4f02a877..13be1a318d 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,7 +1,7 @@ pub mod error; +pub mod exp; pub mod v2; pub mod v3; -pub mod v67; pub use v3::analytics; pub use v3::billing; diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 4f5c5681e9..77910b5a48 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -4,10 +4,10 @@ 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, }; -use crate::models::v67; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -101,11 +101,11 @@ pub struct Project { pub fields: HashMap>, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_server: Option, + pub minecraft_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_java_server: Option, + pub minecraft_java_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_bedrock_server: Option, + 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 diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index b61524635a..ec121f1ee1 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -17,13 +17,13 @@ use crate::{ redis::RedisPool, }, models::{ + exp, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, teams::ProjectPermissions, threads::ThreadType, v3::user_limits::UserLimits, - v67, }, queue::session::AuthQueue, routes::ApiError, @@ -43,7 +43,7 @@ pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("invalid component kinds")] - ComponentKinds(v67::ComponentKindsError), + ComponentKinds(exp::ComponentKindsError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -99,7 +99,10 @@ impl ResponseError for CreateError { } } -/// Creates a new project. +/// Creates a new project with the given components. +/// +/// Components must include `base` ([`exp::base::Project`]), and at least one +/// other component. #[utoipa::path] #[put("/project")] pub async fn create( @@ -107,7 +110,7 @@ pub async fn create( db: web::Data, redis: web::Data, session_queue: web::Data, - web::Json(details): web::Json, + web::Json(details): web::Json, ) -> Result, CreateError> { // check that the user can make a project let (_, user) = get_user_from_headers( @@ -129,7 +132,7 @@ pub async fn create( // check if the given details are valid - v67::component_kinds_valid(&details.component_kinds()) + exp::component_kinds_valid(&details.component_kinds()) .map_err(CreateError::ComponentKinds)?; details.validate().map_err(|err| { @@ -224,7 +227,7 @@ pub async fn create( // component-specific info - async fn insert( + async fn insert( txn: &mut PgTransaction<'_>, project_id: ProjectId, component: Option, @@ -243,7 +246,7 @@ pub async fn create( // use struct destructor syntax, so we get a compile error // if we add a new field and don't add it here - let v67::ProjectCreate { + let exp::ProjectCreate { base: _, minecraft_mod, minecraft_server, diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 92d0f8dd70..59bd169672 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -24,7 +24,7 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; -use crate::models::{self, v67}; +use crate::models::{self, exp}; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -258,9 +258,9 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } #[allow(clippy::too_many_arguments)] @@ -947,7 +947,7 @@ pub async fn project_edit( // components - async fn update( + async fn update( txn: &mut PgTransaction<'_>, project_id: DBProjectId, component: Option, From 103924a9ce86f06a1fac818ee9f218a1479d402f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 30 Jan 2026 15:14:09 +0000 Subject: [PATCH 08/17] New project component metadata schema --- ...70eef9d1af5ff41b097b3552de86d3940e01e.json | 15 ++ ...0bba8ccd2df0995a21bdb34ae3214cef6377.json} | 12 +- ...d977a9613f8aa22669c0f8fe7bab2d5d6192.json} | 7 +- ...da4588d37cf9f0da26cdd23cfe025b191a2d4.json | 22 ++ ...20260114130019_server_listing_projects.sql | 22 +- .../src/database/models/project_item.rs | 57 +++-- apps/labrinth/src/models/exp/minecraft.rs | 219 ++++++++---------- apps/labrinth/src/models/exp/mod.rs | 85 ++++--- .../src/routes/v3/project_creation.rs | 2 + .../src/routes/v3/project_creation/new.rs | 81 +++---- apps/labrinth/src/routes/v3/projects.rs | 75 ++++-- 11 files changed, 321 insertions(+), 276 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json rename apps/labrinth/.sqlx/{query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json => query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json} (85%) rename apps/labrinth/.sqlx/{query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json => query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json} (63%) create mode 100644 apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json 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-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/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json b/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json new file mode 100644 index 0000000000..d838953a8f --- /dev/null +++ b/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4" +} diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index 22c7c01ef8..7de4b975ac 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -1,20 +1,2 @@ -CREATE TABLE minecraft_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - max_players int NOT NULL -); - -CREATE TABLE minecraft_java_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - address varchar(255) NOT NULL -); - -CREATE TABLE minecraft_bedrock_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - address varchar(255) NOT NULL -); +ALTER TABLE mods +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index d9af6760bd..56d97ea7bc 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::ProjectSerial, } impl ProjectBuilder { @@ -215,6 +217,7 @@ impl ProjectBuilder { side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], + components: self.components, }; 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?; @@ -778,24 +785,13 @@ impl DBProject { 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, - -- components - COUNT(c1.id) > 0 AS minecraft_server_exists, - MAX(c1.max_players) AS minecraft_server_max_players, - COUNT(c2.id) > 0 AS minecraft_java_server_exists, - MAX(c2.address) AS minecraft_java_server_address, - COUNT(c3.id) > 0 AS minecraft_bedrock_server_exists, - MAX(c3.address) AS minecraft_bedrock_server_address + 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 - -- components - LEFT JOIN minecraft_server_projects c1 ON c1.id = m.id - LEFT JOIN minecraft_java_server_projects c2 ON c2.id = m.id - LEFT JOIN minecraft_bedrock_server_projects c3 ON c3.id = m.id - WHERE m.id = ANY($1) OR m.slug = ANY($2) GROUP BY t.id, m.id "#, @@ -859,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(), @@ -872,21 +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: if m.minecraft_server_exists.unwrap_or(false) { - Some(exp::minecraft::Server { - max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), - }) - } else { None }, - minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { - Some(exp::minecraft::JavaServer { - address: m.minecraft_java_server_address.unwrap(), - }) - } else { None }, - minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { - Some(exp::minecraft::BedrockServer { - address: m.minecraft_bedrock_server_address.unwrap(), - }) - } else { None }, + minecraft_server: m + .components + .0 + .minecraft_server + .map(exp::ProjectComponent::from_serial), + minecraft_java_server: m + .components + .0 + .minecraft_java_server + .map(exp::ProjectComponent::from_serial), + minecraft_bedrock_server: m + .components + .0 + .minecraft_bedrock_server + .map(exp::ProjectComponent::from_serial), }; acc.insert(m.id, (m.slug, project)); diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 97c883be74..18aa6b68f3 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -1,34 +1,16 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; -use sqlx::{PgTransaction, postgres::PgQueryResult}; use validator::Validate; use crate::{ - database::models::DBProjectId, + database::{PgTransaction, models::DBProjectId}, models::exp::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, }; -pub(super) static RELATIONS: LazyLock> = - LazyLock::new(|| { - use ProjectComponentKind as C; - - vec![ - [C::MinecraftMod].only(), - [ - C::MinecraftServer, - C::MinecraftJavaServer, - C::MinecraftBedrockServer, - ] - .only(), - C::MinecraftJavaServer.requires(C::MinecraftServer), - C::MinecraftBedrockServer.requires(C::MinecraftServer), - ] - }); - define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Mod {} @@ -51,160 +33,147 @@ define! { } } -// impl +relations! { + [MinecraftMod].only(), + [ + MinecraftServer, + MinecraftJavaServer, + MinecraftBedrockServer, + ] + .only(), + MinecraftJavaServer.requires(MinecraftServer), + MinecraftBedrockServer.requires(MinecraftServer), +} impl ProjectComponent for Mod { + type Serial = Self; + + type Edit = ModEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftMod } - async fn insert( - &self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - unimplemented!(); + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for ModEdit { - async fn update( - &self, + type Component = Mod; + + async fn apply_to( + self, _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, - ) -> Result { + _component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { unimplemented!(); } } impl ProjectComponent for Server { + type Serial = Self; + + type Edit = ServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_server_projects (id, max_players) - VALUES ($1, $2) - ", - project_id as _, - self.max_players.cast_signed(), - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for ServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_server_projects - SET max_players = COALESCE($2, max_players) - WHERE id = $1 - ", - project_id as _, - self.max_players.map(|n| n.cast_signed()), - ) - .execute(&mut **txn) - .await + type Component = Server; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(max_players) = self.max_players { + component.max_players = max_players; + } + Ok(()) } } impl ProjectComponent for JavaServer { + type Serial = Self; + + type Edit = JavaServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_java_server_projects (id, address) - VALUES ($1, $2) - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for JavaServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_java_server_projects - SET address = COALESCE($2, address) - WHERE id = $1 - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await + type Component = JavaServer; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(address) = self.address { + component.address = address; + } + Ok(()) } } impl ProjectComponent for BedrockServer { + type Serial = Self; + + type Edit = BedrockServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_bedrock_server_projects (id, address) - VALUES ($1, $2) - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for BedrockServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_bedrock_server_projects - SET address = COALESCE($2, address) - WHERE id = $1 - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await + type Component = BedrockServer; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(address) = self.address { + component.address = address; + } + Ok(()) } } diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index e1cc5af48d..e3b0272d27 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -14,12 +14,22 @@ use std::{collections::HashSet, sync::LazyLock}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgTransaction, postgres::PgQueryResult}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use thiserror::Error; use validator::Validate; -use crate::database::models::DBProjectId; +use crate::database::{PgTransaction, models::DBProjectId}; + +macro_rules! relations { + ($($relations:tt)*) => { + pub(super) static RELATIONS: LazyLock> = + LazyLock::new(|| { + use ProjectComponentKind::*; + + vec![$($relations)*] + }); + }; +} macro_rules! define { ( @@ -62,16 +72,23 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { + // kinds + + #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + const _: () = { + fn assert_implements_project_component() {} + + fn assert_components_implement_trait() { + $(assert_implements_project_component::<$ty>();)* + } + }; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ProjectComponentKind { $($variant_name,)* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ProjectCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* - } + // structs #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { @@ -82,19 +99,19 @@ macro_rules! define_project_components { )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ProjectEdit { - pub base: base::ProjectEdit, + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct ProjectSerial { + $( + pub $field_name: Option<<$ty as ProjectComponent>::Serial>, + )* } - #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] - const _: () = { - fn assert_implements_project_component() {} - - fn assert_components_implement_trait() { - $(assert_implements_project_component::<$ty>();)* - } - }; + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ProjectCreate { + pub base: base::Project, + $(pub $field_name: Option<$ty>,)* + } impl ProjectCreate { #[must_use] @@ -106,6 +123,12 @@ macro_rules! define_project_components { kinds } } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct ProjectEdit { + $(pub $field_name: Option<<$ty as ProjectComponent>::Edit>,)* + } }; } @@ -117,23 +140,27 @@ define_project_components! [ ]; pub trait ProjectComponent: Sized { + type Serial: Serialize + DeserializeOwned; + + type Edit: ProjectComponentEdit; + fn kind() -> ProjectComponentKind; - #[expect(async_fn_in_trait, reason = "internal trait")] - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error>; + fn into_serial(self) -> Self::Serial; + + fn from_serial(serial: Self::Serial) -> Self; } pub trait ProjectComponentEdit: Sized { + type Component: ProjectComponent; + #[expect(async_fn_in_trait, reason = "internal trait")] - async fn update( - &self, + async fn apply_to( + self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, - ) -> Result; + component: &mut Self::Component, + ) -> Result<(), sqlx::Error>; } #[derive(Debug, Clone)] @@ -169,6 +196,8 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { pub enum ComponentKindsError { #[error("no components")] NoComponents, + #[error("component `{target:?}` is missing")] + Missing { target: ProjectComponentKind }, #[error( "only components {only:?} can be together, found extra components {extra:?}" )] diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 9b3ae2fa4b..91d4519825 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; @@ -874,6 +875,7 @@ async fn project_create_inner( .collect(), color: icon_data.and_then(|x| x.2), monetization_status: MonetizationStatus::Monetized, + components: exp::ProjectSerial::default(), }; let project_builder = project_builder_actual.clone(); diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index ec121f1ee1..91ff80ccd0 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,15 +1,12 @@ -use std::any::type_name; - use actix_http::StatusCode; use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; -use eyre::eyre; use rust_decimal::Decimal; -use sqlx::{PgPool, PgTransaction}; use validator::Validate; use crate::{ auth::get_user_from_headers, database::{ + PgPool, models::{ self, DBUser, project_item::ProjectBuilder, thread_item::ThreadBuilder, @@ -17,7 +14,7 @@ use crate::{ redis::RedisPool, }, models::{ - exp, + exp::{self}, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, @@ -139,6 +136,17 @@ pub async fn create( 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::ProjectCreate { + base, + minecraft_mod, + minecraft_server, + minecraft_java_server, + minecraft_bedrock_server, + } = details; + // check if this won't conflict with an existing project let mut txn = db @@ -148,9 +156,9 @@ pub async fn create( let same_slug_record = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", - details.base.slug.to_lowercase() + base.slug.to_lowercase() ) - .fetch_one(&mut *txn) + .fetch_one(&mut txn) .await .wrap_internal_err("failed to query if slug already exists")?; @@ -187,9 +195,9 @@ pub async fn create( project_id: project_id.into(), team_id, organization_id: None, // todo - name: details.base.name, - summary: details.base.summary, - description: details.base.description, + name: base.name.clone(), + summary: base.summary.clone(), + description: base.description.clone(), icon_url: None, raw_icon_url: None, license_url: None, @@ -199,12 +207,23 @@ pub async fn create( status: ProjectStatus::Draft, requested_status: Some(ProjectStatus::Approved), license: "LicenseRef-Unknown".into(), - slug: Some(details.base.slug), + slug: Some(base.slug.clone()), link_urls: vec![], gallery_items: vec![], color: None, // TODO: what if we don't monetize server listing projects? monetization_status: MonetizationStatus::Monetized, + // components + components: exp::ProjectSerial { + minecraft_mod: minecraft_mod + .map(exp::ProjectComponent::into_serial), + minecraft_server: minecraft_server + .map(exp::ProjectComponent::into_serial), + minecraft_java_server: minecraft_java_server + .map(exp::ProjectComponent::into_serial), + minecraft_bedrock_server: minecraft_bedrock_server + .map(exp::ProjectComponent::into_serial), + }, }; project_builder @@ -225,46 +244,6 @@ pub async fn create( .await .wrap_internal_err("failed to insert thread")?; - // component-specific info - - async fn insert( - txn: &mut PgTransaction<'_>, - project_id: ProjectId, - component: Option, - ) -> Result<(), CreateError> { - let Some(component) = component else { - return Ok(()); - }; - component - .insert(txn, project_id.into()) - .await - .wrap_internal_err_with(|| { - eyre!("failed to insert `{}` component", type_name::()) - })?; - Ok(()) - } - - // use struct destructor syntax, so we get a compile error - // if we add a new field and don't add it here - let exp::ProjectCreate { - base: _, - minecraft_mod, - minecraft_server, - minecraft_java_server, - minecraft_bedrock_server, - } = details; - - if let Some(_component) = minecraft_mod { - // todo - return Err(ApiError::Request(eyre!( - "creating a mod project from this endpoint is not supported yet" - )) - .into()); - } - insert(&mut txn, project_id, minecraft_server).await?; - insert(&mut txn, project_id, minecraft_java_server).await?; - insert(&mut txn, project_id, minecraft_bedrock_server).await?; - // and commit! txn.commit() diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 59bd169672..57cff607df 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -288,7 +288,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 { @@ -947,32 +947,75 @@ pub async fn project_edit( // components - async fn update( + async fn update( txn: &mut PgTransaction<'_>, project_id: DBProjectId, - component: Option, + edit: Option, + component: &mut Option, ) -> Result<(), ApiError> { - let Some(component) = component else { + let Some(edit) = edit else { return Ok(()); }; - let result = component - .update(txn, project_id) + let component = component + .as_mut() + .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; + + edit.apply_to(txn, project_id, component) .await .wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) + eyre!("failed to update `{}` component", type_name::()) })?; - if result.rows_affected() == 0 { - return Err(ApiError::Request(eyre!( - "project does not have `{}` component", - type_name::() - ))); - } Ok(()) } - update(&mut transaction, id, new_project.minecraft_server).await?; - update(&mut transaction, id, new_project.minecraft_java_server).await?; - update(&mut transaction, id, new_project.minecraft_bedrock_server).await?; + 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::ProjectComponent::into_serial), + minecraft_java_server: project_item + .minecraft_java_server + .map(exp::ProjectComponent::into_serial), + minecraft_bedrock_server: project_item + .minecraft_bedrock_server + .map(exp::ProjectComponent::into_serial), + }; + + 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 From 7e4d6cf0ae029cc0c3be868e0ef20aac5895bc83 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 15:37:58 +0000 Subject: [PATCH 09/17] Update project component structure for servers --- .../20260207153522_version_components.sql | 2 + apps/labrinth/src/models/exp/base.rs | 6 ++ apps/labrinth/src/models/exp/minecraft.rs | 10 ++ .../src/routes/v3/project_creation/new.rs | 91 ++++++++++++++----- apps/labrinth/src/routes/v3/projects.rs | 4 +- 5 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 apps/labrinth/migrations/20260207153522_version_components.sql diff --git a/apps/labrinth/migrations/20260207153522_version_components.sql b/apps/labrinth/migrations/20260207153522_version_components.sql new file mode 100644 index 0000000000..4c1e933563 --- /dev/null +++ b/apps/labrinth/migrations/20260207153522_version_components.sql @@ -0,0 +1,2 @@ +ALTER TABLE versions +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs index 04a6191e51..c92b96f078 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::models::{ids::OrganizationId, projects::ProjectStatus}; + define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { @@ -22,5 +24,9 @@ define! { /// 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, } } diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 18aa6b68f3..e44967b520 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -15,21 +15,31 @@ define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Mod {} + /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Server { + /// Maximum number of players allowed on the server. pub max_players: u32, } + /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServer { + /// Address (IP or domain name) of the Java server, excluding port. #[validate(length(max = 255))] pub address: String, + /// Port which the server runs on. + pub port: u16, } + /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct BedrockServer { + /// Address (IP or domain name) of the Bedrock server, excluding port. #[validate(length(max = 255))] pub address: String, + /// Port which the server runs on. + pub port: u16, } } diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 91ff80ccd0..af28612a4c 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,5 +1,6 @@ use actix_http::StatusCode; use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; +use eyre::eyre; use rust_decimal::Decimal; use validator::Validate; @@ -8,8 +9,8 @@ use crate::{ database::{ PgPool, models::{ - self, DBUser, project_item::ProjectBuilder, - thread_item::ThreadBuilder, + self, DBOrganization, DBTeamMember, DBUser, + project_item::ProjectBuilder, thread_item::ThreadBuilder, }, redis::RedisPool, }, @@ -18,7 +19,7 @@ use crate::{ ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, - teams::ProjectPermissions, + teams::{OrganizationPermissions, ProjectPermissions}, threads::ThreadType, v3::user_limits::UserLimits, }, @@ -83,10 +84,10 @@ impl CreateError { impl ResponseError for CreateError { fn status_code(&self) -> actix_http::StatusCode { match self { - Self::LimitReached => StatusCode::BAD_REQUEST, - Self::ComponentKinds(_) => StatusCode::BAD_REQUEST, - Self::Validation(_) => StatusCode::BAD_REQUEST, - Self::SlugCollision => StatusCode::BAD_REQUEST, + Self::LimitReached + | Self::ComponentKinds(_) + | Self::Validation(_) + | Self::SlugCollision => StatusCode::BAD_REQUEST, Self::Api(err) => err.status_code(), } } @@ -147,6 +148,15 @@ pub async fn create( minecraft_bedrock_server, } = details; + let exp::base::Project { + name, + slug, + summary, + description, + requested_status, + organization_id, + } = base; + // check if this won't conflict with an existing project let mut txn = db @@ -156,7 +166,7 @@ pub async fn create( let same_slug_record = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", - base.slug.to_lowercase() + slug.to_lowercase() ) .fetch_one(&mut txn) .await @@ -168,8 +178,37 @@ pub async fn create( // create project and supporting records in db - let team_id = { - // TODO organization + 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(), @@ -180,24 +219,33 @@ pub async fn create( payouts_split: Decimal::ONE_HUNDRED, ordering: 0, }]; - let team = models::team_item::TeamBuilder { members }; - team.insert(&mut txn) - .await - .wrap_internal_err("failed to insert team")? + + 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 minecraft_server.is_some() { + MonetizationStatus::ForceDemonetized + } else { + MonetizationStatus::Monetized + }; + let project_builder = ProjectBuilder { project_id: project_id.into(), team_id, - organization_id: None, // todo - name: base.name.clone(), - summary: base.summary.clone(), - description: base.description.clone(), + 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, @@ -205,14 +253,13 @@ pub async fn create( additional_categories: vec![], initial_versions: vec![], status: ProjectStatus::Draft, - requested_status: Some(ProjectStatus::Approved), + requested_status: Some(requested_status), license: "LicenseRef-Unknown".into(), - slug: Some(base.slug.clone()), + slug: Some(slug.clone()), link_urls: vec![], gallery_items: vec![], color: None, - // TODO: what if we don't monetize server listing projects? - monetization_status: MonetizationStatus::Monetized, + monetization_status, // components components: exp::ProjectSerial { minecraft_mod: minecraft_mod diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 57cff607df..65df73a308 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -29,9 +29,7 @@ use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::indexing::remove_documents; -use crate::search::{ - MeilisearchReadClient, SearchConfig, SearchError, search_for_project, -}; +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}; From 5dbdee3c31a35cdebf38b45406719abb16ea55b6 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 18:52:22 +0000 Subject: [PATCH 10/17] wip: version components --- .../20260207153522_version_components.sql | 6 + .../src/database/models/project_item.rs | 12 +- apps/labrinth/src/models/exp/base.rs | 47 ++-- apps/labrinth/src/models/exp/component.rs | 195 ++++++++++++++ apps/labrinth/src/models/exp/minecraft.rs | 189 +++----------- apps/labrinth/src/models/exp/mod.rs | 240 ++++++------------ apps/labrinth/src/models/v3/projects.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 19 +- apps/labrinth/src/routes/v3/projects.rs | 27 +- 9 files changed, 374 insertions(+), 367 deletions(-) create mode 100644 apps/labrinth/src/models/exp/component.rs diff --git a/apps/labrinth/migrations/20260207153522_version_components.sql b/apps/labrinth/migrations/20260207153522_version_components.sql index 4c1e933563..f6c3185264 100644 --- a/apps/labrinth/migrations/20260207153522_version_components.sql +++ b/apps/labrinth/migrations/20260207153522_version_components.sql @@ -1,2 +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 56d97ea7bc..bd993feb15 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -873,17 +873,17 @@ impl DBProject { .components .0 .minecraft_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), minecraft_java_server: m .components .0 .minecraft_java_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), minecraft_bedrock_server: m .components .0 .minecraft_bedrock_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), }; acc.insert(m.id, (m.slug, project)); @@ -1009,7 +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, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs index c92b96f078..1df759f07a 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -3,30 +3,31 @@ use validator::Validate; use crate::models::{ids::OrganizationId, projects::ProjectStatus}; -define! { - #[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( +#[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, - } + 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..ff8b47d061 --- /dev/null +++ b/apps/labrinth/src/models/exp/component.rs @@ -0,0 +1,195 @@ +macro_rules! define { + () => {}; + ( + #[component($component_kind:ident :: $component_kind_variant:ident)] + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident: $ty:ty + ),* $(,)? + } + + $($rest:tt)* + ) => { paste::paste! { + $(#[$meta])* + $vis struct $name { + $( + $(#[$field_meta])* + $field_vis $field: $ty, + )* + } + + $(#[$meta])* + $vis struct [< $name Edit >] { + $( + $(#[$field_meta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + $field_vis $field: Option<$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; + + async fn apply_to( + self, + #[allow(unused_variables)] + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + $( + 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; +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; + + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn apply_to( + self, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error>; +} + +#[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 index e44967b520..132a70dbc8 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -1,30 +1,32 @@ -use std::sync::LazyLock; - use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::{ - database::{PgTransaction, models::DBProjectId}, - models::exp::{ - ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentEdit, ProjectComponentKind, - }, +use crate::models::{ + exp::{ProjectComponentKind, VersionComponentKind, component}, + ids::VersionId, }; -define! { +component::define! { + #[component(ProjectComponentKind::MinecraftMod)] #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct Mod {} + pub struct ModProject {} + #[component(ProjectComponentKind::MinecraftServer)] /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct Server { + pub struct ServerProject { /// Maximum number of players allowed on the server. pub max_players: u32, + /// Country which this server is hosted in. + pub country: String, + /// 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 JavaServer { + pub struct JavaServerProject { /// Address (IP or domain name) of the Java server, excluding port. #[validate(length(max = 255))] pub address: String, @@ -32,9 +34,20 @@ define! { pub port: u16, } + #[component(VersionComponentKind::MinecraftJavaServer)] + /// Listing for a Minecraft Java server. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServerVersion { + /// What modpack version this server is using. + /// + /// If the server is vanilla, this is [`None`]. + pub modpack: Option, + } + + #[component(ProjectComponentKind::MinecraftBedrockServer)] /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct BedrockServer { + pub struct BedrockServerProject { /// Address (IP or domain name) of the Bedrock server, excluding port. #[validate(length(max = 255))] pub address: String, @@ -43,147 +56,15 @@ define! { } } -relations! { - [MinecraftMod].only(), - [ - MinecraftServer, - MinecraftJavaServer, - MinecraftBedrockServer, - ] - .only(), - MinecraftJavaServer.requires(MinecraftServer), - MinecraftBedrockServer.requires(MinecraftServer), -} - -impl ProjectComponent for Mod { - type Serial = Self; - - type Edit = ModEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftMod - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for ModEdit { - type Component = Mod; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - _component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - unimplemented!(); - } -} - -impl ProjectComponent for Server { - type Serial = Self; - - type Edit = ServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for ServerEdit { - type Component = Server; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(max_players) = self.max_players { - component.max_players = max_players; - } - Ok(()) - } -} - -impl ProjectComponent for JavaServer { - type Serial = Self; - - type Edit = JavaServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftJavaServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for JavaServerEdit { - type Component = JavaServer; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(address) = self.address { - component.address = address; - } - Ok(()) - } -} - -impl ProjectComponent for BedrockServer { - type Serial = Self; - - type Edit = BedrockServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftBedrockServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for BedrockServerEdit { - type Component = BedrockServer; +component::relations! { + pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + use ProjectComponentKind::*; - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(address) = self.address { - component.address = address; - } - Ok(()) + [ + [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 index e3b0272d27..dd2f83dd89 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -12,58 +12,12 @@ //! server address), but typically, the version will store this data in *version //! components*. -use std::{collections::HashSet, sync::LazyLock}; +use std::collections::HashSet; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use thiserror::Error; +use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::database::{PgTransaction, models::DBProjectId}; - -macro_rules! relations { - ($($relations:tt)*) => { - pub(super) static RELATIONS: LazyLock> = - LazyLock::new(|| { - use ProjectComponentKind::*; - - vec![$($relations)*] - }); - }; -} - -macro_rules! define { - ( - $(#[$meta:meta])* - $vis:vis struct $name:ident { - $( - $(#[$field_meta:meta])* - $field_vis:vis $field:ident: $ty:ty - ),* $(,)? - } - - $($rest:tt)* - ) => { paste::paste! { - $(#[$meta])* - $vis struct $name { - $( - $(#[$field_meta])* - $field_vis $field: $ty, - )* - } - - $(#[$meta])* - $vis struct [< $name Edit >] { - $( - $(#[$field_meta])* - #[serde(default, skip_serializing_if = "Option::is_none")] - $field_vis $field: Option<$ty>, - )* - } - - define!($($rest)*); - }}; - () => {}; -} +pub mod component; pub mod base; pub mod minecraft; @@ -74,12 +28,15 @@ macro_rules! define_project_components { ) => { // kinds - #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + #[expect(dead_code, reason = "static check so $ty implements `Component`")] const _: () = { - fn assert_implements_project_component() {} + fn assert_implements_component() + where + T: component::Component, + {} fn assert_components_implement_trait() { - $(assert_implements_project_component::<$ty>();)* + $(assert_implements_component::<$ty>();)* } }; @@ -88,6 +45,8 @@ macro_rules! define_project_components { $($variant_name,)* } + impl component::ComponentKind for ProjectComponentKind {} + // structs #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -100,10 +59,9 @@ macro_rules! define_project_components { } #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] - // #[derive(utoipa::ToSchema)] pub struct ProjectSerial { $( - pub $field_name: Option<<$ty as ProjectComponent>::Serial>, + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } @@ -127,128 +85,92 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct ProjectEdit { - $(pub $field_name: Option<<$ty as ProjectComponent>::Edit>,)* + $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* } }; } -define_project_components! [ - (minecraft_mod, MinecraftMod): minecraft::Mod, - (minecraft_server, MinecraftServer): minecraft::Server, - (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServer, - (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, -]; - -pub trait ProjectComponent: Sized { - type Serial: Serialize + DeserializeOwned; - - type Edit: ProjectComponentEdit; +macro_rules! define_version_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + // kinds - fn kind() -> ProjectComponentKind; + #[expect(dead_code, reason = "static check so $ty implements `Component`")] + const _: () = { + fn assert_implements_component() + where + T: component::Component, + {} - fn into_serial(self) -> Self::Serial; + fn assert_components_implement_trait() { + $(assert_implements_component::<$ty>();)* + } + }; - fn from_serial(serial: Self::Serial) -> Self; -} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum VersionComponentKind { + $($variant_name,)* + } -pub trait ProjectComponentEdit: Sized { - type Component: ProjectComponent; + impl component::ComponentKind for VersionComponentKind {} - #[expect(async_fn_in_trait, reason = "internal trait")] - async fn apply_to( - self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error>; -} + // structs -#[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(ProjectComponentKind, ProjectComponentKind), -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Version { + pub base: base::Version, + $( + #[serde(skip_serializing_if = "Option::is_none")] + pub $field_name: Option<$ty>, + )* + } -trait ComponentKindExt { - fn requires(self, other: ProjectComponentKind) -> ComponentRelation; -} + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + pub struct VersionSerial { + $( + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + )* + } -impl ComponentKindExt for ProjectComponentKind { - fn requires(self, other: ProjectComponentKind) -> ComponentRelation { - ComponentRelation::Requires(self, other) - } -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct VersionCreate { + pub base: base::Project, + $(pub $field_name: Option<$ty>,)* + } -trait ComponentKindArrayExt { - fn only(self) -> ComponentRelation; -} + 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 + } + } -impl ComponentKindArrayExt for [ProjectComponentKind; N] { - fn only(self) -> ComponentRelation { - ComponentRelation::Only(self.iter().copied().collect()) - } + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct VersionEdit { + $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + } + }; } -#[derive(Debug, Clone, Error, Serialize, Deserialize)] -pub enum ComponentKindsError { - #[error("no components")] - NoComponents, - #[error("component `{target:?}` is missing")] - Missing { target: ProjectComponentKind }, - #[error( - "only components {only:?} can be together, found extra components {extra:?}" - )] - Only { - only: HashSet, - extra: HashSet, - }, - #[error("component `{target:?}` requires `{requires:?}`")] - Requires { - target: ProjectComponentKind, - requires: ProjectComponentKind, - }, -} +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, +]; -pub fn component_kinds_valid( - kinds: &HashSet, -) -> Result<(), ComponentKindsError> { - static RELATIONS: LazyLock> = LazyLock::new(|| { - let mut relations = Vec::new(); - relations.extend_from_slice(minecraft::RELATIONS.as_slice()); - relations - }); - - if kinds.is_empty() { - return Err(ComponentKindsError::NoComponents); - } +define_version_components![ + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion, +]; - for relation in RELATIONS.iter() { - match relation { - ComponentRelation::Only(set) => { - if kinds.iter().any(|k| set.contains(k)) { - let extra: HashSet<_> = - kinds.difference(set).copied().collect(); - if !extra.is_empty() { - return Err(ComponentKindsError::Only { - only: set.clone(), - extra, - }); - } - } - } - ComponentRelation::Requires(a, b) => { - if kinds.contains(a) && !kinds.contains(b) { - return Err(ComponentKindsError::Requires { - target: *a, - requires: *b, - }); - } - } - } +component::relations! { + pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + minecraft::PROJECT_COMPONENT_RELATIONS.clone() } - - Ok(()) } diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 77910b5a48..7a3a21aa44 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -101,11 +101,11 @@ pub struct Project { pub fields: HashMap>, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_server: Option, + pub minecraft_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_java_server: Option, + pub minecraft_java_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_bedrock_server: Option, + 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 diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index af28612a4c..086c53167f 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -15,7 +15,7 @@ use crate::{ redis::RedisPool, }, models::{ - exp::{self}, + exp::{self, ProjectComponentKind, component::ComponentRelationError}, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, @@ -41,7 +41,7 @@ pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("invalid component kinds")] - ComponentKinds(exp::ComponentKindsError), + ComponentKinds(ComponentRelationError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -130,8 +130,11 @@ pub async fn create( // check if the given details are valid - exp::component_kinds_valid(&details.component_kinds()) - .map_err(CreateError::ComponentKinds)?; + 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)) @@ -263,13 +266,13 @@ pub async fn create( // components components: exp::ProjectSerial { minecraft_mod: minecraft_mod - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_server: minecraft_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_java_server: minecraft_java_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_bedrock_server: minecraft_bedrock_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), }, }; diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 65df73a308..09aa073ced 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -256,9 +256,10 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: + Option, } #[allow(clippy::too_many_arguments)] @@ -945,9 +946,9 @@ pub async fn project_edit( // components - async fn update( - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, + async fn update( + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, edit: Option, component: &mut Option, ) -> Result<(), ApiError> { @@ -958,11 +959,9 @@ pub async fn project_edit( .as_mut() .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; - edit.apply_to(txn, project_id, component) - .await - .wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) - })?; + edit.apply_to(component).await.wrap_internal_err_with(|| { + eyre!("failed to update `{}` component", type_name::()) + })?; Ok(()) } @@ -992,13 +991,13 @@ pub async fn project_edit( minecraft_mod: None, minecraft_server: project_item .minecraft_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_java_server: project_item .minecraft_java_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_bedrock_server: project_item .minecraft_bedrock_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), }; sqlx::query!( From 31a1a04cdf6b919853c8521acea39098ec19104f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 23:11:19 +0000 Subject: [PATCH 11/17] Version components backend API --- ...3c1ce6fb8ac08829345445b027e5a46c4860.json} | 12 +++++++--- .../src/database/models/version_item.rs | 24 +++++++++++++++---- apps/labrinth/src/models/exp/minecraft.rs | 1 + apps/labrinth/src/models/v3/projects.rs | 4 ++++ apps/labrinth/src/routes/v2/versions.rs | 22 +++++++++++------ .../src/routes/v3/version_creation.rs | 1 + apps/labrinth/src/routes/v3/versions.rs | 8 +++---- 7 files changed, 53 insertions(+), 19 deletions(-) rename apps/labrinth/.sqlx/{query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json => query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json} (81%) 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/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index b6f18ee0e4..f22b2e89f7 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}; @@ -719,13 +720,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) @@ -804,6 +806,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 +944,7 @@ impl DBVersion { } } -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, Serialize)] pub struct VersionQueryResult { pub inner: DBVersion, @@ -947,6 +954,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 +1001,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) { diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 132a70dbc8..694e615f04 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -18,6 +18,7 @@ component::define! { /// Maximum number of players allowed on the server. pub max_players: u32, /// Country which this server is hosted in. + #[validate(length(min = 2, max = 2))] pub country: String, /// Which version of the listing this server is currently using. pub active_version: Option, diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 7a3a21aa44..cb8db6fe79 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -701,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>( @@ -772,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/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 708f35b729..6cde6b15ca 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -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/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 9b91a6bf81..692fe20e09 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -473,6 +473,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..0acd034b21 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -175,7 +175,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 +186,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 +204,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) From d01a0fe8311a4dadacb831198658dde813ebf9d5 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 8 Feb 2026 18:32:47 +0000 Subject: [PATCH 12/17] Version component creation --- ...04deb72636f4cbd6927108aadfd6e0d63f1c.json} | 7 +- ...ff7d7d79642345c93002dd774d7b30ac3d66.json} | 4 +- .../src/database/models/project_item.rs | 4 +- .../src/database/models/version_item.rs | 25 +++++- apps/labrinth/src/models/exp/base.rs | 6 +- apps/labrinth/src/models/exp/mod.rs | 84 ++++++++++++++----- .../src/routes/v2/project_creation.rs | 1 + .../src/routes/v2/version_creation.rs | 1 + .../src/routes/v3/project_creation.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 36 ++++---- .../src/routes/v3/version_creation.rs | 9 ++ 11 files changed, 126 insertions(+), 57 deletions(-) rename apps/labrinth/.sqlx/{query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json => query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json} (59%) rename apps/labrinth/.sqlx/{query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json => query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json} (58%) 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-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json similarity index 58% rename from apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json rename to apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json index d838953a8f..a58ac0f232 100644 --- a/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json +++ b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4" + "hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66" } diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index bd993feb15..19949b49d2 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -177,7 +177,7 @@ pub struct ProjectBuilder { pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, - pub components: exp::ProjectSerial, + pub components: exp::ProjectCreate, } impl ProjectBuilder { @@ -217,7 +217,7 @@ impl ProjectBuilder { side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], - components: self.components, + components: self.components.into_db(), }; project_struct.insert(&mut *transaction).await?; diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index f22b2e89f7..429c5ea011 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -38,6 +38,7 @@ pub struct VersionBuilder { pub status: VersionStatus, pub requested_status: Option, pub ordering: Option, + pub components: exp::VersionCreate, } #[derive(Clone)] @@ -207,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?; @@ -286,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, @@ -301,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, @@ -313,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, @@ -332,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?; @@ -764,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| { @@ -1077,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 index 1df759f07a..07b3903f4b 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -13,9 +13,9 @@ pub struct Project { 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) - )] + 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))] diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index dd2f83dd89..30f4dfc4b3 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -17,9 +17,8 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; use validator::Validate; -pub mod component; - pub mod base; +pub mod component; pub mod minecraft; macro_rules! define_project_components { @@ -51,33 +50,42 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { + #[validate(nested)] pub base: base::Project, $( - #[serde(skip_serializing_if = "Option::is_none")] - pub $field_name: Option<$ty>, + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, )* } #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] pub struct ProjectSerial { $( - pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* + #[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); - })* + $( + if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + } + )* kinds } } @@ -85,7 +93,22 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct ProjectEdit { - $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + $( + #[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), + )* + } + } } }; } @@ -119,24 +142,30 @@ macro_rules! define_version_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Version { + #[validate(nested)] pub base: base::Version, $( - #[serde(skip_serializing_if = "Option::is_none")] - pub $field_name: Option<$ty>, + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, )* } - #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct VersionSerial { $( - pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct VersionCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* + #[validate(nested)] + pub base: Option, + $( + #[validate(nested)] + pub $field_name: Option<$ty>, + )* } impl VersionCreate { @@ -153,7 +182,22 @@ macro_rules! define_version_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct VersionEdit { - $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + $( + #[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), + )* + } + } } }; } 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/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/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 91d4519825..8537b1114b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -875,7 +875,7 @@ async fn project_create_inner( .collect(), color: icon_data.and_then(|x| x.2), monetization_status: MonetizationStatus::Monetized, - components: exp::ProjectSerial::default(), + components: exp::ProjectCreate::default(), }; let project_builder = project_builder_actual.clone(); @@ -1085,6 +1085,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 index 086c53167f..258fc63e03 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -40,6 +40,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { 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}")] @@ -58,6 +60,11 @@ impl CreateError { 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}"), @@ -85,6 +92,7 @@ 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, @@ -143,14 +151,6 @@ pub async fn create( // 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::ProjectCreate { - base, - minecraft_mod, - minecraft_server, - minecraft_java_server, - minecraft_bedrock_server, - } = details; - let exp::base::Project { name, slug, @@ -158,7 +158,7 @@ pub async fn create( description, requested_status, organization_id, - } = base; + } = details.base.clone().ok_or(CreateError::MissingBase)?; // check if this won't conflict with an existing project @@ -168,7 +168,9 @@ pub async fn create( .wrap_internal_err("failed to begin transaction")?; let same_slug_record = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "SELECT EXISTS( + SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1 + )", slug.to_lowercase() ) .fetch_one(&mut txn) @@ -236,7 +238,7 @@ pub async fn create( .into(); // TODO: special-case server projects to be unmonetized - let monetization_status = if minecraft_server.is_some() { + let monetization_status = if details.minecraft_server.is_some() { MonetizationStatus::ForceDemonetized } else { MonetizationStatus::Monetized @@ -263,17 +265,7 @@ pub async fn create( gallery_items: vec![], color: None, monetization_status, - // components - components: exp::ProjectSerial { - minecraft_mod: minecraft_mod - .map(exp::component::Component::into_db), - minecraft_server: minecraft_server - .map(exp::component::Component::into_db), - minecraft_java_server: minecraft_java_server - .map(exp::component::Component::into_db), - minecraft_bedrock_server: minecraft_bedrock_server - .map(exp::component::Component::into_db), - }, + components: details, }; project_builder diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 692fe20e09..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(()); From 17f9285988f466f553c853ddccb3df899603ff9c Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 11 Feb 2026 15:12:36 +0000 Subject: [PATCH 13/17] game version fields --- apps/labrinth/src/models/exp/component.rs | 15 +++++-- apps/labrinth/src/models/exp/minecraft.rs | 49 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index ff8b47d061..78afc2cb43 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -5,8 +5,14 @@ macro_rules! define { $(#[$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: $ty:ty + $field_vis:vis $field:ident: $field_ty:ty ),* $(,)? } @@ -16,7 +22,8 @@ macro_rules! define { $vis struct $name { $( $(#[$field_meta])* - $field_vis $field: $ty, + $(#[$field_base_meta])* + $field_vis $field: $field_ty, )* } @@ -24,8 +31,8 @@ macro_rules! define { $vis struct [< $name Edit >] { $( $(#[$field_meta])* - #[serde(default, skip_serializing_if = "Option::is_none")] - $field_vis $field: Option<$ty>, + $(#[$field_edit_meta])* + $field_vis $field: Option<$field_ty>, )* } diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 694e615f04..50ee117307 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -15,11 +15,29 @@ component::define! { /// 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: u32, + 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: String, + 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, } @@ -28,17 +46,40 @@ component::define! { /// 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))] + /// List of supported Minecraft Java client versions which can join this + /// server. + pub supported_game_versions: Vec, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + /// Recommended Minecraft Java client version to use to join this server. + pub recommended_game_version: Option, } #[component(VersionComponentKind::MinecraftJavaServer)] /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServerVersion { + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] /// What modpack version this server is using. /// /// If the server is vanilla, this is [`None`]. @@ -49,9 +90,13 @@ component::define! { /// 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, } From 855ff1db5389cbc68ce8efe792de3b528d7a025d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 10:40:56 +0000 Subject: [PATCH 14/17] utoipa support for projects --- apps/labrinth/src/models/exp/minecraft.rs | 54 ++-- apps/labrinth/src/routes/v2/projects.rs | 34 +-- apps/labrinth/src/routes/v2/teams.rs | 2 +- apps/labrinth/src/routes/v2/versions.rs | 2 +- apps/labrinth/src/routes/v3/mod.rs | 5 + apps/labrinth/src/routes/v3/projects.rs | 294 ++++++++++++++++++---- apps/labrinth/src/routes/v3/teams.rs | 17 +- apps/labrinth/src/routes/v3/versions.rs | 27 +- 8 files changed, 349 insertions(+), 86 deletions(-) diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 50ee117307..ee2197de87 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -57,34 +57,13 @@ component::define! { pub port: u16, #[base(serde(default))] #[edit(serde(default))] - /// List of supported Minecraft Java client versions which can join this - /// server. - pub supported_game_versions: Vec, - #[base()] - #[edit(serde( - default, - skip_serializing_if = "Option::is_none", - with = "serde_with::rust::double_option" - ))] - /// Recommended Minecraft Java client version to use to join this server. - pub recommended_game_version: Option, + pub content: ServerContent, } #[component(VersionComponentKind::MinecraftJavaServer)] /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct JavaServerVersion { - #[base()] - #[edit(serde( - default, - skip_serializing_if = "Option::is_none", - with = "serde_with::rust::double_option" - ))] - /// What modpack version this server is using. - /// - /// If the server is vanilla, this is [`None`]. - pub modpack: Option, - } + pub struct JavaServerVersion {} #[component(ProjectComponentKind::MinecraftBedrockServer)] /// Listing for a Minecraft Bedrock server. @@ -102,6 +81,35 @@ component::define! { } } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +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::*; diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index b853010742..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 project = match v3::projects::project_get( + let project = match v3::projects::project_get_internal( req, info, pool.clone(), @@ -247,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) } @@ -267,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(), @@ -519,7 +519,7 @@ pub async fn project_edit( // 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(), @@ -756,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, @@ -780,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, @@ -816,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 { @@ -867,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, @@ -899,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, @@ -921,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, @@ -942,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) } @@ -956,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/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 6cde6b15ca..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), diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 96c3aaf62d..22db5ffd3c 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -65,6 +65,11 @@ 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), + ); } pub async fn hello_world() -> Result { diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 09aa073ced..c1b2fad076 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -35,7 +35,7 @@ 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; @@ -49,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)] @@ -164,7 +153,19 @@ 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, 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, @@ -194,7 +195,7 @@ pub async fn project_get( Err(ApiError::NotFound) } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct EditProject { #[validate( length(min = 3, max = 64), @@ -263,7 +264,32 @@ pub struct EditProject { } #[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, + 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, @@ -1133,7 +1159,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, @@ -1158,12 +1194,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; @@ -1568,7 +1616,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,)>, @@ -1683,7 +1756,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, @@ -1784,7 +1878,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, @@ -1951,7 +2072,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, @@ -2117,7 +2257,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, @@ -2227,7 +2388,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, @@ -2331,7 +2513,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, @@ -2411,7 +2605,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, @@ -2487,6 +2693,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/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 0acd034b21..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)>, @@ -729,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, From 74019afffa0864f940e5202e03723e0adae5cc8a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 10:46:24 +0000 Subject: [PATCH 15/17] docs --- apps/labrinth/src/models/exp/minecraft.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index ee2197de87..746be25d7d 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -57,11 +57,12 @@ component::define! { pub port: u16, #[base(serde(default))] #[edit(serde(default))] + /// What game content this server is using. pub content: ServerContent, } #[component(VersionComponentKind::MinecraftJavaServer)] - /// Listing for a Minecraft Java server. + /// Version of a Minecraft Java server listing. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServerVersion {} @@ -81,7 +82,9 @@ component::define! { } } +/// 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 { From 565838619a08b0aaaed8607ef35c72a6401b4a66 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 11:21:45 +0000 Subject: [PATCH 16/17] Allow editing components --- apps/labrinth/src/models/exp/component.rs | 21 ++++-- apps/labrinth/src/models/exp/mod.rs | 13 ++++ apps/labrinth/src/routes/mod.rs | 6 +- apps/labrinth/src/routes/v3/mod.rs | 4 +- .../src/routes/v3/project_creation.rs | 8 ++- .../src/routes/v3/project_creation/new.rs | 8 +-- apps/labrinth/src/routes/v3/projects.rs | 69 ++++++++++++++++--- 7 files changed, 100 insertions(+), 29 deletions(-) diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index 78afc2cb43..42f0bfc084 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -57,11 +57,22 @@ macro_rules! define { 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, - ) -> Result<(), sqlx::Error> { + ) -> eyre::Result<()> { $( if let Some(f) = self.$field { component.$field = f; @@ -87,6 +98,7 @@ macro_rules! relations { } pub(crate) use define; +use eyre::Result; pub(crate) use relations; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -115,11 +127,10 @@ pub trait Component: Sized { 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<(), sqlx::Error>; + async fn apply_to(self, component: &mut Self::Component) -> Result<()>; } #[derive(Debug, Clone)] diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index 30f4dfc4b3..8272499379 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -67,6 +67,19 @@ macro_rules! define_project_components { )* } + 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)] 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/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 22db5ffd3c..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) @@ -68,7 +67,8 @@ pub fn utoipa_config( cfg.service( utoipa_actix_web::scope("/v3/project") .wrap(default_cors()) - .configure(projects::utoipa_config), + .configure(projects::utoipa_config) + .configure(project_creation::config), ); } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 8537b1114b..3e35ab361e 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -46,7 +46,7 @@ use validator::Validate; mod new; -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(project_create) .service(project_create_with_id) .configure(new::config); @@ -267,7 +267,8 @@ pub async fn undo_uploads( Ok(()) } -#[post("/project")] +#[utoipa::path] +#[post("")] pub async fn project_create( req: HttpRequest, payload: Multipart, @@ -332,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, diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 258fc63e03..46ce5b26a1 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -28,11 +28,7 @@ use crate::{ util::{error::Context, validate::validation_errors_to_string}, }; -// pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { -// cfg.service(create); -// } - -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(create); } @@ -110,7 +106,7 @@ impl ResponseError for CreateError { /// Components must include `base` ([`exp::base::Project`]), and at least one /// other component. #[utoipa::path] -#[put("/project")] +#[put("")] pub async fn create( req: HttpRequest, db: web::Data, diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index c1b2fad076..f39ecf4352 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -257,10 +257,27 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, + + #[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, + Option>, } #[allow(clippy::too_many_arguments)] @@ -975,19 +992,45 @@ pub async fn project_edit_internal( async fn update( _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, - edit: Option, + edit: Option>, component: &mut Option, ) -> Result<(), ApiError> { let Some(edit) = edit else { + // component is not specified in the input JSON - leave alone return Ok(()); }; - let component = component - .as_mut() - .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; - edit.apply_to(component).await.wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) - })?; + match edit { + Some(edit) => { + // component is specified in the JSON and is non-null + match component { + Some(component) => edit + .apply_to(component) + .await + .wrap_internal_err_with(|| { + eyre!( + "failed to update `{}` component", + type_name::() + ) + })?, + None => { + *component = Some( + edit.create().wrap_request_err_with(|| { + eyre!( + "failed to create `{}` component", + type_name::() + ) + })?, + ); + } + } + } + None => { + // component is `null` in the input JSON - remove component + *component = None; + } + } + Ok(()) } @@ -1026,6 +1069,12 @@ pub async fn project_edit_internal( .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 From 95543e0f489463c69404e169b7a001cdf80b63e7 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 11:36:22 +0000 Subject: [PATCH 17/17] clean up component edit code --- apps/labrinth/src/routes/v3/projects.rs | 52 ++++++++++++------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index f39ecf4352..b9bcfa97eb 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -993,42 +993,40 @@ pub async fn project_edit_internal( _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, edit: Option>, - component: &mut 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 edit { - Some(edit) => { - // component is specified in the JSON and is non-null - match component { - Some(component) => edit - .apply_to(component) - .await - .wrap_internal_err_with(|| { - eyre!( - "failed to update `{}` component", - type_name::() - ) - })?, - None => { - *component = Some( - edit.create().wrap_request_err_with(|| { - eyre!( - "failed to create `{}` component", - type_name::() - ) - })?, - ); - } - } - } - None => { + 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(())