From b497aa5c9d978e081c3de37f3051f1a16dd922a2 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 29 Jan 2026 16:43:48 +0000 Subject: [PATCH 01/24] update frontend to work with new versions list route --- apps/frontend/src/pages/[type]/[id].vue | 32 ++++--------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index c5e680927a..336e5d235a 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1057,15 +1057,11 @@ const currentGameVersion = computed(() => { }) const possibleGameVersions = computed(() => { - return versions.value - .filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value)) - .flatMap((x) => x.game_versions) + return versionsV3.value?.available_game_versions || [] }) const possiblePlatforms = computed(() => { - return versions.value - .filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value)) - .flatMap((x) => x.loaders) + return versionsV3.value?.available_loaders || [] }) const currentPlatform = computed(() => { @@ -1417,29 +1413,11 @@ const filteredVersions = computed(() => { ) }) -const filteredRelease = computed(() => { - return filteredVersions.value.find((x) => x.version_type === 'release') -}) +const filteredRelease = computed(() => versionsV3.value?.latest_versions?.release || null) -const filteredBeta = computed(() => { - return filteredVersions.value.find( - (x) => - x.version_type === 'beta' && - (!filteredRelease.value || - dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))), - ) -}) +const filteredBeta = computed(() => versionsV3.value?.latest_versions?.beta || null) -const filteredAlpha = computed(() => { - return filteredVersions.value.find( - (x) => - x.version_type === 'alpha' && - (!filteredRelease.value || - dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) && - (!filteredBeta.value || - dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))), - ) -}) +const filteredAlpha = computed(() => versionsV3.value?.latest_versions?.alpha || null) const displayCollectionsSearch = ref('') const collections = computed(() => From 0f7734fdca91fbcbe7c13ebaf748fcc33a4a291f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 14 Jan 2026 15:25:48 +0000 Subject: [PATCH 02/24] wip: server listing API --- ...20260114130019_server_listing_projects.sql | 6 + apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v67/base.rs | 24 +++ apps/labrinth/src/models/v67/minecraft.rs | 70 ++++++++ apps/labrinth/src/models/v67/mod.rs | 152 ++++++++++++++++++ .../src/routes/v3/project_creation/new.rs | 77 +++++++++ 6 files changed, 330 insertions(+) create mode 100644 apps/labrinth/migrations/20260114130019_server_listing_projects.sql create mode 100644 apps/labrinth/src/models/v67/base.rs create mode 100644 apps/labrinth/src/models/v67/minecraft.rs create mode 100644 apps/labrinth/src/models/v67/mod.rs create mode 100644 apps/labrinth/src/routes/v3/project_creation/new.rs diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql new file mode 100644 index 0000000000..8cfa15cb4f --- /dev/null +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -0,0 +1,6 @@ +CREATE TABLE minecraft_server_projects ( + id bigint PRIMARY KEY NOT NULL REFERENCES mods(id), + java_address varchar(255) NOT NULL, + bedrock_address varchar(255) NOT NULL, + max_players int +); diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index 8b31a04c71..cb4f02a877 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod error; pub mod v2; pub mod v3; +pub mod v67; pub use v3::analytics; pub use v3::billing; diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs new file mode 100644 index 0000000000..0f54e183e5 --- /dev/null +++ b/apps/labrinth/src/models/v67/base.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct Create { + /// Human-readable friendly name of the project. + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: String, + /// Slug of the project, used in vanity URLs. + #[validate( + length(min = 3, max = 64), + regex(path = *crate::util::validate::RE_URL_SAFE) + )] + pub slug: String, + /// Short description of the project. + #[validate(length(min = 3, max = 255))] + pub summary: String, + /// A long description of the project, in markdown. + #[validate(length(max = 65536))] + pub description: String, +} diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs new file mode 100644 index 0000000000..0753c07546 --- /dev/null +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -0,0 +1,70 @@ +use std::sync::LazyLock; + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::v67::{ + ComponentKindArrayExt, ComponentKindExt, ComponentRelation, + ProjectComponent, ProjectComponentKind, +}; + +pub(super) static RELATIONS: LazyLock> = + LazyLock::new(|| { + use ProjectComponentKind as C; + + vec![ + [C::MinecraftMod].only(), + [ + C::MinecraftServer, + C::MinecraftJavaServer, + C::MinecraftBedrockServer, + ] + .only(), + C::MinecraftJavaServer.requires(C::MinecraftServer), + C::MinecraftBedrockServer.requires(C::MinecraftServer), + ] + }); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModCreate {} + +impl ProjectComponent for ModCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftMod + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct ServerCreate { + pub max_players: Option, +} + +impl ProjectComponent for ServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftServer + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct JavaServerCreate { + #[validate(length(max = 255))] + pub address: String, +} + +impl ProjectComponent for JavaServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftJavaServer + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +pub struct BedrockServerCreate { + #[validate(length(max = 255))] + pub address: String, +} + +impl ProjectComponent for BedrockServerCreate { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftBedrockServer + } +} diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs new file mode 100644 index 0000000000..22aeb8035d --- /dev/null +++ b/apps/labrinth/src/models/v67/mod.rs @@ -0,0 +1,152 @@ +//! Highly experimental and unstable API endpoints. +//! +//! These are used for testing new API patterns and exploring future endpoints, +//! which may or may not make it into an official release. +//! +//! # Projects and versions +//! +//! Projects and versions work in an ECS-like architecture, where each project +//! is an entity (project ID), and components can be attached to that project to +//! determine the project's type, like a Minecraft mod, data pack, etc. Project +//! components *may* store extra data (like a server listing which stores the +//! server address), but typically, the version will store this data in *version +//! components*. + +use std::{collections::HashSet, sync::LazyLock}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use validator::Validate; + +pub mod base; +pub mod minecraft; + +macro_rules! define_project_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + pub struct ProjectCreate { + pub base: base::Create, + $(pub $field_name: Option<$ty>,)* + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ProjectComponentKind { + $($variant_name,)* + } + + #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + const _: () = { + fn assert_implements_project_component() {} + + fn assert_components_implement_trait() { + $(assert_implements_project_component::<$ty>();)* + } + }; + + impl ProjectCreate { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $(if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + })* + kinds + } + } + }; +} + +define_project_components! [ + (minecraft_mod, MinecraftMod): minecraft::ModCreate, + (minecraft_server, MinecraftServer): minecraft::ServerCreate, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerCreate, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerCreate, +]; + +pub trait ProjectComponent { + fn kind() -> ProjectComponentKind; +} + +#[derive(Debug, Clone)] +pub enum ComponentRelation { + /// If one of these components, then it can only be present with other + /// components from this set. + Only(HashSet), + /// If component `0` is present, then `1` must also be present. + Requires(ProjectComponentKind, ProjectComponentKind), +} + +trait ComponentKindExt { + fn requires(self, other: ProjectComponentKind) -> ComponentRelation; +} + +impl ComponentKindExt for ProjectComponentKind { + fn requires(self, other: ProjectComponentKind) -> ComponentRelation { + ComponentRelation::Requires(self, other) + } +} + +trait ComponentKindArrayExt { + fn only(self) -> ComponentRelation; +} + +impl ComponentKindArrayExt for [ProjectComponentKind; N] { + fn only(self) -> ComponentRelation { + ComponentRelation::Only(self.iter().copied().collect()) + } +} + +#[derive(Debug, Clone, Error)] +pub enum ComponentsIncompatibleError { + #[error( + "only components {only:?} can be together, found extra components {extra:?}" + )] + Only { + only: HashSet, + extra: HashSet, + }, + #[error("component `{target:?}` requires `{requires:?}`")] + Requires { + target: ProjectComponentKind, + requires: ProjectComponentKind, + }, +} + +pub fn component_kinds_compatible( + kinds: &HashSet, +) -> Result<(), ComponentsIncompatibleError> { + static RELATIONS: LazyLock> = LazyLock::new(|| { + let mut relations = Vec::new(); + relations.extend_from_slice(minecraft::RELATIONS.as_slice()); + relations + }); + + for relation in RELATIONS.iter() { + match relation { + ComponentRelation::Only(set) => { + if kinds.iter().any(|k| set.contains(k)) { + let extra: HashSet<_> = + kinds.difference(set).cloned().collect(); + if !extra.is_empty() { + return Err(ComponentsIncompatibleError::Only { + only: set.clone(), + extra, + }); + } + } + } + ComponentRelation::Requires(a, b) => { + if kinds.contains(a) && !kinds.contains(b) { + return Err(ComponentsIncompatibleError::Requires { + target: *a, + requires: *b, + }); + } + } + } + } + + Ok(()) +} diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs new file mode 100644 index 0000000000..4f54b053f4 --- /dev/null +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -0,0 +1,77 @@ +use actix_web::web; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use validator::Validate; + +use crate::{ + auth::get_user_from_headers, + database::models, + models::{ids::ProjectId, v3::user_limits::UserLimits, v67}, + util::error::Context, +}; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(create); +} + +#[derive(Debug, Clone, Serialize, Deserialize, Error)] +pub enum CreateError { + #[error("project limit reached")] + LimitReached, + #[error("incompatible components")] + IncompatibleComponents(v67::ComponentsIncompatibleError), +} + +#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +pub struct CreateRequest {} + +/// Creates a new project. +#[utoipa::path] +#[put("/project")] +pub async fn create( + req: HttpRequest, + db: web::Data, + redis: web::Data, + web::Json(details): web::Json, +) -> Result<(), CreateError> { + // check that the user can make a project + let (_, user) = get_user_from_headers( + &req, + &db, + &redis, + session_queue, + Scopes::PROJECT_CREATE, + ) + .await?; + + let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + if limits.current >= limits.max { + return Err(CreateError::LimitReached); + } + + // check if the given details are valid + + v67::component_kinds_compatible(&details.component_kinds()) + .map_err(CreateError::IncompatibleComponents)?; + + details.validate()?; + + // check if this won't conflict with an existing project + + let slug_project_id_option = serde_json::from_value::( + serde_json::Value::String(details.base.slug.to_lowercase()), + ) + .expect("should be able to deserialize"); + + let mut txn = db + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let project_id: ProjectId = models::generate_project_id(&mut txn) + .await + .wrap_internal_err("failed to generate project ID")? + .into(); + + Ok(()) +} From fc8cb7eac2109c0bf0d86b705e7bb4f9b15ca4df Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 19 Jan 2026 23:21:46 +0000 Subject: [PATCH 03/24] wip: v67 project creation endpoint --- ...20260114130019_server_listing_projects.sql | 22 +- apps/labrinth/src/models/v67/base.rs | 2 +- apps/labrinth/src/models/v67/minecraft.rs | 117 +++++++-- apps/labrinth/src/models/v67/mod.rs | 28 +- .../src/routes/v3/project_creation.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 240 ++++++++++++++++-- 6 files changed, 355 insertions(+), 60 deletions(-) diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index 8cfa15cb4f..b2747c23c8 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -1,6 +1,20 @@ CREATE TABLE minecraft_server_projects ( - id bigint PRIMARY KEY NOT NULL REFERENCES mods(id), - java_address varchar(255) NOT NULL, - bedrock_address varchar(255) NOT NULL, - max_players int + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + max_players int +); + +CREATE TABLE minecraft_java_server_projects ( + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + address varchar(255) NOT NULL +); + +CREATE TABLE minecraft_bedrock_server_projects ( + id bigint PRIMARY KEY NOT NULL + REFERENCES mods(id) + ON DELETE CASCADE, + address varchar(255) NOT NULL ); diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs index 0f54e183e5..15bfee80ec 100644 --- a/apps/labrinth/src/models/v67/base.rs +++ b/apps/labrinth/src/models/v67/base.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Create { /// Human-readable friendly name of the project. #[validate( diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs index 0753c07546..2ead660a14 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -1,11 +1,15 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; +use sqlx::PgTransaction; use validator::Validate; -use crate::models::v67::{ - ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentKind, +use crate::{ + database::models::DBProjectId, + models::v67::{ + ComponentKindArrayExt, ComponentKindExt, ComponentRelation, + ProjectComponent, ProjectComponentKind, + }, }; pub(super) static RELATIONS: LazyLock> = @@ -25,46 +29,113 @@ pub(super) static RELATIONS: LazyLock> = ] }); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModCreate {} +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Mod {} -impl ProjectComponent for ModCreate { +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Server { + pub max_players: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct JavaServer { + #[validate(length(max = 255))] + pub address: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct BedrockServer { + #[validate(length(max = 255))] + pub address: String, +} + +// impl + +impl ProjectComponent for Mod { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftMod } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct ServerCreate { - pub max_players: Option, + async fn upsert( + &self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + unimplemented!(); + } } -impl ProjectComponent for ServerCreate { +impl ProjectComponent for Server { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct JavaServerCreate { - #[validate(length(max = 255))] - pub address: String, + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_server_projects (id, max_players) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET max_players = $2 + ", + project_id as _, + self.max_players.map(|n| n.cast_signed()), + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } -impl ProjectComponent for JavaServerCreate { +impl ProjectComponent for JavaServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } -} -#[derive(Debug, Clone, Serialize, Deserialize, Validate)] -pub struct BedrockServerCreate { - #[validate(length(max = 255))] - pub address: String, + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_java_server_projects (id, address) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET address = $2 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } -impl ProjectComponent for BedrockServerCreate { +impl ProjectComponent for BedrockServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } + + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO minecraft_bedrock_server_projects (id, address) + VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET address = $2 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await?; + Ok(()) + } } diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs index 22aeb8035d..b133617bbc 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/v67/mod.rs @@ -15,9 +15,12 @@ use std::{collections::HashSet, sync::LazyLock}; use serde::{Deserialize, Serialize}; +use sqlx::PgTransaction; use thiserror::Error; use validator::Validate; +use crate::database::models::DBProjectId; + pub mod base; pub mod minecraft; @@ -25,7 +28,7 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { - #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { pub base: base::Create, $(pub $field_name: Option<$ty>,)* @@ -59,20 +62,27 @@ macro_rules! define_project_components { } define_project_components! [ - (minecraft_mod, MinecraftMod): minecraft::ModCreate, - (minecraft_server, MinecraftServer): minecraft::ServerCreate, - (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerCreate, - (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerCreate, + (minecraft_mod, MinecraftMod): minecraft::Mod, + (minecraft_server, MinecraftServer): minecraft::Server, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServer, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, ]; pub trait ProjectComponent { fn kind() -> ProjectComponentKind; + + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn upsert( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result<(), sqlx::Error>; } #[derive(Debug, Clone)] pub enum ComponentRelation { - /// If one of these components, then it can only be present with other - /// components from this set. + /// If one of these components is present, then it can only be present with + /// other components from this set. Only(HashSet), /// If component `0` is present, then `1` must also be present. Requires(ProjectComponentKind, ProjectComponentKind), @@ -98,7 +108,7 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { } } -#[derive(Debug, Clone, Error)] +#[derive(Debug, Clone, Error, Serialize, Deserialize)] pub enum ComponentsIncompatibleError { #[error( "only components {only:?} can be together, found extra components {extra:?}" @@ -128,7 +138,7 @@ pub fn component_kinds_compatible( ComponentRelation::Only(set) => { if kinds.iter().any(|k| set.contains(k)) { let extra: HashSet<_> = - kinds.difference(set).cloned().collect(); + kinds.difference(set).copied().collect(); if !extra.is_empty() { return Err(ComponentsIncompatibleError::Only { only: set.clone(), diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 1071c124de..95dcaa6ece 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -43,8 +43,12 @@ use std::sync::Arc; use thiserror::Error; use validator::Validate; +mod new; + pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(project_create).service(project_create_with_id); + cfg.service(project_create) + .service(project_create_with_id) + .configure(new::config); } #[derive(Error, Debug)] diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 4f54b053f4..40146e6757 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,29 +1,105 @@ -use actix_web::web; -use serde::{Deserialize, Serialize}; -use thiserror::Error; +use std::any::type_name; + +use actix_http::StatusCode; +use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; +use eyre::eyre; +use rust_decimal::Decimal; +use sqlx::{PgPool, PgTransaction}; use validator::Validate; use crate::{ auth::get_user_from_headers, - database::models, - models::{ids::ProjectId, v3::user_limits::UserLimits, v67}, - util::error::Context, + database::{ + models::{ + self, DBUser, project_item::ProjectBuilder, + thread_item::ThreadBuilder, + }, + redis::RedisPool, + }, + models::{ + ids::ProjectId, + pats::Scopes, + projects::{MonetizationStatus, ProjectStatus}, + teams::ProjectPermissions, + threads::ThreadType, + v3::user_limits::UserLimits, + v67, + }, + queue::session::AuthQueue, + routes::ApiError, + util::{error::Context, validate::validation_errors_to_string}, }; -pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +// pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { +// cfg.service(create); +// } + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(create); } -#[derive(Debug, Clone, Serialize, Deserialize, Error)] +#[derive(Debug, thiserror::Error)] pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("incompatible components")] IncompatibleComponents(v67::ComponentsIncompatibleError), + #[error("failed to validate request: {0}")] + Validation(String), + #[error("slug collision")] + SlugCollision, + #[error(transparent)] + Api(#[from] ApiError), +} + +impl CreateError { + pub fn as_api_error(&self) -> crate::models::error::ApiError<'_> { + match self { + Self::LimitReached => crate::models::error::ApiError { + error: "limit_reached", + description: self.to_string(), + details: None, + }, + Self::IncompatibleComponents(err) => { + crate::models::error::ApiError { + error: "incompatible_components", + description: self.to_string(), + details: Some( + serde_json::to_value(err) + .expect("should never fail to serialize"), + ), + } + } + Self::Validation(_) => crate::models::error::ApiError { + error: "validation", + description: self.to_string(), + details: None, + }, + Self::SlugCollision => crate::models::error::ApiError { + error: "slug_collision", + description: self.to_string(), + details: None, + }, + Self::Api(err) => err.as_api_error(), + } + } } -#[derive(Debug, Clone, Validate, Serialize, Deserialize)] -pub struct CreateRequest {} +impl ResponseError for CreateError { + fn status_code(&self) -> actix_http::StatusCode { + match self { + Self::LimitReached => StatusCode::BAD_REQUEST, + Self::IncompatibleComponents(_) => StatusCode::BAD_REQUEST, + Self::Validation(_) => StatusCode::BAD_REQUEST, + Self::SlugCollision => StatusCode::BAD_REQUEST, + Self::Api(err) => err.status_code(), + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(self.as_api_error()) + } +} /// Creates a new project. #[utoipa::path] @@ -32,19 +108,23 @@ pub async fn create( req: HttpRequest, db: web::Data, redis: web::Data, + session_queue: web::Data, web::Json(details): web::Json, -) -> Result<(), CreateError> { +) -> Result, CreateError> { // check that the user can make a project let (_, user) = get_user_from_headers( &req, - &db, + &**db, &redis, - session_queue, + &session_queue, Scopes::PROJECT_CREATE, ) - .await?; + .await + .map_err(ApiError::from)?; - let limits = UserLimits::get_for_projects(¤t_user, pool).await?; + let limits = UserLimits::get_for_projects(&user, &db) + .await + .map_err(ApiError::from)?; if limits.current >= limits.max { return Err(CreateError::LimitReached); } @@ -54,24 +134,140 @@ pub async fn create( v67::component_kinds_compatible(&details.component_kinds()) .map_err(CreateError::IncompatibleComponents)?; - details.validate()?; + details.validate().map_err(|err| { + CreateError::Validation(validation_errors_to_string(err, None)) + })?; // check if this won't conflict with an existing project - let slug_project_id_option = serde_json::from_value::( - serde_json::Value::String(details.base.slug.to_lowercase()), - ) - .expect("should be able to deserialize"); - let mut txn = db .begin() .await .wrap_internal_err("failed to begin transaction")?; + let same_slug_record = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + details.base.slug.to_lowercase() + ) + .fetch_one(&mut *txn) + .await + .wrap_internal_err("failed to query if slug already exists")?; + + if same_slug_record.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + + // create project and supporting records in db + + let team_id = { + // TODO organization + let members = vec![models::team_item::TeamMemberBuilder { + user_id: user.id.into(), + role: crate::models::teams::DEFAULT_ROLE.to_owned(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }]; + let team = models::team_item::TeamBuilder { members }; + team.insert(&mut txn) + .await + .wrap_internal_err("failed to insert team")? + }; + let project_id: ProjectId = models::generate_project_id(&mut txn) .await .wrap_internal_err("failed to generate project ID")? .into(); - Ok(()) + let project_builder = ProjectBuilder { + project_id: project_id.into(), + team_id, + organization_id: None, // todo + name: details.base.name, + summary: details.base.summary, + description: details.base.description, + icon_url: None, + raw_icon_url: None, + license_url: None, + categories: vec![], + additional_categories: vec![], + initial_versions: vec![], + status: ProjectStatus::Draft, + requested_status: Some(ProjectStatus::Approved), + license: "LicenseRef-Unknown".into(), + slug: Some(details.base.slug), + link_urls: vec![], + gallery_items: vec![], + color: None, + // TODO: what if we don't monetize server listing projects? + monetization_status: MonetizationStatus::Monetized, + }; + + project_builder + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert project")?; + DBUser::clear_project_cache(&[user.id.into()], &redis) + .await + .wrap_internal_err("failed to clear user project cache")?; + + ThreadBuilder { + type_: ThreadType::Project, + members: vec![], + project_id: Some(project_id.into()), + report_id: None, + } + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert thread")?; + + // component-specific info + + async fn upsert( + txn: &mut PgTransaction<'_>, + project_id: ProjectId, + component: Option, + ) -> Result<(), CreateError> { + let Some(component) = component else { + return Ok(()); + }; + component + .upsert(txn, project_id.into()) + .await + .wrap_internal_err_with(|| { + eyre!("failed to insert `{}` component", type_name::()) + })?; + Ok(()) + } + + // use struct destructor syntax, so we get a compile error + // if we add a new field and don't add it here + let v67::ProjectCreate { + base: _, + minecraft_mod, + minecraft_server, + minecraft_java_server, + minecraft_bedrock_server, + } = details; + + if let Some(_component) = minecraft_mod { + return Err(ApiError::Request(eyre!( + "creating a mod project from this endpoint is not supported yet" + )) + .into()); + } + upsert(&mut txn, project_id, minecraft_server).await?; + upsert(&mut txn, project_id, minecraft_java_server).await?; + upsert(&mut txn, project_id, minecraft_bedrock_server).await?; + + // and commit! + + txn.commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(web::Json(project_id)) } From 669351faad95f4c9e285049a055ac7cce06fa943 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 16:51:20 +0000 Subject: [PATCH 04/24] wip: project components API --- ...20260114130019_server_listing_projects.sql | 2 +- .../src/database/models/project_item.rs | 41 +++++- apps/labrinth/src/models/v3/projects.rs | 11 ++ apps/labrinth/src/models/v67/base.rs | 42 ++++--- apps/labrinth/src/models/v67/minecraft.rs | 119 ++++++++++++++---- apps/labrinth/src/models/v67/mod.rs | 87 +++++++++++-- apps/labrinth/src/routes/v2/projects.rs | 34 ++--- .../src/routes/v3/project_creation.rs | 3 + .../src/routes/v3/project_creation/new.rs | 39 +++--- apps/labrinth/src/routes/v3/projects.rs | 49 +++++++- 10 files changed, 324 insertions(+), 103 deletions(-) diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index b2747c23c8..22c7c01ef8 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -2,7 +2,7 @@ CREATE TABLE minecraft_server_projects ( id bigint PRIMARY KEY NOT NULL REFERENCES mods(id) ON DELETE CASCADE, - max_players int + max_players int NOT NULL ); CREATE TABLE minecraft_java_server_projects ( diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index b4db9530f2..10d8c14d06 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -9,6 +9,7 @@ use crate::database::{PgTransaction, models}; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; +use crate::models::v67; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -767,7 +768,7 @@ impl DBProject { .await?; let projects = sqlx::query!( - " + r#" SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published, m.approved approved, m.queued, m.status status, m.requested_status requested_status, @@ -777,14 +778,28 @@ impl DBProject { t.id thread_id, m.monetization_status monetization_status, m.side_types_migration_review_status side_types_migration_review_status, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, - ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories + ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories, + -- components + COUNT(c1.id) > 0 AS minecraft_server_exists, + MAX(c1.max_players) AS minecraft_server_max_players, + COUNT(c2.id) > 0 AS minecraft_java_server_exists, + MAX(c2.address) AS minecraft_java_server_address, + COUNT(c3.id) > 0 AS minecraft_bedrock_server_exists, + MAX(c3.address) AS minecraft_bedrock_server_address + FROM mods m INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id LEFT JOIN categories c ON mc.joining_category_id = c.id + + -- components + LEFT JOIN minecraft_server_projects c1 ON c1.id = m.id + LEFT JOIN minecraft_java_server_projects c2 ON c2.id = m.id + LEFT JOIN minecraft_bedrock_server_projects c3 ON c3.id = m.id + WHERE m.id = ANY($1) OR m.slug = ANY($2) - GROUP BY t.id, m.id; - ", + GROUP BY t.id, m.id + "#, &project_ids_parsed, &slugs, ) @@ -858,6 +873,21 @@ impl DBProject { urls, aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), thread_id: DBThreadId(m.thread_id), + minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { + Some(v67::minecraft::Server { + max_players: m.minecraft_server_max_players.map(|n| n.cast_unsigned()), + }) + } else { None }, + minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { + Some(v67::minecraft::JavaServer { + address: m.minecraft_java_server_address.unwrap(), + }) + } else { None }, + minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { + Some(v67::minecraft::BedrockServer { + address: m.minecraft_bedrock_server_address.unwrap(), + }) + } else { None }, }; acc.insert(m.id, (m.slug, project)); @@ -983,4 +1013,7 @@ pub struct ProjectQueryResult { pub gallery_items: Vec, pub thread_id: DBThreadId, pub aggregate_version_fields: Vec, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 0ccc193bf1..4f5c5681e9 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -7,6 +7,7 @@ use crate::database::models::version_item::VersionQueryResult; use crate::models::ids::{ FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, }; +use crate::models::v67; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -98,6 +99,13 @@ pub struct Project { /// Aggregated loader-fields across its myriad of versions #[serde(flatten)] pub fields: HashMap>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_java_server: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_bedrock_server: Option, } // This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values @@ -212,6 +220,9 @@ impl From for Project { side_types_migration_review_status: m .side_types_migration_review_status, fields, + minecraft_server: data.minecraft_server, + minecraft_java_server: data.minecraft_java_server, + minecraft_bedrock_server: data.minecraft_bedrock_server, } } } diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/v67/base.rs index 15bfee80ec..04a6191e51 100644 --- a/apps/labrinth/src/models/v67/base.rs +++ b/apps/labrinth/src/models/v67/base.rs @@ -1,24 +1,26 @@ use serde::{Deserialize, Serialize}; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Create { - /// Human-readable friendly name of the project. - #[validate( - length(min = 3, max = 64), - custom(function = "crate::util::validate::validate_name") - )] - pub name: String, - /// Slug of the project, used in vanity URLs. - #[validate( - length(min = 3, max = 64), - regex(path = *crate::util::validate::RE_URL_SAFE) - )] - pub slug: String, - /// Short description of the project. - #[validate(length(min = 3, max = 255))] - pub summary: String, - /// A long description of the project, in markdown. - #[validate(length(max = 65536))] - pub description: String, +define! { + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Project { + /// Human-readable friendly name of the project. + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: String, + /// Slug of the project, used in vanity URLs. + #[validate( + length(min = 3, max = 64), + regex(path = *crate::util::validate::RE_URL_SAFE) + )] + pub slug: String, + /// Short description of the project. + #[validate(length(min = 3, max = 255))] + pub summary: String, + /// A long description of the project, in markdown. + #[validate(length(max = 65536))] + pub description: String, + } } diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/v67/minecraft.rs index 2ead660a14..002bdb5856 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -1,14 +1,14 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; -use sqlx::PgTransaction; +use sqlx::{PgTransaction, postgres::PgQueryResult}; use validator::Validate; use crate::{ database::models::DBProjectId, models::v67::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentKind, + ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, }; @@ -29,24 +29,26 @@ pub(super) static RELATIONS: LazyLock> = ] }); -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Mod {} +define! { + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Mod {} -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct Server { - pub max_players: Option, -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Server { + pub max_players: u32, + } -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct JavaServer { - #[validate(length(max = 255))] - pub address: String, -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServer { + #[validate(length(max = 255))] + pub address: String, + } -#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] -pub struct BedrockServer { - #[validate(length(max = 255))] - pub address: String, + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct BedrockServer { + #[validate(length(max = 255))] + pub address: String, + } } // impl @@ -56,7 +58,7 @@ impl ProjectComponent for Mod { ProjectComponentKind::MinecraftMod } - async fn upsert( + async fn insert( &self, _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, @@ -65,12 +67,22 @@ impl ProjectComponent for Mod { } } +impl ProjectComponentEdit for ModEdit { + async fn update( + &self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + ) -> Result { + unimplemented!(); + } +} + impl ProjectComponent for Server { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -79,10 +91,9 @@ impl ProjectComponent for Server { " INSERT INTO minecraft_server_projects (id, max_players) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET max_players = $2 ", project_id as _, - self.max_players.map(|n| n.cast_signed()), + self.max_players.cast_signed(), ) .execute(&mut **txn) .await?; @@ -90,12 +101,32 @@ impl ProjectComponent for Server { } } +impl ProjectComponentEdit for ServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_server_projects + SET max_players = COALESCE($2, max_players) + WHERE id = $1 + ", + project_id as _, + self.max_players.map(|n| n.cast_signed()), + ) + .execute(&mut **txn) + .await + } +} + impl ProjectComponent for JavaServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -104,7 +135,6 @@ impl ProjectComponent for JavaServer { " INSERT INTO minecraft_java_server_projects (id, address) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET address = $2 ", project_id as _, self.address, @@ -115,12 +145,32 @@ impl ProjectComponent for JavaServer { } } +impl ProjectComponentEdit for JavaServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_java_server_projects + SET address = COALESCE($2, address) + WHERE id = $1 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await + } +} + impl ProjectComponent for BedrockServer { fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, @@ -129,7 +179,6 @@ impl ProjectComponent for BedrockServer { " INSERT INTO minecraft_bedrock_server_projects (id, address) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET address = $2 ", project_id as _, self.address, @@ -139,3 +188,23 @@ impl ProjectComponent for BedrockServer { Ok(()) } } + +impl ProjectComponentEdit for BedrockServerEdit { + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result { + sqlx::query!( + " + UPDATE minecraft_bedrock_server_projects + SET address = COALESCE($2, address) + WHERE id = $1 + ", + project_id as _, + self.address, + ) + .execute(&mut **txn) + .await + } +} diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/v67/mod.rs index b133617bbc..40fd0355ee 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/v67/mod.rs @@ -15,12 +15,46 @@ use std::{collections::HashSet, sync::LazyLock}; use serde::{Deserialize, Serialize}; -use sqlx::PgTransaction; +use sqlx::{PgTransaction, postgres::PgQueryResult}; use thiserror::Error; use validator::Validate; use crate::database::models::DBProjectId; +macro_rules! define { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident: $ty:ty + ),* $(,)? + } + + $($rest:tt)* + ) => { paste::paste! { + $(#[$meta])* + $vis struct $name { + $( + $(#[$field_meta])* + $field_vis $field: $ty, + )* + } + + $(#[$meta])* + $vis struct [< $name Edit >] { + $( + $(#[$field_meta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + $field_vis $field: Option<$ty>, + )* + } + + define!($($rest)*); + }}; + () => {}; +} + pub mod base; pub mod minecraft; @@ -28,15 +62,29 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum ProjectComponentKind { + $($variant_name,)* + } + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { - pub base: base::Create, + pub base: base::Project, $(pub $field_name: Option<$ty>,)* } - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] - pub enum ProjectComponentKind { - $($variant_name,)* + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Project { + pub base: base::Project, + $( + #[serde(skip_serializing_if = "Option::is_none")] + pub $field_name: Option<$ty>, + )* + } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ProjectEdit { + pub base: base::ProjectEdit, } #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] @@ -68,17 +116,26 @@ define_project_components! [ (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, ]; -pub trait ProjectComponent { +pub trait ProjectComponent: Sized { fn kind() -> ProjectComponentKind; #[expect(async_fn_in_trait, reason = "internal trait")] - async fn upsert( + async fn insert( &self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, ) -> Result<(), sqlx::Error>; } +pub trait ProjectComponentEdit: Sized { + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn update( + &self, + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + ) -> Result; +} + #[derive(Debug, Clone)] pub enum ComponentRelation { /// If one of these components is present, then it can only be present with @@ -109,7 +166,9 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { } #[derive(Debug, Clone, Error, Serialize, Deserialize)] -pub enum ComponentsIncompatibleError { +pub enum ComponentKindsError { + #[error("no components")] + NoComponents, #[error( "only components {only:?} can be together, found extra components {extra:?}" )] @@ -124,15 +183,19 @@ pub enum ComponentsIncompatibleError { }, } -pub fn component_kinds_compatible( +pub fn component_kinds_valid( kinds: &HashSet, -) -> Result<(), ComponentsIncompatibleError> { +) -> Result<(), ComponentKindsError> { static RELATIONS: LazyLock> = LazyLock::new(|| { let mut relations = Vec::new(); relations.extend_from_slice(minecraft::RELATIONS.as_slice()); relations }); + if kinds.is_empty() { + return Err(ComponentKindsError::NoComponents); + } + for relation in RELATIONS.iter() { match relation { ComponentRelation::Only(set) => { @@ -140,7 +203,7 @@ pub fn component_kinds_compatible( let extra: HashSet<_> = kinds.difference(set).copied().collect(); if !extra.is_empty() { - return Err(ComponentsIncompatibleError::Only { + return Err(ComponentKindsError::Only { only: set.clone(), extra, }); @@ -149,7 +212,7 @@ pub fn component_kinds_compatible( } ComponentRelation::Requires(a, b) => { if kinds.contains(a) && !kinds.contains(b) { - return Err(ComponentsIncompatibleError::Requires { + return Err(ComponentKindsError::Requires { target: *a, requires: *b, }); diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 5750cd6fd9..b853010742 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -214,7 +214,7 @@ pub async fn project_get( ) -> Result { // Convert V2 data to V3 data // Call V3 project creation - let response = v3::projects::project_get( + let project = match v3::projects::project_get( req, info, pool.clone(), @@ -222,23 +222,21 @@ pub async fn project_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + { + Ok(resp) => resp.0, + Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(err) => return Err(err), + }; // Convert response to V2 format - match v2_reroute::extract_ok_json::(response).await { - Ok(project) => { - let version_item = match project.versions.first() { - Some(vid) => { - version_item::DBVersion::get((*vid).into(), &**pool, &redis) - .await? - } - None => None, - }; - let project = LegacyProject::from(project, version_item); - Ok(HttpResponse::Ok().json(project)) + let version_item = match project.versions.first() { + Some(vid) => { + version_item::DBVersion::get((*vid).into(), &**pool, &redis).await? } - Err(response) => Ok(response), - } + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) } //checks the validity of a project id or slug @@ -512,7 +510,11 @@ pub async fn project_edit( moderation_message_body: v2_new_project.moderation_message_body, monetization_status: v2_new_project.monetization_status, side_types_migration_review_status: None, // Not to be exposed in v2 - loader_fields: HashMap::new(), // Loader fields are not a thing in v2 + // None of the below is present in v2 + loader_fields: HashMap::new(), + minecraft_server: None, + minecraft_java_server: None, + minecraft_bedrock_server: None, }; // This returns 204 or failure so we don't need to do anything with it diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 95dcaa6ece..9b3ae2fa4b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -996,6 +996,9 @@ async fn project_create_inner( side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, fields: HashMap::new(), // Fields instantiate to empty + minecraft_server: None, + minecraft_java_server: None, + minecraft_bedrock_server: None, }; Ok(HttpResponse::Ok().json(response)) diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 40146e6757..b61524635a 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -42,8 +42,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { pub enum CreateError { #[error("project limit reached")] LimitReached, - #[error("incompatible components")] - IncompatibleComponents(v67::ComponentsIncompatibleError), + #[error("invalid component kinds")] + ComponentKinds(v67::ComponentKindsError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -60,16 +60,14 @@ impl CreateError { description: self.to_string(), details: None, }, - Self::IncompatibleComponents(err) => { - crate::models::error::ApiError { - error: "incompatible_components", - description: self.to_string(), - details: Some( - serde_json::to_value(err) - .expect("should never fail to serialize"), - ), - } - } + Self::ComponentKinds(err) => crate::models::error::ApiError { + error: "component_kinds", + description: format!("{self}: {err}"), + details: Some( + serde_json::to_value(err) + .expect("should never fail to serialize"), + ), + }, Self::Validation(_) => crate::models::error::ApiError { error: "validation", description: self.to_string(), @@ -89,7 +87,7 @@ impl ResponseError for CreateError { fn status_code(&self) -> actix_http::StatusCode { match self { Self::LimitReached => StatusCode::BAD_REQUEST, - Self::IncompatibleComponents(_) => StatusCode::BAD_REQUEST, + Self::ComponentKinds(_) => StatusCode::BAD_REQUEST, Self::Validation(_) => StatusCode::BAD_REQUEST, Self::SlugCollision => StatusCode::BAD_REQUEST, Self::Api(err) => err.status_code(), @@ -131,8 +129,8 @@ pub async fn create( // check if the given details are valid - v67::component_kinds_compatible(&details.component_kinds()) - .map_err(CreateError::IncompatibleComponents)?; + v67::component_kinds_valid(&details.component_kinds()) + .map_err(CreateError::ComponentKinds)?; details.validate().map_err(|err| { CreateError::Validation(validation_errors_to_string(err, None)) @@ -226,7 +224,7 @@ pub async fn create( // component-specific info - async fn upsert( + async fn insert( txn: &mut PgTransaction<'_>, project_id: ProjectId, component: Option, @@ -235,7 +233,7 @@ pub async fn create( return Ok(()); }; component - .upsert(txn, project_id.into()) + .insert(txn, project_id.into()) .await .wrap_internal_err_with(|| { eyre!("failed to insert `{}` component", type_name::()) @@ -254,14 +252,15 @@ pub async fn create( } = details; if let Some(_component) = minecraft_mod { + // todo return Err(ApiError::Request(eyre!( "creating a mod project from this endpoint is not supported yet" )) .into()); } - upsert(&mut txn, project_id, minecraft_server).await?; - upsert(&mut txn, project_id, minecraft_java_server).await?; - upsert(&mut txn, project_id, minecraft_bedrock_server).await?; + insert(&mut txn, project_id, minecraft_server).await?; + insert(&mut txn, project_id, minecraft_java_server).await?; + insert(&mut txn, project_id, minecraft_bedrock_server).await?; // and commit! diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 60590e8f31..12fb3ae214 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -1,3 +1,4 @@ +use std::any::type_name; use std::collections::HashMap; use std::sync::Arc; @@ -7,13 +8,13 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{ - DBModerationLock, DBTeamMember, ids as db_ids, image_item, + DBModerationLock, DBProjectId, DBTeamMember, DBTeamMember, ids as db_ids, + ids as db_ids, image_item, image_item, }; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; use crate::database::{PgPool, PgTransaction}; use crate::file_hosting::{FileHost, FileHostPublicity}; -use crate::models; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; @@ -24,17 +25,23 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; +use crate::models::{self, v67}; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::indexing::remove_documents; +use crate::search::{ + MeilisearchReadClient, SearchConfig, SearchError, search_for_project, +}; use crate::search::{SearchConfig, SearchError, search_for_project}; +use crate::util::error::Context; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, web}; use chrono::Utc; +use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -167,7 +174,7 @@ pub async fn project_get( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let string = info.into_inner().0; let project_data = @@ -186,7 +193,7 @@ pub async fn project_get( if let Some(data) = project_data && is_visible_project(&data.inner, &user_option, &pool, false).await? { - return Ok(HttpResponse::Ok().json(Project::from(data))); + return Ok(web::Json(Project::from(data))); } Err(ApiError::NotFound) } @@ -253,6 +260,9 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } #[allow(clippy::too_many_arguments)] @@ -261,7 +271,7 @@ pub async fn project_edit( info: web::Path<(String,)>, pool: web::Data, search_config: web::Data, - new_project: web::Json, + web::Json(new_project): web::Json, redis: web::Data, session_queue: web::Data, moderation_queue: web::Data, @@ -937,6 +947,35 @@ pub async fn project_edit( } } + // components + + async fn update( + txn: &mut PgTransaction<'_>, + project_id: DBProjectId, + component: Option, + ) -> Result<(), ApiError> { + let Some(component) = component else { + return Ok(()); + }; + let result = component + .update(txn, project_id) + .await + .wrap_internal_err_with(|| { + eyre!("failed to update `{}` component", type_name::()) + })?; + if result.rows_affected() == 0 { + return Err(ApiError::Request(eyre!( + "project does not have `{}` component", + type_name::() + ))); + } + Ok(()) + } + + update(&mut transaction, id, new_project.minecraft_server).await?; + update(&mut transaction, id, new_project.minecraft_java_server).await?; + update(&mut transaction, id, new_project.minecraft_bedrock_server).await?; + // check new description and body for links to associated images // if they no longer exist in the description or body, delete them let checkable_strings: Vec<&str> = From f249ce4ebdde88837d5647ac7fe80463efe5565d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 17:16:17 +0000 Subject: [PATCH 05/24] revert accidental change --- apps/frontend/src/pages/[type]/[id].vue | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 336e5d235a..c5e680927a 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1057,11 +1057,15 @@ const currentGameVersion = computed(() => { }) const possibleGameVersions = computed(() => { - return versionsV3.value?.available_game_versions || [] + return versions.value + .filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value)) + .flatMap((x) => x.game_versions) }) const possiblePlatforms = computed(() => { - return versionsV3.value?.available_loaders || [] + return versions.value + .filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value)) + .flatMap((x) => x.loaders) }) const currentPlatform = computed(() => { @@ -1413,11 +1417,29 @@ const filteredVersions = computed(() => { ) }) -const filteredRelease = computed(() => versionsV3.value?.latest_versions?.release || null) +const filteredRelease = computed(() => { + return filteredVersions.value.find((x) => x.version_type === 'release') +}) -const filteredBeta = computed(() => versionsV3.value?.latest_versions?.beta || null) +const filteredBeta = computed(() => { + return filteredVersions.value.find( + (x) => + x.version_type === 'beta' && + (!filteredRelease.value || + dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))), + ) +}) -const filteredAlpha = computed(() => versionsV3.value?.latest_versions?.alpha || null) +const filteredAlpha = computed(() => { + return filteredVersions.value.find( + (x) => + x.version_type === 'alpha' && + (!filteredRelease.value || + dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) && + (!filteredBeta.value || + dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))), + ) +}) const displayCollectionsSearch = ref('') const collections = computed(() => From 824be8ae55879d7f853e6aa27e3288ecfedb011d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 10:16:16 +0000 Subject: [PATCH 06/24] fix up rebase --- apps/labrinth/src/database/models/project_item.rs | 2 +- apps/labrinth/src/routes/v3/projects.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 10d8c14d06..99dc9a1b95 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -875,7 +875,7 @@ impl DBProject { thread_id: DBThreadId(m.thread_id), minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { Some(v67::minecraft::Server { - max_players: m.minecraft_server_max_players.map(|n| n.cast_unsigned()), + max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), }) } else { None }, minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 12fb3ae214..92d0f8dd70 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -8,8 +8,7 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{ - DBModerationLock, DBProjectId, DBTeamMember, DBTeamMember, ids as db_ids, - ids as db_ids, image_item, image_item, + DBModerationLock, DBProjectId, DBTeamMember, ids as db_ids, image_item, }; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; @@ -33,7 +32,6 @@ use crate::search::indexing::remove_documents; use crate::search::{ MeilisearchReadClient, SearchConfig, SearchError, search_for_project, }; -use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::error::Context; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; From 53f12dc40e4d6fecdacda519a6c2fc3958e152c6 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 11:31:38 +0000 Subject: [PATCH 07/24] No more six seven --- .../src/database/models/project_item.rs | 13 ++++++------- apps/labrinth/src/models/{v67 => exp}/base.rs | 0 .../src/models/{v67 => exp}/minecraft.rs | 2 +- apps/labrinth/src/models/{v67 => exp}/mod.rs | 2 +- apps/labrinth/src/models/mod.rs | 2 +- apps/labrinth/src/models/v3/projects.rs | 8 ++++---- .../src/routes/v3/project_creation/new.rs | 17 ++++++++++------- apps/labrinth/src/routes/v3/projects.rs | 10 +++++----- 8 files changed, 28 insertions(+), 26 deletions(-) rename apps/labrinth/src/models/{v67 => exp}/base.rs (100%) rename apps/labrinth/src/models/{v67 => exp}/minecraft.rs (99%) rename apps/labrinth/src/models/{v67 => exp}/mod.rs (99%) diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 99dc9a1b95..d9af6760bd 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -9,7 +9,6 @@ use crate::database::{PgTransaction, models}; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; -use crate::models::v67; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -874,17 +873,17 @@ impl DBProject { aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), thread_id: DBThreadId(m.thread_id), minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { - Some(v67::minecraft::Server { + Some(exp::minecraft::Server { max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), }) } else { None }, minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { - Some(v67::minecraft::JavaServer { + Some(exp::minecraft::JavaServer { address: m.minecraft_java_server_address.unwrap(), }) } else { None }, minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { - Some(v67::minecraft::BedrockServer { + Some(exp::minecraft::BedrockServer { address: m.minecraft_bedrock_server_address.unwrap(), }) } else { None }, @@ -1013,7 +1012,7 @@ pub struct ProjectQueryResult { pub gallery_items: Vec, pub thread_id: DBThreadId, pub aggregate_version_fields: Vec, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/models/v67/base.rs b/apps/labrinth/src/models/exp/base.rs similarity index 100% rename from apps/labrinth/src/models/v67/base.rs rename to apps/labrinth/src/models/exp/base.rs diff --git a/apps/labrinth/src/models/v67/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs similarity index 99% rename from apps/labrinth/src/models/v67/minecraft.rs rename to apps/labrinth/src/models/exp/minecraft.rs index 002bdb5856..97c883be74 100644 --- a/apps/labrinth/src/models/v67/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -6,7 +6,7 @@ use validator::Validate; use crate::{ database::models::DBProjectId, - models::v67::{ + models::exp::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, diff --git a/apps/labrinth/src/models/v67/mod.rs b/apps/labrinth/src/models/exp/mod.rs similarity index 99% rename from apps/labrinth/src/models/v67/mod.rs rename to apps/labrinth/src/models/exp/mod.rs index 40fd0355ee..e1cc5af48d 100644 --- a/apps/labrinth/src/models/v67/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -1,4 +1,4 @@ -//! Highly experimental and unstable API endpoints. +//! Highly experimental and unstable API endpoint models. //! //! These are used for testing new API patterns and exploring future endpoints, //! which may or may not make it into an official release. diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index cb4f02a877..13be1a318d 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -1,7 +1,7 @@ pub mod error; +pub mod exp; pub mod v2; pub mod v3; -pub mod v67; pub use v3::analytics; pub use v3::billing; diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 4f5c5681e9..77910b5a48 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -4,10 +4,10 @@ use std::mem; use crate::database::models::loader_fields::VersionField; use crate::database::models::project_item::{LinkUrl, ProjectQueryResult}; use crate::database::models::version_item::VersionQueryResult; +use crate::models::exp; use crate::models::ids::{ FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId, }; -use crate::models::v67; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -101,11 +101,11 @@ pub struct Project { pub fields: HashMap>, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_server: Option, + pub minecraft_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_java_server: Option, + pub minecraft_java_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_bedrock_server: Option, + pub minecraft_bedrock_server: Option, } // This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index b61524635a..ec121f1ee1 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -17,13 +17,13 @@ use crate::{ redis::RedisPool, }, models::{ + exp, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, teams::ProjectPermissions, threads::ThreadType, v3::user_limits::UserLimits, - v67, }, queue::session::AuthQueue, routes::ApiError, @@ -43,7 +43,7 @@ pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("invalid component kinds")] - ComponentKinds(v67::ComponentKindsError), + ComponentKinds(exp::ComponentKindsError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -99,7 +99,10 @@ impl ResponseError for CreateError { } } -/// Creates a new project. +/// Creates a new project with the given components. +/// +/// Components must include `base` ([`exp::base::Project`]), and at least one +/// other component. #[utoipa::path] #[put("/project")] pub async fn create( @@ -107,7 +110,7 @@ pub async fn create( db: web::Data, redis: web::Data, session_queue: web::Data, - web::Json(details): web::Json, + web::Json(details): web::Json, ) -> Result, CreateError> { // check that the user can make a project let (_, user) = get_user_from_headers( @@ -129,7 +132,7 @@ pub async fn create( // check if the given details are valid - v67::component_kinds_valid(&details.component_kinds()) + exp::component_kinds_valid(&details.component_kinds()) .map_err(CreateError::ComponentKinds)?; details.validate().map_err(|err| { @@ -224,7 +227,7 @@ pub async fn create( // component-specific info - async fn insert( + async fn insert( txn: &mut PgTransaction<'_>, project_id: ProjectId, component: Option, @@ -243,7 +246,7 @@ pub async fn create( // use struct destructor syntax, so we get a compile error // if we add a new field and don't add it here - let v67::ProjectCreate { + let exp::ProjectCreate { base: _, minecraft_mod, minecraft_server, diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 92d0f8dd70..59bd169672 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -24,7 +24,7 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; -use crate::models::{self, v67}; +use crate::models::{self, exp}; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -258,9 +258,9 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } #[allow(clippy::too_many_arguments)] @@ -947,7 +947,7 @@ pub async fn project_edit( // components - async fn update( + async fn update( txn: &mut PgTransaction<'_>, project_id: DBProjectId, component: Option, From 103924a9ce86f06a1fac818ee9f218a1479d402f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 30 Jan 2026 15:14:09 +0000 Subject: [PATCH 08/24] New project component metadata schema --- ...70eef9d1af5ff41b097b3552de86d3940e01e.json | 15 ++ ...0bba8ccd2df0995a21bdb34ae3214cef6377.json} | 12 +- ...d977a9613f8aa22669c0f8fe7bab2d5d6192.json} | 7 +- ...da4588d37cf9f0da26cdd23cfe025b191a2d4.json | 22 ++ ...20260114130019_server_listing_projects.sql | 22 +- .../src/database/models/project_item.rs | 57 +++-- apps/labrinth/src/models/exp/minecraft.rs | 219 ++++++++---------- apps/labrinth/src/models/exp/mod.rs | 85 ++++--- .../src/routes/v3/project_creation.rs | 2 + .../src/routes/v3/project_creation/new.rs | 81 +++---- apps/labrinth/src/routes/v3/projects.rs | 75 ++++-- 11 files changed, 321 insertions(+), 276 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json rename apps/labrinth/.sqlx/{query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json => query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json} (85%) rename apps/labrinth/.sqlx/{query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json => query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json} (63%) create mode 100644 apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json diff --git a/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json b/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json new file mode 100644 index 0000000000..940c72dcde --- /dev/null +++ b/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e" +} diff --git a/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json similarity index 85% rename from apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json rename to apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json index f7cb840845..ce8b181e6f 100644 --- a/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json +++ b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", + "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n m.components AS \"components: sqlx::types::Json\"\n\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ", "describe": { "columns": [ { @@ -137,6 +137,11 @@ "ordinal": 26, "name": "additional_categories", "type_info": "VarcharArray" + }, + { + "ordinal": 27, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" } ], "parameters": { @@ -172,8 +177,9 @@ false, false, null, - null + null, + false ] }, - "hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62" + "hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377" } diff --git a/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json similarity index 63% rename from apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json rename to apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json index af9bf42e59..0b5f5d4ffb 100644 --- a/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json +++ b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ", + "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18,\n $19\n )\n ", "describe": { "columns": [], "parameters": { @@ -22,10 +22,11 @@ "Int4", "Varchar", "Int8", - "Varchar" + "Varchar", + "Jsonb" ] }, "nullable": [] }, - "hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702" + "hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192" } diff --git a/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json b/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json new file mode 100644 index 0000000000..d838953a8f --- /dev/null +++ b/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4" +} diff --git a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql index 22c7c01ef8..7de4b975ac 100644 --- a/apps/labrinth/migrations/20260114130019_server_listing_projects.sql +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -1,20 +1,2 @@ -CREATE TABLE minecraft_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - max_players int NOT NULL -); - -CREATE TABLE minecraft_java_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - address varchar(255) NOT NULL -); - -CREATE TABLE minecraft_bedrock_server_projects ( - id bigint PRIMARY KEY NOT NULL - REFERENCES mods(id) - ON DELETE CASCADE, - address varchar(255) NOT NULL -); +ALTER TABLE mods +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index d9af6760bd..56d97ea7bc 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -6,6 +6,7 @@ use super::{DBUser, ids::*}; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; use crate::database::{PgTransaction, models}; +use crate::models::exp; use crate::models::projects::{ MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, }; @@ -176,6 +177,7 @@ pub struct ProjectBuilder { pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, + pub components: exp::ProjectSerial, } impl ProjectBuilder { @@ -215,6 +217,7 @@ impl ProjectBuilder { side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], + components: self.components, }; project_struct.insert(&mut *transaction).await?; @@ -294,6 +297,7 @@ pub struct DBProject { pub monetization_status: MonetizationStatus, pub side_types_migration_review_status: SideTypesMigrationReviewStatus, pub loaders: Vec, + pub components: exp::ProjectSerial, } impl DBProject { @@ -308,14 +312,16 @@ impl DBProject { published, downloads, icon_url, raw_icon_url, status, requested_status, license_url, license, slug, color, monetization_status, organization_id, - side_types_migration_review_status + side_types_migration_review_status, + components ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, LOWER($14), $15, $16, $17, - $18 + $18, + $19 ) ", self.id as DBProjectId, @@ -335,7 +341,8 @@ impl DBProject { self.color.map(|x| x as i32), self.monetization_status.as_str(), self.organization_id.map(|x| x.0 as i64), - self.side_types_migration_review_status.as_str() + self.side_types_migration_review_status.as_str(), + serde_json::to_value(&self.components).expect("serialization shouldn't fail"), ) .execute(&mut *transaction) .await?; @@ -778,24 +785,13 @@ impl DBProject { m.side_types_migration_review_status side_types_migration_review_status, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories, - -- components - COUNT(c1.id) > 0 AS minecraft_server_exists, - MAX(c1.max_players) AS minecraft_server_max_players, - COUNT(c2.id) > 0 AS minecraft_java_server_exists, - MAX(c2.address) AS minecraft_java_server_address, - COUNT(c3.id) > 0 AS minecraft_bedrock_server_exists, - MAX(c3.address) AS minecraft_bedrock_server_address + m.components AS "components: sqlx::types::Json" FROM mods m INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id LEFT JOIN categories c ON mc.joining_category_id = c.id - -- components - LEFT JOIN minecraft_server_projects c1 ON c1.id = m.id - LEFT JOIN minecraft_java_server_projects c2 ON c2.id = m.id - LEFT JOIN minecraft_bedrock_server_projects c3 ON c3.id = m.id - WHERE m.id = ANY($1) OR m.slug = ANY($2) GROUP BY t.id, m.id "#, @@ -859,6 +855,7 @@ impl DBProject { &m.side_types_migration_review_status, ), loaders, + components: exp::ProjectSerial::default(), }, categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), @@ -872,21 +869,21 @@ impl DBProject { urls, aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), thread_id: DBThreadId(m.thread_id), - minecraft_server: if m.minecraft_server_exists.unwrap_or(false) { - Some(exp::minecraft::Server { - max_players: m.minecraft_server_max_players.unwrap().cast_unsigned(), - }) - } else { None }, - minecraft_java_server: if m.minecraft_java_server_exists.unwrap_or(false) { - Some(exp::minecraft::JavaServer { - address: m.minecraft_java_server_address.unwrap(), - }) - } else { None }, - minecraft_bedrock_server: if m.minecraft_bedrock_server_exists.unwrap_or(false) { - Some(exp::minecraft::BedrockServer { - address: m.minecraft_bedrock_server_address.unwrap(), - }) - } else { None }, + minecraft_server: m + .components + .0 + .minecraft_server + .map(exp::ProjectComponent::from_serial), + minecraft_java_server: m + .components + .0 + .minecraft_java_server + .map(exp::ProjectComponent::from_serial), + minecraft_bedrock_server: m + .components + .0 + .minecraft_bedrock_server + .map(exp::ProjectComponent::from_serial), }; acc.insert(m.id, (m.slug, project)); diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 97c883be74..18aa6b68f3 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -1,34 +1,16 @@ use std::sync::LazyLock; use serde::{Deserialize, Serialize}; -use sqlx::{PgTransaction, postgres::PgQueryResult}; use validator::Validate; use crate::{ - database::models::DBProjectId, + database::{PgTransaction, models::DBProjectId}, models::exp::{ ComponentKindArrayExt, ComponentKindExt, ComponentRelation, ProjectComponent, ProjectComponentEdit, ProjectComponentKind, }, }; -pub(super) static RELATIONS: LazyLock> = - LazyLock::new(|| { - use ProjectComponentKind as C; - - vec![ - [C::MinecraftMod].only(), - [ - C::MinecraftServer, - C::MinecraftJavaServer, - C::MinecraftBedrockServer, - ] - .only(), - C::MinecraftJavaServer.requires(C::MinecraftServer), - C::MinecraftBedrockServer.requires(C::MinecraftServer), - ] - }); - define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Mod {} @@ -51,160 +33,147 @@ define! { } } -// impl +relations! { + [MinecraftMod].only(), + [ + MinecraftServer, + MinecraftJavaServer, + MinecraftBedrockServer, + ] + .only(), + MinecraftJavaServer.requires(MinecraftServer), + MinecraftBedrockServer.requires(MinecraftServer), +} impl ProjectComponent for Mod { + type Serial = Self; + + type Edit = ModEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftMod } - async fn insert( - &self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - unimplemented!(); + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for ModEdit { - async fn update( - &self, + type Component = Mod; + + async fn apply_to( + self, _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, - ) -> Result { + _component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { unimplemented!(); } } impl ProjectComponent for Server { + type Serial = Self; + + type Edit = ServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_server_projects (id, max_players) - VALUES ($1, $2) - ", - project_id as _, - self.max_players.cast_signed(), - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for ServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_server_projects - SET max_players = COALESCE($2, max_players) - WHERE id = $1 - ", - project_id as _, - self.max_players.map(|n| n.cast_signed()), - ) - .execute(&mut **txn) - .await + type Component = Server; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(max_players) = self.max_players { + component.max_players = max_players; + } + Ok(()) } } impl ProjectComponent for JavaServer { + type Serial = Self; + + type Edit = JavaServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftJavaServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_java_server_projects (id, address) - VALUES ($1, $2) - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for JavaServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_java_server_projects - SET address = COALESCE($2, address) - WHERE id = $1 - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await + type Component = JavaServer; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(address) = self.address { + component.address = address; + } + Ok(()) } } impl ProjectComponent for BedrockServer { + type Serial = Self; + + type Edit = BedrockServerEdit; + fn kind() -> ProjectComponentKind { ProjectComponentKind::MinecraftBedrockServer } - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error> { - sqlx::query!( - " - INSERT INTO minecraft_bedrock_server_projects (id, address) - VALUES ($1, $2) - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await?; - Ok(()) + fn into_serial(self) -> Self::Serial { + self + } + + fn from_serial(serial: Self::Serial) -> Self { + serial } } impl ProjectComponentEdit for BedrockServerEdit { - async fn update( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result { - sqlx::query!( - " - UPDATE minecraft_bedrock_server_projects - SET address = COALESCE($2, address) - WHERE id = $1 - ", - project_id as _, - self.address, - ) - .execute(&mut **txn) - .await + type Component = BedrockServer; + + async fn apply_to( + self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + if let Some(address) = self.address { + component.address = address; + } + Ok(()) } } diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index e1cc5af48d..e3b0272d27 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -14,12 +14,22 @@ use std::{collections::HashSet, sync::LazyLock}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgTransaction, postgres::PgQueryResult}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use thiserror::Error; use validator::Validate; -use crate::database::models::DBProjectId; +use crate::database::{PgTransaction, models::DBProjectId}; + +macro_rules! relations { + ($($relations:tt)*) => { + pub(super) static RELATIONS: LazyLock> = + LazyLock::new(|| { + use ProjectComponentKind::*; + + vec![$($relations)*] + }); + }; +} macro_rules! define { ( @@ -62,16 +72,23 @@ macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { + // kinds + + #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + const _: () = { + fn assert_implements_project_component() {} + + fn assert_components_implement_trait() { + $(assert_implements_project_component::<$ty>();)* + } + }; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ProjectComponentKind { $($variant_name,)* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ProjectCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* - } + // structs #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { @@ -82,19 +99,19 @@ macro_rules! define_project_components { )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct ProjectEdit { - pub base: base::ProjectEdit, + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct ProjectSerial { + $( + pub $field_name: Option<<$ty as ProjectComponent>::Serial>, + )* } - #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] - const _: () = { - fn assert_implements_project_component() {} - - fn assert_components_implement_trait() { - $(assert_implements_project_component::<$ty>();)* - } - }; + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct ProjectCreate { + pub base: base::Project, + $(pub $field_name: Option<$ty>,)* + } impl ProjectCreate { #[must_use] @@ -106,6 +123,12 @@ macro_rules! define_project_components { kinds } } + + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct ProjectEdit { + $(pub $field_name: Option<<$ty as ProjectComponent>::Edit>,)* + } }; } @@ -117,23 +140,27 @@ define_project_components! [ ]; pub trait ProjectComponent: Sized { + type Serial: Serialize + DeserializeOwned; + + type Edit: ProjectComponentEdit; + fn kind() -> ProjectComponentKind; - #[expect(async_fn_in_trait, reason = "internal trait")] - async fn insert( - &self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - ) -> Result<(), sqlx::Error>; + fn into_serial(self) -> Self::Serial; + + fn from_serial(serial: Self::Serial) -> Self; } pub trait ProjectComponentEdit: Sized { + type Component: ProjectComponent; + #[expect(async_fn_in_trait, reason = "internal trait")] - async fn update( - &self, + async fn apply_to( + self, txn: &mut PgTransaction<'_>, project_id: DBProjectId, - ) -> Result; + component: &mut Self::Component, + ) -> Result<(), sqlx::Error>; } #[derive(Debug, Clone)] @@ -169,6 +196,8 @@ impl ComponentKindArrayExt for [ProjectComponentKind; N] { pub enum ComponentKindsError { #[error("no components")] NoComponents, + #[error("component `{target:?}` is missing")] + Missing { target: ProjectComponentKind }, #[error( "only components {only:?} can be together, found extra components {extra:?}" )] diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 9b3ae2fa4b..91d4519825 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -10,6 +10,7 @@ use crate::database::models::{self, DBUser, image_item}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError}; use crate::models::error::ApiError; +use crate::models::exp; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -874,6 +875,7 @@ async fn project_create_inner( .collect(), color: icon_data.and_then(|x| x.2), monetization_status: MonetizationStatus::Monetized, + components: exp::ProjectSerial::default(), }; let project_builder = project_builder_actual.clone(); diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index ec121f1ee1..91ff80ccd0 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,15 +1,12 @@ -use std::any::type_name; - use actix_http::StatusCode; use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; -use eyre::eyre; use rust_decimal::Decimal; -use sqlx::{PgPool, PgTransaction}; use validator::Validate; use crate::{ auth::get_user_from_headers, database::{ + PgPool, models::{ self, DBUser, project_item::ProjectBuilder, thread_item::ThreadBuilder, @@ -17,7 +14,7 @@ use crate::{ redis::RedisPool, }, models::{ - exp, + exp::{self}, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, @@ -139,6 +136,17 @@ pub async fn create( CreateError::Validation(validation_errors_to_string(err, None)) })?; + // get component-specific data + // use struct destructor syntax, so we get a compile error + // if we add a new field and don't add it here + let exp::ProjectCreate { + base, + minecraft_mod, + minecraft_server, + minecraft_java_server, + minecraft_bedrock_server, + } = details; + // check if this won't conflict with an existing project let mut txn = db @@ -148,9 +156,9 @@ pub async fn create( let same_slug_record = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", - details.base.slug.to_lowercase() + base.slug.to_lowercase() ) - .fetch_one(&mut *txn) + .fetch_one(&mut txn) .await .wrap_internal_err("failed to query if slug already exists")?; @@ -187,9 +195,9 @@ pub async fn create( project_id: project_id.into(), team_id, organization_id: None, // todo - name: details.base.name, - summary: details.base.summary, - description: details.base.description, + name: base.name.clone(), + summary: base.summary.clone(), + description: base.description.clone(), icon_url: None, raw_icon_url: None, license_url: None, @@ -199,12 +207,23 @@ pub async fn create( status: ProjectStatus::Draft, requested_status: Some(ProjectStatus::Approved), license: "LicenseRef-Unknown".into(), - slug: Some(details.base.slug), + slug: Some(base.slug.clone()), link_urls: vec![], gallery_items: vec![], color: None, // TODO: what if we don't monetize server listing projects? monetization_status: MonetizationStatus::Monetized, + // components + components: exp::ProjectSerial { + minecraft_mod: minecraft_mod + .map(exp::ProjectComponent::into_serial), + minecraft_server: minecraft_server + .map(exp::ProjectComponent::into_serial), + minecraft_java_server: minecraft_java_server + .map(exp::ProjectComponent::into_serial), + minecraft_bedrock_server: minecraft_bedrock_server + .map(exp::ProjectComponent::into_serial), + }, }; project_builder @@ -225,46 +244,6 @@ pub async fn create( .await .wrap_internal_err("failed to insert thread")?; - // component-specific info - - async fn insert( - txn: &mut PgTransaction<'_>, - project_id: ProjectId, - component: Option, - ) -> Result<(), CreateError> { - let Some(component) = component else { - return Ok(()); - }; - component - .insert(txn, project_id.into()) - .await - .wrap_internal_err_with(|| { - eyre!("failed to insert `{}` component", type_name::()) - })?; - Ok(()) - } - - // use struct destructor syntax, so we get a compile error - // if we add a new field and don't add it here - let exp::ProjectCreate { - base: _, - minecraft_mod, - minecraft_server, - minecraft_java_server, - minecraft_bedrock_server, - } = details; - - if let Some(_component) = minecraft_mod { - // todo - return Err(ApiError::Request(eyre!( - "creating a mod project from this endpoint is not supported yet" - )) - .into()); - } - insert(&mut txn, project_id, minecraft_server).await?; - insert(&mut txn, project_id, minecraft_java_server).await?; - insert(&mut txn, project_id, minecraft_bedrock_server).await?; - // and commit! txn.commit() diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 59bd169672..57cff607df 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -288,7 +288,7 @@ pub async fn project_edit( ApiError::Validation(validation_errors_to_string(err, None)) })?; - let Some(project_item) = + let Some(mut project_item) = db_models::DBProject::get(&info.into_inner().0, &**pool, &redis) .await? else { @@ -947,32 +947,75 @@ pub async fn project_edit( // components - async fn update( + async fn update( txn: &mut PgTransaction<'_>, project_id: DBProjectId, - component: Option, + edit: Option, + component: &mut Option, ) -> Result<(), ApiError> { - let Some(component) = component else { + let Some(edit) = edit else { return Ok(()); }; - let result = component - .update(txn, project_id) + let component = component + .as_mut() + .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; + + edit.apply_to(txn, project_id, component) .await .wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) + eyre!("failed to update `{}` component", type_name::()) })?; - if result.rows_affected() == 0 { - return Err(ApiError::Request(eyre!( - "project does not have `{}` component", - type_name::() - ))); - } Ok(()) } - update(&mut transaction, id, new_project.minecraft_server).await?; - update(&mut transaction, id, new_project.minecraft_java_server).await?; - update(&mut transaction, id, new_project.minecraft_bedrock_server).await?; + update( + &mut transaction, + id, + new_project.minecraft_server, + &mut project_item.minecraft_server, + ) + .await?; + update( + &mut transaction, + id, + new_project.minecraft_java_server, + &mut project_item.minecraft_java_server, + ) + .await?; + update( + &mut transaction, + id, + new_project.minecraft_bedrock_server, + &mut project_item.minecraft_bedrock_server, + ) + .await?; + + let components_serial = exp::ProjectSerial { + minecraft_mod: None, + minecraft_server: project_item + .minecraft_server + .map(exp::ProjectComponent::into_serial), + minecraft_java_server: project_item + .minecraft_java_server + .map(exp::ProjectComponent::into_serial), + minecraft_bedrock_server: project_item + .minecraft_bedrock_server + .map(exp::ProjectComponent::into_serial), + }; + + sqlx::query!( + " + UPDATE mods + SET components = $1 + WHERE id = $2 + ", + serde_json::to_value(&components_serial) + .expect("serialization shouldn't fail"), + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to update components")?; // check new description and body for links to associated images // if they no longer exist in the description or body, delete them From 7e4d6cf0ae029cc0c3be868e0ef20aac5895bc83 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 15:37:58 +0000 Subject: [PATCH 09/24] Update project component structure for servers --- .../20260207153522_version_components.sql | 2 + apps/labrinth/src/models/exp/base.rs | 6 ++ apps/labrinth/src/models/exp/minecraft.rs | 10 ++ .../src/routes/v3/project_creation/new.rs | 91 ++++++++++++++----- apps/labrinth/src/routes/v3/projects.rs | 4 +- 5 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 apps/labrinth/migrations/20260207153522_version_components.sql diff --git a/apps/labrinth/migrations/20260207153522_version_components.sql b/apps/labrinth/migrations/20260207153522_version_components.sql new file mode 100644 index 0000000000..4c1e933563 --- /dev/null +++ b/apps/labrinth/migrations/20260207153522_version_components.sql @@ -0,0 +1,2 @@ +ALTER TABLE versions +ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs index 04a6191e51..c92b96f078 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::models::{ids::OrganizationId, projects::ProjectStatus}; + define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { @@ -22,5 +24,9 @@ define! { /// A long description of the project, in markdown. #[validate(length(max = 65536))] pub description: String, + /// What status the user would like the project to be in after review. + pub requested_status: ProjectStatus, + /// What organization the project belongs to. + pub organization_id: Option, } } diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 18aa6b68f3..e44967b520 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -15,21 +15,31 @@ define! { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Mod {} + /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Server { + /// Maximum number of players allowed on the server. pub max_players: u32, } + /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServer { + /// Address (IP or domain name) of the Java server, excluding port. #[validate(length(max = 255))] pub address: String, + /// Port which the server runs on. + pub port: u16, } + /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct BedrockServer { + /// Address (IP or domain name) of the Bedrock server, excluding port. #[validate(length(max = 255))] pub address: String, + /// Port which the server runs on. + pub port: u16, } } diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 91ff80ccd0..af28612a4c 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -1,5 +1,6 @@ use actix_http::StatusCode; use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web}; +use eyre::eyre; use rust_decimal::Decimal; use validator::Validate; @@ -8,8 +9,8 @@ use crate::{ database::{ PgPool, models::{ - self, DBUser, project_item::ProjectBuilder, - thread_item::ThreadBuilder, + self, DBOrganization, DBTeamMember, DBUser, + project_item::ProjectBuilder, thread_item::ThreadBuilder, }, redis::RedisPool, }, @@ -18,7 +19,7 @@ use crate::{ ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, - teams::ProjectPermissions, + teams::{OrganizationPermissions, ProjectPermissions}, threads::ThreadType, v3::user_limits::UserLimits, }, @@ -83,10 +84,10 @@ impl CreateError { impl ResponseError for CreateError { fn status_code(&self) -> actix_http::StatusCode { match self { - Self::LimitReached => StatusCode::BAD_REQUEST, - Self::ComponentKinds(_) => StatusCode::BAD_REQUEST, - Self::Validation(_) => StatusCode::BAD_REQUEST, - Self::SlugCollision => StatusCode::BAD_REQUEST, + Self::LimitReached + | Self::ComponentKinds(_) + | Self::Validation(_) + | Self::SlugCollision => StatusCode::BAD_REQUEST, Self::Api(err) => err.status_code(), } } @@ -147,6 +148,15 @@ pub async fn create( minecraft_bedrock_server, } = details; + let exp::base::Project { + name, + slug, + summary, + description, + requested_status, + organization_id, + } = base; + // check if this won't conflict with an existing project let mut txn = db @@ -156,7 +166,7 @@ pub async fn create( let same_slug_record = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", - base.slug.to_lowercase() + slug.to_lowercase() ) .fetch_one(&mut txn) .await @@ -168,8 +178,37 @@ pub async fn create( // create project and supporting records in db - let team_id = { - // TODO organization + let team = if let Some(organization_id) = organization_id { + let org = DBOrganization::get_id(organization_id.into(), &**db, &redis) + .await + .wrap_internal_err("failed to get organization")? + .wrap_request_err("invalid organization ID")?; + + let team_member = + DBTeamMember::get_from_user_id(org.team_id, user.id.into(), &**db) + .await + .wrap_internal_err( + "failed to get team member of user for organization", + )?; + + let perms = OrganizationPermissions::get_permissions_by_role( + &user.role, + &team_member, + ); + + if !perms + .is_some_and(|p| p.contains(OrganizationPermissions::ADD_PROJECT)) + { + return Err(ApiError::Auth(eyre!( + "no permission to create projects in this organization" + )) + .into()); + } + + models::team_item::TeamBuilder { + members: Vec::new(), + } + } else { let members = vec![models::team_item::TeamMemberBuilder { user_id: user.id.into(), role: crate::models::teams::DEFAULT_ROLE.to_owned(), @@ -180,24 +219,33 @@ pub async fn create( payouts_split: Decimal::ONE_HUNDRED, ordering: 0, }]; - let team = models::team_item::TeamBuilder { members }; - team.insert(&mut txn) - .await - .wrap_internal_err("failed to insert team")? + + models::team_item::TeamBuilder { members } }; + let team_id = team + .insert(&mut txn) + .await + .wrap_internal_err("failed to insert team")?; let project_id: ProjectId = models::generate_project_id(&mut txn) .await .wrap_internal_err("failed to generate project ID")? .into(); + // TODO: special-case server projects to be unmonetized + let monetization_status = if minecraft_server.is_some() { + MonetizationStatus::ForceDemonetized + } else { + MonetizationStatus::Monetized + }; + let project_builder = ProjectBuilder { project_id: project_id.into(), team_id, - organization_id: None, // todo - name: base.name.clone(), - summary: base.summary.clone(), - description: base.description.clone(), + organization_id: organization_id.map(From::from), + name: name.clone(), + summary: summary.clone(), + description: description.clone(), icon_url: None, raw_icon_url: None, license_url: None, @@ -205,14 +253,13 @@ pub async fn create( additional_categories: vec![], initial_versions: vec![], status: ProjectStatus::Draft, - requested_status: Some(ProjectStatus::Approved), + requested_status: Some(requested_status), license: "LicenseRef-Unknown".into(), - slug: Some(base.slug.clone()), + slug: Some(slug.clone()), link_urls: vec![], gallery_items: vec![], color: None, - // TODO: what if we don't monetize server listing projects? - monetization_status: MonetizationStatus::Monetized, + monetization_status, // components components: exp::ProjectSerial { minecraft_mod: minecraft_mod diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 57cff607df..65df73a308 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -29,9 +29,7 @@ use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::indexing::remove_documents; -use crate::search::{ - MeilisearchReadClient, SearchConfig, SearchError, search_for_project, -}; +use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::error::Context; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; From 5dbdee3c31a35cdebf38b45406719abb16ea55b6 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 18:52:22 +0000 Subject: [PATCH 10/24] wip: version components --- .../20260207153522_version_components.sql | 6 + .../src/database/models/project_item.rs | 12 +- apps/labrinth/src/models/exp/base.rs | 47 ++-- apps/labrinth/src/models/exp/component.rs | 195 ++++++++++++++ apps/labrinth/src/models/exp/minecraft.rs | 189 +++----------- apps/labrinth/src/models/exp/mod.rs | 240 ++++++------------ apps/labrinth/src/models/v3/projects.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 19 +- apps/labrinth/src/routes/v3/projects.rs | 27 +- 9 files changed, 374 insertions(+), 367 deletions(-) create mode 100644 apps/labrinth/src/models/exp/component.rs diff --git a/apps/labrinth/migrations/20260207153522_version_components.sql b/apps/labrinth/migrations/20260207153522_version_components.sql index 4c1e933563..f6c3185264 100644 --- a/apps/labrinth/migrations/20260207153522_version_components.sql +++ b/apps/labrinth/migrations/20260207153522_version_components.sql @@ -1,2 +1,8 @@ ALTER TABLE versions ADD COLUMN components JSONB NOT NULL DEFAULT '{}'; + +-- extra metadata for the `minecraft_java_server` version component +CREATE TABLE minecraft_java_server_versions ( + id bigint PRIMARY KEY REFERENCES versions(id), + modpack_id bigint REFERENCES versions(id) +); diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 56d97ea7bc..bd993feb15 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -873,17 +873,17 @@ impl DBProject { .components .0 .minecraft_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), minecraft_java_server: m .components .0 .minecraft_java_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), minecraft_bedrock_server: m .components .0 .minecraft_bedrock_server - .map(exp::ProjectComponent::from_serial), + .map(exp::component::Component::from_db), }; acc.insert(m.id, (m.slug, project)); @@ -1009,7 +1009,7 @@ pub struct ProjectQueryResult { pub gallery_items: Vec, pub thread_id: DBThreadId, pub aggregate_version_fields: Vec, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: Option, } diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs index c92b96f078..1df759f07a 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -3,30 +3,31 @@ use validator::Validate; use crate::models::{ids::OrganizationId, projects::ProjectStatus}; -define! { - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct Project { - /// Human-readable friendly name of the project. - #[validate( - length(min = 3, max = 64), - custom(function = "crate::util::validate::validate_name") - )] - pub name: String, - /// Slug of the project, used in vanity URLs. - #[validate( +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Project { + /// Human-readable friendly name of the project. + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: String, + /// Slug of the project, used in vanity URLs. + #[validate( length(min = 3, max = 64), regex(path = *crate::util::validate::RE_URL_SAFE) )] - pub slug: String, - /// Short description of the project. - #[validate(length(min = 3, max = 255))] - pub summary: String, - /// A long description of the project, in markdown. - #[validate(length(max = 65536))] - pub description: String, - /// What status the user would like the project to be in after review. - pub requested_status: ProjectStatus, - /// What organization the project belongs to. - pub organization_id: Option, - } + pub slug: String, + /// Short description of the project. + #[validate(length(min = 3, max = 255))] + pub summary: String, + /// A long description of the project, in markdown. + #[validate(length(max = 65536))] + pub description: String, + /// What status the user would like the project to be in after review. + pub requested_status: ProjectStatus, + /// What organization the project belongs to. + pub organization_id: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct Version {} diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs new file mode 100644 index 0000000000..ff8b47d061 --- /dev/null +++ b/apps/labrinth/src/models/exp/component.rs @@ -0,0 +1,195 @@ +macro_rules! define { + () => {}; + ( + #[component($component_kind:ident :: $component_kind_variant:ident)] + $(#[$meta:meta])* + $vis:vis struct $name:ident { + $( + $(#[$field_meta:meta])* + $field_vis:vis $field:ident: $ty:ty + ),* $(,)? + } + + $($rest:tt)* + ) => { paste::paste! { + $(#[$meta])* + $vis struct $name { + $( + $(#[$field_meta])* + $field_vis $field: $ty, + )* + } + + $(#[$meta])* + $vis struct [< $name Edit >] { + $( + $(#[$field_meta])* + #[serde(default, skip_serializing_if = "Option::is_none")] + $field_vis $field: Option<$ty>, + )* + } + + impl $crate::models::exp::component::Component for $name { + type Serial = Self; + type Edit = [< $name Edit >]; + type Kind = $component_kind; + + fn kind() -> Self::Kind { + $component_kind::$component_kind_variant + } + + fn into_db(self) -> Self::Serial { + self + } + + fn from_db(serial: Self::Serial) -> Self { + serial + } + } + + impl $crate::models::exp::component::ComponentEdit for [< $name Edit >] { + type Component = $name; + + async fn apply_to( + self, + #[allow(unused_variables)] + component: &mut Self::Component, + ) -> Result<(), sqlx::Error> { + $( + if let Some(f) = self.$field { + component.$field = f; + } + )* + Ok(()) + } + } + + $crate::models::exp::component::define!($($rest)*); + }}; +} + +macro_rules! relations { + ($vis:vis static $name:ident: $component_kind:ty = $expr:block) => { + $vis static $name: std::sync::LazyLock>> = std::sync::LazyLock::new(|| { + #[allow(unused_imports)] + use $crate::models::exp::component::{ComponentKindExt, ComponentKindArrayExt}; + + Vec::<$crate::models::exp::component::ComponentRelation<$component_kind>>::from($expr) + }); + }; +} + +pub(crate) use define; +pub(crate) use relations; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::{collections::HashSet, hash::Hash}; +use thiserror::Error; + +pub trait ComponentKind: + Clone + Send + Sync + PartialEq + Eq + Hash + 'static +{ +} + +pub trait Component: Sized { + type Serial: Serialize + DeserializeOwned; + + type Edit: ComponentEdit; + + type Kind; + + fn kind() -> Self::Kind; + + fn into_db(self) -> Self::Serial; + + fn from_db(serial: Self::Serial) -> Self; +} + +pub trait ComponentEdit: Sized { + type Component: Component; + + #[expect(async_fn_in_trait, reason = "internal trait")] + async fn apply_to( + self, + component: &mut Self::Component, + ) -> Result<(), sqlx::Error>; +} + +#[derive(Debug, Clone)] +pub enum ComponentRelation { + /// If one of these components is present, then it can only be present with + /// other components from this set. + Only(HashSet), + /// If component `0` is present, then `1` must also be present. + Requires(K, K), +} + +pub trait ComponentKindExt { + fn requires(self, other: K) -> ComponentRelation; +} + +impl ComponentKindExt for K { + fn requires(self, other: K) -> ComponentRelation { + ComponentRelation::Requires(self, other) + } +} + +pub trait ComponentKindArrayExt { + fn only(self) -> ComponentRelation; +} + +impl ComponentKindArrayExt for [K; N] { + fn only(self) -> ComponentRelation { + ComponentRelation::Only(self.iter().cloned().collect()) + } +} + +#[derive(Debug, Clone, Error, Serialize, Deserialize)] +pub enum ComponentRelationError { + #[error("no components")] + NoComponents, + #[error("component `{target:?}` is missing")] + Missing { target: K }, + #[error( + "only components {only:?} can be together, found extra components {extra:?}" + )] + Only { only: HashSet, extra: HashSet }, + #[error("component `{target:?}` requires `{requires:?}`")] + Requires { target: K, requires: K }, +} + +pub fn kinds_valid( + kinds: &HashSet, + relations: &[ComponentRelation], +) -> Result<(), ComponentRelationError> { + if kinds.is_empty() { + return Err(ComponentRelationError::NoComponents); + } + + for relation in relations { + match relation { + ComponentRelation::Only(set) => { + if kinds.iter().any(|k| set.contains(k)) { + let extra: HashSet<_> = + kinds.difference(set).cloned().collect(); + if !extra.is_empty() { + return Err(ComponentRelationError::Only { + only: set.clone(), + extra, + }); + } + } + } + ComponentRelation::Requires(a, b) => { + if kinds.contains(a) && !kinds.contains(b) { + return Err(ComponentRelationError::Requires { + target: a.clone(), + requires: b.clone(), + }); + } + } + } + } + + Ok(()) +} diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index e44967b520..132a70dbc8 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -1,30 +1,32 @@ -use std::sync::LazyLock; - use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::{ - database::{PgTransaction, models::DBProjectId}, - models::exp::{ - ComponentKindArrayExt, ComponentKindExt, ComponentRelation, - ProjectComponent, ProjectComponentEdit, ProjectComponentKind, - }, +use crate::models::{ + exp::{ProjectComponentKind, VersionComponentKind, component}, + ids::VersionId, }; -define! { +component::define! { + #[component(ProjectComponentKind::MinecraftMod)] #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct Mod {} + pub struct ModProject {} + #[component(ProjectComponentKind::MinecraftServer)] /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct Server { + pub struct ServerProject { /// Maximum number of players allowed on the server. pub max_players: u32, + /// Country which this server is hosted in. + pub country: String, + /// Which version of the listing this server is currently using. + pub active_version: Option, } + #[component(ProjectComponentKind::MinecraftJavaServer)] /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct JavaServer { + pub struct JavaServerProject { /// Address (IP or domain name) of the Java server, excluding port. #[validate(length(max = 255))] pub address: String, @@ -32,9 +34,20 @@ define! { pub port: u16, } + #[component(VersionComponentKind::MinecraftJavaServer)] + /// Listing for a Minecraft Java server. + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct JavaServerVersion { + /// What modpack version this server is using. + /// + /// If the server is vanilla, this is [`None`]. + pub modpack: Option, + } + + #[component(ProjectComponentKind::MinecraftBedrockServer)] /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct BedrockServer { + pub struct BedrockServerProject { /// Address (IP or domain name) of the Bedrock server, excluding port. #[validate(length(max = 255))] pub address: String, @@ -43,147 +56,15 @@ define! { } } -relations! { - [MinecraftMod].only(), - [ - MinecraftServer, - MinecraftJavaServer, - MinecraftBedrockServer, - ] - .only(), - MinecraftJavaServer.requires(MinecraftServer), - MinecraftBedrockServer.requires(MinecraftServer), -} - -impl ProjectComponent for Mod { - type Serial = Self; - - type Edit = ModEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftMod - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for ModEdit { - type Component = Mod; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - _component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - unimplemented!(); - } -} - -impl ProjectComponent for Server { - type Serial = Self; - - type Edit = ServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for ServerEdit { - type Component = Server; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(max_players) = self.max_players { - component.max_players = max_players; - } - Ok(()) - } -} - -impl ProjectComponent for JavaServer { - type Serial = Self; - - type Edit = JavaServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftJavaServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for JavaServerEdit { - type Component = JavaServer; - - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(address) = self.address { - component.address = address; - } - Ok(()) - } -} - -impl ProjectComponent for BedrockServer { - type Serial = Self; - - type Edit = BedrockServerEdit; - - fn kind() -> ProjectComponentKind { - ProjectComponentKind::MinecraftBedrockServer - } - - fn into_serial(self) -> Self::Serial { - self - } - - fn from_serial(serial: Self::Serial) -> Self { - serial - } -} - -impl ProjectComponentEdit for BedrockServerEdit { - type Component = BedrockServer; +component::relations! { + pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + use ProjectComponentKind::*; - async fn apply_to( - self, - _txn: &mut PgTransaction<'_>, - _project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { - if let Some(address) = self.address { - component.address = address; - } - Ok(()) + [ + [MinecraftMod].only(), + [MinecraftServer, MinecraftJavaServer, MinecraftBedrockServer].only(), + MinecraftJavaServer.requires(MinecraftServer), + MinecraftBedrockServer.requires(MinecraftServer), + ] } } diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index e3b0272d27..dd2f83dd89 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -12,58 +12,12 @@ //! server address), but typically, the version will store this data in *version //! components*. -use std::{collections::HashSet, sync::LazyLock}; +use std::collections::HashSet; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use thiserror::Error; +use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::database::{PgTransaction, models::DBProjectId}; - -macro_rules! relations { - ($($relations:tt)*) => { - pub(super) static RELATIONS: LazyLock> = - LazyLock::new(|| { - use ProjectComponentKind::*; - - vec![$($relations)*] - }); - }; -} - -macro_rules! define { - ( - $(#[$meta:meta])* - $vis:vis struct $name:ident { - $( - $(#[$field_meta:meta])* - $field_vis:vis $field:ident: $ty:ty - ),* $(,)? - } - - $($rest:tt)* - ) => { paste::paste! { - $(#[$meta])* - $vis struct $name { - $( - $(#[$field_meta])* - $field_vis $field: $ty, - )* - } - - $(#[$meta])* - $vis struct [< $name Edit >] { - $( - $(#[$field_meta])* - #[serde(default, skip_serializing_if = "Option::is_none")] - $field_vis $field: Option<$ty>, - )* - } - - define!($($rest)*); - }}; - () => {}; -} +pub mod component; pub mod base; pub mod minecraft; @@ -74,12 +28,15 @@ macro_rules! define_project_components { ) => { // kinds - #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] + #[expect(dead_code, reason = "static check so $ty implements `Component`")] const _: () = { - fn assert_implements_project_component() {} + fn assert_implements_component() + where + T: component::Component, + {} fn assert_components_implement_trait() { - $(assert_implements_project_component::<$ty>();)* + $(assert_implements_component::<$ty>();)* } }; @@ -88,6 +45,8 @@ macro_rules! define_project_components { $($variant_name,)* } + impl component::ComponentKind for ProjectComponentKind {} + // structs #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -100,10 +59,9 @@ macro_rules! define_project_components { } #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] - // #[derive(utoipa::ToSchema)] pub struct ProjectSerial { $( - pub $field_name: Option<<$ty as ProjectComponent>::Serial>, + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } @@ -127,128 +85,92 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct ProjectEdit { - $(pub $field_name: Option<<$ty as ProjectComponent>::Edit>,)* + $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* } }; } -define_project_components! [ - (minecraft_mod, MinecraftMod): minecraft::Mod, - (minecraft_server, MinecraftServer): minecraft::Server, - (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServer, - (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, -]; - -pub trait ProjectComponent: Sized { - type Serial: Serialize + DeserializeOwned; - - type Edit: ProjectComponentEdit; +macro_rules! define_version_components { + ( + $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? + ) => { + // kinds - fn kind() -> ProjectComponentKind; + #[expect(dead_code, reason = "static check so $ty implements `Component`")] + const _: () = { + fn assert_implements_component() + where + T: component::Component, + {} - fn into_serial(self) -> Self::Serial; + fn assert_components_implement_trait() { + $(assert_implements_component::<$ty>();)* + } + }; - fn from_serial(serial: Self::Serial) -> Self; -} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] + pub enum VersionComponentKind { + $($variant_name,)* + } -pub trait ProjectComponentEdit: Sized { - type Component: ProjectComponent; + impl component::ComponentKind for VersionComponentKind {} - #[expect(async_fn_in_trait, reason = "internal trait")] - async fn apply_to( - self, - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error>; -} + // structs -#[derive(Debug, Clone)] -pub enum ComponentRelation { - /// If one of these components is present, then it can only be present with - /// other components from this set. - Only(HashSet), - /// If component `0` is present, then `1` must also be present. - Requires(ProjectComponentKind, ProjectComponentKind), -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct Version { + pub base: base::Version, + $( + #[serde(skip_serializing_if = "Option::is_none")] + pub $field_name: Option<$ty>, + )* + } -trait ComponentKindExt { - fn requires(self, other: ProjectComponentKind) -> ComponentRelation; -} + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + pub struct VersionSerial { + $( + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + )* + } -impl ComponentKindExt for ProjectComponentKind { - fn requires(self, other: ProjectComponentKind) -> ComponentRelation { - ComponentRelation::Requires(self, other) - } -} + #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + pub struct VersionCreate { + pub base: base::Project, + $(pub $field_name: Option<$ty>,)* + } -trait ComponentKindArrayExt { - fn only(self) -> ComponentRelation; -} + impl VersionCreate { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $(if self.$field_name.is_some() { + kinds.insert(VersionComponentKind::$variant_name); + })* + kinds + } + } -impl ComponentKindArrayExt for [ProjectComponentKind; N] { - fn only(self) -> ComponentRelation { - ComponentRelation::Only(self.iter().copied().collect()) - } + #[derive(Debug, Clone, Serialize, Deserialize, Validate)] + // #[derive(utoipa::ToSchema)] + pub struct VersionEdit { + $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + } + }; } -#[derive(Debug, Clone, Error, Serialize, Deserialize)] -pub enum ComponentKindsError { - #[error("no components")] - NoComponents, - #[error("component `{target:?}` is missing")] - Missing { target: ProjectComponentKind }, - #[error( - "only components {only:?} can be together, found extra components {extra:?}" - )] - Only { - only: HashSet, - extra: HashSet, - }, - #[error("component `{target:?}` requires `{requires:?}`")] - Requires { - target: ProjectComponentKind, - requires: ProjectComponentKind, - }, -} +define_project_components![ + (minecraft_mod, MinecraftMod): minecraft::ModProject, + (minecraft_server, MinecraftServer): minecraft::ServerProject, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject, +]; -pub fn component_kinds_valid( - kinds: &HashSet, -) -> Result<(), ComponentKindsError> { - static RELATIONS: LazyLock> = LazyLock::new(|| { - let mut relations = Vec::new(); - relations.extend_from_slice(minecraft::RELATIONS.as_slice()); - relations - }); - - if kinds.is_empty() { - return Err(ComponentKindsError::NoComponents); - } +define_version_components![ + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerVersion, +]; - for relation in RELATIONS.iter() { - match relation { - ComponentRelation::Only(set) => { - if kinds.iter().any(|k| set.contains(k)) { - let extra: HashSet<_> = - kinds.difference(set).copied().collect(); - if !extra.is_empty() { - return Err(ComponentKindsError::Only { - only: set.clone(), - extra, - }); - } - } - } - ComponentRelation::Requires(a, b) => { - if kinds.contains(a) && !kinds.contains(b) { - return Err(ComponentKindsError::Requires { - target: *a, - requires: *b, - }); - } - } - } +component::relations! { + pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { + minecraft::PROJECT_COMPONENT_RELATIONS.clone() } - - Ok(()) } diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 77910b5a48..7a3a21aa44 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -101,11 +101,11 @@ pub struct Project { pub fields: HashMap>, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_server: Option, + pub minecraft_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_java_server: Option, + pub minecraft_java_server: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub minecraft_bedrock_server: Option, + pub minecraft_bedrock_server: Option, } // This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index af28612a4c..086c53167f 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -15,7 +15,7 @@ use crate::{ redis::RedisPool, }, models::{ - exp::{self}, + exp::{self, ProjectComponentKind, component::ComponentRelationError}, ids::ProjectId, pats::Scopes, projects::{MonetizationStatus, ProjectStatus}, @@ -41,7 +41,7 @@ pub enum CreateError { #[error("project limit reached")] LimitReached, #[error("invalid component kinds")] - ComponentKinds(exp::ComponentKindsError), + ComponentKinds(ComponentRelationError), #[error("failed to validate request: {0}")] Validation(String), #[error("slug collision")] @@ -130,8 +130,11 @@ pub async fn create( // check if the given details are valid - exp::component_kinds_valid(&details.component_kinds()) - .map_err(CreateError::ComponentKinds)?; + exp::component::kinds_valid( + &details.component_kinds(), + &exp::PROJECT_COMPONENT_RELATIONS, + ) + .map_err(CreateError::ComponentKinds)?; details.validate().map_err(|err| { CreateError::Validation(validation_errors_to_string(err, None)) @@ -263,13 +266,13 @@ pub async fn create( // components components: exp::ProjectSerial { minecraft_mod: minecraft_mod - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_server: minecraft_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_java_server: minecraft_java_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_bedrock_server: minecraft_bedrock_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), }, }; diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 65df73a308..09aa073ced 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -256,9 +256,10 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, - pub minecraft_bedrock_server: Option, + pub minecraft_server: Option, + pub minecraft_java_server: Option, + pub minecraft_bedrock_server: + Option, } #[allow(clippy::too_many_arguments)] @@ -945,9 +946,9 @@ pub async fn project_edit( // components - async fn update( - txn: &mut PgTransaction<'_>, - project_id: DBProjectId, + async fn update( + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, edit: Option, component: &mut Option, ) -> Result<(), ApiError> { @@ -958,11 +959,9 @@ pub async fn project_edit( .as_mut() .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; - edit.apply_to(txn, project_id, component) - .await - .wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) - })?; + edit.apply_to(component).await.wrap_internal_err_with(|| { + eyre!("failed to update `{}` component", type_name::()) + })?; Ok(()) } @@ -992,13 +991,13 @@ pub async fn project_edit( minecraft_mod: None, minecraft_server: project_item .minecraft_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_java_server: project_item .minecraft_java_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), minecraft_bedrock_server: project_item .minecraft_bedrock_server - .map(exp::ProjectComponent::into_serial), + .map(exp::component::Component::into_db), }; sqlx::query!( From 31a1a04cdf6b919853c8521acea39098ec19104f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 7 Feb 2026 23:11:19 +0000 Subject: [PATCH 11/24] Version components backend API --- ...3c1ce6fb8ac08829345445b027e5a46c4860.json} | 12 +++++++--- .../src/database/models/version_item.rs | 24 +++++++++++++++---- apps/labrinth/src/models/exp/minecraft.rs | 1 + apps/labrinth/src/models/v3/projects.rs | 4 ++++ apps/labrinth/src/routes/v2/versions.rs | 22 +++++++++++------ .../src/routes/v3/version_creation.rs | 1 + apps/labrinth/src/routes/v3/versions.rs | 8 +++---- 7 files changed, 53 insertions(+), 19 deletions(-) rename apps/labrinth/.sqlx/{query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json => query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json} (81%) diff --git a/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json similarity index 81% rename from apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json rename to apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json index 5fc3bd90c9..c67f73498d 100644 --- a/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json +++ b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ", + "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json\"\n FROM versions v\n WHERE v.id = ANY($1);\n ", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "ordinal": 12, "name": "ordering", "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" } ], "parameters": { @@ -87,8 +92,9 @@ false, false, true, - true + true, + false ] }, - "hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6" + "hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860" } diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index b6f18ee0e4..f22b2e89f7 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{ QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, }; use crate::database::redis::RedisPool; +use crate::models::exp; use crate::models::projects::{FileType, VersionStatus}; use crate::routes::internal::delphi::DelphiRunParameters; use chrono::{DateTime, Utc}; @@ -719,13 +720,14 @@ impl DBVersion { ).await?; let res = sqlx::query!( - " + r#" SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number, v.changelog changelog, v.date_published date_published, v.downloads downloads, - v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering + v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering, + v.components AS "components: sqlx::types::Json" FROM versions v WHERE v.id = ANY($1); - ", + "#, &version_ids ) .fetch(&mut exec) @@ -804,6 +806,11 @@ impl DBVersion { project_types, games, dependencies, + minecraft_java_server: v + .components + .0 + .minecraft_java_server + .map(exp::component::Component::from_db), }; acc.insert(v.id, query_version); @@ -937,7 +944,7 @@ impl DBVersion { } } -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, Serialize)] pub struct VersionQueryResult { pub inner: DBVersion, @@ -947,6 +954,7 @@ pub struct VersionQueryResult { pub project_types: Vec, pub games: Vec, pub dependencies: Vec, + pub minecraft_java_server: Option, } #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -993,6 +1001,14 @@ impl std::cmp::PartialOrd for VersionQueryResult { } } +impl std::cmp::PartialEq for VersionQueryResult { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl std::cmp::Eq for VersionQueryResult {} + impl std::cmp::Ord for DBVersion { fn cmp(&self, other: &Self) -> Ordering { let ordering_order = match (self.ordering, other.ordering) { diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 132a70dbc8..694e615f04 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -18,6 +18,7 @@ component::define! { /// Maximum number of players allowed on the server. pub max_players: u32, /// Country which this server is hosted in. + #[validate(length(min = 2, max = 2))] pub country: String, /// Which version of the listing this server is currently using. pub active_version: Option, diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index 7a3a21aa44..cb8db6fe79 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -701,6 +701,9 @@ pub struct Version { #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, + + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_java_server: Option, } pub fn skip_nulls<'de, D>( @@ -772,6 +775,7 @@ impl From for Version { .into_iter() .map(|vf| (vf.field_name, vf.value.serialize_internal())) .collect(), + minecraft_java_server: data.minecraft_java_server, } } } diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 708f35b729..6cde6b15ca 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -211,6 +211,7 @@ pub async fn version_get( let response = v3::versions::version_get_helper(req, id, pool, redis, session_queue) .await + .map(|b| HttpResponse::Ok().json(b)) .or_else(v2_reroute::flatten_404_error)?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -277,7 +278,7 @@ pub async fn version_edit( } // Get the older version to get info from - let old_version = v3::versions::version_get_helper( + let old_version = match v3::versions::version_get_helper( req.clone(), (*info).0, pool.clone(), @@ -285,12 +286,19 @@ pub async fn version_edit( session_queue.clone(), ) .await - .or_else(v2_reroute::flatten_404_error)?; - let old_version = - match v2_reroute::extract_ok_json::(old_version).await { - Ok(version) => version, - Err(response) => return Ok(response), - }; + { + Ok(resp) => resp, + Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(err) => return Err(err), + }; + let old_version = match v2_reroute::extract_ok_json::( + HttpResponse::Ok().json(old_version.0), + ) + .await + { + Ok(version) => version, + Err(response) => return Ok(response), + }; // If this has 'mrpack_loaders' as a loader field previously, this is a modpack. // Therefore, if we are modifying the 'loader' field in this case, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 9b91a6bf81..692fe20e09 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -473,6 +473,7 @@ async fn version_create_inner( dependencies: version_data.dependencies, loaders: version_data.loaders, fields: version_data.fields, + minecraft_java_server: None, }; let project_id = builder.project_id; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index c58cb7eb40..0acd034b21 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -175,7 +175,7 @@ pub async fn version_get( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let id = info.into_inner().0; version_get_helper(req, id, pool, redis, session_queue).await } @@ -186,7 +186,7 @@ pub async fn version_get_helper( pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { +) -> Result, ApiError> { let version_data = database::models::DBVersion::get(id.into(), &**pool, &redis).await?; @@ -204,9 +204,7 @@ pub async fn version_get_helper( if let Some(data) = version_data && is_visible_version(&data.inner, &user_option, &pool, &redis).await? { - return Ok( - HttpResponse::Ok().json(models::projects::Version::from(data)) - ); + return Ok(web::Json(models::projects::Version::from(data))); } Err(ApiError::NotFound) From d01a0fe8311a4dadacb831198658dde813ebf9d5 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 8 Feb 2026 18:32:47 +0000 Subject: [PATCH 12/24] Version component creation --- ...04deb72636f4cbd6927108aadfd6e0d63f1c.json} | 7 +- ...ff7d7d79642345c93002dd774d7b30ac3d66.json} | 4 +- .../src/database/models/project_item.rs | 4 +- .../src/database/models/version_item.rs | 25 +++++- apps/labrinth/src/models/exp/base.rs | 6 +- apps/labrinth/src/models/exp/mod.rs | 84 ++++++++++++++----- .../src/routes/v2/project_creation.rs | 1 + .../src/routes/v2/version_creation.rs | 1 + .../src/routes/v3/project_creation.rs | 6 +- .../src/routes/v3/project_creation/new.rs | 36 ++++---- .../src/routes/v3/version_creation.rs | 9 ++ 11 files changed, 126 insertions(+), 57 deletions(-) rename apps/labrinth/.sqlx/{query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json => query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json} (59%) rename apps/labrinth/.sqlx/{query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json => query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json} (58%) diff --git a/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json similarity index 59% rename from apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json rename to apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json index 10cba08014..460f5477c3 100644 --- a/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json +++ b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ", + "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ", "describe": { "columns": [], "parameters": { @@ -16,10 +16,11 @@ "Varchar", "Bool", "Varchar", - "Int4" + "Int4", + "Jsonb" ] }, "nullable": [] }, - "hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce" + "hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c" } diff --git a/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json similarity index 58% rename from apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json rename to apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json index d838953a8f..a58ac0f232 100644 --- a/apps/labrinth/.sqlx/query-ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4.json +++ b/apps/labrinth/.sqlx/query-b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "ef91f2b725b5a81f56d9031bf95da4588d37cf9f0da26cdd23cfe025b191a2d4" + "hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66" } diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index bd993feb15..19949b49d2 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -177,7 +177,7 @@ pub struct ProjectBuilder { pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, - pub components: exp::ProjectSerial, + pub components: exp::ProjectCreate, } impl ProjectBuilder { @@ -217,7 +217,7 @@ impl ProjectBuilder { side_types_migration_review_status: SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], - components: self.components, + components: self.components.into_db(), }; project_struct.insert(&mut *transaction).await?; diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index f22b2e89f7..429c5ea011 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -38,6 +38,7 @@ pub struct VersionBuilder { pub status: VersionStatus, pub requested_status: Option, pub ordering: Option, + pub components: exp::VersionCreate, } #[derive(Clone)] @@ -207,6 +208,7 @@ impl VersionBuilder { status: self.status, requested_status: self.requested_status, ordering: self.ordering, + components: self.components.into_db(), }; version.insert(transaction).await?; @@ -286,7 +288,7 @@ impl DBLoaderVersion { } } -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Deserialize, Serialize)] pub struct DBVersion { pub id: DBVersionId, pub project_id: DBProjectId, @@ -301,8 +303,17 @@ pub struct DBVersion { pub status: VersionStatus, pub requested_status: Option, pub ordering: Option, + pub components: exp::VersionSerial, } +impl PartialEq for DBVersion { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for DBVersion {} + impl DBVersion { pub async fn insert( &self, @@ -313,12 +324,14 @@ impl DBVersion { INSERT INTO versions ( id, mod_id, author_id, name, version_number, changelog, date_published, downloads, - version_type, featured, status, ordering + version_type, featured, status, ordering, + components ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, - $9, $10, $11, $12 + $9, $10, $11, $12, + $13 ) ", self.id as DBVersionId, @@ -332,7 +345,9 @@ impl DBVersion { &self.version_type, self.featured, self.status.as_str(), - self.ordering + self.ordering, + serde_json::to_value(&self.components) + .expect("serialization shouldn't fail"), ) .execute(&mut *transaction) .await?; @@ -764,6 +779,7 @@ impl DBVersion { requested_status: v.requested_status .map(|x| VersionStatus::from_string(&x)), ordering: v.ordering, + components: exp::VersionSerial::default(), }, files: { let mut files = files.into_iter().map(|x| { @@ -1077,6 +1093,7 @@ mod tests { featured: false, status: VersionStatus::Listed, requested_status: None, + components: exp::VersionSerial::default(), } } } diff --git a/apps/labrinth/src/models/exp/base.rs b/apps/labrinth/src/models/exp/base.rs index 1df759f07a..07b3903f4b 100644 --- a/apps/labrinth/src/models/exp/base.rs +++ b/apps/labrinth/src/models/exp/base.rs @@ -13,9 +13,9 @@ pub struct Project { pub name: String, /// Slug of the project, used in vanity URLs. #[validate( - length(min = 3, max = 64), - regex(path = *crate::util::validate::RE_URL_SAFE) - )] + length(min = 3, max = 64), + regex(path = *crate::util::validate::RE_URL_SAFE) + )] pub slug: String, /// Short description of the project. #[validate(length(min = 3, max = 255))] diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index dd2f83dd89..30f4dfc4b3 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -17,9 +17,8 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; use validator::Validate; -pub mod component; - pub mod base; +pub mod component; pub mod minecraft; macro_rules! define_project_components { @@ -51,33 +50,42 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Project { + #[validate(nested)] pub base: base::Project, $( - #[serde(skip_serializing_if = "Option::is_none")] - pub $field_name: Option<$ty>, + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, )* } #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] pub struct ProjectSerial { $( - pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* + #[validate(nested)] + pub base: Option, + $( + #[validate(nested)] + pub $field_name: Option<$ty>, + )* } impl ProjectCreate { #[must_use] pub fn component_kinds(&self) -> HashSet { let mut kinds = HashSet::new(); - $(if self.$field_name.is_some() { - kinds.insert(ProjectComponentKind::$variant_name); - })* + $( + if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + } + )* kinds } } @@ -85,7 +93,22 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct ProjectEdit { - $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + $( + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>, + )* + } + + // logic + + impl ProjectCreate { + pub fn into_db(self) -> ProjectSerial { + ProjectSerial { + $( + $field_name: self.$field_name.map(component::Component::into_db), + )* + } + } } }; } @@ -119,24 +142,30 @@ macro_rules! define_version_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct Version { + #[validate(nested)] pub base: base::Version, $( - #[serde(skip_serializing_if = "Option::is_none")] - pub $field_name: Option<$ty>, + #[serde(skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub $field_name: Option<$ty>, )* } - #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct VersionSerial { $( - pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>, )* } - #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct VersionCreate { - pub base: base::Project, - $(pub $field_name: Option<$ty>,)* + #[validate(nested)] + pub base: Option, + $( + #[validate(nested)] + pub $field_name: Option<$ty>, + )* } impl VersionCreate { @@ -153,7 +182,22 @@ macro_rules! define_version_components { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] // #[derive(utoipa::ToSchema)] pub struct VersionEdit { - $(pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,)* + $( + #[validate(nested)] + pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>, + )* + } + + // logic + + impl VersionCreate { + pub fn into_db(self) -> VersionSerial { + VersionSerial { + $( + $field_name: self.$field_name.map(component::Component::into_db), + )* + } + } } }; } diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 543bb34876..facecac170 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -200,6 +200,7 @@ pub async fn project_create( uploaded_images: v.uploaded_images, ordering: v.ordering, fields, + minecraft_java_server: None, } }) .collect(); diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 4fab4ccc60..3d938f7032 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -222,6 +222,7 @@ pub async fn version_create( uploaded_images: legacy_create.uploaded_images, ordering: legacy_create.ordering, fields, + minecraft_java_server: None, }) } }, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 91d4519825..8537b1114b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -875,7 +875,7 @@ async fn project_create_inner( .collect(), color: icon_data.and_then(|x| x.2), monetization_status: MonetizationStatus::Monetized, - components: exp::ProjectSerial::default(), + components: exp::ProjectCreate::default(), }; let project_builder = project_builder_actual.clone(); @@ -1085,6 +1085,10 @@ async fn create_initial_version( version_type: version_data.release_channel.to_string(), requested_status: None, ordering: version_data.ordering, + components: exp::VersionCreate { + base: None, + minecraft_java_server: version_data.minecraft_java_server.clone(), + }, }; Ok(version) diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 086c53167f..258fc63e03 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -40,6 +40,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { pub enum CreateError { #[error("project limit reached")] LimitReached, + #[error("missing base component")] + MissingBase, #[error("invalid component kinds")] ComponentKinds(ComponentRelationError), #[error("failed to validate request: {0}")] @@ -58,6 +60,11 @@ impl CreateError { description: self.to_string(), details: None, }, + Self::MissingBase => crate::models::error::ApiError { + error: "missing_base", + description: self.to_string(), + details: None, + }, Self::ComponentKinds(err) => crate::models::error::ApiError { error: "component_kinds", description: format!("{self}: {err}"), @@ -85,6 +92,7 @@ impl ResponseError for CreateError { fn status_code(&self) -> actix_http::StatusCode { match self { Self::LimitReached + | Self::MissingBase | Self::ComponentKinds(_) | Self::Validation(_) | Self::SlugCollision => StatusCode::BAD_REQUEST, @@ -143,14 +151,6 @@ pub async fn create( // get component-specific data // use struct destructor syntax, so we get a compile error // if we add a new field and don't add it here - let exp::ProjectCreate { - base, - minecraft_mod, - minecraft_server, - minecraft_java_server, - minecraft_bedrock_server, - } = details; - let exp::base::Project { name, slug, @@ -158,7 +158,7 @@ pub async fn create( description, requested_status, organization_id, - } = base; + } = details.base.clone().ok_or(CreateError::MissingBase)?; // check if this won't conflict with an existing project @@ -168,7 +168,9 @@ pub async fn create( .wrap_internal_err("failed to begin transaction")?; let same_slug_record = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods WHERE text_id_lower = $1)", + "SELECT EXISTS( + SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1 + )", slug.to_lowercase() ) .fetch_one(&mut txn) @@ -236,7 +238,7 @@ pub async fn create( .into(); // TODO: special-case server projects to be unmonetized - let monetization_status = if minecraft_server.is_some() { + let monetization_status = if details.minecraft_server.is_some() { MonetizationStatus::ForceDemonetized } else { MonetizationStatus::Monetized @@ -263,17 +265,7 @@ pub async fn create( gallery_items: vec![], color: None, monetization_status, - // components - components: exp::ProjectSerial { - minecraft_mod: minecraft_mod - .map(exp::component::Component::into_db), - minecraft_server: minecraft_server - .map(exp::component::Component::into_db), - minecraft_java_server: minecraft_java_server - .map(exp::component::Component::into_db), - minecraft_bedrock_server: minecraft_bedrock_server - .map(exp::component::Component::into_db), - }, + components: details, }; project_builder diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 692fe20e09..0a3aad8c74 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -12,6 +12,7 @@ use crate::database::models::version_item::{ use crate::database::models::{self, DBOrganization, image_item}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::exp; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -93,6 +94,10 @@ pub struct InitialVersionData { #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, + + #[serde(default)] + #[validate(nested)] + pub minecraft_java_server: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -324,6 +329,10 @@ async fn version_create_inner( status: version_create_data.status, requested_status: None, ordering: version_create_data.ordering, + components: exp::VersionCreate { + base: None, + minecraft_java_server: version_create_data.minecraft_java_server.clone(), + }, }); return Ok(()); From 17f9285988f466f553c853ddccb3df899603ff9c Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 11 Feb 2026 15:12:36 +0000 Subject: [PATCH 13/24] game version fields --- apps/labrinth/src/models/exp/component.rs | 15 +++++-- apps/labrinth/src/models/exp/minecraft.rs | 49 ++++++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index ff8b47d061..78afc2cb43 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -5,8 +5,14 @@ macro_rules! define { $(#[$meta:meta])* $vis:vis struct $name:ident { $( + #[base( + $($field_base_meta:meta),* + )] + #[edit( + $($field_edit_meta:meta),* + )] $(#[$field_meta:meta])* - $field_vis:vis $field:ident: $ty:ty + $field_vis:vis $field:ident: $field_ty:ty ),* $(,)? } @@ -16,7 +22,8 @@ macro_rules! define { $vis struct $name { $( $(#[$field_meta])* - $field_vis $field: $ty, + $(#[$field_base_meta])* + $field_vis $field: $field_ty, )* } @@ -24,8 +31,8 @@ macro_rules! define { $vis struct [< $name Edit >] { $( $(#[$field_meta])* - #[serde(default, skip_serializing_if = "Option::is_none")] - $field_vis $field: Option<$ty>, + $(#[$field_edit_meta])* + $field_vis $field: Option<$field_ty>, )* } diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 694e615f04..50ee117307 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -15,11 +15,29 @@ component::define! { /// Listing for a Minecraft server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ServerProject { + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] /// Maximum number of players allowed on the server. - pub max_players: u32, + pub max_players: Option, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] /// Country which this server is hosted in. #[validate(length(min = 2, max = 2))] - pub country: String, + pub country: Option, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] /// Which version of the listing this server is currently using. pub active_version: Option, } @@ -28,17 +46,40 @@ component::define! { /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServerProject { + #[base()] + #[edit(serde(default))] /// Address (IP or domain name) of the Java server, excluding port. #[validate(length(max = 255))] pub address: String, + #[base()] + #[edit(serde(default))] /// Port which the server runs on. pub port: u16, + #[base(serde(default))] + #[edit(serde(default))] + /// List of supported Minecraft Java client versions which can join this + /// server. + pub supported_game_versions: Vec, + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] + /// Recommended Minecraft Java client version to use to join this server. + pub recommended_game_version: Option, } #[component(VersionComponentKind::MinecraftJavaServer)] /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServerVersion { + #[base()] + #[edit(serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + ))] /// What modpack version this server is using. /// /// If the server is vanilla, this is [`None`]. @@ -49,9 +90,13 @@ component::define! { /// Listing for a Minecraft Bedrock server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct BedrockServerProject { + #[base()] + #[edit(serde(default))] /// Address (IP or domain name) of the Bedrock server, excluding port. #[validate(length(max = 255))] pub address: String, + #[base()] + #[edit(serde(default))] /// Port which the server runs on. pub port: u16, } From 855ff1db5389cbc68ce8efe792de3b528d7a025d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 10:40:56 +0000 Subject: [PATCH 14/24] utoipa support for projects --- apps/labrinth/src/models/exp/minecraft.rs | 54 ++-- apps/labrinth/src/routes/v2/projects.rs | 34 +-- apps/labrinth/src/routes/v2/teams.rs | 2 +- apps/labrinth/src/routes/v2/versions.rs | 2 +- apps/labrinth/src/routes/v3/mod.rs | 5 + apps/labrinth/src/routes/v3/projects.rs | 294 ++++++++++++++++++---- apps/labrinth/src/routes/v3/teams.rs | 17 +- apps/labrinth/src/routes/v3/versions.rs | 27 +- 8 files changed, 349 insertions(+), 86 deletions(-) diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 50ee117307..ee2197de87 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -57,34 +57,13 @@ component::define! { pub port: u16, #[base(serde(default))] #[edit(serde(default))] - /// List of supported Minecraft Java client versions which can join this - /// server. - pub supported_game_versions: Vec, - #[base()] - #[edit(serde( - default, - skip_serializing_if = "Option::is_none", - with = "serde_with::rust::double_option" - ))] - /// Recommended Minecraft Java client version to use to join this server. - pub recommended_game_version: Option, + pub content: ServerContent, } #[component(VersionComponentKind::MinecraftJavaServer)] /// Listing for a Minecraft Java server. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] - pub struct JavaServerVersion { - #[base()] - #[edit(serde( - default, - skip_serializing_if = "Option::is_none", - with = "serde_with::rust::double_option" - ))] - /// What modpack version this server is using. - /// - /// If the server is vanilla, this is [`None`]. - pub modpack: Option, - } + pub struct JavaServerVersion {} #[component(ProjectComponentKind::MinecraftBedrockServer)] /// Listing for a Minecraft Bedrock server. @@ -102,6 +81,35 @@ component::define! { } } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub enum ServerContent { + /// Server runs modded content with a modpack found on the Modrinth platform. + Modpack { + /// Version ID of the modpack which the server runs. + /// + /// This version may or may not belong to the server project, since + /// server projects may also be treated as modpacks. + version_id: VersionId, + }, + /// Server is a vanilla Minecraft server. + Vanilla { + /// List of supported Minecraft Java client versions which can join this + /// server. + supported_game_versions: Vec, + /// Recommended Minecraft Java client version to use to join this server. + recommended_game_version: Option, + }, +} + +impl Default for ServerContent { + fn default() -> Self { + ServerContent::Vanilla { + supported_game_versions: Vec::new(), + recommended_game_version: None, + } + } +} + component::relations! { pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { use ProjectComponentKind::*; diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index b853010742..bce315d25a 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -214,7 +214,7 @@ pub async fn project_get( ) -> Result { // Convert V2 data to V3 data // Call V3 project creation - let project = match v3::projects::project_get( + let project = match v3::projects::project_get_internal( req, info, pool.clone(), @@ -247,7 +247,7 @@ pub async fn project_get_check( redis: web::Data, ) -> Result { // Returns an id only, do not need to convert - v3::projects::project_get_check(info, pool, redis) + v3::projects::project_get_check_internal(info, pool, redis) .await .or_else(v2_reroute::flatten_404_error) } @@ -267,7 +267,7 @@ pub async fn dependency_list( session_queue: web::Data, ) -> Result { // TODO: tests, probably - let response = v3::projects::dependency_list( + let response = v3::projects::dependency_list_internal( req, info, pool.clone(), @@ -519,7 +519,7 @@ pub async fn project_edit( // This returns 204 or failure so we don't need to do anything with it let project_id = info.clone().0; - let mut response = v3::projects::project_edit( + let mut response = v3::projects::project_edit_internal( req.clone(), info, pool.clone(), @@ -756,7 +756,7 @@ pub async fn project_icon_edit( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_icon_edit( + v3::projects::project_icon_edit_internal( web::Query(v3::projects::Extension { ext: ext.ext }), req, info, @@ -780,7 +780,7 @@ pub async fn delete_project_icon( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::delete_project_icon( + v3::projects::delete_project_icon_internal( req, info, pool, @@ -816,7 +816,7 @@ pub async fn add_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::add_gallery_item( + v3::projects::add_gallery_item_internal( web::Query(v3::projects::Extension { ext: ext.ext }), req, web::Query(v3::projects::GalleryCreateQuery { @@ -867,7 +867,7 @@ pub async fn edit_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::edit_gallery_item( + v3::projects::edit_gallery_item_internal( req, web::Query(v3::projects::GalleryEditQuery { url: item.url, @@ -899,7 +899,7 @@ pub async fn delete_gallery_item( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::delete_gallery_item( + v3::projects::delete_gallery_item_internal( req, web::Query(v3::projects::GalleryDeleteQuery { url: item.url }), pool, @@ -921,7 +921,7 @@ pub async fn project_delete( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_delete( + v3::projects::project_delete_internal( req, info, pool, @@ -942,7 +942,7 @@ pub async fn project_follow( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_follow(req, info, pool, redis, session_queue) + v3::projects::project_follow_internal(req, info, pool, redis, session_queue) .await .or_else(v2_reroute::flatten_404_error) } @@ -956,7 +956,13 @@ pub async fn project_unfollow( session_queue: web::Data, ) -> Result { // Returns NoContent, so no need to convert - v3::projects::project_unfollow(req, info, pool, redis, session_queue) - .await - .or_else(v2_reroute::flatten_404_error) + v3::projects::project_unfollow_internal( + req, + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) } diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index 5810f436c1..3ca3942c5e 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -39,7 +39,7 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { - let response = v3::teams::team_members_get_project( + let response = v3::teams::team_members_get_project_internal( req, info, pool, diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 6cde6b15ca..a95cf901c7 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -104,7 +104,7 @@ pub async fn version_list( include_changelog: filters.include_changelog, }; - let response = v3::versions::version_list( + let response = v3::versions::version_list_internal( req, info, web::Query(filters), diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 96c3aaf62d..22db5ffd3c 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -65,6 +65,11 @@ pub fn utoipa_config( .wrap(default_cors()) .configure(payouts::config), ); + cfg.service( + utoipa_actix_web::scope("/v3/project") + .wrap(default_cors()) + .configure(projects::utoipa_config), + ); } pub async fn hello_world() -> Result { diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 09aa073ced..c1b2fad076 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -35,7 +35,7 @@ use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use chrono::Utc; use eyre::eyre; use futures::TryStreamExt; @@ -49,38 +49,27 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.route("projects", web::get().to(projects_get)); cfg.route("projects", web::patch().to(projects_edit)); cfg.route("projects_random", web::get().to(random_projects_get)); +} - cfg.service( - web::scope("project") - .route("{id}", web::get().to(project_get)) - .route("{id}/check", web::get().to(project_get_check)) - .route("{id}", web::delete().to(project_delete)) - .route("{id}", web::patch().to(project_edit)) - .route("{id}/icon", web::patch().to(project_icon_edit)) - .route("{id}/icon", web::delete().to(delete_project_icon)) - .route("{id}/gallery", web::post().to(add_gallery_item)) - .route("{id}/gallery", web::patch().to(edit_gallery_item)) - .route("{id}/gallery", web::delete().to(delete_gallery_item)) - .route("{id}/follow", web::post().to(project_follow)) - .route("{id}/follow", web::delete().to(project_unfollow)) - .route("{id}/organization", web::get().to(project_get_organization)) - .service( - web::scope("{project_id}") - .route( - "members", - web::get().to(super::teams::team_members_get_project), - ) - .route( - "version", - web::get().to(super::versions::version_list), - ) - .route( - "version/{slug}", - web::get().to(super::versions::version_project_get), - ) - .route("dependencies", web::get().to(dependency_list)), - ), - ); +pub fn utoipa_config( + cfg: &mut utoipa_actix_web::service_config::ServiceConfig, +) { + cfg.service(project_get) + .service(project_get_check) + .service(project_delete) + .service(project_edit) + .service(project_icon_edit) + .service(delete_project_icon) + .service(add_gallery_item) + .service(edit_gallery_item) + .service(delete_gallery_item) + .service(project_follow) + .service(project_unfollow) + .service(project_get_organization) + .service(super::teams::team_members_get_project) + .service(super::versions::version_list) + .service(super::versions::version_project_get) + .service(dependency_list); } #[derive(Deserialize, Validate)] @@ -164,7 +153,19 @@ pub async fn projects_get( Ok(HttpResponse::Ok().json(projects)) } -pub async fn project_get( +#[utoipa::path] +#[get("/{id}")] +async fn project_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + project_get_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_get_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -194,7 +195,7 @@ pub async fn project_get( Err(ApiError::NotFound) } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct EditProject { #[validate( length(min = 3, max = 64), @@ -263,7 +264,32 @@ pub struct EditProject { } #[allow(clippy::too_many_arguments)] -pub async fn project_edit( +#[utoipa::path] +#[patch("/{id}")] +async fn project_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + search_config: web::Data, + web::Json(new_project): web::Json, + redis: web::Data, + session_queue: web::Data, + moderation_queue: web::Data, +) -> Result { + project_edit_internal( + req, + info, + pool, + search_config, + web::Json(new_project), + redis, + session_queue, + moderation_queue, + ) + .await +} + +pub async fn project_edit_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -1133,7 +1159,17 @@ pub async fn project_search( } //checks the validity of a project id or slug -pub async fn project_get_check( +#[utoipa::path] +#[get("/{id}/check")] +async fn project_get_check( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + project_get_check_internal(info, pool, redis).await +} + +pub async fn project_get_check_internal( info: web::Path<(String,)>, pool: web::Data, redis: web::Data, @@ -1158,12 +1194,24 @@ pub struct DependencyInfo { pub versions: Vec, } +#[utoipa::path] +#[get("/{project_id}/dependencies")] pub async fn dependency_list( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, +) -> Result { + dependency_list_internal(req, info, pool, redis, session_queue).await +} + +pub async fn dependency_list_internal( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, ) -> Result { let string = info.into_inner().0; @@ -1568,7 +1616,32 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] -pub async fn project_icon_edit( +#[utoipa::path] +#[patch("/{id}/icon")] +async fn project_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + project_icon_edit_internal( + web::Query(ext), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await +} + +pub async fn project_icon_edit_internal( web::Query(ext): web::Query, req: HttpRequest, info: web::Path<(String,)>, @@ -1683,7 +1756,28 @@ pub async fn project_icon_edit( Ok(HttpResponse::NoContent().body("")) } -pub async fn delete_project_icon( +#[utoipa::path] +#[delete("/{id}/icon")] +async fn delete_project_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + delete_project_icon_internal( + req, + info, + pool, + redis, + file_host, + session_queue, + ) + .await +} + +pub async fn delete_project_icon_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -1784,7 +1878,34 @@ pub struct GalleryCreateQuery { } #[allow(clippy::too_many_arguments)] +#[utoipa::path] +#[post("/{id}/gallery")] pub async fn add_gallery_item( + web::Query(ext): web::Query, + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + add_gallery_item_internal( + web::Query(ext), + req, + web::Query(item), + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await +} + +pub async fn add_gallery_item_internal( web::Query(ext): web::Query, req: HttpRequest, web::Query(item): web::Query, @@ -1951,7 +2072,26 @@ pub struct GalleryEditQuery { pub ordering: Option, } -pub async fn edit_gallery_item( +#[utoipa::path] +#[patch("/{id}/gallery")] +async fn edit_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + edit_gallery_item_internal( + req, + web::Query(item), + pool, + redis, + session_queue, + ) + .await +} + +pub async fn edit_gallery_item_internal( req: HttpRequest, web::Query(item): web::Query, pool: web::Data, @@ -2117,7 +2257,28 @@ pub struct GalleryDeleteQuery { pub url: String, } -pub async fn delete_gallery_item( +#[utoipa::path] +#[delete("/{id}/gallery")] +async fn delete_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + delete_gallery_item_internal( + req, + web::Query(item), + pool, + redis, + file_host, + session_queue, + ) + .await +} + +pub async fn delete_gallery_item_internal( req: HttpRequest, web::Query(item): web::Query, pool: web::Data, @@ -2227,7 +2388,28 @@ pub async fn delete_gallery_item( Ok(HttpResponse::NoContent().body("")) } -pub async fn project_delete( +#[utoipa::path] +#[delete("/{id}")] +async fn project_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + search_config: web::Data, + session_queue: web::Data, +) -> Result { + project_delete_internal( + req, + info, + pool, + redis, + search_config, + session_queue, + ) + .await +} + +pub async fn project_delete_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2331,7 +2513,19 @@ pub async fn project_delete( } } -pub async fn project_follow( +#[utoipa::path] +#[post("/{id}/follow")] +async fn project_follow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + project_follow_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_follow_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2411,7 +2605,19 @@ pub async fn project_follow( } } -pub async fn project_unfollow( +#[utoipa::path] +#[delete("/{id}/follow")] +async fn project_unfollow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + project_unfollow_internal(req, info, pool, redis, session_queue).await +} + +pub async fn project_unfollow_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, @@ -2487,6 +2693,8 @@ pub async fn project_unfollow( } } +#[utoipa::path] +#[get("/{id}/organization")] pub async fn project_get_organization( req: HttpRequest, info: web::Path<(String,)>, diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 489d77128a..1b5d7e7968 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -12,7 +12,7 @@ use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::UserId; use eyre::eyre; use rust_decimal::Decimal; @@ -40,7 +40,20 @@ pub fn config(cfg: &mut web::ServiceConfig) { // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) // They can be differentiated by the "organization_permissions" field being null or not -pub async fn team_members_get_project( +#[utoipa::path] +#[get("/{project_id}/members")] +async fn team_members_get_project( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + team_members_get_project_internal(req, info, pool, redis, session_queue) + .await +} + +pub async fn team_members_get_project_internal( req: HttpRequest, info: web::Path<(String,)>, pool: web::Data, diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 0acd034b21..06a8b2b609 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -29,7 +29,7 @@ use crate::search::SearchConfig; use crate::search::indexing::remove_documents; use crate::util::img; use crate::util::validate::validation_errors_to_string; -use actix_web::{HttpRequest, HttpResponse, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::base62_impl::parse_base62; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -55,6 +55,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { } // Given a project ID/slug and a version slug +#[utoipa::path] +#[get("/{project_id}/version/{slug}")] pub async fn version_project_get( req: HttpRequest, info: web::Path<(String, String)>, @@ -729,7 +731,28 @@ pub struct VersionListFilters { pub include_changelog: bool, } -pub async fn version_list( +#[utoipa::path] +#[get("/{project_id}/version")] +async fn version_list( + req: HttpRequest, + info: web::Path<(String,)>, + web::Query(filters): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + version_list_internal( + req, + info, + web::Query(filters), + pool, + redis, + session_queue, + ) + .await +} + +pub async fn version_list_internal( req: HttpRequest, info: web::Path<(String,)>, web::Query(filters): web::Query, From 74019afffa0864f940e5202e03723e0adae5cc8a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 10:46:24 +0000 Subject: [PATCH 15/24] docs --- apps/labrinth/src/models/exp/minecraft.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index ee2197de87..746be25d7d 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -57,11 +57,12 @@ component::define! { pub port: u16, #[base(serde(default))] #[edit(serde(default))] + /// What game content this server is using. pub content: ServerContent, } #[component(VersionComponentKind::MinecraftJavaServer)] - /// Listing for a Minecraft Java server. + /// Version of a Minecraft Java server listing. #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct JavaServerVersion {} @@ -81,7 +82,9 @@ component::define! { } } +/// What game content a [`JavaServerProject`] is using. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum ServerContent { /// Server runs modded content with a modpack found on the Modrinth platform. Modpack { From 565838619a08b0aaaed8607ef35c72a6401b4a66 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 11:21:45 +0000 Subject: [PATCH 16/24] Allow editing components --- apps/labrinth/src/models/exp/component.rs | 21 ++++-- apps/labrinth/src/models/exp/mod.rs | 13 ++++ apps/labrinth/src/routes/mod.rs | 6 +- apps/labrinth/src/routes/v3/mod.rs | 4 +- .../src/routes/v3/project_creation.rs | 8 ++- .../src/routes/v3/project_creation/new.rs | 8 +-- apps/labrinth/src/routes/v3/projects.rs | 69 ++++++++++++++++--- 7 files changed, 100 insertions(+), 29 deletions(-) diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index 78afc2cb43..42f0bfc084 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -57,11 +57,22 @@ macro_rules! define { impl $crate::models::exp::component::ComponentEdit for [< $name Edit >] { type Component = $name; + fn create(self) -> eyre::Result { + Ok($name { + $( + $field: eyre::OptionExt::ok_or_eyre( + self.$field, + concat!("missing field `", stringify!($field), "`") + )?, + )* + }) + } + async fn apply_to( self, #[allow(unused_variables)] component: &mut Self::Component, - ) -> Result<(), sqlx::Error> { + ) -> eyre::Result<()> { $( if let Some(f) = self.$field { component.$field = f; @@ -87,6 +98,7 @@ macro_rules! relations { } pub(crate) use define; +use eyre::Result; pub(crate) use relations; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -115,11 +127,10 @@ pub trait Component: Sized { pub trait ComponentEdit: Sized { type Component: Component; + fn create(self) -> Result; + #[expect(async_fn_in_trait, reason = "internal trait")] - async fn apply_to( - self, - component: &mut Self::Component, - ) -> Result<(), sqlx::Error>; + async fn apply_to(self, component: &mut Self::Component) -> Result<()>; } #[derive(Debug, Clone)] diff --git a/apps/labrinth/src/models/exp/mod.rs b/apps/labrinth/src/models/exp/mod.rs index 30f4dfc4b3..8272499379 100644 --- a/apps/labrinth/src/models/exp/mod.rs +++ b/apps/labrinth/src/models/exp/mod.rs @@ -67,6 +67,19 @@ macro_rules! define_project_components { )* } + impl ProjectSerial { + #[must_use] + pub fn component_kinds(&self) -> HashSet { + let mut kinds = HashSet::new(); + $( + if self.$field_name.is_some() { + kinds.insert(ProjectComponentKind::$variant_name); + } + )* + kinds + } + } + #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectCreate { #[validate(nested)] diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index f3696a3ff5..d0c5822798 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -219,9 +219,9 @@ impl ApiError { } }, description: match self { - Self::Internal(e) => format!("{e:#?}"), - Self::Request(e) => format!("{e:#?}"), - Self::Auth(e) => format!("{e:#?}"), + Self::Internal(e) => format!("{e:#}"), + Self::Request(e) => format!("{e:#}"), + Self::Auth(e) => format!("{e:#}"), _ => self.to_string(), }, details: match self { diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 22db5ffd3c..471853fbc8 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -36,7 +36,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(images::config) .configure(notifications::config) .configure(organizations::config) - .configure(project_creation::config) .configure(projects::config) .configure(reports::config) .configure(shared_instance_version_creation::config) @@ -68,7 +67,8 @@ pub fn utoipa_config( cfg.service( utoipa_actix_web::scope("/v3/project") .wrap(default_cors()) - .configure(projects::utoipa_config), + .configure(projects::utoipa_config) + .configure(project_creation::config), ); } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 8537b1114b..3e35ab361e 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -46,7 +46,7 @@ use validator::Validate; mod new; -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(project_create) .service(project_create_with_id) .configure(new::config); @@ -267,7 +267,8 @@ pub async fn undo_uploads( Ok(()) } -#[post("/project")] +#[utoipa::path] +#[post("")] pub async fn project_create( req: HttpRequest, payload: Multipart, @@ -332,7 +333,8 @@ pub async fn project_create_internal( /// Allows creating a project with a specific ID. /// /// This is a testing endpoint only accessible behind an admin key. -#[post("/project/{id}", guard = "admin_key_guard")] +#[utoipa::path] +#[post("/{id}", guard = "admin_key_guard")] pub async fn project_create_with_id( req: HttpRequest, mut payload: Multipart, diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 258fc63e03..46ce5b26a1 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -28,11 +28,7 @@ use crate::{ util::{error::Context, validate::validation_errors_to_string}, }; -// pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { -// cfg.service(create); -// } - -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(create); } @@ -110,7 +106,7 @@ impl ResponseError for CreateError { /// Components must include `base` ([`exp::base::Project`]), and at least one /// other component. #[utoipa::path] -#[put("/project")] +#[put("")] pub async fn create( req: HttpRequest, db: web::Data, diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index c1b2fad076..f39ecf4352 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -257,10 +257,27 @@ pub struct EditProject { Option, #[serde(flatten)] pub loader_fields: HashMap, - pub minecraft_server: Option, - pub minecraft_java_server: Option, + + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub minecraft_server: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] + pub minecraft_java_server: + Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_with::rust::double_option" + )] pub minecraft_bedrock_server: - Option, + Option>, } #[allow(clippy::too_many_arguments)] @@ -975,19 +992,45 @@ pub async fn project_edit_internal( async fn update( _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, - edit: Option, + edit: Option>, component: &mut Option, ) -> Result<(), ApiError> { let Some(edit) = edit else { + // component is not specified in the input JSON - leave alone return Ok(()); }; - let component = component - .as_mut() - .wrap_request_err_with(|| eyre!("attempted to edit `{}` component which is not present on this project", type_name::()))?; - edit.apply_to(component).await.wrap_internal_err_with(|| { - eyre!("failed to update `{}` component", type_name::()) - })?; + match edit { + Some(edit) => { + // component is specified in the JSON and is non-null + match component { + Some(component) => edit + .apply_to(component) + .await + .wrap_internal_err_with(|| { + eyre!( + "failed to update `{}` component", + type_name::() + ) + })?, + None => { + *component = Some( + edit.create().wrap_request_err_with(|| { + eyre!( + "failed to create `{}` component", + type_name::() + ) + })?, + ); + } + } + } + None => { + // component is `null` in the input JSON - remove component + *component = None; + } + } + Ok(()) } @@ -1026,6 +1069,12 @@ pub async fn project_edit_internal( .map(exp::component::Component::into_db), }; + exp::component::kinds_valid( + &components_serial.component_kinds(), + &exp::PROJECT_COMPONENT_RELATIONS, + ) + .wrap_request_err("invalid component kinds")?; + sqlx::query!( " UPDATE mods From 95543e0f489463c69404e169b7a001cdf80b63e7 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 12 Feb 2026 11:36:22 +0000 Subject: [PATCH 17/24] clean up component edit code --- apps/labrinth/src/routes/v3/projects.rs | 52 ++++++++++++------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index f39ecf4352..b9bcfa97eb 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -993,42 +993,40 @@ pub async fn project_edit_internal( _txn: &mut PgTransaction<'_>, _project_id: DBProjectId, edit: Option>, - component: &mut Option, + mut component: &mut Option, ) -> Result<(), ApiError> { let Some(edit) = edit else { // component is not specified in the input JSON - leave alone return Ok(()); }; - match edit { - Some(edit) => { - // component is specified in the JSON and is non-null - match component { - Some(component) => edit - .apply_to(component) - .await - .wrap_internal_err_with(|| { - eyre!( - "failed to update `{}` component", - type_name::() - ) - })?, - None => { - *component = Some( - edit.create().wrap_request_err_with(|| { - eyre!( - "failed to create `{}` component", - type_name::() - ) - })?, - ); - } - } - } - None => { + match (&mut component, edit) { + (None, None) => {} + (Some(_), None) => { // component is `null` in the input JSON - remove component *component = None; } + (None, Some(edit)) => { + // component is specified in the JSON and is non-null - create new component + *component = + Some(edit.create().wrap_request_err_with(|| { + eyre!( + "failed to create `{}` component", + type_name::() + ) + })?); + } + (Some(component), Some(edit)) => { + // edit component + edit.apply_to(component).await.wrap_internal_err_with( + || { + eyre!( + "failed to update `{}` component", + type_name::() + ) + }, + )?; + } } Ok(()) From ca3771a5da9d49b2c8b6c7ad5745c30066fd6bda Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 15:22:14 +0000 Subject: [PATCH 18/24] wip: ping minecraft servers queue --- ...704deb72636f4cbd6927108aadfd6e0d63f1c.json | 26 --- ...f4eeff66ab4165a9f4980032e114db4dc1286.json | 26 --- ...70eef9d1af5ff41b097b3552de86d3940e01e.json | 15 -- ...70bba8ccd2df0995a21bdb34ae3214cef6377.json | 185 ---------------- ...b3c1ce6fb8ac08829345445b027e5a46c4860.json | 100 --------- ...d2402f52fea71e27b08e7926fcc2a9e62c0f3.json | 20 -- ...afedb074492b4ec7f2457c14113f5fd13aa02.json | 18 -- ...e5c93783c7641b019fdb698a1ec0be1393606.json | 17 -- ...7d977a9613f8aa22669c0f8fe7bab2d5d6192.json | 32 --- apps/labrinth/src/background_task.rs | 23 ++ apps/labrinth/src/clickhouse/mod.rs | 21 ++ apps/labrinth/src/lib.rs | 29 +-- apps/labrinth/src/queue/mod.rs | 1 + apps/labrinth/src/queue/server_ping.rs | 202 ++++++++++++++++++ 14 files changed, 263 insertions(+), 452 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json delete mode 100644 apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json delete mode 100644 apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json delete mode 100644 apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json delete mode 100644 apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json delete mode 100644 apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json delete mode 100644 apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json delete mode 100644 apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json delete mode 100644 apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json create mode 100644 apps/labrinth/src/queue/server_ping.rs diff --git a/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json deleted file mode 100644 index 460f5477c3..0000000000 --- a/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Int4", - "Varchar", - "Bool", - "Varchar", - "Int4", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c" -} diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json deleted file mode 100644 index 921f7f92d9..0000000000 --- a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" -} diff --git a/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json b/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json deleted file mode 100644 index 940c72dcde..0000000000 --- a/apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json deleted file mode 100644 index ce8b181e6f..0000000000 --- a/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "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 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": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "summary", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "follows", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "raw_icon_url", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "published", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "approved", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "queued", - "type_info": "Timestamptz" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "license_url", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "team_id", - "type_info": "Int8" - }, - { - "ordinal": 15, - "name": "organization_id", - "type_info": "Int8" - }, - { - "ordinal": 16, - "name": "license", - "type_info": "Varchar" - }, - { - "ordinal": 17, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "moderation_message", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "moderation_message_body", - "type_info": "Varchar" - }, - { - "ordinal": 20, - "name": "webhook_sent", - "type_info": "Bool" - }, - { - "ordinal": 21, - "name": "color", - "type_info": "Int4" - }, - { - "ordinal": 22, - "name": "thread_id", - "type_info": "Int8" - }, - { - "ordinal": 23, - "name": "monetization_status", - "type_info": "Varchar" - }, - { - "ordinal": 24, - "name": "side_types_migration_review_status", - "type_info": "Varchar" - }, - { - "ordinal": 25, - "name": "categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 26, - "name": "additional_categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 27, - "name": "components: sqlx::types::Json", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - true, - false, - false, - true, - true, - false, - true, - true, - false, - true, - false, - true, - true, - true, - false, - true, - false, - false, - false, - null, - null, - false - ] - }, - "hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377" -} diff --git a/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json deleted file mode 100644 index c67f73498d..0000000000 --- a/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json\"\n FROM versions v\n WHERE v.id = ANY($1);\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "version_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "version_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "featured", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 13, - "name": "components: sqlx::types::Json", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - false - ] - }, - "hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860" -} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json deleted file mode 100644 index 89bd8147dc..0000000000 --- a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" -} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json deleted file mode 100644 index 469c30168a..0000000000 --- a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" -} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json deleted file mode 100644 index 52e020ebf2..0000000000 --- a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" -} diff --git a/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json deleted file mode 100644 index 0b5f5d4ffb..0000000000 --- a/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 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": { - "Left": [ - "Int8", - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Int4", - "Varchar", - "Text", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Text", - "Int4", - "Varchar", - "Int8", - "Varchar", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192" -} diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 1783e36e26..9d5d146001 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -25,6 +25,7 @@ pub enum BackgroundTask { IndexSubscriptions, Migrations, Mail, + PingMinecraftJavaServers, } impl BackgroundTask { @@ -76,6 +77,9 @@ impl BackgroundTask { Mail => { run_email(email_queue).await; } + PingMinecraftJavaServers => { + ping_minecraft_java_servers(pool, redis_pool, clickhouse).await + } } } } @@ -232,6 +236,25 @@ pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) { info!("Done syncing payout statuses"); } +pub async fn ping_minecraft_java_servers( + pool: PgPool, + redis_pool: RedisPool, + clickhouse: clickhouse::Client, +) { + info!("Started pinging Minecraft Java servers"); + + let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new( + pool, redis_pool, clickhouse, + ); + + match server_ping_queue.ping_minecraft_java_servers().await { + Ok(_) => info!("Successfully pinged Minecraft Java servers"), + Err(e) => warn!("Failed to ping Minecraft Java servers: {e:?}"), + } + + info!("Done pinging Minecraft Java servers"); +} + mod version_updater { use std::sync::LazyLock; diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index 2c3fc6da7f..2fc74ffd69 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -160,5 +160,26 @@ pub async fn init_client_with_database( .execute() .await?; + client + .query(&format!( + " + CREATE TABLE IF NOT EXISTS {database}.minecraft_java_server_pings {cluster_line} + ( + recorded DateTime64(4), + project_id UInt64, + address String, + port UInt16, + online Bool, + latency_ms Nullable(UInt32) + ) + ENGINE = {engine} + {ttl} + PRIMARY KEY (project_id, recorded) + SETTINGS index_granularity = 8192 + " + )) + .execute() + .await?; + Ok(client.with_database(database)) } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index dce5404992..3b3f6b13b5 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -229,21 +229,24 @@ pub fn app_setup( let analytics_queue_ref = analytics_queue.clone(); let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); - scheduler.run(Duration::from_secs(15), move || { - let client_ref = client_ref.clone(); - let analytics_queue_ref = analytics_queue_ref.clone(); - let pool_ref = pool_ref.clone(); + scheduler.run(Duration::from_secs(15), { let redis_ref = redis_ref.clone(); - - async move { - debug!("Indexing analytics queue"); - let result = analytics_queue_ref - .index(client_ref, &redis_ref, &pool_ref) - .await; - if let Err(e) = result { - warn!("Indexing analytics queue failed: {:?}", e); + move || { + let client_ref = client_ref.clone(); + let analytics_queue_ref = analytics_queue_ref.clone(); + let pool_ref = pool_ref.clone(); + let redis_ref = redis_ref.clone(); + + async move { + debug!("Indexing analytics queue"); + let result = analytics_queue_ref + .index(client_ref, &redis_ref, &pool_ref) + .await; + if let Err(e) = result { + warn!("Indexing analytics queue failed: {:?}", e); + } + debug!("Done indexing analytics queue"); } - debug!("Done indexing analytics queue"); } }); } diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs index 5ad33d01d6..666670dc0b 100644 --- a/apps/labrinth/src/queue/mod.rs +++ b/apps/labrinth/src/queue/mod.rs @@ -3,5 +3,6 @@ pub mod billing; pub mod email; pub mod moderation; pub mod payouts; +pub mod server_ping; pub mod session; pub mod socket; diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs new file mode 100644 index 0000000000..8b2589717f --- /dev/null +++ b/apps/labrinth/src/queue/server_ping.rs @@ -0,0 +1,202 @@ +use crate::database::PgPool; +use crate::database::redis::RedisPool; +use crate::models::exp; +use chrono::Utc; +use clickhouse::{Client, Row}; +use futures::TryStreamExt; +use serde::Serialize; +use sqlx::types::Json; +use std::time::Instant; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +pub struct ServerPingQueue { + pub pg: PgPool, + pub redis: RedisPool, + pub clickhouse: Client, +} + +impl ServerPingQueue { + pub fn new(pg: PgPool, redis: RedisPool, clickhouse: Client) -> Self { + Self { + pg, + redis, + clickhouse, + } + } + + pub async fn ping_minecraft_java_servers( + &self, + ) -> Result<(), ServerPingError> { + let mut stream = sqlx::query!( + r#" + SELECT id, components AS "components: Json" + FROM mods + WHERE status = 'approved' + "# + ) + .fetch(&self.pg); + + let mut ping_results = Vec::new(); + + while let Some(row) = stream.try_next().await? { + let project_id: u64 = row.id as u64; + let components: exp::ProjectSerial = row.components.0; + + if let Some(java_server) = components.minecraft_java_server { + let java_server: exp::minecraft::JavaServerProject = + exp::component::Component::from_db(java_server); + let address = &java_server.address; + let port = java_server.port; + + let ping_result = + self.ping_minecraft_server(address, port).await; + let recorded = Utc::now().timestamp_millis(); + + ping_results.push(ServerPingRecord { + recorded, + project_id, + address: address.clone(), + port, + online: ping_result.is_some(), + latency_ms: ping_result.map(|r| r.as_millis() as u32), + }); + } + } + + if !ping_results.is_empty() { + let mut insert = self + .clickhouse + .insert::("minecraft_java_server_pings") + .await?; + + for result in &ping_results { + insert.write(result).await?; + } + + insert.end().await?; + } + + Ok(()) + } + + async fn ping_minecraft_server( + &self, + address: &str, + port: u16, + ) -> Option { + let start = Instant::now(); + + match TcpStream::connect((address, port)).await { + Ok(mut stream) => { + let handshake = create_handshake(address, port); + let status_request = vec![0x01, 0x00]; + + if stream.write_all(&handshake).await.is_err() { + return None; + } + + if stream.write_all(&status_request).await.is_err() { + return None; + } + + if stream.flush().await.is_err() { + return None; + } + + let mut response_len_bytes = [0u8; 5]; + if stream.read_exact(&mut response_len_bytes).await.is_err() { + return None; + } + + let response_len = varint_decode(&response_len_bytes[1..])?; + + let mut response = vec![0u8; response_len as usize + 1]; + response[0] = response_len_bytes[0]; + + if stream.read_exact(&mut response[1..]).await.is_err() { + return None; + } + + Some(start.elapsed()) + } + Err(_) => None, + } + } +} + +fn create_handshake(address: &str, port: u16) -> Vec { + let mut packet = Vec::new(); + + packet.extend_from_slice(&varint_encode(0x00)); + + packet.extend_from_slice(&varint_encode(765)); + packet.extend_from_slice(&varint_encode(address.len() as i32)); + packet.extend_from_slice(address.as_bytes()); + + packet.extend_from_slice(&port.to_be_bytes()); + + packet.extend_from_slice(&varint_encode(1)); + + let mut handshake = Vec::new(); + handshake.extend_from_slice(&varint_encode(packet.len() as i32)); + handshake.extend_from_slice(&packet); + + handshake +} + +fn varint_encode(mut value: i32) -> Vec { + let mut bytes = Vec::new(); + loop { + let mut byte = (value & 0x7F) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + break; + } + } + bytes +} + +fn varint_decode(bytes: &[u8]) -> Option { + let mut result = 0i32; + let mut shift = 0; + + for &byte in bytes { + result |= ((byte & 0x7F) as i32) << shift; + if byte & 0x80 == 0 { + return Some(result); + } + shift += 7; + if shift >= 32 { + return None; + } + } + + None +} + +#[derive(Debug, Row, Serialize, Clone)] +struct ServerPingRecord { + recorded: i64, + project_id: u64, + address: String, + port: u16, + online: bool, + latency_ms: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServerPingError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Clickhouse error: {0}")] + Clickhouse(#[from] clickhouse::error::Error), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} From 79e7a239104338c8ec34a040832f9d4506b1c19d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 16:19:41 +0000 Subject: [PATCH 19/24] wip: ping queue --- apps/labrinth/src/queue/server_ping.rs | 185 +++++++++++++------------ 1 file changed, 95 insertions(+), 90 deletions(-) diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index 8b2589717f..29c790af26 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -7,7 +7,6 @@ use futures::TryStreamExt; use serde::Serialize; use sqlx::types::Json; use std::time::Instant; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; pub struct ServerPingQueue { @@ -49,8 +48,7 @@ impl ServerPingQueue { let address = &java_server.address; let port = java_server.port; - let ping_result = - self.ping_minecraft_server(address, port).await; + let ping_result = self.ping_server(address, port).await; let recorded = Utc::now().timestamp_millis(); ping_results.push(ServerPingRecord { @@ -80,7 +78,7 @@ impl ServerPingQueue { Ok(()) } - async fn ping_minecraft_server( + async fn ping_server( &self, address: &str, port: u16, @@ -88,97 +86,12 @@ impl ServerPingQueue { let start = Instant::now(); match TcpStream::connect((address, port)).await { - Ok(mut stream) => { - let handshake = create_handshake(address, port); - let status_request = vec![0x01, 0x00]; - - if stream.write_all(&handshake).await.is_err() { - return None; - } - - if stream.write_all(&status_request).await.is_err() { - return None; - } - - if stream.flush().await.is_err() { - return None; - } - - let mut response_len_bytes = [0u8; 5]; - if stream.read_exact(&mut response_len_bytes).await.is_err() { - return None; - } - - let response_len = varint_decode(&response_len_bytes[1..])?; - - let mut response = vec![0u8; response_len as usize + 1]; - response[0] = response_len_bytes[0]; - - if stream.read_exact(&mut response[1..]).await.is_err() { - return None; - } - - Some(start.elapsed()) - } + Ok(_stream) => Some(start.elapsed()), Err(_) => None, } } } -fn create_handshake(address: &str, port: u16) -> Vec { - let mut packet = Vec::new(); - - packet.extend_from_slice(&varint_encode(0x00)); - - packet.extend_from_slice(&varint_encode(765)); - packet.extend_from_slice(&varint_encode(address.len() as i32)); - packet.extend_from_slice(address.as_bytes()); - - packet.extend_from_slice(&port.to_be_bytes()); - - packet.extend_from_slice(&varint_encode(1)); - - let mut handshake = Vec::new(); - handshake.extend_from_slice(&varint_encode(packet.len() as i32)); - handshake.extend_from_slice(&packet); - - handshake -} - -fn varint_encode(mut value: i32) -> Vec { - let mut bytes = Vec::new(); - loop { - let mut byte = (value & 0x7F) as u8; - value >>= 7; - if value != 0 { - byte |= 0x80; - } - bytes.push(byte); - if value == 0 { - break; - } - } - bytes -} - -fn varint_decode(bytes: &[u8]) -> Option { - let mut result = 0i32; - let mut shift = 0; - - for &byte in bytes { - result |= ((byte & 0x7F) as i32) << shift; - if byte & 0x80 == 0 { - return Some(result); - } - shift += 7; - if shift >= 32 { - return None; - } - } - - None -} - #[derive(Debug, Row, Serialize, Clone)] struct ServerPingRecord { recorded: i64, @@ -200,3 +113,95 @@ pub enum ServerPingError { #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), } + +#[cfg(test)] +mod tests { + use super::*; + + #[actix_rt::test] + async fn test_ping_server_success() { + let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") + .map(PgPool::from) + .unwrap(); + let mock_redis = RedisPool::new("test_server_ping"); + let mock_clickhouse = Client::default(); + + let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); + + let result = queue.ping_server("example.com", 80).await; + + assert!( + result.is_some(), + "Connection to example.com:80 should succeed" + ); + assert!( + result.unwrap().as_millis() > 0, + "Latency should be positive" + ); + } + + #[actix_rt::test] + async fn test_ping_server_invalid_address() { + let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") + .map(PgPool::from) + .unwrap(); + let mock_redis = RedisPool::new("test_server_ping"); + let mock_clickhouse = Client::default(); + + let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); + + let result = queue + .ping_server("this-domain-does-not-exist.invalid", 80) + .await; + + assert!( + result.is_none(), + "Connection to invalid address should fail" + ); + } + + #[actix_rt::test] + async fn test_ping_server_timeout() { + let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") + .map(PgPool::from) + .unwrap(); + let mock_redis = RedisPool::new("test_server_ping"); + let mock_clickhouse = Client::default(); + + let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); + + let result = queue.ping_server("192.0.2.1", 9999).await; + + assert!(result.is_none(), "Connection timeout should return None"); + } + + #[test] + fn test_server_ping_record_serialization() { + let record = ServerPingRecord { + recorded: Utc::now().timestamp_millis(), + project_id: 12345, + address: "example.com".to_string(), + port: 80, + online: true, + latency_ms: Some(42), + }; + + let json = serde_json::to_string(&record); + assert!(json.is_ok(), "ServerPingRecord should serialize to JSON"); + } + + #[test] + fn test_server_ping_queue_new() { + let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") + .map(PgPool::from) + .unwrap(); + let mock_redis = RedisPool::new("test_server_ping"); + let mock_clickhouse = Client::default(); + + let _queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); + + let _ = _queue.pg; + let _ = _queue.redis; + let _ = _queue.clickhouse; + } +} From c4f65a8721be9e7807d209431f2a3472a37c38e8 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 16:44:43 +0000 Subject: [PATCH 20/24] ping queue with tests --- apps/labrinth/src/models/exp/component.rs | 4 - apps/labrinth/src/queue/server_ping.rs | 200 +++++++++------------- 2 files changed, 81 insertions(+), 123 deletions(-) diff --git a/apps/labrinth/src/models/exp/component.rs b/apps/labrinth/src/models/exp/component.rs index 42f0bfc084..a3af42a39f 100644 --- a/apps/labrinth/src/models/exp/component.rs +++ b/apps/labrinth/src/models/exp/component.rs @@ -180,10 +180,6 @@ pub fn kinds_valid( kinds: &HashSet, relations: &[ComponentRelation], ) -> Result<(), ComponentRelationError> { - if kinds.is_empty() { - return Err(ComponentRelationError::NoComponents); - } - for relation in relations { match relation { ComponentRelation::Only(set) => { diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index 29c790af26..5ed9e96591 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -1,13 +1,14 @@ -use crate::database::PgPool; use crate::database::redis::RedisPool; use crate::models::exp; +use crate::{database::PgPool, util::error::Context}; use chrono::Utc; use clickhouse::{Client, Row}; use futures::TryStreamExt; use serde::Serialize; use sqlx::types::Json; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio::net::TcpStream; +use tracing::info; pub struct ServerPingQueue { pub pg: PgPool, @@ -24,55 +25,75 @@ impl ServerPingQueue { } } - pub async fn ping_minecraft_java_servers( - &self, - ) -> Result<(), ServerPingError> { - let mut stream = sqlx::query!( + pub async fn ping_minecraft_java_servers(&self) -> eyre::Result<()> { + let mut server_projects = sqlx::query!( r#" SELECT id, components AS "components: Json" FROM mods - WHERE status = 'approved' + WHERE status = 'approved' AND components ? 'minecraft_java_server' "# ) .fetch(&self.pg); let mut ping_results = Vec::new(); - while let Some(row) = stream.try_next().await? { + while let Some(row) = server_projects.try_next().await? { let project_id: u64 = row.id as u64; let components: exp::ProjectSerial = row.components.0; - if let Some(java_server) = components.minecraft_java_server { - let java_server: exp::minecraft::JavaServerProject = - exp::component::Component::from_db(java_server); - let address = &java_server.address; - let port = java_server.port; + let Some(java_server) = components.minecraft_java_server else { + continue; + }; - let ping_result = self.ping_server(address, port).await; - let recorded = Utc::now().timestamp_millis(); + let java_server: exp::minecraft::JavaServerProject = + exp::component::Component::from_db(java_server); + let address = &java_server.address; + let port = java_server.port; - ping_results.push(ServerPingRecord { + let recorded = Utc::now().timestamp_millis(); + let ping_record = match self.ping_server(address, port).await { + Ok(record) => ServerPingRecord { recorded, project_id, address: address.clone(), port, - online: ping_result.is_some(), - latency_ms: ping_result.map(|r| r.as_millis() as u32), - }); - } + online: true, + latency_ms: Some(record.latency.as_millis() as u32), + }, + Err(err) => { + info!("Failed to ping {address}:{port}: {err:?}"); + ServerPingRecord { + recorded, + project_id, + address: address.clone(), + port, + online: false, + latency_ms: None, + } + } + }; + + ping_results.push(ping_record); } if !ping_results.is_empty() { let mut insert = self .clickhouse .insert::("minecraft_java_server_pings") - .await?; + .await + .wrap_err("failed to begin inserting ping records")?; for result in &ping_results { - insert.write(result).await?; + insert + .write(result) + .await + .wrap_err("failed to write ping record")?; } - insert.end().await?; + insert + .end() + .await + .wrap_err("failed to end inserting ping records")?; } Ok(()) @@ -82,16 +103,23 @@ impl ServerPingQueue { &self, address: &str, port: u16, - ) -> Option { + ) -> eyre::Result { let start = Instant::now(); - match TcpStream::connect((address, port)).await { - Ok(_stream) => Some(start.elapsed()), - Err(_) => None, - } + let _stream = TcpStream::connect((address, port)) + .await + .wrap_err("failed to connect to address and port")?; + Ok(PingRecord { + latency: start.elapsed(), + }) } } +#[derive(Debug)] +struct PingRecord { + latency: Duration, +} + #[derive(Debug, Row, Serialize, Clone)] struct ServerPingRecord { recorded: i64, @@ -102,106 +130,40 @@ struct ServerPingRecord { latency_ms: Option, } -#[derive(Debug, thiserror::Error)] -pub enum ServerPingError { - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("Clickhouse error: {0}")] - Clickhouse(#[from] clickhouse::error::Error), - - #[error("Serialization error: {0}")] - Serialization(#[from] serde_json::Error), -} - #[cfg(test)] mod tests { + use crate::test::{ + api_v3::ApiV3, + environment::{TestEnvironment, with_test_environment}, + }; + use super::*; #[actix_rt::test] async fn test_ping_server_success() { - let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") - .map(PgPool::from) - .unwrap(); - let mock_redis = RedisPool::new("test_server_ping"); - let mock_clickhouse = Client::default(); - - let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); - - let result = queue.ping_server("example.com", 80).await; - - assert!( - result.is_some(), - "Connection to example.com:80 should succeed" - ); - assert!( - result.unwrap().as_millis() > 0, - "Latency should be positive" - ); + with_test_environment(None, |env: TestEnvironment| async move { + let queue = ServerPingQueue::new( + env.db.pool, + env.db.redis_pool, + crate::clickhouse::init_client().await.unwrap(), + ); + + queue.ping_server("example.com", 80).await.unwrap(); + }) + .await; } #[actix_rt::test] async fn test_ping_server_invalid_address() { - let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") - .map(PgPool::from) - .unwrap(); - let mock_redis = RedisPool::new("test_server_ping"); - let mock_clickhouse = Client::default(); - - let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); - - let result = queue - .ping_server("this-domain-does-not-exist.invalid", 80) - .await; - - assert!( - result.is_none(), - "Connection to invalid address should fail" - ); - } - - #[actix_rt::test] - async fn test_ping_server_timeout() { - let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") - .map(PgPool::from) - .unwrap(); - let mock_redis = RedisPool::new("test_server_ping"); - let mock_clickhouse = Client::default(); - - let queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); - - let result = queue.ping_server("192.0.2.1", 9999).await; - - assert!(result.is_none(), "Connection timeout should return None"); - } - - #[test] - fn test_server_ping_record_serialization() { - let record = ServerPingRecord { - recorded: Utc::now().timestamp_millis(), - project_id: 12345, - address: "example.com".to_string(), - port: 80, - online: true, - latency_ms: Some(42), - }; - - let json = serde_json::to_string(&record); - assert!(json.is_ok(), "ServerPingRecord should serialize to JSON"); - } - - #[test] - fn test_server_ping_queue_new() { - let mock_pg = sqlx::PgPool::connect_lazy("postgresql://localhost/test") - .map(PgPool::from) - .unwrap(); - let mock_redis = RedisPool::new("test_server_ping"); - let mock_clickhouse = Client::default(); - - let _queue = ServerPingQueue::new(mock_pg, mock_redis, mock_clickhouse); - - let _ = _queue.pg; - let _ = _queue.redis; - let _ = _queue.clickhouse; + with_test_environment(None, |env: TestEnvironment| async move { + let queue = ServerPingQueue::new( + env.db.pool, + env.db.redis_pool, + crate::clickhouse::init_client().await.unwrap(), + ); + + _ = queue.ping_server("invalid.invalid", 80).await.unwrap_err(); + }) + .await; } } From 05255c7d516d205470a097fed2df051f3dd9c23a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 17:48:22 +0000 Subject: [PATCH 21/24] mc ping server info + timeout --- Cargo.lock | 14 ++++++ Cargo.toml | 1 + apps/labrinth/.env.docker-compose | 1 + apps/labrinth/.env.local | 1 + apps/labrinth/Cargo.toml | 1 + apps/labrinth/src/clickhouse/mod.rs | 7 ++- apps/labrinth/src/lib.rs | 30 ++++++----- apps/labrinth/src/queue/server_ping.rs | 70 +++++++++++++++++++------- 8 files changed, 89 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8245cfff0d..e498a45ec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-minecraft-ping" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "async-process" version = "2.5.0" @@ -4730,6 +4743,7 @@ dependencies = [ "arc-swap", "argon2", "ariadne", + "async-minecraft-ping", "async-stripe", "async-trait", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index d5d9ba9131..d36fe2c020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" chrono = "0.4.42" +async-minecraft-ping = { version = "0.8.0" } cidre = { version = "0.11.3", default-features = false, features = [ "macos_15_0" ] } diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 49ddff31c9..f25136cd84 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -155,3 +155,4 @@ MURALPAY_TRANSFER_API_KEY=none MURALPAY_SOURCE_ACCOUNT_ID=none DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 +SERVER_PING_TIMEOUT=10000 diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 98b92c184f..7088ae7ee7 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -166,3 +166,4 @@ MURALPAY_TRANSFER_API_KEY=none MURALPAY_SOURCE_ACCOUNT_ID=none DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 +SERVER_PING_TIMEOUT=10000 diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 76cbff499e..bbc9dbc5c9 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -19,6 +19,7 @@ actix-web = { workspace = true } actix-web-prom = { workspace = true, features = ["process"] } actix-ws = { workspace = true } arc-swap = { workspace = true } +async-minecraft-ping = { workspace = true } argon2 = { workspace = true } ariadne = { workspace = true } async-stripe = { workspace = true, features = [ diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index 2fc74ffd69..fec76ceae5 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -170,7 +170,12 @@ pub async fn init_client_with_database( address String, port UInt16, online Bool, - latency_ms Nullable(UInt32) + latency_ms Nullable(UInt32), + description Nullable(String), + version_name Nullable(String), + version_protocol Nullable(UInt32), + players_online Nullable(UInt32), + players_max Nullable(UInt32) ) ENGINE = {engine} {ttl} diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 3b3f6b13b5..236ee8bdaf 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -229,24 +229,21 @@ pub fn app_setup( let analytics_queue_ref = analytics_queue.clone(); let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); - scheduler.run(Duration::from_secs(15), { + scheduler.run(Duration::from_secs(15), move || { + let client_ref = client_ref.clone(); + let analytics_queue_ref = analytics_queue_ref.clone(); + let pool_ref = pool_ref.clone(); let redis_ref = redis_ref.clone(); - move || { - let client_ref = client_ref.clone(); - let analytics_queue_ref = analytics_queue_ref.clone(); - let pool_ref = pool_ref.clone(); - let redis_ref = redis_ref.clone(); - - async move { - debug!("Indexing analytics queue"); - let result = analytics_queue_ref - .index(client_ref, &redis_ref, &pool_ref) - .await; - if let Err(e) = result { - warn!("Indexing analytics queue failed: {:?}", e); - } - debug!("Done indexing analytics queue"); + + async move { + debug!("Indexing analytics queue"); + let result = analytics_queue_ref + .index(client_ref, &redis_ref, &pool_ref) + .await; + if let Err(e) = result { + warn!("Indexing analytics queue failed: {:?}", e); } + debug!("Done indexing analytics queue"); } }); } @@ -534,6 +531,7 @@ pub fn check_env_vars() -> bool { failed |= check_var::("MURALPAY_SOURCE_ACCOUNT_ID"); failed |= check_var::("DEFAULT_AFFILIATE_REVENUE_SPLIT"); + failed |= check_var::("SERVER_PING_TIMEOUT"); failed } diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index 5ed9e96591..1da38649b3 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -1,13 +1,13 @@ use crate::database::redis::RedisPool; use crate::models::exp; use crate::{database::PgPool, util::error::Context}; +use async_minecraft_ping::{ServerDescription, StatusResponse}; use chrono::Utc; use clickhouse::{Client, Row}; use futures::TryStreamExt; use serde::Serialize; use sqlx::types::Json; use std::time::{Duration, Instant}; -use tokio::net::TcpStream; use tracing::info; pub struct ServerPingQueue { @@ -52,13 +52,20 @@ impl ServerPingQueue { let recorded = Utc::now().timestamp_millis(); let ping_record = match self.ping_server(address, port).await { - Ok(record) => ServerPingRecord { + Ok((status, latency)) => ServerPingRecord { recorded, project_id, address: address.clone(), port, - online: true, - latency_ms: Some(record.latency.as_millis() as u32), + latency_ms: Some(latency.as_millis() as u32), + description: match status.description { + ServerDescription::Plain(text) + | ServerDescription::Object { text } => Some(text), + }, + version_name: Some(status.version.name), + version_protocol: Some(status.version.protocol), + players_online: Some(status.players.online), + players_max: Some(status.players.max), }, Err(err) => { info!("Failed to ping {address}:{port}: {err:?}"); @@ -67,8 +74,12 @@ impl ServerPingQueue { project_id, address: address.clone(), port, - online: false, latency_ms: None, + description: None, + version_name: None, + version_protocol: None, + players_online: None, + players_max: None, } } }; @@ -103,31 +114,48 @@ impl ServerPingQueue { &self, address: &str, port: u16, - ) -> eyre::Result { + ) -> eyre::Result<(StatusResponse, Duration)> { let start = Instant::now(); - let _stream = TcpStream::connect((address, port)) + let task = async move { + let conn = async_minecraft_ping::ConnectionConfig::build(address) + .with_port(port) + .connect() + .await + .wrap_err("failed to connect to server")?; + + let status = conn + .status() + .await + .wrap_err("failed to get server status")? + .status; + Ok((status, start.elapsed())) + }; + + let timeout = dotenvy::var("SERVER_PING_TIMEOUT") + .unwrap() + .parse::() + .wrap_err("failed to parse SERVER_PING_TIMEOUT")?; + + tokio::time::timeout(Duration::from_millis(timeout), task) .await - .wrap_err("failed to connect to address and port")?; - Ok(PingRecord { - latency: start.elapsed(), - }) + .wrap_err("server ping timed out") + .flatten() } } -#[derive(Debug)] -struct PingRecord { - latency: Duration, -} - #[derive(Debug, Row, Serialize, Clone)] struct ServerPingRecord { recorded: i64, project_id: u64, address: String, port: u16, - online: bool, latency_ms: Option, + description: Option, + version_name: Option, + version_protocol: Option, + players_online: Option, + players_max: Option, } #[cfg(test)] @@ -148,7 +176,8 @@ mod tests { crate::clickhouse::init_client().await.unwrap(), ); - queue.ping_server("example.com", 80).await.unwrap(); + let _status = + queue.ping_server("mc.hypixel.net", 25565).await.unwrap(); }) .await; } @@ -162,7 +191,10 @@ mod tests { crate::clickhouse::init_client().await.unwrap(), ); - _ = queue.ping_server("invalid.invalid", 80).await.unwrap_err(); + _ = queue + .ping_server("invalid.invalid", 25565) + .await + .unwrap_err(); }) .await; } From e625cc898e6bad8b28aa743a77c38bc89329c784 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 21:28:08 +0000 Subject: [PATCH 22/24] sqlx prepare --- ...704deb72636f4cbd6927108aadfd6e0d63f1c.json | 26 +++ ...f4eeff66ab4165a9f4980032e114db4dc1286.json | 26 +++ ...70eef9d1af5ff41b097b3552de86d3940e01e.json | 15 ++ ...70bba8ccd2df0995a21bdb34ae3214cef6377.json | 185 ++++++++++++++++++ ...b3c1ce6fb8ac08829345445b027e5a46c4860.json | 100 ++++++++++ ...bfe9ec805cfdc7e9145115b15808ba2b9d6e4.json | 26 +++ ...d2402f52fea71e27b08e7926fcc2a9e62c0f3.json | 20 ++ ...afedb074492b4ec7f2457c14113f5fd13aa02.json | 18 ++ ...e5c93783c7641b019fdb698a1ec0be1393606.json | 17 ++ ...7d977a9613f8aa22669c0f8fe7bab2d5d6192.json | 32 +++ 10 files changed, 465 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json create mode 100644 apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json create mode 100644 apps/labrinth/.sqlx/query-46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e.json create mode 100644 apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json create mode 100644 apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json create mode 100644 apps/labrinth/.sqlx/query-b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4.json create mode 100644 apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json create mode 100644 apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json create mode 100644 apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json create mode 100644 apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json diff --git a/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json new file mode 100644 index 0000000000..460f5477c3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Int4", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c" +} diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json new file mode 100644 index 0000000000..921f7f92d9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "status: PayoutStatus", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" +} 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-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json new file mode 100644 index 0000000000..ce8b181e6f --- /dev/null +++ b/apps/labrinth/.sqlx/query-59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377.json @@ -0,0 +1,185 @@ +{ + "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 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": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "follows", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "raw_icon_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "approved", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "queued", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "license_url", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "organization_id", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "license", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "moderation_message", + "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "moderation_message_body", + "type_info": "Varchar" + }, + { + "ordinal": 20, + "name": "webhook_sent", + "type_info": "Bool" + }, + { + "ordinal": 21, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 23, + "name": "monetization_status", + "type_info": "Varchar" + }, + { + "ordinal": 24, + "name": "side_types_migration_review_status", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 26, + "name": "additional_categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 27, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + false, + null, + null, + false + ] + }, + "hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377" +} diff --git a/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json new file mode 100644 index 0000000000..c67f73498d --- /dev/null +++ b/apps/labrinth/.sqlx/query-760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json\"\n FROM versions v\n WHERE v.id = ANY($1);\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "changelog", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "version_type", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "components: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860" +} diff --git a/apps/labrinth/.sqlx/query-b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4.json b/apps/labrinth/.sqlx/query-b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4.json new file mode 100644 index 0000000000..5d7f4d94f6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, components AS \"components: Json\"\n FROM mods\n WHERE status = 'approved' AND components ? 'minecraft_java_server'\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "components: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4" +} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json new file mode 100644 index 0000000000..89bd8147dc --- /dev/null +++ b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status: PayoutStatus", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" +} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json new file mode 100644 index 0000000000..469c30168a --- /dev/null +++ b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" +} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json new file mode 100644 index 0000000000..52e020ebf2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" +} diff --git a/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json new file mode 100644 index 0000000000..0b5f5d4ffb --- /dev/null +++ b/apps/labrinth/.sqlx/query-cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192.json @@ -0,0 +1,32 @@ +{ + "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 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": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Int4", + "Varchar", + "Int8", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192" +} From fb9b40be2b68fcb19eced73a4bab3040bc64fc5e Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 21:29:21 +0000 Subject: [PATCH 23/24] tombi fmt --- apps/labrinth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index bbc9dbc5c9..dfe6fb9b90 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -19,9 +19,9 @@ actix-web = { workspace = true } actix-web-prom = { workspace = true, features = ["process"] } actix-ws = { workspace = true } arc-swap = { workspace = true } -async-minecraft-ping = { workspace = true } argon2 = { workspace = true } ariadne = { workspace = true } +async-minecraft-ping = { workspace = true } async-stripe = { workspace = true, features = [ "billing", "checkout", From 0f194690dfa173a15ec1d87a6467558134e73b5d Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 15 Feb 2026 21:32:16 +0000 Subject: [PATCH 24/24] tombi fmt --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d36fe2c020..5d8705401a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ arc-swap = "1.7.1" argon2 = { version = "0.5.3", features = ["std"] } ariadne = { path = "packages/ariadne" } async-compression = { version = "0.4.32", default-features = false } +async-minecraft-ping = { version = "0.8.0" } async-recursion = "1.1.1" async-stripe = { version = "0.41.0", default-features = false, features = [ "runtime-tokio-hyper-rustls", @@ -49,7 +50,6 @@ bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" chrono = "0.4.42" -async-minecraft-ping = { version = "0.8.0" } cidre = { version = "0.11.3", default-features = false, features = [ "macos_15_0" ] }