From a4dd12b7e422ddf89eb37a1c47eee117edbc1eb4 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 13 Jan 2026 12:44:14 +0000 Subject: [PATCH 1/7] update frontend to work with new versions list route --- apps/frontend/src/pages/[type]/[id].vue | 42 ++++--------------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 4767f0e15d..7614a1bbb4 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1063,15 +1063,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(() => { @@ -1418,29 +1414,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(() => @@ -1476,14 +1454,12 @@ let project, dependencies, versions, versionsV3, - resetVersionsV2, organization, resetOrganization, projectV2Error, projectV3Error, membersError, dependenciesError, - versionsError, versionsV3Error, resetVersionsV3 try { @@ -1492,7 +1468,6 @@ try { { data: projectV3, error: projectV3Error, refresh: resetProjectV3 }, { data: allMembers, error: membersError, refresh: resetMembers }, { data: dependencies, error: dependenciesError }, - { data: versions, error: versionsError, refresh: resetVersionsV2 }, { data: versionsV3, error: versionsV3Error, refresh: resetVersionsV3 }, { data: organization, refresh: resetOrganization }, ] = await Promise.all([ @@ -1595,13 +1570,9 @@ async function resetProject() { } async function resetVersions() { - await resetVersionsV2() await resetVersionsV3() - versions.value = (versions.value ?? []).map((v) => ({ - ...v, - environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment, - })) + versions.value = versionsV3.value?.versions || [] } function handleError(err, project = false) { @@ -1621,7 +1592,6 @@ handleError(projectV2Error, true) handleError(projectV3Error) handleError(membersError) handleError(dependenciesError) -handleError(versionsError) handleError(versionsV3Error) if (!project.value) { From 052418c720fd45160c43330563c84813179ddb14 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Wed, 14 Jan 2026 15:25:48 +0000 Subject: [PATCH 2/7] 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 1e8d3a21ec1ccefebe5d60cd10733dd3a85eb1f6 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 19 Jan 2026 23:21:46 +0000 Subject: [PATCH 3/7] 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 ac9328afc1..0a89c000dd 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -42,8 +42,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 0beb280b619a35ed14339f7525875be12902f7ed Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 16:51:20 +0000 Subject: [PATCH 4/7] 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, 323 insertions(+), 104 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 717ebf7e47..99ff351d8f 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::redis::RedisPool; 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 664148cc2e..b5b5c6805f 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -217,7 +217,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(), @@ -225,23 +225,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 @@ -515,7 +513,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 0a89c000dd..95d79b17f7 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -990,6 +990,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 4f0d981d1e..8550f1ed3f 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,12 +8,12 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{DBGalleryItem, DBModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{ - DBModerationLock, DBTeamMember, ids as db_ids, image_item, + DBModerationLock, DBProjectId, DBTeamMember, 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::file_hosting::{FileHost, FileHostPublicity}; -use crate::models; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; @@ -23,6 +24,7 @@ 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; @@ -30,17 +32,20 @@ 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}; use serde_json::json; -use sqlx::PgPool; +use sqlx::{PgPool, PgTransaction}; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { @@ -169,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 = @@ -188,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) } @@ -255,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)] @@ -263,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, @@ -939,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 e096541d8385099cc6bce9a8d379663c6a37ccc7 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 20 Jan 2026 17:16:17 +0000 Subject: [PATCH 5/7] revert accidental change --- apps/frontend/src/pages/[type]/[id].vue | 42 +++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 7614a1bbb4..4767f0e15d 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1063,11 +1063,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(() => { @@ -1414,11 +1418,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(() => @@ -1454,12 +1476,14 @@ let project, dependencies, versions, versionsV3, + resetVersionsV2, organization, resetOrganization, projectV2Error, projectV3Error, membersError, dependenciesError, + versionsError, versionsV3Error, resetVersionsV3 try { @@ -1468,6 +1492,7 @@ try { { data: projectV3, error: projectV3Error, refresh: resetProjectV3 }, { data: allMembers, error: membersError, refresh: resetMembers }, { data: dependencies, error: dependenciesError }, + { data: versions, error: versionsError, refresh: resetVersionsV2 }, { data: versionsV3, error: versionsV3Error, refresh: resetVersionsV3 }, { data: organization, refresh: resetOrganization }, ] = await Promise.all([ @@ -1570,9 +1595,13 @@ async function resetProject() { } async function resetVersions() { + await resetVersionsV2() await resetVersionsV3() - versions.value = versionsV3.value?.versions || [] + versions.value = (versions.value ?? []).map((v) => ({ + ...v, + environment: versionsV3.value?.find((v3) => v3.id === v.id)?.environment, + })) } function handleError(err, project = false) { @@ -1592,6 +1621,7 @@ handleError(projectV2Error, true) handleError(projectV3Error) handleError(membersError) handleError(dependenciesError) +handleError(versionsError) handleError(versionsV3Error) if (!project.value) { From 51900bab3a863f082865cdb8d340d50b88b57de1 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 10:16:16 +0000 Subject: [PATCH 6/7] 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 99ff351d8f..45fa75918c 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 8550f1ed3f..3c1d2614b0 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}; @@ -32,7 +31,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 4d89f113915c546b85878bc70ddbd73a4b541285 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 23 Jan 2026 11:31:38 +0000 Subject: [PATCH 7/7] No more six seven --- .../src/database/models/project_item.rs | 14 +++++++------- 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, 29 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 45fa75918c..f281a14d12 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -6,10 +6,10 @@ use super::{DBUser, ids::*}; use crate::database::models; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; +use crate::models::exp; 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 +874,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 +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, + 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 3c1d2614b0..96d7a6ec60 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -23,7 +23,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,