mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0210dd3799 | ||
|
|
0f194690df | ||
|
|
fb9b40be2b | ||
|
|
e625cc898e | ||
|
|
05255c7d51 | ||
|
|
c4f65a8721 | ||
|
|
79e7a23910 | ||
|
|
ca3771a5da | ||
|
|
95543e0f48 | ||
|
|
565838619a | ||
|
|
74019afffa | ||
|
|
855ff1db53 | ||
|
|
17f9285988 | ||
|
|
d01a0fe831 | ||
|
|
31a1a04cdf | ||
|
|
5dbdee3c31 | ||
|
|
7e4d6cf0ae | ||
|
|
103924a9ce | ||
|
|
53f12dc40e | ||
|
|
824be8ae55 | ||
|
|
f249ce4ebd | ||
|
|
669351faad | ||
|
|
fc8cb7eac2 | ||
|
|
0f7734fdca | ||
|
|
b497aa5c9d |
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -155,3 +155,4 @@ MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=none
|
||||
|
||||
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
|
||||
SERVER_PING_TIMEOUT=10000
|
||||
|
||||
@@ -166,3 +166,4 @@ MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=none
|
||||
|
||||
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
|
||||
SERVER_PING_TIMEOUT=10000
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
Generated
+26
@@ -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"
|
||||
}
|
||||
Generated
+22
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod error;
|
||||
pub mod exp;
|
||||
pub mod v2;
|
||||
pub mod v3;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,)>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user