From aff685041a778bb4e2722ac3d56a89bc7c547e4f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 29 Jan 2026 16:43:48 +0000 Subject: [PATCH 1/8] 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 f6ab15a6ca..160baacccd 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1044,15 +1044,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(() => { @@ -1404,29 +1400,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 600d482a12c330a775380653dd5a62d9d4552938 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 14 Jan 2026 15:25:48 +0000 Subject: [PATCH 2/8] 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 36a702ba8376813fa0e80c9adf2783ef122ef24f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 19 Jan 2026 23:21:46 +0000 Subject: [PATCH 3/8] 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 c20619085c..9f6f1a6b55 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 a6e46ae819de2430b37dad871952ee76c458fdd0 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 16:51:20 +0000 Subject: [PATCH 4/8] 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 9f6f1a6b55..e7c599f186 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -991,6 +991,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 420cec9deb4f17707c4f615e0bc0ee5d3f9cc095 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 17:16:17 +0000 Subject: [PATCH 5/8] 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 160baacccd..f6ab15a6ca 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1044,11 +1044,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(() => { @@ -1400,11 +1404,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 d70ad84efd46f2ca9423908570f116150232d4f0 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 10:16:16 +0000 Subject: [PATCH 6/8] 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 a98174b55b0d97e052e9318021bd546c64af7412 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 11:31:38 +0000 Subject: [PATCH 7/8] 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 45eb02fdda1859cdecff2c1d0ea8eebbb5a32025 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 30 Jan 2026 15:14:09 +0000 Subject: [PATCH 8/8] 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 e7c599f186..a758aa01df 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; @@ -869,6 +870,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