Compare commits

...
Author SHA1 Message Date
aecsocket 0210dd3799 wip: clean up experimental api 2026-02-17 01:16:00 +00:00
aecsocket 0f194690df tombi fmt 2026-02-15 21:32:16 +00:00
aecsocket fb9b40be2b tombi fmt 2026-02-15 21:29:21 +00:00
aecsocket e625cc898e sqlx prepare 2026-02-15 21:28:08 +00:00
aecsocket 05255c7d51 mc ping server info + timeout 2026-02-15 17:48:22 +00:00
aecsocket c4f65a8721 ping queue with tests 2026-02-15 16:44:43 +00:00
aecsocket 79e7a23910 wip: ping queue 2026-02-15 16:19:41 +00:00
aecsocket ca3771a5da wip: ping minecraft servers queue 2026-02-15 15:22:14 +00:00
aecsocket 95543e0f48 clean up component edit code 2026-02-12 11:36:22 +00:00
aecsocket 565838619a Allow editing components 2026-02-12 11:21:45 +00:00
aecsocket 74019afffa docs 2026-02-12 10:46:24 +00:00
aecsocket 855ff1db53 utoipa support for projects 2026-02-12 10:40:56 +00:00
aecsocket 17f9285988 game version fields 2026-02-11 15:12:36 +00:00
aecsocket d01a0fe831 Version component creation 2026-02-11 14:00:46 +00:00
aecsocket 31a1a04cdf Version components backend API 2026-02-11 14:00:46 +00:00
aecsocket 5dbdee3c31 wip: version components 2026-02-11 14:00:46 +00:00
aecsocket 7e4d6cf0ae Update project component structure for servers 2026-02-11 14:00:46 +00:00
aecsocket 103924a9ce New project component metadata schema 2026-02-11 14:00:46 +00:00
aecsocket 53f12dc40e No more six seven 2026-02-11 14:00:46 +00:00
aecsocket 824be8ae55 fix up rebase 2026-02-11 14:00:46 +00:00
aecsocket f249ce4ebd revert accidental change 2026-02-11 14:00:46 +00:00
aecsocket 669351faad wip: project components API 2026-02-11 14:00:46 +00:00
aecsocket fc8cb7eac2 wip: v67 project creation endpoint 2026-02-11 14:00:46 +00:00
aecsocket 0f7734fdca wip: server listing API 2026-02-11 14:00:46 +00:00
aecsocket b497aa5c9d update frontend to work with new versions list route 2026-02-11 14:00:46 +00:00
39 changed files with 1748 additions and 128 deletions
Generated
+14
View File
@@ -640,6 +640,19 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
dependencies = [
"async-trait",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -4730,6 +4743,7 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
+1
View File
@@ -32,6 +32,7 @@ arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { version = "0.8.0" }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
+1
View File
@@ -155,3 +155,4 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
+1
View File
@@ -166,3 +166,4 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=none
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Varchar",
"Bool",
"Varchar",
"Int4"
"Int4",
"Jsonb"
]
},
"nullable": []
},
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Int8"
]
},
"nullable": []
},
"hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ",
"describe": {
"columns": [
{
@@ -137,6 +137,11 @@
"ordinal": 26,
"name": "additional_categories",
"type_info": "VarcharArray"
},
{
"ordinal": 27,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -172,8 +177,9 @@
false,
false,
null,
null
null,
false
]
},
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
"hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json<exp::VersionSerial>\"\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"ordinal": 12,
"name": "ordering",
"type_info": "Int4"
},
{
"ordinal": 13,
"name": "components: sqlx::types::Json<exp::VersionSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -87,8 +92,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6"
"hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE status = 'approved' AND components ? 'minecraft_java_server'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "components: Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "b1e9a9740543d6128aba4b7d984bfe9ec805cfdc7e9145115b15808ba2b9d6e4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18,\n $19\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -22,10 +22,11 @@
"Int4",
"Varchar",
"Int8",
"Varchar"
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
"hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192"
}
+3
View File
@@ -10,6 +10,7 @@ name = "labrinth"
path = "src/main.rs"
[dependencies]
labrinth-derive = { workspace = true }
actix-cors = { workspace = true }
actix-files = { workspace = true }
actix-http = { workspace = true }
@@ -21,6 +22,7 @@ actix-ws = { workspace = true }
arc-swap = { workspace = true }
argon2 = { workspace = true }
ariadne = { workspace = true }
async-minecraft-ping = { workspace = true }
async-stripe = { workspace = true, features = [
"billing",
"checkout",
@@ -30,6 +32,7 @@ async-stripe = { workspace = true, features = [
async-trait = { workspace = true }
base64 = { workspace = true }
bitflags = { workspace = true }
bon = { workspace = true }
bytes = { workspace = true }
censor = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
@@ -0,0 +1,2 @@
ALTER TABLE mods
ADD COLUMN components JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,8 @@
ALTER TABLE versions
ADD COLUMN components JSONB NOT NULL DEFAULT '{}';
-- extra metadata for the `minecraft_java_server` version component
CREATE TABLE minecraft_java_server_versions (
id bigint PRIMARY KEY REFERENCES versions(id),
modpack_id bigint REFERENCES versions(id)
);
+23
View File
@@ -25,6 +25,7 @@ pub enum BackgroundTask {
IndexSubscriptions,
Migrations,
Mail,
PingMinecraftJavaServers,
}
impl BackgroundTask {
@@ -76,6 +77,9 @@ impl BackgroundTask {
Mail => {
run_email(email_queue).await;
}
PingMinecraftJavaServers => {
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
}
}
}
}
@@ -232,6 +236,25 @@ pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) {
info!("Done syncing payout statuses");
}
pub async fn ping_minecraft_java_servers(
pool: PgPool,
redis_pool: RedisPool,
clickhouse: clickhouse::Client,
) {
info!("Started pinging Minecraft Java servers");
let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new(
pool, redis_pool, clickhouse,
);
match server_ping_queue.ping_minecraft_java_servers().await {
Ok(_) => info!("Successfully pinged Minecraft Java servers"),
Err(e) => warn!("Failed to ping Minecraft Java servers: {e:?}"),
}
info!("Done pinging Minecraft Java servers");
}
mod version_updater {
use std::sync::LazyLock;
+26
View File
@@ -160,5 +160,31 @@ pub async fn init_client_with_database(
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.minecraft_java_server_pings {cluster_line}
(
recorded DateTime64(4),
project_id UInt64,
address String,
port UInt16,
online Bool,
latency_ms Nullable(UInt32),
description Nullable(String),
version_name Nullable(String),
version_protocol Nullable(UInt32),
players_online Nullable(UInt32),
players_max Nullable(UInt32)
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
Ok(client.with_database(database))
}
@@ -6,6 +6,7 @@ use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::models::exp;
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
};
@@ -176,6 +177,7 @@ pub struct ProjectBuilder {
pub gallery_items: Vec<DBGalleryItem>,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub components: exp::ProjectCreate,
}
impl ProjectBuilder {
@@ -215,6 +217,7 @@ impl ProjectBuilder {
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
components: self.components.into_db(),
};
project_struct.insert(&mut *transaction).await?;
@@ -294,6 +297,7 @@ pub struct DBProject {
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
pub components: exp::ProjectSerial,
}
impl DBProject {
@@ -308,14 +312,16 @@ impl DBProject {
published, downloads, icon_url, raw_icon_url, status, requested_status,
license_url, license,
slug, color, monetization_status, organization_id,
side_types_migration_review_status
side_types_migration_review_status,
components
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17,
$18
$18,
$19
)
",
self.id as DBProjectId,
@@ -335,7 +341,8 @@ impl DBProject {
self.color.map(|x| x as i32),
self.monetization_status.as_str(),
self.organization_id.map(|x| x.0 as i64),
self.side_types_migration_review_status.as_str()
self.side_types_migration_review_status.as_str(),
serde_json::to_value(&self.components).expect("serialization shouldn't fail"),
)
.execute(&mut *transaction)
.await?;
@@ -767,7 +774,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 +784,17 @@ 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,
m.components AS "components: sqlx::types::Json<exp::ProjectSerial>"
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
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,
)
@@ -845,6 +855,7 @@ impl DBProject {
&m.side_types_migration_review_status,
),
loaders,
components: exp::ProjectSerial::default(),
},
categories: m.categories.unwrap_or_default(),
additional_categories: m.additional_categories.unwrap_or_default(),
@@ -858,6 +869,21 @@ impl DBProject {
urls,
aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true),
thread_id: DBThreadId(m.thread_id),
minecraft_server: m
.components
.0
.minecraft_server
.map(exp::component::Component::from_serial),
minecraft_java_server: m
.components
.0
.minecraft_java_server
.map(exp::component::Component::from_serial),
minecraft_bedrock_server: m
.components
.0
.minecraft_bedrock_server
.map(exp::component::Component::from_serial),
};
acc.insert(m.id, (m.slug, project));
@@ -983,4 +1009,7 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec<DBGalleryItem>,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec<VersionField>,
pub minecraft_server: Option<exp::minecraft::ServerProject>,
pub minecraft_java_server: Option<exp::minecraft::JavaServerProject>,
pub minecraft_bedrock_server: Option<exp::minecraft::BedrockServerProject>,
}
@@ -285,7 +285,7 @@ impl DBLoaderVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct DBVersion {
pub id: DBVersionId,
pub project_id: DBProjectId,
@@ -302,6 +302,14 @@ pub struct DBVersion {
pub ordering: Option<i32>,
}
impl PartialEq for DBVersion {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for DBVersion {}
impl DBVersion {
pub async fn insert(
&self,
@@ -331,7 +339,7 @@ impl DBVersion {
&self.version_type,
self.featured,
self.status.as_str(),
self.ordering
self.ordering,
)
.execute(&mut *transaction)
.await?;
@@ -719,13 +727,13 @@ impl DBVersion {
).await?;
let res = sqlx::query!(
"
r#"
SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,
v.changelog changelog, v.date_published date_published, v.downloads downloads,
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering
FROM versions v
WHERE v.id = ANY($1);
",
"#,
&version_ids
)
.fetch(&mut exec)
@@ -937,7 +945,7 @@ impl DBVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct VersionQueryResult {
pub inner: DBVersion,
@@ -993,6 +1001,14 @@ impl std::cmp::PartialOrd for VersionQueryResult {
}
}
impl std::cmp::PartialEq for VersionQueryResult {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl std::cmp::Eq for VersionQueryResult {}
impl std::cmp::Ord for DBVersion {
fn cmp(&self, other: &Self) -> Ordering {
let ordering_order = match (self.ordering, other.ordering) {
+1
View File
@@ -531,6 +531,7 @@ pub fn check_env_vars() -> bool {
failed |= check_var::<String>("MURALPAY_SOURCE_ACCOUNT_ID");
failed |= check_var::<String>("DEFAULT_AFFILIATE_REVENUE_SPLIT");
failed |= check_var::<String>("SERVER_PING_TIMEOUT");
failed
}
+33
View File
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::models::{ids::OrganizationId, projects::ProjectStatus};
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Project {
/// Human-readable friendly name of the project.
#[validate(
length(min = 3, max = 64),
custom(function = "crate::util::validate::validate_name")
)]
pub name: String,
/// Slug of the project, used in vanity URLs.
#[validate(
length(min = 3, max = 64),
regex(path = *crate::util::validate::RE_URL_SAFE)
)]
pub slug: String,
/// Short description of the project.
#[validate(length(min = 3, max = 255))]
pub summary: String,
/// A long description of the project, in markdown.
#[validate(length(max = 65536))]
pub description: String,
/// What status the user would like the project to be in after review.
pub requested_status: ProjectStatus,
/// What organization the project belongs to.
pub organization_id: Option<OrganizationId>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {}
+122
View File
@@ -0,0 +1,122 @@
macro_rules! relations {
($vis:vis static $name:ident: $component_kind:ty = $expr:block) => {
$vis static $name: std::sync::LazyLock<Vec<$crate::models::exp::component::ComponentRelation<$component_kind>>> = std::sync::LazyLock::new(|| {
#[allow(unused_imports)]
use $crate::models::exp::component::{ComponentKindExt, ComponentKindArrayExt};
Vec::<$crate::models::exp::component::ComponentRelation<$component_kind>>::from($expr)
});
};
}
use eyre::Result;
pub(crate) use relations;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{collections::HashSet, hash::Hash};
use thiserror::Error;
pub trait ComponentKind:
Clone + Send + Sync + PartialEq + Eq + Hash + 'static
{
}
pub trait Component: Sized {
type Kind;
type Serial: Serialize + DeserializeOwned;
type Get;
type Edit: ComponentEdit<Component = Self>;
fn kind() -> Self::Kind;
fn into_serial(self) -> Self::Serial;
fn from_serial(serial: Self::Serial) -> Self;
}
pub trait ComponentEdit: Sized {
type Component: Component<Edit = Self>;
fn create(self) -> Result<Self::Component>;
#[expect(async_fn_in_trait, reason = "internal trait")]
async fn apply_to(self, component: &mut Self::Component) -> Result<()>;
}
#[derive(Debug, Clone)]
pub enum ComponentRelation<K> {
/// If one of these components is present, then it can only be present with
/// other components from this set.
Only(HashSet<K>),
/// If component `0` is present, then `1` must also be present.
Requires(K, K),
}
pub trait ComponentKindExt<K> {
fn requires(self, other: K) -> ComponentRelation<K>;
}
impl<K> ComponentKindExt<K> for K {
fn requires(self, other: K) -> ComponentRelation<K> {
ComponentRelation::Requires(self, other)
}
}
pub trait ComponentKindArrayExt<K> {
fn only(self) -> ComponentRelation<K>;
}
impl<K: ComponentKind, const N: usize> ComponentKindArrayExt<K> for [K; N] {
fn only(self) -> ComponentRelation<K> {
ComponentRelation::Only(self.iter().cloned().collect())
}
}
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
pub enum ComponentRelationError<K: ComponentKind> {
#[error("no components")]
NoComponents,
#[error("component `{target:?}` is missing")]
Missing { target: K },
#[error(
"only components {only:?} can be together, found extra components {extra:?}"
)]
Only { only: HashSet<K>, extra: HashSet<K> },
#[error("component `{target:?}` requires `{requires:?}`")]
Requires { target: K, requires: K },
}
pub fn kinds_valid<K: ComponentKind>(
kinds: &HashSet<K>,
relations: &[ComponentRelation<K>],
) -> Result<(), ComponentRelationError<K>> {
for relation in relations {
match relation {
ComponentRelation::Only(set) => {
if kinds.iter().any(|k| set.contains(k)) {
let extra: HashSet<_> =
kinds.difference(set).cloned().collect();
if !extra.is_empty() {
return Err(ComponentRelationError::Only {
only: set.clone(),
extra,
});
}
}
}
ComponentRelation::Requires(a, b) => {
if kinds.contains(a) && !kinds.contains(b) {
return Err(ComponentRelationError::Requires {
target: a.clone(),
requires: b.clone(),
});
}
}
}
}
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use chrono::{DateTime, Utc};
use labrinth_derive::Component;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::models::{
exp::{ProjectComponentKind, component},
ids::VersionId,
};
/// Listing for a Minecraft server.
#[derive(
Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, Component,
)]
pub struct ServerProject {
/// Maximum number of players allowed on the server.
pub max_players: Option<u32>,
/// Country which this server is hosted in.
#[validate(length(min = 2, max = 2))]
pub country: Option<String>,
/// Which version of the listing this server is currently using.
pub active_version: Option<VersionId>,
}
/// Listing for a Minecraft Java server.
#[derive(
Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, Component,
)]
pub struct JavaServerProject {
/// Address (IP or domain name) of the Java server, excluding port.
#[validate(length(max = 255))]
pub address: String,
/// Port which the server runs on.
pub port: u16,
/// What game content this server is using.
pub content: ServerContent,
#[component(synthetic)]
/// Last received ping data from the server.
pub ping: Option<JavaServerPing>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPing {
/// When this ping data was gathered.
pub pinged_at: DateTime<Utc>,
/// If the ping was successful, contains server ping information.
pub data: Option<JavaServerPingData>,
}
/// Ping data for a Minecraft Java server.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JavaServerPingData {
/// Server description/MOTD as shown in the server list.
pub description: String,
/// Number of players currently online.
pub players_online: u32,
/// Maximum number of players allowed on the server.
pub players_max: u32,
}
/// Listing for a Minecraft Bedrock server.
#[derive(
Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema, Component,
)]
pub struct BedrockServerProject {
/// Address (IP or domain name) of the Bedrock server, excluding port.
#[validate(length(max = 255))]
pub address: String,
/// Port which the server runs on.
pub port: u16,
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
Modpack {
/// Version ID of the modpack which the server runs.
///
/// This version may or may not belong to the server project, since
/// server projects may also be treated as modpacks.
version_id: VersionId,
},
/// Server is a vanilla Minecraft server.
Vanilla {
/// List of supported Minecraft Java client versions which can join this
/// server.
supported_game_versions: Vec<String>,
/// Recommended Minecraft Java client version to use to join this server.
recommended_game_version: Option<String>,
},
}
impl Default for ServerContent {
fn default() -> Self {
ServerContent::Vanilla {
supported_game_versions: Vec::new(),
recommended_game_version: None,
}
}
}
component::relations! {
pub(super) static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
use ProjectComponentKind::*;
[
[MinecraftServer, MinecraftJavaServer, MinecraftBedrockServer].only(),
MinecraftJavaServer.requires(MinecraftServer),
MinecraftBedrockServer.requires(MinecraftServer),
]
}
}
+239
View File
@@ -0,0 +1,239 @@
//! 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.
//!
//! # 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;
use serde::{Deserialize, Serialize};
use validator::Validate;
use crate::database::models::DBProjectId;
pub mod base;
pub mod component;
pub mod minecraft;
macro_rules! define_project_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
// #[expect(dead_code, reason = "static check so $ty implements `Component`")]
// const _: () = {
// fn assert_implements_component<T>()
// where
// T: component::Component<Kind = ProjectComponentKind>,
// {}
// fn assert_components_implement_trait() {
// $(assert_implements_component::<$ty>();)*
// }
// };
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProjectComponentKind {
$($variant_name,)*
}
impl component::ComponentKind for ProjectComponentKind {}
// // structs
// #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
// pub struct Project {
// #[validate(nested)]
// pub base: base::Project,
// $(
// #[serde(skip_serializing_if = "Option::is_none")]
// #[validate(nested)]
// pub $field_name: Option<$ty>,
// )*
// }
// #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
// pub struct ProjectSerial {
// $(
// #[validate(nested)]
// pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>,
// )*
// }
// impl ProjectSerial {
// #[must_use]
// pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
// let mut kinds = HashSet::new();
// $(
// if self.$field_name.is_some() {
// kinds.insert(ProjectComponentKind::$variant_name);
// }
// )*
// kinds
// }
// }
// impl ProjectCreate {
// #[must_use]
// pub fn component_kinds(&self) -> HashSet<ProjectComponentKind> {
// let mut kinds = HashSet::new();
// $(
// if self.$field_name.is_some() {
// kinds.insert(ProjectComponentKind::$variant_name);
// }
// )*
// kinds
// }
// }
// #[derive(Debug, Clone, Serialize, Deserialize, Validate)]
// // #[derive(utoipa::ToSchema)]
// pub struct ProjectEdit {
// $(
// #[validate(nested)]
// pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,
// )*
// }
// // logic
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct ProjectCreate {
#[validate(nested)]
pub base: Option<base::Project>,
$(
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
};
}
macro_rules! define_version_components {
(
$(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)?
) => {
// kinds
#[expect(dead_code, reason = "static check so $ty implements `Component`")]
const _: () = {
fn assert_implements_component<T>()
where
T: component::Component<Kind = VersionComponentKind>,
{}
fn assert_components_implement_trait() {
$(assert_implements_component::<$ty>();)*
}
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum VersionComponentKind {
$($variant_name,)*
}
impl component::ComponentKind for VersionComponentKind {}
// structs
#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct Version {
#[validate(nested)]
pub base: base::Version,
$(
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VersionSerial {
$(
pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Serial>,
)*
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct VersionCreate {
#[validate(nested)]
pub base: Option<base::Project>,
$(
#[validate(nested)]
pub $field_name: Option<$ty>,
)*
}
impl VersionCreate {
#[must_use]
pub fn component_kinds(&self) -> HashSet<VersionComponentKind> {
let mut kinds = HashSet::new();
$(if self.$field_name.is_some() {
kinds.insert(VersionComponentKind::$variant_name);
})*
kinds
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
// #[derive(utoipa::ToSchema)]
pub struct VersionEdit {
$(
#[validate(nested)]
pub $field_name: Option<<$ty as $crate::models::exp::component::Component>::Edit>,
)*
}
// logic
impl VersionCreate {
pub fn into_db(self) -> VersionSerial {
VersionSerial {
$(
$field_name: self.$field_name.map(component::Component::into_db),
)*
}
}
}
};
}
define_project_components![
(minecraft_server, MinecraftServer): minecraft::ServerProject,
(minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject,
(minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject,
];
define_version_components![];
component::relations! {
pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = {
minecraft::PROJECT_COMPONENT_RELATIONS.clone()
}
}
pub async fn query_project_components(
db: impl crate::database::Executor,
project_ids: &[DBProjectId],
slugs: &[&str],
) -> eyre::Result<()> {
let rows = sqlx::query!(
r#"
SELECT m.components AS "components: sqlx::types::Json<ProjectSerial>"
FROM mods m
WHERE m.id = ANY($1) OR m.slug = ANY($2)
"#,
&project_ids,
&slugs,
)
.fetch(db);
}
+1
View File
@@ -1,4 +1,5 @@
pub mod error;
pub mod exp;
pub mod v2;
pub mod v3;
+11
View File
@@ -4,6 +4,7 @@ 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,
};
@@ -98,6 +99,13 @@ pub struct Project {
/// Aggregated loader-fields across its myriad of versions
#[serde(flatten)]
pub fields: HashMap<String, Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minecraft_server: Option<exp::minecraft::ServerProject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minecraft_java_server: Option<exp::minecraft::JavaServerProject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minecraft_bedrock_server: Option<exp::minecraft::BedrockServerProject>,
}
// 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<ProjectQueryResult> 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,
}
}
}
+1
View File
@@ -3,5 +3,6 @@ pub mod billing;
pub mod email;
pub mod moderation;
pub mod payouts;
pub mod server_ping;
pub mod session;
pub mod socket;
+201
View File
@@ -0,0 +1,201 @@
use crate::database::redis::RedisPool;
use crate::models::exp;
use crate::{database::PgPool, util::error::Context};
use async_minecraft_ping::{ServerDescription, StatusResponse};
use chrono::Utc;
use clickhouse::{Client, Row};
use futures::TryStreamExt;
use serde::Serialize;
use sqlx::types::Json;
use std::time::{Duration, Instant};
use tracing::info;
pub struct ServerPingQueue {
pub pg: PgPool,
pub redis: RedisPool,
pub clickhouse: Client,
}
impl ServerPingQueue {
pub fn new(pg: PgPool, redis: RedisPool, clickhouse: Client) -> Self {
Self {
pg,
redis,
clickhouse,
}
}
pub async fn ping_minecraft_java_servers(&self) -> eyre::Result<()> {
let mut server_projects = sqlx::query!(
r#"
SELECT id, components AS "components: Json<exp::ProjectSerial>"
FROM mods
WHERE status = 'approved' AND components ? 'minecraft_java_server'
"#
)
.fetch(&self.pg);
let mut ping_results = Vec::new();
while let Some(row) = server_projects.try_next().await? {
let project_id: u64 = row.id as u64;
let components: exp::ProjectSerial = row.components.0;
let Some(java_server) = components.minecraft_java_server else {
continue;
};
let java_server: exp::minecraft::JavaServerProject =
exp::component::Component::from_serial(java_server);
let address = &java_server.address;
let port = java_server.port;
let recorded = Utc::now().timestamp_millis();
let ping_record = match self.ping_server(address, port).await {
Ok((status, latency)) => ServerPingRecord {
recorded,
project_id,
address: address.clone(),
port,
latency_ms: Some(latency.as_millis() as u32),
description: match status.description {
ServerDescription::Plain(text)
| ServerDescription::Object { text } => Some(text),
},
version_name: Some(status.version.name),
version_protocol: Some(status.version.protocol),
players_online: Some(status.players.online),
players_max: Some(status.players.max),
},
Err(err) => {
info!("Failed to ping {address}:{port}: {err:?}");
ServerPingRecord {
recorded,
project_id,
address: address.clone(),
port,
latency_ms: None,
description: None,
version_name: None,
version_protocol: None,
players_online: None,
players_max: None,
}
}
};
ping_results.push(ping_record);
}
if !ping_results.is_empty() {
let mut insert = self
.clickhouse
.insert::<ServerPingRecord>("minecraft_java_server_pings")
.await
.wrap_err("failed to begin inserting ping records")?;
for result in &ping_results {
insert
.write(result)
.await
.wrap_err("failed to write ping record")?;
}
insert
.end()
.await
.wrap_err("failed to end inserting ping records")?;
}
Ok(())
}
async fn ping_server(
&self,
address: &str,
port: u16,
) -> eyre::Result<(StatusResponse, Duration)> {
let start = Instant::now();
let task = async move {
let conn = async_minecraft_ping::ConnectionConfig::build(address)
.with_port(port)
.connect()
.await
.wrap_err("failed to connect to server")?;
let status = conn
.status()
.await
.wrap_err("failed to get server status")?
.status;
Ok((status, start.elapsed()))
};
let timeout = dotenvy::var("SERVER_PING_TIMEOUT")
.unwrap()
.parse::<u64>()
.wrap_err("failed to parse SERVER_PING_TIMEOUT")?;
tokio::time::timeout(Duration::from_millis(timeout), task)
.await
.wrap_err("server ping timed out")
.flatten()
}
}
#[derive(Debug, Row, Serialize, Clone)]
struct ServerPingRecord {
recorded: i64,
project_id: u64,
address: String,
port: u16,
latency_ms: Option<u32>,
description: Option<String>,
version_name: Option<String>,
version_protocol: Option<u32>,
players_online: Option<u32>,
players_max: Option<u32>,
}
#[cfg(test)]
mod tests {
use crate::test::{
api_v3::ApiV3,
environment::{TestEnvironment, with_test_environment},
};
use super::*;
#[actix_rt::test]
async fn test_ping_server_success() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let queue = ServerPingQueue::new(
env.db.pool,
env.db.redis_pool,
crate::clickhouse::init_client().await.unwrap(),
);
let _status =
queue.ping_server("mc.hypixel.net", 25565).await.unwrap();
})
.await;
}
#[actix_rt::test]
async fn test_ping_server_invalid_address() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let queue = ServerPingQueue::new(
env.db.pool,
env.db.redis_pool,
crate::clickhouse::init_client().await.unwrap(),
);
_ = queue
.ping_server("invalid.invalid", 25565)
.await
.unwrap_err();
})
.await;
}
}
+3 -3
View File
@@ -219,9 +219,9 @@ impl ApiError {
}
},
description: match self {
Self::Internal(e) => format!("{e:#?}"),
Self::Request(e) => format!("{e:#?}"),
Self::Auth(e) => format!("{e:#?}"),
Self::Internal(e) => format!("{e:#}"),
Self::Request(e) => format!("{e:#}"),
Self::Auth(e) => format!("{e:#}"),
_ => self.to_string(),
},
details: match self {
@@ -200,6 +200,7 @@ pub async fn project_create(
uploaded_images: v.uploaded_images,
ordering: v.ordering,
fields,
minecraft_java_server: None,
}
})
.collect();
+37 -29
View File
@@ -214,7 +214,7 @@ pub async fn project_get(
) -> Result<HttpResponse, ApiError> {
// Convert V2 data to V3 data
// Call V3 project creation
let response = v3::projects::project_get(
let project = match v3::projects::project_get_internal(
req,
info,
pool.clone(),
@@ -222,23 +222,21 @@ pub async fn project_get(
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)?;
{
Ok(resp) => resp.0,
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
Err(err) => return Err(err),
};
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Project>(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
@@ -249,7 +247,7 @@ pub async fn project_get_check(
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
// Returns an id only, do not need to convert
v3::projects::project_get_check(info, pool, redis)
v3::projects::project_get_check_internal(info, pool, redis)
.await
.or_else(v2_reroute::flatten_404_error)
}
@@ -269,7 +267,7 @@ pub async fn dependency_list(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// TODO: tests, probably
let response = v3::projects::dependency_list(
let response = v3::projects::dependency_list_internal(
req,
info,
pool.clone(),
@@ -512,12 +510,16 @@ 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
let project_id = info.clone().0;
let mut response = v3::projects::project_edit(
let mut response = v3::projects::project_edit_internal(
req.clone(),
info,
pool.clone(),
@@ -754,7 +756,7 @@ pub async fn project_icon_edit(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_icon_edit(
v3::projects::project_icon_edit_internal(
web::Query(v3::projects::Extension { ext: ext.ext }),
req,
info,
@@ -778,7 +780,7 @@ pub async fn delete_project_icon(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::delete_project_icon(
v3::projects::delete_project_icon_internal(
req,
info,
pool,
@@ -814,7 +816,7 @@ pub async fn add_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::add_gallery_item(
v3::projects::add_gallery_item_internal(
web::Query(v3::projects::Extension { ext: ext.ext }),
req,
web::Query(v3::projects::GalleryCreateQuery {
@@ -865,7 +867,7 @@ pub async fn edit_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::edit_gallery_item(
v3::projects::edit_gallery_item_internal(
req,
web::Query(v3::projects::GalleryEditQuery {
url: item.url,
@@ -897,7 +899,7 @@ pub async fn delete_gallery_item(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::delete_gallery_item(
v3::projects::delete_gallery_item_internal(
req,
web::Query(v3::projects::GalleryDeleteQuery { url: item.url }),
pool,
@@ -919,7 +921,7 @@ pub async fn project_delete(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_delete(
v3::projects::project_delete_internal(
req,
info,
pool,
@@ -940,7 +942,7 @@ pub async fn project_follow(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_follow(req, info, pool, redis, session_queue)
v3::projects::project_follow_internal(req, info, pool, redis, session_queue)
.await
.or_else(v2_reroute::flatten_404_error)
}
@@ -954,7 +956,13 @@ pub async fn project_unfollow(
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
// Returns NoContent, so no need to convert
v3::projects::project_unfollow(req, info, pool, redis, session_queue)
.await
.or_else(v2_reroute::flatten_404_error)
v3::projects::project_unfollow_internal(
req,
info,
pool,
redis,
session_queue,
)
.await
.or_else(v2_reroute::flatten_404_error)
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub async fn team_members_get_project(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = v3::teams::team_members_get_project(
let response = v3::teams::team_members_get_project_internal(
req,
info,
pool,
@@ -222,6 +222,7 @@ pub async fn version_create(
uploaded_images: legacy_create.uploaded_images,
ordering: legacy_create.ordering,
fields,
minecraft_java_server: None,
})
}
},
+16 -8
View File
@@ -104,7 +104,7 @@ pub async fn version_list(
include_changelog: filters.include_changelog,
};
let response = v3::versions::version_list(
let response = v3::versions::version_list_internal(
req,
info,
web::Query(filters),
@@ -211,6 +211,7 @@ pub async fn version_get(
let response =
v3::versions::version_get_helper(req, id, pool, redis, session_queue)
.await
.map(|b| HttpResponse::Ok().json(b))
.or_else(v2_reroute::flatten_404_error)?;
// Convert response to V2 format
match v2_reroute::extract_ok_json::<Version>(response).await {
@@ -277,7 +278,7 @@ pub async fn version_edit(
}
// Get the older version to get info from
let old_version = v3::versions::version_get_helper(
let old_version = match v3::versions::version_get_helper(
req.clone(),
(*info).0,
pool.clone(),
@@ -285,12 +286,19 @@ pub async fn version_edit(
session_queue.clone(),
)
.await
.or_else(v2_reroute::flatten_404_error)?;
let old_version =
match v2_reroute::extract_ok_json::<Version>(old_version).await {
Ok(version) => version,
Err(response) => return Ok(response),
};
{
Ok(resp) => resp,
Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")),
Err(err) => return Err(err),
};
let old_version = match v2_reroute::extract_ok_json::<Version>(
HttpResponse::Ok().json(old_version.0),
)
.await
{
Ok(version) => version,
Err(response) => return Ok(response),
};
// If this has 'mrpack_loaders' as a loader field previously, this is a modpack.
// Therefore, if we are modifying the 'loader' field in this case,
+6 -1
View File
@@ -36,7 +36,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(images::config)
.configure(notifications::config)
.configure(organizations::config)
.configure(project_creation::config)
.configure(projects::config)
.configure(reports::config)
.configure(shared_instance_version_creation::config)
@@ -65,6 +64,12 @@ pub fn utoipa_config(
.wrap(default_cors())
.configure(payouts::config),
);
cfg.service(
utoipa_actix_web::scope("/v3/project")
.wrap(default_cors())
.configure(projects::utoipa_config)
.configure(project_creation::config),
);
}
pub async fn hello_world() -> Result<HttpResponse, ApiError> {
@@ -10,6 +10,7 @@ use crate::database::models::{self, DBUser, image_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError};
use crate::models::error::ApiError;
use crate::models::exp;
use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
@@ -43,8 +44,12 @@ use std::sync::Arc;
use thiserror::Error;
use validator::Validate;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(project_create).service(project_create_with_id);
mod new;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(project_create)
.service(project_create_with_id)
.configure(new::config);
}
#[derive(Error, Debug)]
@@ -262,7 +267,8 @@ pub async fn undo_uploads(
Ok(())
}
#[post("/project")]
#[utoipa::path]
#[post("")]
pub async fn project_create(
req: HttpRequest,
payload: Multipart,
@@ -327,7 +333,8 @@ pub async fn project_create_internal(
/// Allows creating a project with a specific ID.
///
/// This is a testing endpoint only accessible behind an admin key.
#[post("/project/{id}", guard = "admin_key_guard")]
#[utoipa::path]
#[post("/{id}", guard = "admin_key_guard")]
pub async fn project_create_with_id(
req: HttpRequest,
mut payload: Multipart,
@@ -870,6 +877,7 @@ async fn project_create_inner(
.collect(),
color: icon_data.and_then(|x| x.2),
monetization_status: MonetizationStatus::Monetized,
components: exp::ProjectCreate::default(),
};
let project_builder = project_builder_actual.clone();
@@ -992,6 +1000,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))
@@ -1076,6 +1087,10 @@ async fn create_initial_version(
version_type: version_data.release_channel.to_string(),
requested_status: None,
ordering: version_data.ordering,
components: exp::VersionCreate {
base: None,
minecraft_java_server: version_data.minecraft_java_server.clone(),
},
};
Ok(version)
@@ -0,0 +1,292 @@
use actix_http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, ResponseError, put, web};
use eyre::eyre;
use rust_decimal::Decimal;
use validator::Validate;
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{
self, DBOrganization, DBTeamMember, DBUser,
project_item::ProjectBuilder, thread_item::ThreadBuilder,
},
redis::RedisPool,
},
models::{
exp::{self, ProjectComponentKind, component::ComponentRelationError},
ids::ProjectId,
pats::Scopes,
projects::{MonetizationStatus, ProjectStatus},
teams::{OrganizationPermissions, ProjectPermissions},
threads::ThreadType,
v3::user_limits::UserLimits,
},
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);
}
#[derive(Debug, thiserror::Error)]
pub enum CreateError {
#[error("project limit reached")]
LimitReached,
#[error("missing base component")]
MissingBase,
#[error("invalid component kinds")]
ComponentKinds(ComponentRelationError<ProjectComponentKind>),
#[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::MissingBase => crate::models::error::ApiError {
error: "missing_base",
description: self.to_string(),
details: None,
},
Self::ComponentKinds(err) => crate::models::error::ApiError {
error: "component_kinds",
description: format!("{self}: {err}"),
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
| Self::MissingBase
| Self::ComponentKinds(_)
| Self::Validation(_)
| 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 with the given components.
///
/// Components must include `base` ([`exp::base::Project`]), and at least one
/// other component.
#[utoipa::path]
#[put("")]
pub async fn create(
req: HttpRequest,
db: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
web::Json(details): web::Json<exp::ProjectCreate>,
) -> Result<web::Json<ProjectId>, 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
exp::component::kinds_valid(
&details.component_kinds(),
&exp::PROJECT_COMPONENT_RELATIONS,
)
.map_err(CreateError::ComponentKinds)?;
details.validate().map_err(|err| {
CreateError::Validation(validation_errors_to_string(err, None))
})?;
// get component-specific data
// use struct destructor syntax, so we get a compile error
// if we add a new field and don't add it here
let exp::base::Project {
name,
slug,
summary,
description,
requested_status,
organization_id,
} = details.base.clone().ok_or(CreateError::MissingBase)?;
// 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 slug = $1 OR text_id_lower = $1
)",
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 = if let Some(organization_id) = organization_id {
let org = DBOrganization::get_id(organization_id.into(), &**db, &redis)
.await
.wrap_internal_err("failed to get organization")?
.wrap_request_err("invalid organization ID")?;
let team_member =
DBTeamMember::get_from_user_id(org.team_id, user.id.into(), &**db)
.await
.wrap_internal_err(
"failed to get team member of user for organization",
)?;
let perms = OrganizationPermissions::get_permissions_by_role(
&user.role,
&team_member,
);
if !perms
.is_some_and(|p| p.contains(OrganizationPermissions::ADD_PROJECT))
{
return Err(ApiError::Auth(eyre!(
"no permission to create projects in this organization"
))
.into());
}
models::team_item::TeamBuilder {
members: Vec::new(),
}
} else {
let members = vec![models::team_item::TeamMemberBuilder {
user_id: user.id.into(),
role: crate::models::teams::DEFAULT_ROLE.to_owned(),
is_owner: true,
permissions: ProjectPermissions::all(),
organization_permissions: None,
accepted: true,
payouts_split: Decimal::ONE_HUNDRED,
ordering: 0,
}];
models::team_item::TeamBuilder { members }
};
let team_id = team
.insert(&mut txn)
.await
.wrap_internal_err("failed to insert team")?;
let project_id: ProjectId = models::generate_project_id(&mut txn)
.await
.wrap_internal_err("failed to generate project ID")?
.into();
// TODO: special-case server projects to be unmonetized
let monetization_status = if details.minecraft_server.is_some() {
MonetizationStatus::ForceDemonetized
} else {
MonetizationStatus::Monetized
};
let project_builder = ProjectBuilder {
project_id: project_id.into(),
team_id,
organization_id: organization_id.map(From::from),
name: name.clone(),
summary: summary.clone(),
description: description.clone(),
icon_url: None,
raw_icon_url: None,
license_url: None,
categories: vec![],
additional_categories: vec![],
initial_versions: vec![],
status: ProjectStatus::Draft,
requested_status: Some(requested_status),
license: "LicenseRef-Unknown".into(),
slug: Some(slug.clone()),
link_urls: vec![],
gallery_items: vec![],
color: None,
monetization_status,
components: details,
};
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")?;
// and commit!
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(web::Json(project_id))
}
+381 -49
View File
@@ -1,3 +1,4 @@
use std::any::type_name;
use std::collections::HashMap;
use std::sync::Arc;
@@ -7,13 +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, ids as db_ids, image_item,
};
use crate::database::redis::RedisPool;
use crate::database::{self, models as db_models};
use crate::database::{PgPool, PgTransaction};
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models;
use crate::models::ids::{ProjectId, VersionId};
use crate::models::images::ImageContext;
use crate::models::notifications::NotificationBody;
@@ -24,17 +24,20 @@ use crate::models::projects::{
};
use crate::models::teams::ProjectPermissions;
use crate::models::threads::MessageBody;
use crate::models::{self, exp};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::indexing::remove_documents;
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 actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
@@ -46,38 +49,27 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("projects", web::get().to(projects_get));
cfg.route("projects", web::patch().to(projects_edit));
cfg.route("projects_random", web::get().to(random_projects_get));
}
cfg.service(
web::scope("project")
.route("{id}", web::get().to(project_get))
.route("{id}/check", web::get().to(project_get_check))
.route("{id}", web::delete().to(project_delete))
.route("{id}", web::patch().to(project_edit))
.route("{id}/icon", web::patch().to(project_icon_edit))
.route("{id}/icon", web::delete().to(delete_project_icon))
.route("{id}/gallery", web::post().to(add_gallery_item))
.route("{id}/gallery", web::patch().to(edit_gallery_item))
.route("{id}/gallery", web::delete().to(delete_gallery_item))
.route("{id}/follow", web::post().to(project_follow))
.route("{id}/follow", web::delete().to(project_unfollow))
.route("{id}/organization", web::get().to(project_get_organization))
.service(
web::scope("{project_id}")
.route(
"members",
web::get().to(super::teams::team_members_get_project),
)
.route(
"version",
web::get().to(super::versions::version_list),
)
.route(
"version/{slug}",
web::get().to(super::versions::version_project_get),
)
.route("dependencies", web::get().to(dependency_list)),
),
);
pub fn utoipa_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
) {
cfg.service(project_get)
.service(project_get_check)
.service(project_delete)
.service(project_edit)
.service(project_icon_edit)
.service(delete_project_icon)
.service(add_gallery_item)
.service(edit_gallery_item)
.service(delete_gallery_item)
.service(project_follow)
.service(project_unfollow)
.service(project_get_organization)
.service(super::teams::team_members_get_project)
.service(super::versions::version_list)
.service(super::versions::version_project_get)
.service(dependency_list);
}
#[derive(Deserialize, Validate)]
@@ -161,13 +153,25 @@ pub async fn projects_get(
Ok(HttpResponse::Ok().json(projects))
}
pub async fn project_get(
#[utoipa::path]
#[get("/{id}")]
async fn project_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
) -> Result<web::Json<Project>, ApiError> {
project_get_internal(req, info, pool, redis, session_queue).await
}
pub async fn project_get_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Project>, ApiError> {
let string = info.into_inner().0;
let project_data =
@@ -186,12 +190,12 @@ 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)
}
#[derive(Serialize, Deserialize, Validate)]
#[derive(Serialize, Deserialize, Validate, utoipa::ToSchema)]
pub struct EditProject {
#[validate(
length(min = 3, max = 64),
@@ -253,15 +257,61 @@ pub struct EditProject {
Option<SideTypesMigrationReviewStatus>,
#[serde(flatten)]
pub loader_fields: HashMap<String, serde_json::Value>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_server: Option<Option<exp::minecraft::ServerProjectEdit>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_java_server:
Option<Option<exp::minecraft::JavaServerProjectEdit>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub minecraft_bedrock_server:
Option<Option<exp::minecraft::BedrockServerProjectEdit>>,
}
#[allow(clippy::too_many_arguments)]
pub async fn project_edit(
#[utoipa::path]
#[patch("/{id}")]
async fn project_edit(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
new_project: web::Json<EditProject>,
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
) -> Result<HttpResponse, ApiError> {
project_edit_internal(
req,
info,
pool,
search_config,
web::Json(new_project),
redis,
session_queue,
moderation_queue,
)
.await
}
pub async fn project_edit_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
search_config: web::Data<SearchConfig>,
web::Json(new_project): web::Json<EditProject>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
moderation_queue: web::Data<AutomatedModerationQueue>,
@@ -280,7 +330,7 @@ pub async fn project_edit(
ApiError::Validation(validation_errors_to_string(err, None))
})?;
let Some(project_item) =
let Some(mut project_item) =
db_models::DBProject::get(&info.into_inner().0, &**pool, &redis)
.await?
else {
@@ -937,6 +987,106 @@ pub async fn project_edit(
}
}
// components
async fn update<E: exp::component::ComponentEdit>(
_txn: &mut PgTransaction<'_>,
_project_id: DBProjectId,
edit: Option<Option<E>>,
mut component: &mut Option<E::Component>,
) -> Result<(), ApiError> {
let Some(edit) = edit else {
// component is not specified in the input JSON - leave alone
return Ok(());
};
match (&mut component, edit) {
(None, None) => {}
(Some(_), None) => {
// component is `null` in the input JSON - remove component
*component = None;
}
(None, Some(edit)) => {
// component is specified in the JSON and is non-null - create new component
*component =
Some(edit.create().wrap_request_err_with(|| {
eyre!(
"failed to create `{}` component",
type_name::<E>()
)
})?);
}
(Some(component), Some(edit)) => {
// edit component
edit.apply_to(component).await.wrap_internal_err_with(
|| {
eyre!(
"failed to update `{}` component",
type_name::<E>()
)
},
)?;
}
}
Ok(())
}
update(
&mut transaction,
id,
new_project.minecraft_server,
&mut project_item.minecraft_server,
)
.await?;
update(
&mut transaction,
id,
new_project.minecraft_java_server,
&mut project_item.minecraft_java_server,
)
.await?;
update(
&mut transaction,
id,
new_project.minecraft_bedrock_server,
&mut project_item.minecraft_bedrock_server,
)
.await?;
let components_serial = exp::ProjectSerial {
minecraft_mod: None,
minecraft_server: project_item
.minecraft_server
.map(exp::component::Component::into_serial),
minecraft_java_server: project_item
.minecraft_java_server
.map(exp::component::Component::into_serial),
minecraft_bedrock_server: project_item
.minecraft_bedrock_server
.map(exp::component::Component::into_serial),
};
exp::component::kinds_valid(
&components_serial.component_kinds(),
&exp::PROJECT_COMPONENT_RELATIONS,
)
.wrap_request_err("invalid component kinds")?;
sqlx::query!(
"
UPDATE mods
SET components = $1
WHERE id = $2
",
serde_json::to_value(&components_serial)
.expect("serialization shouldn't fail"),
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("failed to update components")?;
// check new description and body for links to associated images
// if they no longer exist in the description or body, delete them
let checkable_strings: Vec<&str> =
@@ -1056,7 +1206,17 @@ pub async fn project_search(
}
//checks the validity of a project id or slug
pub async fn project_get_check(
#[utoipa::path]
#[get("/{id}/check")]
async fn project_get_check(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
project_get_check_internal(info, pool, redis).await
}
pub async fn project_get_check_internal(
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
@@ -1081,12 +1241,24 @@ pub struct DependencyInfo {
pub versions: Vec<models::projects::Version>,
}
#[utoipa::path]
#[get("/{project_id}/dependencies")]
pub async fn dependency_list(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
dependency_list_internal(req, info, pool, redis, session_queue).await
}
pub async fn dependency_list_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let string = info.into_inner().0;
@@ -1491,7 +1663,32 @@ pub struct Extension {
}
#[allow(clippy::too_many_arguments)]
pub async fn project_icon_edit(
#[utoipa::path]
#[patch("/{id}/icon")]
async fn project_icon_edit(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_icon_edit_internal(
web::Query(ext),
req,
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
pub async fn project_icon_edit_internal(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
info: web::Path<(String,)>,
@@ -1606,7 +1803,28 @@ pub async fn project_icon_edit(
Ok(HttpResponse::NoContent().body(""))
}
pub async fn delete_project_icon(
#[utoipa::path]
#[delete("/{id}/icon")]
async fn delete_project_icon(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
delete_project_icon_internal(
req,
info,
pool,
redis,
file_host,
session_queue,
)
.await
}
pub async fn delete_project_icon_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -1707,7 +1925,34 @@ pub struct GalleryCreateQuery {
}
#[allow(clippy::too_many_arguments)]
#[utoipa::path]
#[post("/{id}/gallery")]
pub async fn add_gallery_item(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
web::Query(item): web::Query<GalleryCreateQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
payload: web::Payload,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
add_gallery_item_internal(
web::Query(ext),
req,
web::Query(item),
info,
pool,
redis,
file_host,
payload,
session_queue,
)
.await
}
pub async fn add_gallery_item_internal(
web::Query(ext): web::Query<Extension>,
req: HttpRequest,
web::Query(item): web::Query<GalleryCreateQuery>,
@@ -1874,7 +2119,26 @@ pub struct GalleryEditQuery {
pub ordering: Option<i64>,
}
pub async fn edit_gallery_item(
#[utoipa::path]
#[patch("/{id}/gallery")]
async fn edit_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
edit_gallery_item_internal(
req,
web::Query(item),
pool,
redis,
session_queue,
)
.await
}
pub async fn edit_gallery_item_internal(
req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>,
pool: web::Data<PgPool>,
@@ -2040,7 +2304,28 @@ pub struct GalleryDeleteQuery {
pub url: String,
}
pub async fn delete_gallery_item(
#[utoipa::path]
#[delete("/{id}/gallery")]
async fn delete_gallery_item(
req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
delete_gallery_item_internal(
req,
web::Query(item),
pool,
redis,
file_host,
session_queue,
)
.await
}
pub async fn delete_gallery_item_internal(
req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>,
pool: web::Data<PgPool>,
@@ -2150,7 +2435,28 @@ pub async fn delete_gallery_item(
Ok(HttpResponse::NoContent().body(""))
}
pub async fn project_delete(
#[utoipa::path]
#[delete("/{id}")]
async fn project_delete(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_config: web::Data<SearchConfig>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_delete_internal(
req,
info,
pool,
redis,
search_config,
session_queue,
)
.await
}
pub async fn project_delete_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -2254,7 +2560,19 @@ pub async fn project_delete(
}
}
pub async fn project_follow(
#[utoipa::path]
#[post("/{id}/follow")]
async fn project_follow(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_follow_internal(req, info, pool, redis, session_queue).await
}
pub async fn project_follow_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -2334,7 +2652,19 @@ pub async fn project_follow(
}
}
pub async fn project_unfollow(
#[utoipa::path]
#[delete("/{id}/follow")]
async fn project_unfollow(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
project_unfollow_internal(req, info, pool, redis, session_queue).await
}
pub async fn project_unfollow_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
@@ -2410,6 +2740,8 @@ pub async fn project_unfollow(
}
}
#[utoipa::path]
#[get("/{id}/organization")]
pub async fn project_get_organization(
req: HttpRequest,
info: web::Path<(String,)>,
+15 -2
View File
@@ -12,7 +12,7 @@ use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_web::{HttpRequest, HttpResponse, get, web};
use ariadne::ids::UserId;
use eyre::eyre;
use rust_decimal::Decimal;
@@ -40,7 +40,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
// also the members of the organization's team if the project is associated with an organization
// (Unlike team_members_get_project, which only returns the members of the project's team)
// They can be differentiated by the "organization_permissions" field being null or not
pub async fn team_members_get_project(
#[utoipa::path]
#[get("/{project_id}/members")]
async fn team_members_get_project(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
team_members_get_project_internal(req, info, pool, redis, session_queue)
.await
}
pub async fn team_members_get_project_internal(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
+28 -7
View File
@@ -29,7 +29,7 @@ use crate::search::SearchConfig;
use crate::search::indexing::remove_documents;
use crate::util::img;
use crate::util::validate::validation_errors_to_string;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_web::{HttpRequest, HttpResponse, get, web};
use ariadne::ids::base62_impl::parse_base62;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
@@ -55,6 +55,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
// Given a project ID/slug and a version slug
#[utoipa::path]
#[get("/{project_id}/version/{slug}")]
pub async fn version_project_get(
req: HttpRequest,
info: web::Path<(String, String)>,
@@ -175,7 +177,7 @@ pub async fn version_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
) -> Result<web::Json<models::projects::Version>, ApiError> {
let id = info.into_inner().0;
version_get_helper(req, id, pool, redis, session_queue).await
}
@@ -186,7 +188,7 @@ pub async fn version_get_helper(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
) -> Result<web::Json<models::projects::Version>, ApiError> {
let version_data =
database::models::DBVersion::get(id.into(), &**pool, &redis).await?;
@@ -204,9 +206,7 @@ pub async fn version_get_helper(
if let Some(data) = version_data
&& is_visible_version(&data.inner, &user_option, &pool, &redis).await?
{
return Ok(
HttpResponse::Ok().json(models::projects::Version::from(data))
);
return Ok(web::Json(models::projects::Version::from(data)));
}
Err(ApiError::NotFound)
@@ -731,7 +731,28 @@ pub struct VersionListFilters {
pub include_changelog: bool,
}
pub async fn version_list(
#[utoipa::path]
#[get("/{project_id}/version")]
async fn version_list(
req: HttpRequest,
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
version_list_internal(
req,
info,
web::Query(filters),
pool,
redis,
session_queue,
)
.await
}
pub async fn version_list_internal(
req: HttpRequest,
info: web::Path<(String,)>,
web::Query(filters): web::Query<VersionListFilters>,