diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 90d129d9a1..a6ba1f7081 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [dependencies] actix-cors = { workspace = true } actix-files = { workspace = true } -actix-http = { workspace = true, optional = true } +actix-http = { workspace = true } actix-multipart = { workspace = true } actix-rt = { workspace = true } actix-web = { workspace = true } @@ -149,7 +149,7 @@ tikv-jemallocator = { workspace = true, features = [ ] } [features] -test = ["dep:actix-http"] +test = [] [lints] workspace = true 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..22c7c01ef8 --- /dev/null +++ b/apps/labrinth/migrations/20260114130019_server_listing_projects.sql @@ -0,0 +1,20 @@ +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 +); 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/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/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 new file mode 100644 index 0000000000..04a6191e51 --- /dev/null +++ b/apps/labrinth/src/models/v67/base.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +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 new file mode 100644 index 0000000000..002bdb5856 --- /dev/null +++ b/apps/labrinth/src/models/v67/minecraft.rs @@ -0,0 +1,210 @@ +use std::sync::LazyLock; + +use serde::{Deserialize, Serialize}; +use sqlx::{PgTransaction, postgres::PgQueryResult}; +use validator::Validate; + +use crate::{ + database::models::DBProjectId, + models::v67::{ + 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 {} + + #[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 BedrockServer { + #[validate(length(max = 255))] + pub address: String, + } +} + +// impl + +impl ProjectComponent for Mod { + fn kind() -> ProjectComponentKind { + ProjectComponentKind::MinecraftMod + } + + async fn insert( + &self, + _txn: &mut PgTransaction<'_>, + _project_id: DBProjectId, + ) -> Result<(), sqlx::Error> { + unimplemented!(); + } +} + +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 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(()) + } +} + +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 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(()) + } +} + +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 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(()) + } +} + +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 new file mode 100644 index 0000000000..40fd0355ee --- /dev/null +++ b/apps/labrinth/src/models/v67/mod.rs @@ -0,0 +1,225 @@ +//! 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 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; + +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::Project, + $(pub $field_name: Option<$ty>,)* + } + + #[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`")] + 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::Mod, + (minecraft_server, MinecraftServer): minecraft::Server, + (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServer, + (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServer, +]; + +pub trait ProjectComponent: Sized { + fn kind() -> ProjectComponentKind; + + #[expect(async_fn_in_trait, reason = "internal trait")] + 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 + /// 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, Serialize, Deserialize)] +pub enum ComponentKindsError { + #[error("no components")] + NoComponents, + #[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_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); + } + + 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, + }); + } + } + } + } + + Ok(()) +} 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 ac9328afc1..95d79b17f7 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)] @@ -986,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 new file mode 100644 index 0000000000..b61524635a --- /dev/null +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -0,0 +1,272 @@ +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::{ + 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) { +// cfg.service(create); +// } + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(create); +} + +#[derive(Debug, thiserror::Error)] +pub enum CreateError { + #[error("project limit reached")] + LimitReached, + #[error("invalid component kinds")] + ComponentKinds(v67::ComponentKindsError), + #[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::ComponentKinds(err) => crate::models::error::ApiError { + error: "component_kinds", + description: format!("{self}: {err}"), + details: Some( + serde_json::to_value(err) + .expect("should never fail to serialize"), + ), + }, + Self::Validation(_) => crate::models::error::ApiError { + error: "validation", + description: self.to_string(), + details: None, + }, + Self::SlugCollision => crate::models::error::ApiError { + error: "slug_collision", + description: self.to_string(), + details: None, + }, + Self::Api(err) => err.as_api_error(), + } + } +} + +impl ResponseError for CreateError { + fn status_code(&self) -> actix_http::StatusCode { + match self { + Self::LimitReached => StatusCode::BAD_REQUEST, + Self::ComponentKinds(_) => 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] +#[put("/project")] +pub async fn create( + req: HttpRequest, + db: web::Data, + redis: web::Data, + session_queue: web::Data, + web::Json(details): web::Json, +) -> Result, CreateError> { + // check that the user can make a project + let (_, user) = get_user_from_headers( + &req, + &**db, + &redis, + &session_queue, + Scopes::PROJECT_CREATE, + ) + .await + .map_err(ApiError::from)?; + + let limits = UserLimits::get_for_projects(&user, &db) + .await + .map_err(ApiError::from)?; + if limits.current >= limits.max { + return Err(CreateError::LimitReached); + } + + // check if the given details are valid + + v67::component_kinds_valid(&details.component_kinds()) + .map_err(CreateError::ComponentKinds)?; + + details.validate().map_err(|err| { + CreateError::Validation(validation_errors_to_string(err, None)) + })?; + + // check if this won't conflict with an existing project + + let mut txn = db + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let same_slug_record = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE 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(); + + 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 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 v67::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() + .await + .wrap_internal_err("failed to commit transaction")?; + + Ok(web::Json(project_id)) +} diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 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> =