From 521ccab383f2ac7df780932814c44e9a6b9b49c3 Mon Sep 17 00:00:00 2001 From: Jai A Date: Tue, 3 Oct 2023 18:52:21 -0700 Subject: [PATCH 01/31] search patch for accurate loader/gv filtering --- sqlx-data.json | 338 ++++++++++++++-------------- src/search/indexing/local_import.rs | 12 +- src/search/indexing/mod.rs | 6 +- src/search/mod.rs | 2 + 4 files changed, 185 insertions(+), 173 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index 46c3d051..0ffe44e4 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1797,172 +1797,6 @@ }, "query": "\n UPDATE users\n SET password = $1\n WHERE (id = $2)\n " }, - "4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "project_type", - "ordinal": 1, - "type_info": "Int4" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "downloads", - "ordinal": 4, - "type_info": "Int4" - }, - { - "name": "follows", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 6, - "type_info": "Varchar" - }, - { - "name": "published", - "ordinal": 7, - "type_info": "Timestamptz" - }, - { - "name": "approved", - "ordinal": 8, - "type_info": "Timestamptz" - }, - { - "name": "updated", - "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "team_id", - "ordinal": 10, - "type_info": "Int8" - }, - { - "name": "license", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "status_name", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "color", - "ordinal": 14, - "type_info": "Int4" - }, - { - "name": "client_side_type", - "ordinal": 15, - "type_info": "Varchar" - }, - { - "name": "server_side_type", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "project_type_name", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "categories", - "ordinal": 19, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 20, - "type_info": "VarcharArray" - }, - { - "name": "loaders", - "ordinal": 21, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 22, - "type_info": "VarcharArray" - }, - { - "name": "gallery", - "ordinal": 23, - "type_info": "VarcharArray" - }, - { - "name": "featured_gallery", - "ordinal": 24, - "type_info": "VarcharArray" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - false, - true, - false, - true, - false, - false, - false, - false, - null, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray", - "Text" - ] - } - }, - "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($1)\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE m.status = ANY($2)\n GROUP BY m.id, cs.id, ss.id, pt.id, u.id;\n " - }, "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { "describe": { "columns": [], @@ -2320,6 +2154,178 @@ }, "query": "\n SELECT c.id id, c.user_id user_id FROM collections c\n WHERE c.user_id = $2 AND c.id = ANY($1)\n " }, + "56d0e2c6e37f97d5300ca783f475a770a3d0ab84ef4297b40f56a11ebb6053cc": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "version_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "project_type", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "title", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "downloads", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "follows", + "ordinal": 6, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "published", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "approved", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "updated", + "ordinal": 10, + "type_info": "Timestamptz" + }, + { + "name": "team_id", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "license", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "status_name", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 15, + "type_info": "Int4" + }, + { + "name": "client_side_type", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "server_side_type", + "ordinal": 17, + "type_info": "Varchar" + }, + { + "name": "project_type_name", + "ordinal": 18, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 19, + "type_info": "Varchar" + }, + { + "name": "categories", + "ordinal": 20, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 21, + "type_info": "VarcharArray" + }, + { + "name": "loaders", + "ordinal": 22, + "type_info": "VarcharArray" + }, + { + "name": "versions", + "ordinal": 23, + "type_info": "VarcharArray" + }, + { + "name": "gallery", + "ordinal": 24, + "type_info": "VarcharArray" + }, + { + "name": "featured_gallery", + "ordinal": 25, + "type_info": "VarcharArray" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Text" + ] + } + }, + "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, cs.id, ss.id, pt.id, u.id;\n " + }, "5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976": { "describe": { "columns": [ diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 7c612bee..ca8b0e54 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -12,7 +12,7 @@ pub async fn index_local(pool: PgPool) -> Result, Index Ok( sqlx::query!( " - SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, + SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.published published, m.approved approved, m.updated updated, m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color, cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username, @@ -22,10 +22,10 @@ pub async fn index_local(pool: PgPool) -> Result, Index ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery - FROM mods m + FROM versions v + INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2) LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id - LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($1) LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id @@ -36,8 +36,8 @@ pub async fn index_local(pool: PgPool) -> Result, Index INNER JOIN side_types ss ON m.server_side = ss.id INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE INNER JOIN users u ON tm.user_id = u.id - WHERE m.status = ANY($2) - GROUP BY m.id, cs.id, ss.id, pt.id, u.id; + WHERE v.status != ANY($1) + GROUP BY v.id, m.id, cs.id, ss.id, pt.id, u.id; ", &*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::>(), &*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_searchable()).map(|x| x.to_string()).collect::>(), @@ -57,6 +57,7 @@ pub async fn index_local(pool: PgPool) -> Result, Index let versions = m.versions.unwrap_or_default(); let project_id: crate::models::projects::ProjectId = ProjectId(m.id).into(); + let version_id: crate::models::projects::ProjectId = ProjectId(m.version_id).into(); let license = match m.license.split(' ').next() { Some(license) => license.to_string(), @@ -69,6 +70,7 @@ pub async fn index_local(pool: PgPool) -> Result, Index }; UploadSearchProject { + version_id: version_id.to_string(), project_id: project_id.to_string(), title: m.title, description: m.description, diff --git a/src/search/indexing/mod.rs b/src/search/indexing/mod.rs index d0cd58d7..bcb5abd5 100644 --- a/src/search/indexing/mod.rs +++ b/src/search/indexing/mod.rs @@ -69,7 +69,7 @@ async fn create_index( }, )) => { // Only create index and set settings if the index doesn't already exist - let task = client.create_index(name, Some("project_id")).await?; + let task = client.create_index(name, Some("version_id")).await?; let task = task.wait_for_completion(client, None, None).await?; let index = task .try_make_index(client) @@ -103,7 +103,7 @@ async fn add_to_index( ) -> Result<(), IndexingError> { for chunk in mods.chunks(MEILISEARCH_CHUNK_SIZE) { index - .add_documents(chunk, Some("project_id")) + .add_documents(chunk, Some("version_id")) .await? .wait_for_completion(client, None, None) .await?; @@ -150,6 +150,7 @@ pub async fn add_projects( fn default_settings() -> Settings { Settings::new() + .with_distinct_attribute("project_id") .with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES) .with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES) .with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES) @@ -161,6 +162,7 @@ fn default_settings() -> Settings { const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ "project_id", + "version_id", "project_type", "slug", "author", diff --git a/src/search/mod.rs b/src/search/mod.rs index af8155a1..84dcc1c7 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -71,6 +71,7 @@ impl SearchConfig { /// This contains some extra data that is not returned by search results. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct UploadSearchProject { + pub version_id: String, pub project_id: String, pub project_type: String, pub slug: Option, @@ -111,6 +112,7 @@ pub struct SearchResults { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResultSearchProject { + pub version_id: String, pub project_id: String, pub project_type: String, pub slug: Option, From 96f8e78223bae85901a3a10a78cf4d33fb4fda8b Mon Sep 17 00:00:00 2001 From: Jai A Date: Fri, 6 Oct 2023 17:44:11 -0700 Subject: [PATCH 02/31] backup --- migrations/20231005230721_dynamic-fields.sql | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 migrations/20231005230721_dynamic-fields.sql diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql new file mode 100644 index 00000000..d85bea57 --- /dev/null +++ b/migrations/20231005230721_dynamic-fields.sql @@ -0,0 +1,31 @@ +CREATE TABLE loader_fields ( + id serial PRIMARY KEY, + loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, + field varchar(64) NOT NULL, + field_type varchar(64) NOT NULL, + enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, + optional BOOLEAN NOT NULL DEFAULT true, + -- for int- min/max val, for text- min len, for enum- min items, for bool- nth + min_val integer NULL, + max_val integer NULL +); + +CREATE TABLE loader_field_enums ( + id serial PRIMARY KEY, + enum_name varchar(64) NOT NULL, + ordering int NULL, + hidable BOOLEAN NOT NULL DEFAULT FALSE, + metadata +); + +ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; + +CREATE TABLE version_fields ( + id serial PRIMARY KEY, + version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + field_id integer REFERENCES loader_fields ON UPDATE CASCADE NOT NULL, + -- for int/bool values + int_value integer NULL, + enum_value integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, + string_value text NULL +); \ No newline at end of file From 81834ef7e20088f3e88ff140cf042287356d8189 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 16 Oct 2023 23:29:28 -0700 Subject: [PATCH 03/31] basic search test --- migrations/20231005230721_dynamic-fields.sql | 26 ++- src/routes/v2/admin.rs | 14 +- tests/common/api_v2.rs | 1 + tests/common/database.rs | 2 +- tests/common/dummy_data.rs | 142 ++++++++++-- tests/common/environment.rs | 1 + tests/common/request_data.rs | 41 ++-- tests/search.rs | 216 +++++++++++++++++++ tests/user.rs | 6 +- 9 files changed, 408 insertions(+), 41 deletions(-) create mode 100644 tests/search.rs diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index d85bea57..3887ed74 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -1,23 +1,26 @@ +CREATE TABLE loader_field_enums ( + id serial PRIMARY KEY, + enum_name varchar(64) NOT NULL, + ordering int NULL, + hidable BOOLEAN NOT NULL DEFAULT FALSE, + metadata varchar(128) +); + CREATE TABLE loader_fields ( id serial PRIMARY KEY, loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, field varchar(64) NOT NULL, + -- "int", "text", "enum", "bool", + -- "array(int)", "array(text)", "array(enum)", "array(bool)" field_type varchar(64) NOT NULL, + -- only for enum enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, optional BOOLEAN NOT NULL DEFAULT true, - -- for int- min/max val, for text- min len, for enum- min items, for bool- nth + -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing min_val integer NULL, max_val integer NULL ); -CREATE TABLE loader_field_enums ( - id serial PRIMARY KEY, - enum_name varchar(64) NOT NULL, - ordering int NULL, - hidable BOOLEAN NOT NULL DEFAULT FALSE, - metadata -); - ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; CREATE TABLE version_fields ( @@ -28,4 +31,7 @@ CREATE TABLE version_fields ( int_value integer NULL, enum_value integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, string_value text NULL -); \ No newline at end of file +); + +-- DROP TABLE side_types; +-- DROP TABLE game_versions; diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index cc5bd9e6..a8889277 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -10,6 +10,7 @@ use crate::queue::download::DownloadQueue; use crate::queue::maxmind::MaxMindIndexer; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::search::SearchConfig; use crate::util::date::get_current_tenths_of_ms; use crate::util::guards::admin_key_guard; use crate::util::routes::read_from_payload; @@ -28,7 +29,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("admin") .service(count_download) - .service(trolley_webhook), + .service(trolley_webhook) + .service(force_reindex) ); } @@ -319,3 +321,13 @@ pub async fn trolley_webhook( Ok(HttpResponse::NoContent().finish()) } + +#[post("/_force_reindex")] +pub async fn force_reindex( + pool: web::Data, + config: web::Data, +) -> Result { + use crate::search::indexing::index_projects; + index_projects(pool.as_ref().clone(), &config).await?; + Ok(HttpResponse::NoContent().finish()) +} \ No newline at end of file diff --git a/tests/common/api_v2.rs b/tests/common/api_v2.rs index 3bd98576..d182a758 100644 --- a/tests/common/api_v2.rs +++ b/tests/common/api_v2.rs @@ -19,6 +19,7 @@ use labrinth::models::{ use serde_json::json; use std::rc::Rc; +#[derive(Clone)] pub struct ApiV2 { pub test_app: Rc>, } diff --git a/tests/common/database.rs b/tests/common/database.rs index 483a44d9..40c778f4 100644 --- a/tests/common/database.rs +++ b/tests/common/database.rs @@ -70,7 +70,7 @@ impl TemporaryDatabase { let pool = PgPoolOptions::new() .min_connections(0) - .max_connections(4) + .max_connections(16) .max_lifetime(Some(Duration::from_secs(60 * 60))) .connect(&temp_db_url) .await diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index e66a88bc..ccc0a69f 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -1,7 +1,10 @@ +use std::io::{Write, Cursor}; + use actix_web::test::{self, TestRequest}; use labrinth::{models::projects::Project, models::projects::Version}; use serde_json::json; use sqlx::Executor; +use zip::{write::FileOptions, ZipWriter, CompressionMethod}; use crate::common::{actix::AppendsMultipart, database::USER_USER_PAT}; @@ -23,13 +26,110 @@ pub const DUMMY_CATEGORIES: &'static [&str] = &[ ]; #[allow(dead_code)] -pub enum DummyJarFile { +pub enum TestFile { DummyProjectAlpha, DummyProjectBeta, BasicMod, BasicModDifferent, + // Randomly generates a valid .jar with a random hash. + // Unlike the other dummy jar files, this one is not a static file. + // and BasicModRandom.bytes() will return a different file each time. + BasicModRandom { + filename: String, + bytes: Vec, + }, + BasicModpackRandom { + filename: String, + bytes: Vec, + }, } +impl TestFile { + pub fn build_random_jar() -> Self { + let filename = format!("random-mod-{}.jar", rand::random::()); + + let fabric_mod_json = serde_json::json!({ + "schemaVersion": 1, + "id": filename, + "version": "1.0.1", + + "name": filename, + "description": "Does nothing", + "authors": [ + "user" + ], + "contact": { + "homepage": "https://www.modrinth.com", + "sources": "https://www.modrinth.com", + "issues": "https://www.modrinth.com" + }, + + "license": "MIT", + "icon": "none.png", + + "environment": "client", + "entrypoints": { + "main": [ + "io.github.modrinth.Modrinth" + ] + }, + "depends": { + "minecraft": ">=1.20-" + } + } + ).to_string(); + + // Create a simulated zip file + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file("fabric.mod.json", FileOptions::default().compression_method(CompressionMethod::Stored)).unwrap(); + zip.write_all(fabric_mod_json.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + let bytes = cursor.into_inner(); + + TestFile::BasicModRandom { + filename, + bytes, + } + } + + pub fn build_random_mrpack() -> Self { + let filename = format!("random-modpack-{}.mrpack", rand::random::()); + + let modrinth_index_json = serde_json::json!({ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.20.1-9.6", + "name": filename, + "files": [], + "dependencies": { + "fabric-loader": "0.14.22", + "minecraft": "1.20.1" + } + } + ).to_string(); + + // Create a simulated zip file + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file("modrinth.index.json", FileOptions::default().compression_method(CompressionMethod::Stored)).unwrap(); + zip.write_all(modrinth_index_json.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + let bytes = cursor.into_inner(); + + TestFile::BasicModpackRandom { + filename, + bytes, + } + } + +} + +#[derive(Clone)] pub struct DummyData { pub alpha_team_id: String, pub beta_team_id: String, @@ -86,7 +186,7 @@ pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) .v2 .add_public_project(get_public_project_creation_data( "alpha", - DummyJarFile::DummyProjectAlpha, + TestFile::DummyProjectAlpha, )) .await } @@ -94,7 +194,7 @@ pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version) { // Adds dummy data to the database with sqlx (projects, versions, threads) // Generate test project data. - let jar = DummyJarFile::DummyProjectBeta; + let jar = TestFile::DummyProjectBeta; let json_data = json!( { "title": "Test Project Beta", @@ -168,29 +268,45 @@ pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version) (project, version) } -impl DummyJarFile { +impl TestFile { pub fn filename(&self) -> String { match self { - DummyJarFile::DummyProjectAlpha => "dummy-project-alpha.jar", - DummyJarFile::DummyProjectBeta => "dummy-project-beta.jar", - DummyJarFile::BasicMod => "basic-mod.jar", - DummyJarFile::BasicModDifferent => "basic-mod-different.jar", + TestFile::DummyProjectAlpha => "dummy-project-alpha.jar", + TestFile::DummyProjectBeta => "dummy-project-beta.jar", + TestFile::BasicMod => "basic-mod.jar", + TestFile::BasicModDifferent => "basic-mod-different.jar", + TestFile::BasicModRandom { filename, .. } => filename, + TestFile::BasicModpackRandom { filename, .. } => filename, } .to_string() } pub fn bytes(&self) -> Vec { match self { - DummyJarFile::DummyProjectAlpha => { + TestFile::DummyProjectAlpha => { include_bytes!("../../tests/files/dummy-project-alpha.jar").to_vec() } - DummyJarFile::DummyProjectBeta => { + TestFile::DummyProjectBeta => { include_bytes!("../../tests/files/dummy-project-beta.jar").to_vec() } - DummyJarFile::BasicMod => include_bytes!("../../tests/files/basic-mod.jar").to_vec(), - DummyJarFile::BasicModDifferent => { + TestFile::BasicMod => include_bytes!("../../tests/files/basic-mod.jar").to_vec(), + TestFile::BasicModDifferent => { include_bytes!("../../tests/files/basic-mod-different.jar").to_vec() - } + }, + TestFile::BasicModRandom { bytes, .. } => bytes.clone(), + TestFile::BasicModpackRandom { bytes, .. } => bytes.clone(), } } + + pub fn project_type(&self) -> String { + match self { + TestFile::DummyProjectAlpha => "mod", + TestFile::DummyProjectBeta => "mod", + TestFile::BasicMod => "mod", + TestFile::BasicModDifferent => "mod", + TestFile::BasicModRandom { .. } => "mod", + + TestFile::BasicModpackRandom { .. } => "modpack", + }.to_string() + } } diff --git a/tests/common/environment.rs b/tests/common/environment.rs index e3aa2ca9..a1a242e7 100644 --- a/tests/common/environment.rs +++ b/tests/common/environment.rs @@ -29,6 +29,7 @@ where // Must be called in an #[actix_rt::test] context. It also simulates a // temporary sqlx db like #[sqlx::test] would. // Use .call(req) on it directly to make a test call as if test::call_service(req) were being used. +#[derive(Clone)] pub struct TestEnvironment { test_app: Rc>, pub db: TemporaryDatabase, diff --git a/tests/common/request_data.rs b/tests/common/request_data.rs index 85dce64b..d0342b86 100644 --- a/tests/common/request_data.rs +++ b/tests/common/request_data.rs @@ -1,22 +1,36 @@ use serde_json::json; -use super::{actix::MultipartSegment, dummy_data::DummyJarFile}; +use super::{actix::MultipartSegment, dummy_data::TestFile}; use crate::common::actix::MultipartSegmentData; pub struct ProjectCreationRequestData { pub slug: String, - pub jar: DummyJarFile, + pub jar: TestFile, pub segment_data: Vec, } pub fn get_public_project_creation_data( slug: &str, - jar: DummyJarFile, + jar: TestFile, ) -> ProjectCreationRequestData { - let json_data = json!( + let json_data = get_public_project_creation_data_json(slug, &jar); + let multipart_data = get_public_project_creation_data_multipart(&json_data, &jar); + ProjectCreationRequestData { + slug: slug.to_string(), + jar, + segment_data: multipart_data, + } +} + +pub fn get_public_project_creation_data_json( + slug: &str, + jar: &TestFile, +) -> serde_json::Value { + json!( { "title": format!("Test Project {slug}"), "slug": slug, + "project_type": jar.project_type(), "description": "A dummy project for testing with.", "body": "This project is approved, and versions are listed.", "client_side": "required", @@ -32,16 +46,21 @@ pub fn get_public_project_creation_data( "featured": true }], "categories": [], - "license_id": "MIT" + "license_id": "MIT", } - ); + ) +} +pub fn get_public_project_creation_data_multipart( + json_data: &serde_json::Value, + jar: &TestFile, +) -> Vec { // Basic json let json_segment = MultipartSegment { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + data: MultipartSegmentData::Text(serde_json::to_string(json_data).unwrap()), }; // Basic file @@ -52,9 +71,5 @@ pub fn get_public_project_creation_data( data: MultipartSegmentData::Binary(jar.bytes()), }; - ProjectCreationRequestData { - slug: slug.to_string(), - jar, - segment_data: vec![json_segment.clone(), file_segment.clone()], - } -} + vec![json_segment, file_segment] +} \ No newline at end of file diff --git a/tests/search.rs b/tests/search.rs new file mode 100644 index 00000000..07615a26 --- /dev/null +++ b/tests/search.rs @@ -0,0 +1,216 @@ +use std::collections::HashMap; +use std::sync::Arc; +use actix_web::test; +use common::dummy_data::TestFile; +use common::request_data; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::projects::Project; +use labrinth::search::SearchResults; +use serde_json::json; +use futures::stream::StreamExt; +use crate::common::database::*; +use crate::common::dummy_data::DUMMY_CATEGORIES; +use crate::common::{actix::AppendsMultipart, environment::TestEnvironment}; + +// importing common module. +mod common; + +#[actix_rt::test] +async fn search_projects() { + // Test setup and dummy data + let test_env = TestEnvironment::build_with_dummy().await; + let test_name = test_env.db.database_name.clone(); + // Add dummy projects of various categories for searchability + let mut project_creation_futures = vec![]; + + let create_async_future = |id: u64, pat: &'static str, is_modpack : bool, modify_json : Box| { + let test_env = test_env.clone(); + let slug = format!("{test_name}-searchable-project-{id}"); + + let jar = if is_modpack { + TestFile::build_random_mrpack() + } else { + TestFile::build_random_jar() + }; + let mut basic_project_json = request_data::get_public_project_creation_data_json(&slug, &jar); + modify_json(&mut basic_project_json); + + let basic_project_multipart = + request_data::get_public_project_creation_data_multipart(&basic_project_json, &jar); + // Add a project- simple, should work. + let req = test::TestRequest::post() + .uri("/v2/project") + .append_header(("Authorization", pat)) + .set_multipart(basic_project_multipart) + .to_request(); + + async move { + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 200); + + let project : Project = test::read_body_json(resp).await; + + // Approve, so that the project is searchable + let req = test::TestRequest::patch() + .uri(&format!("/v2/project/{project_id}", project_id = project.id)) + .append_header(("Authorization", MOD_USER_PAT)) + .set_json(json!({ + "status": "approved" + })) + .to_request(); + + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 204); + (project.id.0, id) + }}; + + let id = 0; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[4..6]); + json["server_side"] = json!("required"); + json["license_id"] = json!("LGPL-3.0-or-later"); + }; + project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + + let id = 1; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[0..2]); + json["client_side"] = json!("optional"); + }; + project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + + let id = 2; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[0..2]); + json["server_side"] = json!("required"); + json["title"] = json!("Mysterious Project"); + }; + project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + + let id = 3; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[0..3]); + json["server_side"] = json!("required"); + json["initial_versions"][0]["version_number"] = json!("1.2.4"); + json["title"] = json!("Mysterious Project"); + json["license_id"] = json!("LicenseRef-All-Rights-Reserved"); // closed source + }; + project_creation_futures.push(create_async_future(id, FRIEND_USER_PAT, false, Box::new(modify_json))); + + let id = 4; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[0..3]); + json["client_side"] = json!("optional"); + json["initial_versions"][0]["version_number"] = json!("1.2.5"); + }; + project_creation_futures.push(create_async_future(id, USER_USER_PAT, true, Box::new(modify_json))); + + let id = 5; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[5..6]); + json["client_side"] = json!("optional"); + json["initial_versions"][0]["version_number"] = json!("1.2.5"); + json["license_id"] = json!("LGPL-3.0-or-later"); + }; + project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + + let id = 6; + let modify_json = | json : &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[5..6]); + json["client_side"] = json!("optional"); + json["server_side"] = json!("required"); + json["license_id"] = json!("LGPL-3.0-or-later"); + }; + project_creation_futures.push(create_async_future(id, FRIEND_USER_PAT, false, Box::new(modify_json))); + + // Await all project creation + // Returns a mapping of: + // project id -> test id + let id_conversion : Arc> = Arc::new(futures::future::join_all(project_creation_futures).await.into_iter().collect()); + + // Pairs of: + // 1. vec of search facets + // 2. expected project ids to be returned by this search + let pairs = vec![ + (json!([ + ["categories:fabric"] + ]), vec![0,1,2,3,4,5,6 + ]), + (json!([ + ["categories:forge"] + ]), vec![]), + (json!([ + ["categories:fabric", "categories:forge"] + ]), vec![0,1,2,3,4,5,6]), + (json!([ + ["categories:fabric"], + ["categories:forge"] + ]), vec![]), + (json!([ + ["categories:fabric"], + [&format!("categories:{}", DUMMY_CATEGORIES[0])], + ]), vec![1,2,3,4]), + (json!([ + ["project_type:modpack"] + ]), vec![4]), + (json!([ + ["client_side:required"] + ]), vec![0,2,3]), + (json!([ + ["server_side:required"] + ]), vec![0,2,3,6]), + (json!([ + ["open_source:true"] + ]), vec![0,1,2,4,5,6]), + (json!([ + ["license:MIT"] + ]), vec![1,2,4]), + (json!([ + [r#"title:'Mysterious Project'"#] + ]), vec![2,3]), + (json!([ + ["author:user"] + ]), vec![0,1,2,4,5]) + ]; + // TODO: versions, game versions + + // Untested: + // - downloads (not varied) + // - color (not varied) + // - created_timestamp (not varied) + // - modified_timestamp (not varied) + + // Forcibly reset the search index + let req = test::TestRequest::post() + .uri("/v2/admin/_force_reindex") + .append_header(("Modrinth-Admin", dotenvy::var("LABRINTH_ADMIN_KEY").unwrap())) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!(resp.status(), 204); + + // Test searches + let stream = futures::stream::iter(pairs); + stream.for_each_concurrent(10, |(facets, mut expected_project_ids)| { + let test_env = test_env.clone(); + let id_conversion = id_conversion.clone(); + let test_name = test_name.clone(); + async move { + let req = test::TestRequest::get() + .uri(&format!("/v2/search?query={test_name}&facets={facets}", facets=urlencoding::encode(&facets.to_string()))) + .append_header(("Authorization", USER_USER_PAT)) + .set_json(&facets) + .to_request(); + let resp = test_env.call(req).await; + let status = resp.status(); + assert_eq!(status, 200); + let projects : SearchResults = test::read_body_json(resp).await; + let mut found_project_ids : Vec = projects.hits.into_iter().map(|p| id_conversion[&parse_base62(&p.project_id).unwrap()]).collect(); + expected_project_ids.sort(); + found_project_ids.sort(); + assert_eq!(found_project_ids, expected_project_ids); + } + }).await; + + // Cleanup test db + test_env.cleanup().await; +} \ No newline at end of file diff --git a/tests/user.rs b/tests/user.rs index efb1f920..930f4d11 100644 --- a/tests/user.rs +++ b/tests/user.rs @@ -3,7 +3,7 @@ use common::{ environment::with_test_environment, }; -use crate::common::{dummy_data::DummyJarFile, request_data::get_public_project_creation_data}; +use crate::common::{dummy_data::TestFile, request_data::get_public_project_creation_data}; mod common; @@ -17,7 +17,7 @@ pub async fn get_user_projects_after_creating_project_returns_new_project() { let (project, _) = api .add_public_project(get_public_project_creation_data( "slug", - DummyJarFile::BasicMod, + TestFile::BasicMod, )) .await; @@ -36,7 +36,7 @@ pub async fn get_user_projects_after_deleting_project_shows_removal() { let (project, _) = api .add_public_project(get_public_project_creation_data( "iota", - DummyJarFile::BasicMod, + TestFile::BasicMod, )) .await; api.get_user_projects_deserialized(USER_USER_ID, USER_USER_PAT) From 61488b9afc2d12d6a505aa65b5f6a31643649276 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 17 Oct 2023 09:34:13 -0700 Subject: [PATCH 04/31] finished test --- tests/common/api_v2/mod.rs | 8 ++++ tests/common/api_v2/project.rs | 26 +++++++++++- tests/common/dummy_data.rs | 16 ++++++- tests/common/request_data.rs | 40 +++++++----------- tests/files/dummy_data.sql | 8 ++++ tests/search.rs | 76 ++++++++++++++-------------------- 6 files changed, 104 insertions(+), 70 deletions(-) diff --git a/tests/common/api_v2/mod.rs b/tests/common/api_v2/mod.rs index 2ecc144e..1257dcc3 100644 --- a/tests/common/api_v2/mod.rs +++ b/tests/common/api_v2/mod.rs @@ -17,4 +17,12 @@ impl ApiV2 { pub async fn call(&self, req: actix_http::Request) -> ServiceResponse { self.test_app.call(req).await.unwrap() } + + pub async fn reset_search_index(&self) -> ServiceResponse { + let req = actix_web::test::TestRequest::post() + .uri("/v2/admin/_force_reindex") + .append_header(("Modrinth-Admin", dotenvy::var("LABRINTH_ADMIN_KEY").unwrap())) + .to_request(); + self.call(req).await + } } diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 0ca69bca..2a014767 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -4,7 +4,7 @@ use actix_web::{ test::{self, TestRequest}, }; use bytes::Bytes; -use labrinth::models::projects::{Project, Version}; +use labrinth::{models::projects::{Project, Version}, search::SearchResults}; use serde_json::json; use crate::common::{ @@ -186,5 +186,29 @@ impl ApiV2 { self.call(req).await } } + + pub async fn search_deserialized(&self, query : Option<&str>, facets : Option, pat : &str) -> SearchResults { + + let query_field = if let Some(query) = query { + format!("&query={}", urlencoding::encode(query)) + } else { + "".to_string() + }; + + let facets_field = if let Some(facets) = facets { + format!("&facets={}", urlencoding::encode(&facets.to_string())) + } else { + "".to_string() + }; + + let req = test::TestRequest::get() + .uri(&format!("/v2/search?{}{}", query_field, facets_field)) + .append_header(("Authorization", pat)) + .to_request(); + let resp = self.call(req).await; + let status = resp.status(); + assert_eq!(status, 200); + test::read_body_json(resp).await + } } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index 27416899..ae1b4aa2 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -408,7 +408,7 @@ pub async fn get_organization_zeta(test_env: &TestEnvironment) -> Organization { organization } -impl DummyJarFile { +impl TestFile { pub fn filename(&self) -> String { match self { TestFile::DummyProjectAlpha => "dummy-project-alpha.jar", @@ -433,8 +433,22 @@ impl DummyJarFile { TestFile::BasicModDifferent => { include_bytes!("../../tests/files/basic-mod-different.jar").to_vec() } + TestFile::BasicModRandom { bytes, .. } => bytes.clone(), + TestFile::BasicModpackRandom { bytes, .. } => bytes.clone(), } } + + pub fn project_type(&self) -> String { + match self { + TestFile::DummyProjectAlpha => "mod", + TestFile::DummyProjectBeta => "mod", + TestFile::BasicMod => "mod", + TestFile::BasicModDifferent => "mod", + TestFile::BasicModRandom { .. } => "mod", + + TestFile::BasicModpackRandom { .. } => "modpack" + }.to_string() + } } impl DummyImage { diff --git a/tests/common/request_data.rs b/tests/common/request_data.rs index 5bab6f0d..6e795a47 100644 --- a/tests/common/request_data.rs +++ b/tests/common/request_data.rs @@ -23,6 +23,19 @@ pub fn get_public_project_creation_data( slug: &str, version_jar: Option, ) -> ProjectCreationRequestData { + let json_data = get_public_project_creation_data_json(slug, version_jar.as_ref()); + let multipart_data = get_public_project_creation_data_multipart(&json_data, version_jar.as_ref()); + ProjectCreationRequestData { + slug: slug.to_string(), + jar: version_jar, + segment_data: multipart_data, + } +} + +pub fn get_public_project_creation_data_json( + slug: &str, + version_jar: Option<&TestFile>, +) -> serde_json::Value { let initial_versions = if let Some(ref jar) = version_jar { json!([{ "file_parts": [jar.filename()], @@ -40,24 +53,11 @@ pub fn get_public_project_creation_data( let is_draft = version_jar.is_none(); - let json_data = get_public_project_creation_data_json(slug, &jar); - let multipart_data = get_public_project_creation_data_multipart(&json_data, &jar); - ProjectCreationRequestData { - slug: slug.to_string(), - jar, - segment_data: multipart_data, - } -} - -pub fn get_public_project_creation_data_json( - slug: &str, - jar: &TestFile, -) -> serde_json::Value { json!( { "title": format!("Test Project {slug}"), "slug": slug, - "project_type": jar.project_type(), + "project_type": version_jar.as_ref().map(|f| f.project_type()).unwrap_or("mod".to_string()), "description": "A dummy project for testing with.", "body": "This project is approved, and versions are listed.", "client_side": "required", @@ -72,7 +72,7 @@ pub fn get_public_project_creation_data_json( pub fn get_public_project_creation_data_multipart( json_data: &serde_json::Value, - jar: &TestFile, + version_jar: Option<&TestFile>, ) -> Vec { // Basic json let json_segment = MultipartSegment { @@ -82,7 +82,7 @@ pub fn get_public_project_creation_data_multipart( data: MultipartSegmentData::Text(serde_json::to_string(json_data).unwrap()), }; - let segment_data = if let Some(ref jar) = version_jar { + if let Some(ref jar) = version_jar { // Basic file let file_segment = MultipartSegment { name: jar.filename(), @@ -94,14 +94,6 @@ pub fn get_public_project_creation_data_multipart( vec![json_segment.clone(), file_segment] } else { vec![json_segment.clone()] - }; - - vec![json_segment, file_segment] -} - ProjectCreationRequestData { - slug: slug.to_string(), - jar: version_jar, - segment_data, } } diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 487397a5..334baebe 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -22,6 +22,14 @@ INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, -- -- Sample game versions, loaders, categories INSERT INTO game_versions (id, version, type, created) VALUES (20000, '1.20.1', 'release', timezone('utc', now())); +INSERT INTO game_versions (id, version, type, created) +VALUES (20001, '1.20.2', 'release', timezone('utc', now())); +INSERT INTO game_versions (id, version, type, created) +VALUES (20002, '1.20.3', 'release', timezone('utc', now())); +INSERT INTO game_versions (id, version, type, created) +VALUES (20003, '1.20.4', 'release', timezone('utc', now())); +INSERT INTO game_versions (id, version, type, created) +VALUES (20004, '1.20.5', 'release', timezone('utc', now())); INSERT INTO loaders (id, loader) VALUES (1, 'fabric'); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,1); diff --git a/tests/search.rs b/tests/search.rs index 07615a26..f0dfa114 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -10,6 +10,7 @@ use serde_json::json; use futures::stream::StreamExt; use crate::common::database::*; use crate::common::dummy_data::DUMMY_CATEGORIES; +use crate::common::request_data::ProjectCreationRequestData; use crate::common::{actix::AppendsMultipart, environment::TestEnvironment}; // importing common module. @@ -18,13 +19,14 @@ mod common; #[actix_rt::test] async fn search_projects() { // Test setup and dummy data - let test_env = TestEnvironment::build_with_dummy().await; + let test_env = TestEnvironment::build(Some(8)).await; + let api = &test_env.v2; let test_name = test_env.db.database_name.clone(); + // Add dummy projects of various categories for searchability let mut project_creation_futures = vec![]; let create_async_future = |id: u64, pat: &'static str, is_modpack : bool, modify_json : Box| { - let test_env = test_env.clone(); let slug = format!("{test_name}-searchable-project-{id}"); let jar = if is_modpack { @@ -32,38 +34,29 @@ async fn search_projects() { } else { TestFile::build_random_jar() }; - let mut basic_project_json = request_data::get_public_project_creation_data_json(&slug, &jar); + let mut basic_project_json = request_data::get_public_project_creation_data_json(&slug, Some(&jar)); modify_json(&mut basic_project_json); let basic_project_multipart = - request_data::get_public_project_creation_data_multipart(&basic_project_json, &jar); + request_data::get_public_project_creation_data_multipart(&basic_project_json, Some(&jar)); // Add a project- simple, should work. - let req = test::TestRequest::post() - .uri("/v2/project") - .append_header(("Authorization", pat)) - .set_multipart(basic_project_multipart) - .to_request(); - + let req = api.add_public_project(ProjectCreationRequestData { + slug: slug.clone(), + jar: Some(jar), + segment_data: basic_project_multipart.clone(), + }, pat); async move { - let resp = test_env.call(req).await; - assert_eq!(resp.status(), 200); - - let project : Project = test::read_body_json(resp).await; + let (project, _) = req.await; // Approve, so that the project is searchable - let req = test::TestRequest::patch() - .uri(&format!("/v2/project/{project_id}", project_id = project.id)) - .append_header(("Authorization", MOD_USER_PAT)) - .set_json(json!({ - "status": "approved" - })) - .to_request(); - - let resp = test_env.call(req).await; + let resp = api.edit_project(&project.id.to_string(), json!({ + "status": "approved" + }), MOD_USER_PAT).await; assert_eq!(resp.status(), 204); (project.id.0, id) }}; + // Test project 0 let id = 0; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[4..6]); @@ -72,6 +65,7 @@ async fn search_projects() { }; project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + // Test project 1 let id = 1; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..2]); @@ -79,6 +73,7 @@ async fn search_projects() { }; project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + // Test project 2 let id = 2; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..2]); @@ -87,33 +82,37 @@ async fn search_projects() { }; project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + // Test project 3 let id = 3; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..3]); json["server_side"] = json!("required"); - json["initial_versions"][0]["version_number"] = json!("1.2.4"); + json["initial_versions"][0]["game_versions"] = json!(["1.20.4"]); json["title"] = json!("Mysterious Project"); json["license_id"] = json!("LicenseRef-All-Rights-Reserved"); // closed source }; project_creation_futures.push(create_async_future(id, FRIEND_USER_PAT, false, Box::new(modify_json))); + // Test project 4 let id = 4; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..3]); json["client_side"] = json!("optional"); - json["initial_versions"][0]["version_number"] = json!("1.2.5"); + json["initial_versions"][0]["game_versions"] = json!(["1.20.5"]); }; project_creation_futures.push(create_async_future(id, USER_USER_PAT, true, Box::new(modify_json))); - + + // Test project 5 let id = 5; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[5..6]); json["client_side"] = json!("optional"); - json["initial_versions"][0]["version_number"] = json!("1.2.5"); + json["initial_versions"][0]["game_versions"] = json!(["1.20.5"]); json["license_id"] = json!("LGPL-3.0-or-later"); }; project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + // Test project 6 let id = 6; let modify_json = | json : &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[5..6]); @@ -170,10 +169,12 @@ async fn search_projects() { ]), vec![2,3]), (json!([ ["author:user"] - ]), vec![0,1,2,4,5]) + ]), vec![0,1,2,4,5]), + (json!([ + ["versions:1.20.5"] + ]), vec![4,5]), ]; // TODO: versions, game versions - // Untested: // - downloads (not varied) // - color (not varied) @@ -181,29 +182,16 @@ async fn search_projects() { // - modified_timestamp (not varied) // Forcibly reset the search index - let req = test::TestRequest::post() - .uri("/v2/admin/_force_reindex") - .append_header(("Modrinth-Admin", dotenvy::var("LABRINTH_ADMIN_KEY").unwrap())) - .to_request(); - let resp = test_env.call(req).await; + let resp = api.reset_search_index().await; assert_eq!(resp.status(), 204); // Test searches let stream = futures::stream::iter(pairs); stream.for_each_concurrent(10, |(facets, mut expected_project_ids)| { - let test_env = test_env.clone(); let id_conversion = id_conversion.clone(); let test_name = test_name.clone(); async move { - let req = test::TestRequest::get() - .uri(&format!("/v2/search?query={test_name}&facets={facets}", facets=urlencoding::encode(&facets.to_string()))) - .append_header(("Authorization", USER_USER_PAT)) - .set_json(&facets) - .to_request(); - let resp = test_env.call(req).await; - let status = resp.status(); - assert_eq!(status, 200); - let projects : SearchResults = test::read_body_json(resp).await; + let projects = api.search_deserialized(Some(&test_name), Some(facets), USER_USER_PAT).await; let mut found_project_ids : Vec = projects.hits.into_iter().map(|p| id_conversion[&parse_base62(&p.project_id).unwrap()]).collect(); expected_project_ids.sort(); found_project_ids.sort(); From 39a7dac9ed2ab8e6a0b9aec51c0ba41c85bd3553 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 18 Oct 2023 19:26:13 -0700 Subject: [PATCH 05/31] incomplete commit; backing up --- migrations/20231005230721_dynamic-fields.sql | 113 ++- src/database/models/categories.rs | 285 +----- src/database/models/ids.rs | 16 + src/database/models/loader_fields.rs | 615 ++++++++++++ src/database/models/mod.rs | 3 + src/database/models/project_item.rs | 73 +- src/database/models/version_item.rs | 133 +-- src/lib.rs | 2 +- src/models/projects.rs | 13 - src/routes/maven.rs | 7 +- src/routes/mod.rs | 6 + src/routes/updates.rs | 34 +- src/routes/v2/collections.rs | 3 +- src/routes/v2/organizations.rs | 2 +- src/routes/v2/project_creation.rs | 979 +------------------ src/routes/v2/projects.rs | 792 +-------------- src/routes/v2/tags.rs | 59 +- src/routes/v2/version_creation.rs | 853 +--------------- src/routes/v2/version_file.rs | 220 +---- src/routes/v2/versions.rs | 505 +--------- src/routes/v2_reroute.rs | 107 ++ src/routes/v3/mod.rs | 14 +- src/routes/v3/project_creation.rs | 846 ++++++++++++++++ src/routes/v3/projects.rs | 885 +++++++++++++++++ src/routes/v3/tags.rs | 131 +++ src/routes/v3/version_creation.rs | 929 ++++++++++++++++++ src/routes/v3/version_file.rs | 318 ++++++ src/routes/v3/versions.rs | 597 +++++++++++ src/scheduler.rs | 87 +- src/search/indexing/local_import.rs | 34 +- src/search/mod.rs | 4 - src/util/routes.rs | 2 +- src/util/webhook.rs | 57 +- src/validate/mod.rs | 16 +- tests/common/dummy_data.rs | 4 + tests/files/dummy_data.sql | 39 +- tests/project.rs | 27 +- tests/search.rs | 5 +- 38 files changed, 4878 insertions(+), 3937 deletions(-) create mode 100644 src/database/models/loader_fields.rs create mode 100644 src/routes/v2_reroute.rs create mode 100644 src/routes/v3/project_creation.rs create mode 100644 src/routes/v3/projects.rs create mode 100644 src/routes/v3/tags.rs create mode 100644 src/routes/v3/version_creation.rs create mode 100644 src/routes/v3/version_file.rs create mode 100644 src/routes/v3/versions.rs diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 3887ed74..eb561e16 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -1,37 +1,108 @@ +CREATE TABLE games ( + id int PRIMARY KEY, + name varchar(64) +); + +INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); +INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); +ALTER TABLE mods ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java +ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java + CREATE TABLE loader_field_enums ( - id serial PRIMARY KEY, - enum_name varchar(64) NOT NULL, - ordering int NULL, - hidable BOOLEAN NOT NULL DEFAULT FALSE, - metadata varchar(128) + id serial PRIMARY KEY, + game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1, + enum_name varchar(64) NOT NULL, + ordering int NULL, + hidable BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE loader_field_enum_values ( + id serial PRIMARY KEY, + enum_id integer REFERENCES loader_field_enums ON UPDATE CASCADE NOT NULL, + value varchar(64) NOT NULL, + ordering int NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- metadata is json of all the extra data for this enum value + metadata jsonb NULL, + + original_id integer, -- This is for mapping only- it is dropped before the end of the migration + + CONSTRAINT unique_variant_per_enum UNIQUE (enum_id, value) + ); CREATE TABLE loader_fields ( - id serial PRIMARY KEY, - loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, - field varchar(64) NOT NULL, - -- "int", "text", "enum", "bool", - -- "array(int)", "array(text)", "array(enum)", "array(bool)" - field_type varchar(64) NOT NULL, - -- only for enum - enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, - optional BOOLEAN NOT NULL DEFAULT true, - -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing - min_val integer NULL, - max_val integer NULL + id serial PRIMARY KEY, + loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, + field varchar(64) NOT NULL, + -- "int", "text", "enum", "bool", + -- "array(int)", "array(text)", "array(enum)", "array(bool)" + field_type varchar(64) NOT NULL, + -- only for enum + enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, + optional BOOLEAN NOT NULL DEFAULT true, + -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing + min_val integer NULL, + max_val integer NULL, + + CONSTRAINT unique_field_name_per_loader UNIQUE (loader_id, field) ); ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; CREATE TABLE version_fields ( - id serial PRIMARY KEY, + id bigint PRIMARY KEY, version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, field_id integer REFERENCES loader_fields ON UPDATE CASCADE NOT NULL, -- for int/bool values int_value integer NULL, - enum_value integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, + enum_value integer REFERENCES loader_field_enum_values ON UPDATE CASCADE NULL, string_value text NULL ); --- DROP TABLE side_types; --- DROP TABLE game_versions; +-- Convert side_types +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (1, 'side_types', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value) SELECT id, 1, name FROM side_types st; + +INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val, max_val) SELECT l.id, 'client_side', 'enum', 1, false, 1, 1 FROM loaders l; +INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val, max_val) SELECT l.id, 'server_side', 'enum', 1, false, 1, 1 FROM loaders l; + +INSERT INTO version_fields (version_id, field_id, enum_value) +SELECT v.id, 1, m.client_side +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loader_field_enum_values lfev ON m.client_side = lfev.original_id +WHERE client_side IS NOT NULL AND lfev.enum_id = 1; + +INSERT INTO version_fields (version_id, field_id, enum_value) +SELECT v.id, 1, m.server_side +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loader_field_enum_values lfev ON m.client_side = lfev.original_id +WHERE server_side IS NOT NULL AND lfev.enum_id = 1; + +ALTER TABLE mods DROP COLUMN client_side; +ALTER TABLE mods DROP COLUMN server_side; +DROP TABLE side_types; + +-- Convert game_versions +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (2, 'game_versions', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value, created, metadata) +SELECT id, 2, version, created, json_build_object('type', type, 'major', major) FROM game_versions; + +INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val) SELECT l.id, 'game_versions', 'enum', 2, false, 1 FROM loaders l; + +INSERT INTO version_fields(version_id, field_id, enum_value) +SELECT gvv.joining_version_id, 2, lfev.id +FROM game_versions_versions gvv INNER JOIN loader_field_enum_values lfev ON gvv.game_version_id = lfev.original_id +WHERE lfev.enum_id = 2; + +DROP TABLE game_versions_versions; +DROP TABLE game_versions; + +-- Drop original_id columns +ALTER TABLE loader_field_enum_values DROP COLUMN original_id; + +-- drop 'minecraft-java' as default +ALTER TABLE loaders ALTER COLUMN game_id DROP DEFAULT; +ALTER TABLE loader_field_enums ALTER COLUMN game_id DROP DEFAULT; diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs index abb6d7f2..2d1943a6 100644 --- a/src/database/models/categories.rs +++ b/src/database/models/categories.rs @@ -9,34 +9,12 @@ use serde::{Deserialize, Serialize}; const TAGS_NAMESPACE: &str = "tags"; + pub struct ProjectType { pub id: ProjectTypeId, pub name: String, } -pub struct SideType { - pub id: SideTypeId, - pub name: String, -} - -#[derive(Serialize, Deserialize)] -pub struct Loader { - pub id: LoaderId, - pub loader: String, - pub icon: String, - pub supported_project_types: Vec, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct GameVersion { - pub id: GameVersionId, - pub version: String, - #[serde(rename = "type")] - pub type_: String, - pub created: DateTime, - pub major: bool, -} - #[derive(Serialize, Deserialize)] pub struct Category { pub id: CategoryId, @@ -139,220 +117,6 @@ impl Category { } } -impl Loader { - pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT id FROM loaders - WHERE loader = $1 - ", - name - ) - .fetch_optional(exec) - .await?; - - Ok(result.map(|r| LoaderId(r.id))) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let res: Option> = redis - .get_deserialized_from_json(TAGS_NAMESPACE, "loader") - .await?; - - if let Some(res) = res { - return Ok(res); - } - - let result = sqlx::query!( - " - SELECT l.id id, l.loader loader, l.icon icon, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types - FROM loaders l - LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id - LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - GROUP BY l.id; - " - ) - .fetch_many(exec) - .try_filter_map(|e| async { - Ok(e.right().map(|x| Loader { - id: LoaderId(x.id), - loader: x.loader, - icon: x.icon, - supported_project_types: x - .project_types - .unwrap_or_default() - .iter() - .map(|x| x.to_string()) - .collect(), - })) - }) - .try_collect::>() - .await?; - - redis - .set_serialized_to_json(TAGS_NAMESPACE, "loader", &result, None) - .await?; - - Ok(result) - } -} - -#[derive(Default)] -pub struct GameVersionBuilder<'a> { - pub version: Option<&'a str>, - pub version_type: Option<&'a str>, - pub date: Option<&'a DateTime>, -} - -impl GameVersion { - pub fn builder() -> GameVersionBuilder<'static> { - GameVersionBuilder::default() - } - - pub async fn get_id<'a, E>( - version: &str, - exec: E, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT id FROM game_versions - WHERE version = $1 - ", - version - ) - .fetch_optional(exec) - .await?; - - Ok(result.map(|r| GameVersionId(r.id))) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let res: Option> = redis - .get_deserialized_from_json(TAGS_NAMESPACE, "game_version") - .await?; - - if let Some(res) = res { - return Ok(res); - } - - let result = sqlx::query!( - " - SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major FROM game_versions gv - ORDER BY created DESC - " - ) - .fetch_many(exec) - .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { - id: GameVersionId(c.id), - version: c.version_, - type_: c.type_, - created: c.created, - major: c.major - })) }) - .try_collect::>() - .await?; - - redis - .set_serialized_to_json(TAGS_NAMESPACE, "game_version", &result, None) - .await?; - Ok(result) - } - - pub async fn list_filter<'a, E>( - version_type_option: Option<&str>, - major_option: Option, - exec: E, - redis: &RedisPool, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = Self::list(exec, redis) - .await? - .into_iter() - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = version_type_option { - bool &= &*x.type_ == version_type; - } - if let Some(major) = major_option { - bool &= x.major == major; - } - - bool - }) - .collect(); - - Ok(result) - } -} - -impl<'a> GameVersionBuilder<'a> { - /// The game version. Spaces must be replaced with '_' for it to be valid - pub fn version(self, version: &'a str) -> Result, DatabaseError> { - Ok(Self { - version: Some(version), - ..self - }) - } - - pub fn version_type( - self, - version_type: &'a str, - ) -> Result, DatabaseError> { - Ok(Self { - version_type: Some(version_type), - ..self - }) - } - - pub fn created(self, created: &'a DateTime) -> GameVersionBuilder<'a> { - Self { - date: Some(created), - ..self - } - } - - pub async fn insert<'b, E>(self, exec: E) -> Result - where - E: sqlx::Executor<'b, Database = sqlx::Postgres>, - { - // This looks like a mess, but it *should* work - // This allows game versions to be partially updated without - // replacing the unspecified fields with defaults. - let result = sqlx::query!( - " - INSERT INTO game_versions (version, type, created) - VALUES ($1, COALESCE($2, 'other'), COALESCE($3, timezone('utc', now()))) - ON CONFLICT (version) DO UPDATE - SET type = COALESCE($2, game_versions.type), - created = COALESCE($3, game_versions.created) - RETURNING id - ", - self.version, - self.version_type, - self.date.map(chrono::DateTime::naive_utc), - ) - .fetch_one(exec) - .await?; - - Ok(GameVersionId(result.id)) - } -} impl DonationPlatform { pub async fn get_id<'a, E>( @@ -510,50 +274,3 @@ impl ProjectType { } } -impl SideType { - pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT id FROM side_types - WHERE name = $1 - ", - name - ) - .fetch_optional(exec) - .await?; - - Ok(result.map(|r| SideTypeId(r.id))) - } - - pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let res: Option> = redis - .get_deserialized_from_json(TAGS_NAMESPACE, "side_type") - .await?; - - if let Some(res) = res { - return Ok(res); - } - - let result = sqlx::query!( - " - SELECT name FROM side_types - " - ) - .fetch_many(exec) - .try_filter_map(|e| async { Ok(e.right().map(|c| c.name)) }) - .try_collect::>() - .await?; - - redis - .set_serialized_to_json(TAGS_NAMESPACE, "side_type", &result, None) - .await?; - - Ok(result) - } -} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 1d1af665..7ac0efe7 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -238,6 +238,22 @@ pub struct SessionId(pub i64); #[sqlx(transparent)] pub struct ImageId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct LoaderFieldId(pub i32); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct LoaderFieldEnumId(pub i32); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct LoaderFieldEnumValueId(pub i32); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct GameId(pub i32); + use crate::models::ids; impl From for ProjectId { diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs new file mode 100644 index 00000000..b8a93d6f --- /dev/null +++ b/src/database/models/loader_fields.rs @@ -0,0 +1,615 @@ +use crate::database::redis::RedisPool; + +use super::ids::*; +use super::DatabaseError; +use chrono::DateTime; +use chrono::Utc; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +const GAME_LOADERS_NAMESPACE: &str = "game_loaders"; +const LOADER_FIELD_ID_NAMESPACE: &str = "loader_field_ids"; // from str to id +const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; +const LOADER_FIELD_ENUMS_NAMESPACE: &str = "loader_field_enums"; +const VERSION_FIELDS_NAMESPACE: &str = "version_fields_enums"; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum Game { + MinecraftJava, + MinecraftBedrock +} + +impl Game { + pub fn name(&self) -> &'static str { + match self { + Game::MinecraftJava => "minecraft_java", + Game::MinecraftBedrock => "minecraft_bedrock" + } + } + + pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM games + WHERE name = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| GameId(r.id))) + } + +} + +#[derive(Serialize, Deserialize)] +pub struct Loader { + pub id: LoaderId, + pub loader: String, + pub icon: String, + pub supported_project_types: Vec, +} + +impl Loader { + pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM loaders + WHERE loader = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| LoaderId(r.id))) + } + + pub async fn list<'a, E>(game_name : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let result = sqlx::query!( + " + SELECT l.id id, l.loader loader, l.icon icon, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types + FROM loaders l + LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id + LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id + WHERE l.loader = $1 + GROUP BY l.id; + ", + game_name + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|x| Loader { + id: LoaderId(x.id), + loader: x.loader, + icon: x.icon, + supported_project_types: x + .project_types + .unwrap_or_default() + .iter() + .map(|x| x.to_string()) + .collect(), + })) + }) + .try_collect::>() + .await?; + + Ok(result) + } +} + + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct LoaderField { + pub id: LoaderFieldId, + pub loader_id: LoaderId, + pub field: String, + pub field_type: LoaderFieldType, + pub optional: bool, + pub min_val: Option, + pub max_val: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum LoaderFieldType { + Integer, + Text, + Enum(LoaderFieldEnumId), + Boolean, + ArrayInteger, + ArrayText, + ArrayEnum(LoaderFieldEnumId), + ArrayBoolean, + Unknown +} +impl LoaderFieldType { + pub fn build(field_name : &str, loader_field_enum : Option) -> LoaderFieldType { + match (field_name, loader_field_enum) { + ("integer", _) => LoaderFieldType::Integer, + ("text", _) => LoaderFieldType::Text, + ("boolean", _) => LoaderFieldType::Boolean, + ("array_integer", _) => LoaderFieldType::ArrayInteger, + ("array_text", _) => LoaderFieldType::ArrayText, + ("array_boolean", _) => LoaderFieldType::ArrayBoolean, + ("enum", Some(id)) => LoaderFieldType::Enum(LoaderFieldEnumId(id)), + ("array_enum", Some(id)) => LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)), + _ => LoaderFieldType::Unknown + } + } + + pub fn to_str(&self) -> &'static str { + match self { + LoaderFieldType::Integer => "integer", + LoaderFieldType::Text => "text", + LoaderFieldType::Boolean => "boolean", + LoaderFieldType::ArrayInteger => "array_integer", + LoaderFieldType::ArrayText => "array_text", + LoaderFieldType::ArrayBoolean => "array_boolean", + LoaderFieldType::Enum(_) => "enum", + LoaderFieldType::ArrayEnum(_) => "array_enum", + LoaderFieldType::Unknown => "unknown" + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct LoaderFieldEnum { + pub id: LoaderFieldEnumId, + pub game_id: GameId, + pub enum_name: String, + pub ordering: Option, + pub hidable: bool, +} + + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct LoaderFieldEnumValue { + pub id: LoaderFieldEnumValueId, + pub enum_id: LoaderFieldEnumId, + pub value: String, + pub ordering: Option, + pub created: DateTime, + pub metadata: serde_json::Value, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct VersionField { + pub version_id: VersionId, + pub field_id: LoaderFieldId, + pub value: VersionFieldValue, +} +impl VersionField { + pub fn build(loader_field : LoaderField, version_id : VersionId, query_version_fields : Vec) -> Result { + + let value = VersionFieldValue::build(&loader_field.field_type, query_version_fields)?; + Ok(VersionField { + version_id, + field_id: loader_field.id, + value + }) + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum VersionFieldValue { + Integer(i64), + Text(String), + Enum(LoaderFieldEnumId, LoaderFieldEnumValue), + Boolean(bool), + ArrayInteger(Vec), + ArrayText(Vec), + ArrayEnum(LoaderFieldEnumId, Vec), + ArrayBoolean(Vec), + Unknown +} +impl VersionFieldValue { + pub fn build(field_type : &LoaderFieldType, qvfs : Vec) -> Result { + let field_name = field_type.to_str(); + // TODO: should not use numbers , should use id with tostring + let get_first = |qvfs: Vec| -> Result { + if qvfs.len() > 1 { + return Err(DatabaseError::SchemaError( + format!("Multiple fields for field {}", field_name) + )); + } + Ok(qvfs.into_iter().next().ok_or_else(|| DatabaseError::SchemaError( + format!("No version fields for field {}", field_name) + ))?) + }; + + // TODO: should not use numbers , should use id with tostring + let did_not_exist_error = |field_name : &str, desired_field : &str| DatabaseError::SchemaError( + format!("Field name {} for field {} in does not exist", desired_field , field_name)); + + Ok(match field_type { + LoaderFieldType::Integer => VersionFieldValue::Integer( + get_first(qvfs)?.int_value.ok_or(did_not_exist_error(field_name, "int_value"))? + ), + LoaderFieldType::Text => VersionFieldValue::Text( + get_first(qvfs)?.string_value.ok_or(did_not_exist_error( field_name, "string_value"))? + ), + LoaderFieldType::Boolean => VersionFieldValue::Boolean( + get_first(qvfs)?.int_value.ok_or(did_not_exist_error(field_name, "int_value"))? != 0 + ), + LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger( + qvfs.into_iter().map(|qvf| + Ok::(qvf.int_value.ok_or(did_not_exist_error(field_name, "int_value"))?)).collect::>()? + ), + LoaderFieldType::ArrayText => VersionFieldValue::ArrayText( + qvfs.into_iter().map(|qvf| + Ok::(qvf.string_value.ok_or(did_not_exist_error( field_name, "string_value"))?)).collect::>()? + ), + LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean( + qvfs.into_iter().map(|qvf| + Ok::(qvf.int_value.ok_or(did_not_exist_error( field_name, "int_value"))? != 0)).collect::>()? + ), + + LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, + get_first(qvfs)?.enum_value.ok_or(did_not_exist_error( field_name, "enum_value"))? + ), + LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum(*id, + qvfs.into_iter().map(|qvf| + Ok::(qvf.enum_value.ok_or(did_not_exist_error( field_name, "enum_value"))?)).collect::>()? + ), + LoaderFieldType::Unknown => VersionFieldValue::Unknown + }) + } + +} + + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QueryVersionField { + pub version_id: VersionId, + pub field_id: LoaderFieldId, + pub int_value: Option, + pub enum_value: Option, + pub string_value: Option, +} + + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SideType { + pub id: SideTypeId, + pub name: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct GameVersion { + pub id: LoaderFieldEnumValueId, + pub version: String, + #[serde(rename = "type")] + pub type_: String, + pub created: DateTime, + pub major: bool, +} + +// game version from loaderfieldenumvalue +// TODO: remove, after moving gameversion to legacy minecraft +impl GameVersion { + fn from(game_version: LoaderFieldEnumValue) -> Result { + // TODO: should not use numbers , should use id with tostring + let version_type = game_version.metadata.get("type").map(|x| x.as_str()).flatten().ok_or_else(|| format!("Could not read GameVersion {}: Missing version type", game_version.id.0)).unwrap_or_default().to_string(); + let major = game_version.metadata.get("major").map(|x| x.as_bool()).flatten().ok_or_else(|| format!("Could not read GameVersion {}: Missing version major", game_version.id.0)).unwrap_or_default(); + + Ok(Self { + id: game_version.id, + version: game_version.value, + type_: version_type, + created: game_version.created, + major, + }) + } +} + +impl LoaderField { + + pub async fn get_field<'a, E>( + field : &str, + loader_id: LoaderId, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let fields = Self::get_fields(field, &[loader_id], exec).await?; + Ok(fields.into_iter().next()) + } + + pub async fn get_fields<'a, E>( + field : &str, + loader_ids : &[LoaderId], + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type + FROM loader_fields lf + WHERE loader_id = ANY($1) AND field = $2 + ", + &loader_ids.into_iter().map(|l|l.0).collect::>(), + field + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|r| + LoaderField { + id: LoaderFieldId(r.id), + loader_id: LoaderId(r.loader_id), + field: r.field, + field_type: LoaderFieldType::build(&r.field_type, r.enum_type), + optional: r.optional, + min_val: r.min_val, + max_val: r.max_val + } + )) }) + .try_collect::>() + .await?; + + Ok(result) + } +} + +impl LoaderFieldEnum { + pub async fn get<'a, E>(enum_name : &str, game_name : &str, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT lfe.id, lfe.game_id, lfe.enum_name, lfe.ordering, lfe.hidable + FROM loader_field_enums lfe + INNER JOIN games g ON lfe.game_id = g.id + WHERE g.name = $1 AND lfe.enum_name = $2 + ", + game_name, + enum_name + ) + .fetch_optional(exec).await?; + + + Ok(result.map(|l| LoaderFieldEnum { + id: LoaderFieldEnumId(l.id), + game_id: GameId(l.game_id), + enum_name: l.enum_name, + ordering: l.ordering, + hidable: l.hidable, + } + )) + } +} + +impl LoaderFieldEnumValue { + pub async fn list<'a, E>(loader_field_enum_id : LoaderFieldEnumId, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let result = sqlx::query!( + " + SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values + " + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| + LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(c.id), + enum_id: LoaderFieldEnumId(c.enum_id), + value: c.value, + ordering: c.ordering, + created: c.created, + metadata: c.metadata.unwrap_or_default() + } + )) }) + .try_collect::>() + .await?; + + Ok(result) + } + + // Matches filter against metadata of enum values + pub async fn list_filter<'a, E>( + loader_field_enum_id : LoaderFieldEnumId, + filter : serde_json::Value, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let filter = filter.as_object().ok_or(DatabaseError::SchemaError("Filter must be an object".to_string()))?; + + let result = Self::list(loader_field_enum_id, exec, redis) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + for (key, value) in filter { + if let Some(metadata_value) = x.metadata.get(key) { + bool &= metadata_value == value; + } else { + bool = false; + } + } + bool + }) + .collect(); + + Ok(result) + } + +} + +#[derive(Default)] +pub struct GameVersionBuilder<'a> { + pub version: Option<&'a str>, + pub version_type: Option<&'a str>, + pub date: Option<&'a DateTime>, +} + +impl<'a> GameVersionBuilder<'a> { + pub fn new() -> Self { + Self::default() + } + /// The game version. Spaces must be replaced with '_' for it to be valid + pub fn version(self, version: &'a str) -> Result, DatabaseError> { + Ok(Self { + version: Some(version), + ..self + }) + } + + pub fn version_type( + self, + version_type: &'a str, + ) -> Result, DatabaseError> { + Ok(Self { + version_type: Some(version_type), + ..self + }) + } + + pub fn created(self, created: &'a DateTime) -> GameVersionBuilder<'a> { + Self { + date: Some(created), + ..self + } + } + + pub async fn insert<'b, E>(self, exec: E, redis: &RedisPool) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy + { + // TODO: this is hardcoded for minecraft_java + let game_name = Game::MinecraftJava.name(); + let game_versions_enum = LoaderFieldEnum::get("game_versions", game_name, exec, redis).await? + .ok_or(DatabaseError::SchemaError("Missing loaders field: 'game_versions'".to_string()))?; + + + // Get enum id for game versions + let metadata = json!({ + "type": self.version_type, + "major": false + }); + + // This looks like a mess, but it *should* work + // This allows game versions to be partially updated without + // replacing the unspecified fields with defaults. + let result = sqlx::query!( + " + INSERT INTO loader_field_enum_values (enum_id, value, created, metadata) + VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4) + ON CONFLICT (enum_id, value) DO UPDATE + SET metadata = COALESCE($4, loader_field_enum_values.metadata), + created = COALESCE($3, loader_field_enum_values.created) + RETURNING id + ", + game_versions_enum.id.0, + self.version, + self.date.map(chrono::DateTime::naive_utc), + metadata + ) + .fetch_one(exec) + .await?; + + Ok(GameVersionId(result.id)) + } + +} + +impl GameVersion { + pub fn builder() -> GameVersionBuilder<'static> { + GameVersionBuilder::default() + } + + // pub async fn get_id<'a, E>( + // version: &str, + // exec: E, + // ) -> Result, DatabaseError> + // where + // E: sqlx::Executor<'a, Database = sqlx::Postgres>, + // { + // let result = sqlx::query!( + // " + // SELECT id FROM game_versions + // WHERE version = $1 + // ", + // version + // ) + // .fetch_optional(exec) + // .await?; + + // Ok(result.map(|r| GameVersionId(r.id))) + // } + + // pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> + // where + // E: sqlx::Executor<'a, Database = sqlx::Postgres>, + // { + // let result = sqlx::query!( + // " + // SELECT + // SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major FROM game_versions gv + // ORDER BY created DESC + // " + // ) + // .fetch_many(exec) + // .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { + // id: GameVersionId(c.id), + // version: c.version_, + // type_: c.type_, + // created: c.created, + // major: c.major + // })) }) + // .try_collect::>() + // .await?; + + // Ok(result) + // } + + // pub async fn list_filter<'a, E>( + // version_type_option: Option<&str>, + // major_option: Option, + // exec: E, + // redis: &RedisPool, + // ) -> Result, DatabaseError> + // where + // E: sqlx::Executor<'a, Database = sqlx::Postgres>, + // { + // let result = Self::list(exec, redis) + // .await? + // .into_iter() + // .filter(|x| { + // let mut bool = true; + + // if let Some(version_type) = version_type_option { + // bool &= &*x.type_ == version_type; + // } + // if let Some(major) = major_option { + // bool &= x.major == major; + // } + + // bool + // }) + // .collect(); + + // Ok(result) + // } +} + diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index bfd6e781..16dac493 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -5,6 +5,7 @@ pub mod collection_item; pub mod flow_item; pub mod ids; pub mod image_item; +pub mod loader_fields; pub mod notification_item; pub mod organization_item; pub mod pat_item; @@ -39,4 +40,6 @@ pub enum DatabaseError { RedisPool(#[from] deadpool_redis::PoolError), #[error("Error while serializing with the cache: {0}")] SerdeCacheError(#[from] serde_json::Error), + #[error("Schema error: {0}")] + SchemaError(String) } diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 2615fbc2..e8aa9711 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -141,6 +141,7 @@ impl ModCategory { #[derive(Clone)] pub struct ProjectBuilder { pub project_id: ProjectId, + pub game_id : GameId, pub project_type_id: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, @@ -155,15 +156,11 @@ pub struct ProjectBuilder { pub discord_url: Option, pub categories: Vec, pub additional_categories: Vec, - pub initial_versions: Vec, pub status: ProjectStatus, pub requested_status: Option, - pub client_side: SideTypeId, - pub server_side: SideTypeId, pub license: String, pub slug: Option, pub donation_urls: Vec, - pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, } @@ -175,6 +172,7 @@ impl ProjectBuilder { ) -> Result { let project_struct = Project { id: self.project_id, + game_id : self.game_id, project_type: self.project_type_id, team_id: self.team_id, organization_id: self.organization_id, @@ -200,8 +198,6 @@ impl ProjectBuilder { wiki_url: self.wiki_url, license_url: self.license_url, discord_url: self.discord_url, - client_side: self.client_side, - server_side: self.server_side, license: self.license, slug: self.slug, moderation_message: None, @@ -210,28 +206,19 @@ impl ProjectBuilder { color: self.color, monetization_status: self.monetization_status, loaders: vec![], - game_versions: vec![], }; project_struct.insert(&mut *transaction).await?; let ProjectBuilder { donation_urls, - gallery_items, categories, additional_categories, .. } = self; - for mut version in self.initial_versions { - version.project_id = self.project_id; - version.insert(&mut *transaction).await?; - } - DonationUrl::insert_many_projects(donation_urls, self.project_id, &mut *transaction) .await?; - GalleryItem::insert_many(gallery_items, self.project_id, &mut *transaction).await?; - let project_id = self.project_id; let mod_categories = categories .into_iter() @@ -244,7 +231,6 @@ impl ProjectBuilder { .collect_vec(); ModCategory::insert_many(mod_categories, &mut *transaction).await?; - Project::update_game_versions(self.project_id, &mut *transaction).await?; Project::update_loaders(self.project_id, &mut *transaction).await?; Ok(self.project_id) @@ -253,6 +239,7 @@ impl ProjectBuilder { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Project { pub id: ProjectId, + pub game_id : GameId, pub project_type: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, @@ -274,8 +261,6 @@ pub struct Project { pub wiki_url: Option, pub license_url: Option, pub discord_url: Option, - pub client_side: SideTypeId, - pub server_side: SideTypeId, pub license: String, pub slug: Option, pub moderation_message: Option, @@ -284,7 +269,6 @@ pub struct Project { pub color: Option, pub monetization_status: MonetizationStatus, pub loaders: Vec, - pub game_versions: Vec, } impl Project { @@ -298,15 +282,15 @@ impl Project { id, team_id, title, description, body, published, downloads, icon_url, issues_url, source_url, wiki_url, status, requested_status, discord_url, - client_side, server_side, license_url, license, + license_url, license, slug, project_type, color, monetization_status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, $17, $18, - LOWER($19), $20, $21, $22 + $15, $16, LOWER($17), $18, + $19, $20 ) ", self.id as ProjectId, @@ -323,8 +307,6 @@ impl Project { self.status.as_str(), self.requested_status.map(|x| x.as_str()), self.discord_url.as_ref(), - self.client_side as SideTypeId, - self.server_side as SideTypeId, self.license_url.as_ref(), &self.license, self.slug.as_ref(), @@ -580,12 +562,12 @@ impl Project { .collect(); let db_projects: Vec = sqlx::query!( " - SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, + SELECT m.id id, m.game_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.body body, m.published published, m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, - m.team_id team_id, m.organization_id organization_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, - cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color, + 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, + pt.name project_type_name, m.webhook_sent, m.color, t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions, 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, @@ -594,8 +576,6 @@ impl Project { JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations FROM mods m INNER JOIN project_types pt ON pt.id = m.project_type - INNER JOIN side_types cs ON m.client_side = cs.id - INNER JOIN side_types ss ON m.server_side = ss.id INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_gallery mg ON mg.mod_id = m.id LEFT JOIN mods_donations md ON md.joining_mod_id = m.id @@ -604,7 +584,7 @@ impl Project { LEFT JOIN categories c ON mc.joining_category_id = c.id LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3) WHERE m.id = ANY($1) OR m.slug = ANY($2) - GROUP BY pt.id, cs.id, ss.id, t.id, m.id; + GROUP BY pt.id, t.id, m.id; ", &project_ids_parsed, &remaining_strings.into_iter().map(|x| x.to_string().to_lowercase()).collect::>(), @@ -618,6 +598,7 @@ impl Project { QueryProject { inner: Project { id: ProjectId(id), + game_id: GameId(m.game_id), project_type: ProjectTypeId(m.project_type), team_id: TeamId(m.team_id), organization_id: m.organization_id.map(OrganizationId), @@ -633,14 +614,12 @@ impl Project { wiki_url: m.wiki_url.clone(), license_url: m.license_url.clone(), discord_url: m.discord_url.clone(), - client_side: SideTypeId(m.client_side), status: ProjectStatus::from_string( &m.status, ), requested_status: m.requested_status.map(|x| ProjectStatus::from_string( &x, )), - server_side: SideTypeId(m.server_side), license: m.license.clone(), slug: m.slug.clone(), body: m.body.clone(), @@ -655,7 +634,6 @@ impl Project { &m.monetization_status, ), loaders: m.loaders, - game_versions: m.game_versions, }, project_type: m.project_type_name, categories: m.categories.unwrap_or_default(), @@ -689,8 +667,6 @@ impl Project { donation_urls: serde_json::from_value( m.donations.unwrap_or_default(), ).ok().unwrap_or_default(), - client_side: crate::models::projects::SideType::from_string(&m.client_side_type), - server_side: crate::models::projects::SideType::from_string(&m.server_side_type), thread_id: ThreadId(m.thread_id), }})) }) @@ -768,31 +744,6 @@ impl Project { Ok(dependencies) } - pub async fn update_game_versions( - id: ProjectId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), sqlx::error::Error> { - sqlx::query!( - " - UPDATE mods - SET game_versions = ( - SELECT COALESCE(ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null), array[]::varchar[]) - FROM versions v - INNER JOIN game_versions_versions gvv ON v.id = gvv.joining_version_id - INNER JOIN game_versions gv on gvv.game_version_id = gv.id - WHERE v.mod_id = mods.id AND v.status != ALL($2) - ) - WHERE id = $1 - ", - id as ProjectId, - &*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::>() - ) - .execute(&mut *transaction) - .await?; - - Ok(()) - } - pub async fn update_loaders( id: ProjectId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -851,7 +802,5 @@ pub struct QueryProject { pub versions: Vec, pub donation_urls: Vec, pub gallery_items: Vec, - pub client_side: crate::models::projects::SideType, - pub server_side: crate::models::projects::SideType, pub thread_id: ThreadId, } diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 3848459f..32381d44 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,5 +1,9 @@ use super::ids::*; use super::DatabaseError; +use super::loader_fields::LoaderField; +use super::loader_fields::VersionField; +use crate::database::models::loader_fields::LoaderFieldType; +use crate::database::models::loader_fields::QueryVersionField; use crate::database::redis::RedisPool; use crate::models::projects::{FileType, VersionStatus}; use chrono::{DateTime, Utc}; @@ -22,7 +26,6 @@ pub struct VersionBuilder { pub changelog: String, pub files: Vec, pub dependencies: Vec, - pub game_versions: Vec, pub loaders: Vec, pub version_type: String, pub featured: bool, @@ -232,7 +235,6 @@ impl VersionBuilder { let VersionBuilder { dependencies, loaders, - game_versions, files, version_id, .. @@ -247,12 +249,6 @@ impl VersionBuilder { .collect_vec(); LoaderVersion::insert_many(loader_versions, &mut *transaction).await?; - let game_version_versions = game_versions - .iter() - .map(|v| VersionVersion::new(*v, version_id)) - .collect_vec(); - VersionVersion::insert_many(game_version_versions, &mut *transaction).await?; - Ok(self.version_id) } } @@ -287,36 +283,6 @@ impl LoaderVersion { } } -#[derive(derive_new::new)] -pub struct VersionVersion { - pub game_version_id: GameVersionId, - pub joining_version_id: VersionId, -} - -impl VersionVersion { - pub async fn insert_many( - items: Vec, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - let (game_version_ids, version_ids): (Vec<_>, Vec<_>) = items - .into_iter() - .map(|i| (i.game_version_id.0, i.joining_version_id.0)) - .unzip(); - sqlx::query!( - " - INSERT INTO game_versions_versions (game_version_id, joining_version_id) - SELECT * FROM UNNEST($1::integer[], $2::bigint[]) - ", - &game_version_ids[..], - &version_ids[..], - ) - .execute(&mut *transaction) - .await?; - - Ok(()) - } -} - #[derive(Clone, Deserialize, Serialize)] pub struct Version { pub id: VersionId, @@ -397,8 +363,8 @@ impl Version { sqlx::query!( " - DELETE FROM game_versions_versions gvv - WHERE gvv.joining_version_id = $1 + DELETE FROM version_fields vf + WHERE vf.version_id = $1 ", id as VersionId, ) @@ -490,11 +456,6 @@ impl Version { .execute(&mut *transaction) .await?; - crate::database::models::Project::update_game_versions( - ProjectId(project_id.mod_id), - &mut *transaction, - ) - .await?; crate::database::models::Project::update_loaders( ProjectId(project_id.mod_id), &mut *transaction, @@ -555,19 +516,43 @@ impl Version { 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, - JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files, JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes, - JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies + JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies, + + JSONB_AGG( + DISTINCT jsonb_build_object( + 'values', jsonb_build_object( + 'vf_id', vf.id, + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value + ), + 'lf_id', lf.id, + 'l_id', lf.loader_id, + 'field', lf.field, + 'field_type', lf.field_type, + 'enum_type', lf.enum_type, + 'min_val', lf.min_val, + 'max_val', lf.max_val, + 'optional', lf.optional, + + 'enum_name', lfe.enum_name + ) + ) version_fields + FROM versions v - LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id - LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id LEFT OUTER JOIN loaders l on lv.loader_id = l.id LEFT OUTER JOIN files f on v.id = f.version_id LEFT OUTER JOIN hashes h on f.id = h.file_id LEFT OUTER JOIN dependencies d on v.id = d.dependent_id + LEFT OUTER JOIN version_fields vf on v.id = vf.version_id + LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id + LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id + WHERE v.id = ANY($1) GROUP BY v.id ORDER BY v.date_published ASC; @@ -659,22 +644,44 @@ impl Version { files }, - game_versions: { + version_fields: { #[derive(Deserialize)] - struct GameVersion { - pub version: String, - pub created: DateTime, + struct QueryVersionFieldCombined { + values: Vec, + + lf_id: i32, + l_id: i32, + field: String, + field_type: String, + enum_type: Option, + min_val: Option, + max_val: Option, + optional: bool, + + enum_name: Option, } - let mut game_versions: Vec = serde_json::from_value( - v.game_versions.unwrap_or_default(), - ) - .ok() - .unwrap_or_default(); - - game_versions.sort_by(|a, b| a.created.cmp(&b.created)); - - game_versions.into_iter().map(|x| x.version).collect() + let query_version_field_combined: Vec = serde_json::from_value( + v.version_fields.unwrap_or_default()).unwrap_or_default(); + + let version_id = VersionId(v.id); + query_version_field_combined.into_iter().filter_map( |q| { + let loader_field = LoaderField { + id: LoaderFieldId(q.lf_id), + loader_id: LoaderId(q.l_id), + field: q.field, + field_type: LoaderFieldType::build(&q.field_type, q.enum_type), + optional: q.optional, + min_val: q.min_val, + max_val: q.max_val + }; + VersionField::build( + loader_field, + version_id, + + q.values + ).ok() + }).collect() }, loaders: v.loaders.unwrap_or_default(), dependencies: serde_json::from_value( @@ -856,7 +863,7 @@ pub struct QueryVersion { pub inner: Version, pub files: Vec, - pub game_versions: Vec, + pub version_fields: Vec, pub loaders: Vec, pub dependencies: Vec, } diff --git a/src/lib.rs b/src/lib.rs index fe4a8307..6dbd8d01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,7 +137,7 @@ pub fn app_setup( } }); - scheduler::schedule_versions(&mut scheduler, pool.clone()); + scheduler::schedule_versions(&mut scheduler, pool.clone(), redis_pool.clone()); let download_queue = web::Data::new(DownloadQueue::new()); diff --git a/src/models/projects.rs b/src/models/projects.rs index 63f48f11..bf8bfaba 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -66,11 +66,6 @@ pub struct Project { /// The license of this project pub license: License, - /// The support range for the client project* - pub client_side: SideType, - /// The support range for the server project - pub server_side: SideType, - /// The total number of downloads the project has had. pub downloads: u32, /// The total number of followers this project has accumulated @@ -81,8 +76,6 @@ pub struct Project { /// A list of the categories that the project is in. pub additional_categories: Vec, - /// A list of game versions this project supports - pub game_versions: Vec, /// A list of loaders this project supports pub loaders: Vec, @@ -162,13 +155,10 @@ impl From for Project { }, url: m.license_url, }, - client_side: data.client_side, - server_side: data.server_side, downloads: m.downloads as u32, followers: m.follows as u32, categories: data.categories, additional_categories: data.additional_categories, - game_versions: m.game_versions, loaders: m.loaders, versions: data.versions.into_iter().map(|v| v.into()).collect(), icon_url: m.icon_url, @@ -487,8 +477,6 @@ pub struct Version { pub files: Vec, /// A list of projects that this version depends on. pub dependencies: Vec, - /// A list of versions of Minecraft that this version of the project supports. - pub game_versions: Vec, /// The loaders that this version works on pub loaders: Vec, } @@ -540,7 +528,6 @@ impl From for Version { dependency_type: DependencyType::from_string(d.dependency_type.as_str()), }) .collect(), - game_versions: data.game_versions.into_iter().map(GameVersion).collect(), loaders: data.loaders.into_iter().map(Loader).collect(), } } diff --git a/src/routes/maven.rs b/src/routes/maven.rs index d3e7a3c5..55d981d7 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -1,4 +1,4 @@ -use crate::database::models::categories::Loader; +use crate::database::models::loader_fields::{Loader, Game}; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::{QueryFile, QueryVersion}; use crate::database::redis::RedisPool; @@ -179,7 +179,7 @@ async fn find_version( return Ok(exact_matches.get(0).map(|x| (*x).clone())); }; - let db_loaders: HashSet = Loader::list(pool, redis) + let db_loaders: HashSet = Loader::list(Game::MinecraftJava.name(), pool, redis) .await? .into_iter() .map(|x| x.loader) @@ -198,9 +198,6 @@ async fn find_version( if !loaders.is_empty() { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - if !game_versions.is_empty() { - bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - } bool }) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 4a80c6d2..6e381d51 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -11,6 +11,8 @@ use futures::FutureExt; pub mod v2; pub mod v3; +pub mod v2_reroute; + mod analytics; mod index; mod maven; @@ -118,6 +120,8 @@ pub enum ApiError { PasswordStrengthCheck(#[from] zxcvbn::ZxcvbnError), #[error("{0}")] Mail(#[from] crate::auth::email::MailError), + #[error("Error while rerouting request: {0}")] + Reroute(#[from] reqwest::Error), } impl actix_web::ResponseError for ApiError { @@ -144,6 +148,7 @@ impl actix_web::ResponseError for ApiError { ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::PasswordStrengthCheck(..) => StatusCode::BAD_REQUEST, ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -171,6 +176,7 @@ impl actix_web::ResponseError for ApiError { ApiError::PasswordStrengthCheck(..) => "strength_check_error", ApiError::Mail(..) => "mail_error", ApiError::Clickhouse(..) => "clickhouse_error", + ApiError::Reroute(..) => "reroute_error", }, description: &self.to_string(), }) diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 004621a9..66e03197 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -94,23 +94,23 @@ pub async fn forge_updates( promos: HashMap::new(), }; - for version in versions { - if version.version_type == VersionType::Release { - for game_version in &version.game_versions { - response - .promos - .entry(format!("{}-recommended", game_version.0)) - .or_insert_with(|| version.version_number.clone()); - } - } - - for game_version in &version.game_versions { - response - .promos - .entry(format!("{}-latest", game_version.0)) - .or_insert_with(|| version.version_number.clone()); - } - } + // for version in versions { + // if version.version_type == VersionType::Release { + // for game_version in &version.game_versions { + // response + // .promos + // .entry(format!("{}-recommended", game_version.0)) + // .or_insert_with(|| version.version_number.clone()); + // } + // } + + // for game_version in &version.game_versions { + // response + // .promos + // .entry(format!("{}-latest", game_version.0)) + // .or_insert_with(|| version.version_number.clone()); + // } + // } Ok(HttpResponse::Ok().json(response)) } diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs index 89778754..e825b9a3 100644 --- a/src/routes/v2/collections.rs +++ b/src/routes/v2/collections.rs @@ -9,6 +9,7 @@ use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::routes::v3::project_creation::CreateError; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; @@ -21,8 +22,6 @@ use sqlx::PgPool; use std::sync::Arc; use validator::Validate; -use super::project_creation::CreateError; - pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(collections_get); cfg.service(collection_create); diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index d4c8a056..77f7481b 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -11,7 +11,7 @@ use crate::models::organizations::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; -use crate::routes::v2::project_creation::CreateError; +use crate::routes::v3::project_creation::CreateError; use crate::routes::ApiError; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 328ed44c..d34b2179 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -1,5 +1,6 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::loader_fields::Game; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, image_item, User}; use crate::database::redis::RedisPool; @@ -12,10 +13,14 @@ use crate::models::projects::{ DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, VersionStatus, }; +use actix_web::http::header::HeaderValue; + use crate::models::teams::ProjectPermissions; use crate::models::threads::ThreadType; use crate::models::users::UserId; use crate::queue::session::AuthQueue; +use crate::routes::{v3, v2_reroute}; +use crate::routes::v3::project_creation::CreateError; use crate::search::indexing::IndexingError; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -23,7 +28,9 @@ use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; +use bytes::Bytes; use chrono::Utc; +use futures::TryStreamExt; use futures::stream::StreamExt; use image::ImageError; use rust_decimal::Decimal; @@ -32,972 +39,30 @@ use sqlx::postgres::PgPool; use std::sync::Arc; use thiserror::Error; use validator::Validate; +use serde_json::json; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_create); } -#[derive(Error, Debug)] -pub enum CreateError { - #[error("Environment Error")] - EnvError(#[from] dotenvy::Error), - #[error("An unknown database error occurred")] - SqlxDatabaseError(#[from] sqlx::Error), - #[error("Database Error: {0}")] - DatabaseError(#[from] models::DatabaseError), - #[error("Indexing Error: {0}")] - IndexingError(#[from] IndexingError), - #[error("Error while parsing multipart payload: {0}")] - MultipartError(#[from] actix_multipart::MultipartError), - #[error("Error while parsing JSON: {0}")] - SerDeError(#[from] serde_json::Error), - #[error("Error while validating input: {0}")] - ValidationError(String), - #[error("Error while uploading file: {0}")] - FileHostingError(#[from] FileHostingError), - #[error("Error while validating uploaded file: {0}")] - FileValidationError(#[from] crate::validate::ValidationError), - #[error("{}", .0)] - MissingValueError(String), - #[error("Invalid format for image: {0}")] - InvalidIconFormat(String), - #[error("Error with multipart data: {0}")] - InvalidInput(String), - #[error("Invalid game version: {0}")] - InvalidGameVersion(String), - #[error("Invalid loader: {0}")] - InvalidLoader(String), - #[error("Invalid category: {0}")] - InvalidCategory(String), - #[error("Invalid file type for version file: {0}")] - InvalidFileType(String), - #[error("Slug collides with other project's id!")] - SlugCollision, - #[error("Authentication Error: {0}")] - Unauthorized(#[from] AuthenticationError), - #[error("Authentication Error: {0}")] - CustomAuthenticationError(String), - #[error("Image Parsing Error: {0}")] - ImageError(#[from] ImageError), -} - -impl actix_web::ResponseError for CreateError { - fn status_code(&self) -> StatusCode { - match self { - CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR, - CreateError::SqlxDatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, - CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, - CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR, - CreateError::FileHostingError(..) => StatusCode::INTERNAL_SERVER_ERROR, - CreateError::SerDeError(..) => StatusCode::BAD_REQUEST, - CreateError::MultipartError(..) => StatusCode::BAD_REQUEST, - CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, - CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, - CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, - CreateError::CustomAuthenticationError(..) => StatusCode::UNAUTHORIZED, - CreateError::SlugCollision => StatusCode::BAD_REQUEST, - CreateError::ValidationError(..) => StatusCode::BAD_REQUEST, - CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST, - CreateError::ImageError(..) => StatusCode::BAD_REQUEST, - } - } - - fn error_response(&self) -> HttpResponse { - HttpResponse::build(self.status_code()).json(ApiError { - error: match self { - CreateError::EnvError(..) => "environment_error", - CreateError::SqlxDatabaseError(..) => "database_error", - CreateError::DatabaseError(..) => "database_error", - CreateError::IndexingError(..) => "indexing_error", - CreateError::FileHostingError(..) => "file_hosting_error", - CreateError::SerDeError(..) => "invalid_input", - CreateError::MultipartError(..) => "invalid_input", - CreateError::MissingValueError(..) => "invalid_input", - CreateError::InvalidIconFormat(..) => "invalid_input", - CreateError::InvalidInput(..) => "invalid_input", - CreateError::InvalidGameVersion(..) => "invalid_input", - CreateError::InvalidLoader(..) => "invalid_input", - CreateError::InvalidCategory(..) => "invalid_input", - CreateError::InvalidFileType(..) => "invalid_input", - CreateError::Unauthorized(..) => "unauthorized", - CreateError::CustomAuthenticationError(..) => "unauthorized", - CreateError::SlugCollision => "invalid_input", - CreateError::ValidationError(..) => "invalid_input", - CreateError::FileValidationError(..) => "invalid_input", - CreateError::ImageError(..) => "invalid_image", - }, - description: &self.to_string(), - }) - } -} - -fn default_project_type() -> String { - "mod".to_string() -} - -fn default_requested_status() -> ProjectStatus { - ProjectStatus::Approved -} - -#[derive(Serialize, Deserialize, Validate, Clone)] -struct ProjectCreateData { - #[validate( - length(min = 3, max = 64), - custom(function = "crate::util::validate::validate_name") - )] - #[serde(alias = "mod_name")] - /// The title or name of the project. - pub title: String, - #[validate(length(min = 1, max = 64))] - #[serde(default = "default_project_type")] - /// The project type of this mod - pub project_type: String, - #[validate( - length(min = 3, max = 64), - regex = "crate::util::validate::RE_URL_SAFE" - )] - #[serde(alias = "mod_slug")] - /// The slug of a project, used for vanity URLs - pub slug: String, - #[validate(length(min = 3, max = 255))] - #[serde(alias = "mod_description")] - /// A short description of the project. - pub description: String, - #[validate(length(max = 65536))] - #[serde(alias = "mod_body")] - /// A long description of the project, in markdown. - pub body: String, - - /// The support range for the client project - pub client_side: SideType, - /// The support range for the server project - pub server_side: SideType, - - #[validate(length(max = 32))] - #[validate] - /// A list of initial versions to upload with the created project - pub initial_versions: Vec, - #[validate(length(max = 3))] - /// A list of the categories that the project is in. - pub categories: Vec, - #[validate(length(max = 256))] - #[serde(default = "Vec::new")] - /// A list of the categories that the project is in. - pub additional_categories: Vec, - - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 2048) - )] - /// An optional link to where to submit bugs or issues with the project. - pub issues_url: Option, - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 2048) - )] - /// An optional link to the source code for the project. - pub source_url: Option, - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 2048) - )] - /// An optional link to the project's wiki page or other relevant information. - pub wiki_url: Option, - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 2048) - )] - /// An optional link to the project's license page - pub license_url: Option, - #[validate( - custom(function = "crate::util::validate::validate_url"), - length(max = 2048) - )] - /// An optional link to the project's discord. - pub discord_url: Option, - /// An optional list of all donation links the project has\ - #[validate] - pub donation_urls: Option>, - - /// An optional boolean. If true, the project will be created as a draft. - pub is_draft: Option, - - /// The license id that the project follows - pub license_id: String, - - #[validate(length(max = 64))] - #[validate] - /// The multipart names of the gallery items to upload - pub gallery_items: Option>, - #[serde(default = "default_requested_status")] - /// The status of the mod to be set once it is approved - pub requested_status: ProjectStatus, - - // Associations to uploaded images in body/description - #[validate(length(max = 10))] - #[serde(default)] - pub uploaded_images: Vec, - - /// The id of the organization to create the project in - pub organization_id: Option, -} - -#[derive(Serialize, Deserialize, Validate, Clone)] -pub struct NewGalleryItem { - /// The name of the multipart item where the gallery media is located - pub item: String, - /// Whether the gallery item should show in search or not - pub featured: bool, - #[validate(length(min = 1, max = 2048))] - /// The title of the gallery item - pub title: Option, - #[validate(length(min = 1, max = 2048))] - /// The description of the gallery item - pub description: Option, - pub ordering: i64, -} - -pub struct UploadedFile { - pub file_id: String, - pub file_name: String, -} - -pub async fn undo_uploads( - file_host: &dyn FileHost, - uploaded_files: &[UploadedFile], -) -> Result<(), CreateError> { - for file in uploaded_files { - file_host - .delete_file_version(&file.file_id, &file.file_name) - .await?; - } - Ok(()) -} - #[post("project")] pub async fn project_create( req: HttpRequest, - mut payload: Multipart, - client: Data, - redis: Data, - file_host: Data>, - session_queue: Data, + payload: Multipart, ) -> Result { - let mut transaction = client.begin().await?; - let mut uploaded_files = Vec::new(); - - let result = project_create_inner( - req, - &mut payload, - &mut transaction, - &***file_host, - &mut uploaded_files, - &client, - &redis, - &session_queue, - ) - .await; - - if result.is_err() { - let undo_result = undo_uploads(&***file_host, &uploaded_files).await; - let rollback_result = transaction.rollback().await; - undo_result?; - if let Err(e) = rollback_result { - return Err(e.into()); - } - } else { - transaction.commit().await?; - } - - result + // Redirects to V3 route + let self_addr = dotenvy::var("SELF_ADDR")?; + let url = format!("{self_addr}/v3/project"); + let response = v2_reroute::reroute_multipart(&url, req, payload, |json | { + // Convert input data to V3 format + json["game_name"] = json!("minecraft_java"); + }).await?; + let response = HttpResponse::build(response.status()) + .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) + .body(response.bytes().await.unwrap_or_default()); + + // TODO: Convert response to V2 format + Ok(response) } -/* - -Project Creation Steps: -Get logged in user - Must match the author in the version creation - -1. Data - - Gets "data" field from multipart form; must be first - - Verification: string lengths - - Create versions - - Some shared logic with version creation - - Create list of VersionBuilders - - Create ProjectBuilder - -2. Upload - - Icon: check file format & size - - Upload to backblaze & record URL - - Project files - - Check for matching version - - File size limits? - - Check file type - - Eventually, malware scan - - Upload to backblaze & create VersionFileBuilder - - - -3. Creation - - Database stuff - - Add project data to indexing queue -*/ - -#[allow(clippy::too_many_arguments)] -async fn project_create_inner( - req: HttpRequest, - payload: &mut Multipart, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - file_host: &dyn FileHost, - uploaded_files: &mut Vec, - pool: &PgPool, - redis: &RedisPool, - session_queue: &AuthQueue, -) -> Result { - // The base URL for files uploaded to backblaze - let cdn_url = dotenvy::var("CDN_URL")?; - - // The currently logged in user - let current_user = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Some(&[Scopes::PROJECT_CREATE]), - ) - .await? - .1; - - let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); - - let project_create_data; - let mut versions; - let mut versions_map = std::collections::HashMap::new(); - let mut gallery_urls = Vec::new(); - - let all_game_versions = models::categories::GameVersion::list(&mut *transaction, redis).await?; - let all_loaders = models::categories::Loader::list(&mut *transaction, redis).await?; - - { - // The first multipart field must be named "data" and contain a - // JSON `ProjectCreateData` object. - - let mut field = payload - .next() - .await - .map(|m| m.map_err(CreateError::MultipartError)) - .unwrap_or_else(|| { - Err(CreateError::MissingValueError(String::from( - "No `data` field in multipart upload", - ))) - })?; - - let content_disposition = field.content_disposition(); - let name = content_disposition - .get_name() - .ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?; - - if name != "data" { - return Err(CreateError::InvalidInput(String::from( - "`data` field must come before file fields", - ))); - } - let mut data = Vec::new(); - while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); - } - let create_data: ProjectCreateData = serde_json::from_slice(&data)?; - - create_data - .validate() - .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - - let slug_project_id_option: Option = - serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); - - if let Some(slug_project_id) = slug_project_id_option { - let slug_project_id: models::ids::ProjectId = slug_project_id.into(); - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) - ", - slug_project_id as models::ids::ProjectId - ) - .fetch_one(&mut *transaction) - .await - .map_err(|e| CreateError::DatabaseError(e.into()))?; - - if results.exists.unwrap_or(false) { - return Err(CreateError::SlugCollision); - } - } - - { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) - ", - create_data.slug - ) - .fetch_one(&mut *transaction) - .await - .map_err(|e| CreateError::DatabaseError(e.into()))?; - - if results.exists.unwrap_or(false) { - return Err(CreateError::SlugCollision); - } - } - - // Create VersionBuilders for the versions specified in `initial_versions` - versions = Vec::with_capacity(create_data.initial_versions.len()); - for (i, data) in create_data.initial_versions.iter().enumerate() { - // Create a map of multipart field names to version indices - for name in &data.file_parts { - if versions_map.insert(name.to_owned(), i).is_some() { - // If the name is already used - return Err(CreateError::InvalidInput(String::from( - "Duplicate multipart field name", - ))); - } - } - versions.push( - create_initial_version( - data, - project_id, - current_user.id, - &all_game_versions, - &all_loaders, - &create_data.project_type, - transaction, - ) - .await?, - ); - } - project_create_data = create_data; - } - - let project_type_id = models::categories::ProjectType::get_id( - project_create_data.project_type.as_str(), - &mut *transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Project Type {} does not exist.", - project_create_data.project_type.clone() - )) - })?; - - let mut icon_data = None; - - let mut error = None; - while let Some(item) = payload.next().await { - let mut field: Field = item?; - - if error.is_some() { - continue; - } - - let result = async { - let content_disposition = field.content_disposition().clone(); - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::MissingValueError("Missing content name".to_string()) - })?; - - let (file_name, file_extension) = - super::version_creation::get_name_ext(&content_disposition)?; - - if name == "icon" { - if icon_data.is_some() { - return Err(CreateError::InvalidInput(String::from( - "Projects can only have one icon", - ))); - } - // Upload the icon to the cdn - icon_data = Some( - process_icon_upload( - uploaded_files, - project_id.0, - file_extension, - file_host, - field, - &cdn_url, - ) - .await?, - ); - return Ok(()); - } - - if let Some(gallery_items) = &project_create_data.gallery_items { - if gallery_items.iter().filter(|a| a.featured).count() > 1 { - return Err(CreateError::InvalidInput(String::from( - "Only one gallery image can be featured.", - ))); - } - - if let Some(item) = gallery_items.iter().find(|x| x.item == name) { - let data = read_from_field( - &mut field, - 5 * (1 << 20), - "Gallery image exceeds the maximum of 5MiB.", - ) - .await?; - - let hash = sha1::Sha1::from(&data).hexdigest(); - let (_, file_extension) = - super::version_creation::get_name_ext(&content_disposition)?; - let content_type = crate::util::ext::get_image_content_type(file_extension) - .ok_or_else(|| { - CreateError::InvalidIconFormat(file_extension.to_string()) - })?; - - let url = format!("data/{project_id}/images/{hash}.{file_extension}"); - let upload_data = file_host - .upload_file(content_type, &url, data.freeze()) - .await?; - - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: upload_data.file_name, - }); - - gallery_urls.push(crate::models::projects::GalleryItem { - url: format!("{cdn_url}/{url}"), - featured: item.featured, - title: item.title.clone(), - description: item.description.clone(), - created: Utc::now(), - ordering: item.ordering, - }); - - return Ok(()); - } - } - - let index = if let Some(i) = versions_map.get(name) { - *i - } else { - return Err(CreateError::InvalidInput(format!( - "File `{file_name}` (field {name}) isn't specified in the versions data" - ))); - }; - - // `index` is always valid for these lists - let created_version = versions.get_mut(index).unwrap(); - let version_data = project_create_data.initial_versions.get(index).unwrap(); - - // Upload the new jar file - super::version_creation::upload_file( - &mut field, - file_host, - version_data.file_parts.len(), - uploaded_files, - &mut created_version.files, - &mut created_version.dependencies, - &cdn_url, - &content_disposition, - project_id, - created_version.version_id.into(), - &project_create_data.project_type, - version_data.loaders.clone(), - version_data.game_versions.clone(), - all_game_versions.clone(), - version_data.primary_file.is_some(), - version_data.primary_file.as_deref() == Some(name), - None, - transaction, - ) - .await?; - - Ok(()) - } - .await; - - if result.is_err() { - error = result.err(); - } - } - - if let Some(error) = error { - return Err(error); - } - - { - // Check to make sure that all specified files were uploaded - for (version_data, builder) in project_create_data - .initial_versions - .iter() - .zip(versions.iter()) - { - if version_data.file_parts.len() != builder.files.len() { - return Err(CreateError::InvalidInput(String::from( - "Some files were specified in initial_versions but not uploaded", - ))); - } - } - - // Convert the list of category names to actual categories - let mut categories = Vec::with_capacity(project_create_data.categories.len()); - for category in &project_create_data.categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut *transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - categories.push(id); - } - - let mut additional_categories = - Vec::with_capacity(project_create_data.additional_categories.len()); - for category in &project_create_data.additional_categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut *transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - additional_categories.push(id); - } - - let team = models::team_item::TeamBuilder { - members: vec![models::team_item::TeamMemberBuilder { - user_id: current_user.id.into(), - role: crate::models::teams::OWNER_ROLE.to_owned(), - // Allow all permissions for project creator, even if attached to a project - permissions: ProjectPermissions::all(), - organization_permissions: None, - accepted: true, - payouts_split: Decimal::ONE_HUNDRED, - ordering: 0, - }], - }; - - let team_id = team.insert(&mut *transaction).await?; - - let status; - if project_create_data.is_draft.unwrap_or(false) { - status = ProjectStatus::Draft; - } else { - status = ProjectStatus::Processing; - - if project_create_data.initial_versions.is_empty() { - return Err(CreateError::InvalidInput(String::from( - "Project submitted for review with no initial versions", - ))); - } - } - - if !project_create_data.requested_status.can_be_requested() { - return Err(CreateError::InvalidInput(String::from( - "Specified requested status is not allowed to be requested", - ))); - } - - let client_side_id = models::categories::SideType::get_id( - project_create_data.client_side.as_str(), - &mut *transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("Client side type specified does not exist.".to_string()) - })?; - - let server_side_id = models::categories::SideType::get_id( - project_create_data.server_side.as_str(), - &mut *transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("Server side type specified does not exist.".to_string()) - })?; - - let license_id = - spdx::Expression::parse(&project_create_data.license_id).map_err(|err| { - CreateError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) - })?; - - let mut donation_urls = vec![]; - - if let Some(urls) = &project_create_data.donation_urls { - for url in urls { - let platform_id = - models::categories::DonationPlatform::get_id(&url.id, &mut *transaction) - .await? - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Donation platform {} does not exist.", - url.id.clone() - )) - })?; - - donation_urls.push(models::project_item::DonationUrl { - platform_id, - platform_short: "".to_string(), - platform_name: "".to_string(), - url: url.url.clone(), - }) - } - } - - let project_builder_actual = models::project_item::ProjectBuilder { - project_id: project_id.into(), - project_type_id, - team_id, - organization_id: project_create_data.organization_id, - title: project_create_data.title, - description: project_create_data.description, - body: project_create_data.body, - icon_url: icon_data.clone().map(|x| x.0), - issues_url: project_create_data.issues_url, - source_url: project_create_data.source_url, - wiki_url: project_create_data.wiki_url, - - license_url: project_create_data.license_url, - discord_url: project_create_data.discord_url, - categories, - additional_categories, - initial_versions: versions, - status, - requested_status: Some(project_create_data.requested_status), - client_side: client_side_id, - server_side: server_side_id, - license: license_id.to_string(), - slug: Some(project_create_data.slug), - donation_urls, - gallery_items: gallery_urls - .iter() - .map(|x| models::project_item::GalleryItem { - image_url: x.url.clone(), - featured: x.featured, - title: x.title.clone(), - description: x.description.clone(), - created: x.created, - ordering: x.ordering, - }) - .collect(), - color: icon_data.and_then(|x| x.1), - monetization_status: MonetizationStatus::Monetized, - }; - let project_builder = project_builder_actual.clone(); - - let now = Utc::now(); - - let id = project_builder_actual.insert(&mut *transaction).await?; - User::clear_project_cache(&[current_user.id.into()], redis).await?; - - for image_id in project_create_data.uploaded_images { - if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut *transaction, redis).await? - { - let image: Image = db_image.into(); - if !matches!(image.context, ImageContext::Project { .. }) - || image.context.inner_id().is_some() - { - return Err(CreateError::InvalidInput(format!( - "Image {} is not unused and in the 'project' context", - image_id - ))); - } - - sqlx::query!( - " - UPDATE uploaded_images - SET mod_id = $1 - WHERE id = $2 - ", - id as models::ids::ProjectId, - image_id.0 as i64 - ) - .execute(&mut *transaction) - .await?; - - image_item::Image::clear_cache(image.id.into(), redis).await?; - } else { - return Err(CreateError::InvalidInput(format!( - "Image {} does not exist", - image_id - ))); - } - } - - let thread_id = ThreadBuilder { - type_: ThreadType::Project, - members: vec![], - project_id: Some(id), - report_id: None, - } - .insert(&mut *transaction) - .await?; - - let response = crate::models::projects::Project { - id: project_id, - slug: project_builder.slug.clone(), - project_type: project_create_data.project_type.clone(), - team: team_id.into(), - organization: project_create_data.organization_id.map(|x| x.into()), - title: project_builder.title.clone(), - description: project_builder.description.clone(), - body: project_builder.body.clone(), - body_url: None, - published: now, - updated: now, - approved: None, - queued: None, - status, - requested_status: project_builder.requested_status, - moderator_message: None, - license: License { - id: project_create_data.license_id.clone(), - name: "".to_string(), - url: project_builder.license_url.clone(), - }, - client_side: project_create_data.client_side, - server_side: project_create_data.server_side, - downloads: 0, - followers: 0, - categories: project_create_data.categories, - additional_categories: project_create_data.additional_categories, - game_versions: vec![], - loaders: vec![], - versions: project_builder - .initial_versions - .iter() - .map(|v| v.version_id.into()) - .collect::>(), - icon_url: project_builder.icon_url.clone(), - issues_url: project_builder.issues_url.clone(), - source_url: project_builder.source_url.clone(), - wiki_url: project_builder.wiki_url.clone(), - discord_url: project_builder.discord_url.clone(), - donation_urls: project_create_data.donation_urls.clone(), - gallery: gallery_urls, - color: project_builder.color, - thread_id: thread_id.into(), - monetization_status: MonetizationStatus::Monetized, - }; - - Ok(HttpResponse::Ok().json(response)) - } -} - -async fn create_initial_version( - version_data: &InitialVersionData, - project_id: ProjectId, - author: UserId, - all_game_versions: &[models::categories::GameVersion], - all_loaders: &[models::categories::Loader], - project_type: &str, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result { - if version_data.project_id.is_some() { - return Err(CreateError::InvalidInput(String::from( - "Found project id in initial version for new project", - ))); - } - - version_data - .validate() - .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; - - // Randomly generate a new id to be used for the version - let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - - let game_versions = version_data - .game_versions - .iter() - .map(|x| { - all_game_versions - .iter() - .find(|y| y.version == x.0) - .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let loaders = version_data - .loaders - .iter() - .map(|x| { - all_loaders - .iter() - .find(|y| { - y.loader == x.0 - && y.supported_project_types - .contains(&project_type.to_string()) - }) - .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let dependencies = version_data - .dependencies - .iter() - .map(|d| models::version_item::DependencyBuilder { - version_id: d.version_id.map(|x| x.into()), - project_id: d.project_id.map(|x| x.into()), - dependency_type: d.dependency_type.to_string(), - file_name: None, - }) - .collect::>(); - - let version = models::version_item::VersionBuilder { - version_id: version_id.into(), - project_id: project_id.into(), - author_id: author.into(), - name: version_data.version_title.clone(), - version_number: version_data.version_number.clone(), - changelog: version_data.version_body.clone().unwrap_or_default(), - files: Vec::new(), - dependencies, - game_versions, - loaders, - featured: version_data.featured, - status: VersionStatus::Listed, - version_type: version_data.release_channel.to_string(), - requested_status: None, - }; - - Ok(version) -} - -async fn process_icon_upload( - uploaded_files: &mut Vec, - id: u64, - file_extension: &str, - file_host: &dyn FileHost, - mut field: Field, - cdn_url: &str, -) -> Result<(String, Option), CreateError> { - if let Some(content_type) = crate::util::ext::get_image_content_type(file_extension) { - let data = read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; - - let color = crate::util::img::get_color_from_img(&data)?; - - let hash = sha1::Sha1::from(&data).hexdigest(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/{id}/{hash}.{file_extension}"), - data.freeze(), - ) - .await?; - - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: upload_data.file_name.clone(), - }); - - Ok((format!("{}/{}", cdn_url, upload_data.file_name), color)) - } else { - Err(CreateError::InvalidIconFormat(file_extension.to_string())) - } -} diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index e67972dd..da4f81d7 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -17,7 +17,8 @@ use crate::models::projects::{ use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::{ApiError, v2_reroute}; +use crate::routes::v3::projects::delete_from_index; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::img; use crate::util::routes::read_from_payload; @@ -388,748 +389,21 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - new_project - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let string = info.into_inner().0; - let result = db_models::Project::get(&string, &**pool, &redis).await?; - - if let Some(project_item) = result { - let id = project_item.inner.id; - - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ); - - if let Some(perms) = permissions { - let mut transaction = pool.begin().await?; - - if let Some(title) = &new_project.title { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the title of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET title = $1 - WHERE (id = $2) - ", - title.trim(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(description) = &new_project.description { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the description of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET description = $1 - WHERE (id = $2) - ", - description, - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(status) = &new_project.status { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the status of this project!" - .to_string(), - )); - } - - if !(user.role.is_mod() - || !project_item.inner.status.is_approved() - && status == &ProjectStatus::Processing - || project_item.inner.status.is_approved() && status.can_be_requested()) - { - return Err(ApiError::CustomAuthentication( - "You don't have permission to set this status!".to_string(), - )); - } - - if status == &ProjectStatus::Processing { - if project_item.versions.is_empty() { - return Err(ApiError::InvalidInput(String::from( - "Project submitted for review with no initial versions", - ))); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() - WHERE (id = $1) - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - UPDATE threads - SET show_in_mod_inbox = FALSE - WHERE id = $1 - ", - project_item.thread_id as db_ids::ThreadId, - ) - .execute(&mut *transaction) - .await?; - } - - if status.is_approved() && !project_item.inner.status.is_approved() { - sqlx::query!( - " - UPDATE mods - SET approved = NOW() - WHERE id = $1 AND approved IS NULL - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if status.is_searchable() && !project_item.inner.webhook_sent { - if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { - crate::util::webhook::send_discord_webhook( - project_item.inner.id.into(), - &pool, - &redis, - webhook_url, - None, - ) - .await - .ok(); - - sqlx::query!( - " - UPDATE mods - SET webhook_sent = TRUE - WHERE id = $1 - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - } - - if user.role.is_mod() { - if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK") { - crate::util::webhook::send_discord_webhook( - project_item.inner.id.into(), - &pool, - &redis, - webhook_url, - Some( - format!( - "**[{}]({}/user/{})** changed project status from **{}** to **{}**", - user.username, - dotenvy::var("SITE_URL")?, - user.username, - &project_item.inner.status.as_friendly_str(), - status.as_friendly_str(), - ) - .to_string(), - ), - ) - .await - .ok(); - } - } - - if team_member.map(|x| !x.accepted).unwrap_or(true) { - let notified_members = sqlx::query!( - " - SELECT tm.user_id id - FROM team_members tm - WHERE tm.team_id = $1 AND tm.accepted - ", - project_item.inner.team_id as db_ids::TeamId - ) - .fetch_many(&mut *transaction) - .try_filter_map(|e| async { Ok(e.right().map(|c| db_models::UserId(c.id))) }) - .try_collect::>() - .await?; - - NotificationBuilder { - body: NotificationBody::StatusChange { - project_id: project_item.inner.id.into(), - old_status: project_item.inner.status, - new_status: *status, - }, - } - .insert_many(notified_members, &mut transaction, &redis) - .await?; - } - - ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: MessageBody::StatusChange { - new_status: *status, - old_status: project_item.inner.status, - }, - thread_id: project_item.thread_id, - } - .insert(&mut transaction) - .await?; - - sqlx::query!( - " - UPDATE mods - SET status = $1 - WHERE (id = $2) - ", - status.as_str(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - if project_item.inner.status.is_searchable() && !status.is_searchable() { - delete_from_index(id.into(), config).await?; - } - } - - if let Some(requested_status) = &new_project.requested_status { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the requested status of this project!" - .to_string(), - )); - } - - if !requested_status - .map(|x| x.can_be_requested()) - .unwrap_or(true) - { - return Err(ApiError::InvalidInput(String::from( - "Specified status cannot be requested!", - ))); - } - - sqlx::query!( - " - UPDATE mods - SET requested_status = $1 - WHERE (id = $2) - ", - requested_status.map(|x| x.as_str()), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if perms.contains(ProjectPermissions::EDIT_DETAILS) { - if new_project.categories.is_some() { - sqlx::query!( - " - DELETE FROM mods_categories - WHERE joining_mod_id = $1 AND is_additional = FALSE - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if new_project.additional_categories.is_some() { - sqlx::query!( - " - DELETE FROM mods_categories - WHERE joining_mod_id = $1 AND is_additional = TRUE - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - } - - if let Some(categories) = &new_project.categories { - edit_project_categories( - categories, - &perms, - id as db_ids::ProjectId, - false, - &mut transaction, - ) - .await?; - } - - if let Some(categories) = &new_project.additional_categories { - edit_project_categories( - categories, - &perms, - id as db_ids::ProjectId, - true, - &mut transaction, - ) - .await?; - } - - if let Some(issues_url) = &new_project.issues_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the issues URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET issues_url = $1 - WHERE (id = $2) - ", - issues_url.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(source_url) = &new_project.source_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the source URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET source_url = $1 - WHERE (id = $2) - ", - source_url.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(wiki_url) = &new_project.wiki_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the wiki URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET wiki_url = $1 - WHERE (id = $2) - ", - wiki_url.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(license_url) = &new_project.license_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the license URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET license_url = $1 - WHERE (id = $2) - ", - license_url.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(discord_url) = &new_project.discord_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the discord URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET discord_url = $1 - WHERE (id = $2) - ", - discord_url.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(slug) = &new_project.slug { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the slug of this project!" - .to_string(), - )); - } - - let slug_project_id_option: Option = parse_base62(slug).ok(); - if let Some(slug_project_id) = slug_project_id_option { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) - ", - slug_project_id as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!".to_string(), - )); - } - } - - // Make sure the new slug is different from the old one - // We are able to unwrap here because the slug is always set - if !slug.eq(&project_item.inner.slug.clone().unwrap_or_default()) { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) - ", - slug - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!".to_string(), - )); - } - } - - sqlx::query!( - " - UPDATE mods - SET slug = LOWER($1) - WHERE (id = $2) - ", - Some(slug), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(new_side) = &new_project.client_side { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the side type of this mod!" - .to_string(), - )); - } - - let side_type_id = - db_models::categories::SideType::get_id(new_side.as_str(), &mut *transaction) - .await? - .expect("No database entry found for side type"); - - sqlx::query!( - " - UPDATE mods - SET client_side = $1 - WHERE (id = $2) - ", - side_type_id as db_models::SideTypeId, - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(new_side) = &new_project.server_side { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the side type of this project!" - .to_string(), - )); - } - - let side_type_id = - db_models::categories::SideType::get_id(new_side.as_str(), &mut *transaction) - .await? - .expect("No database entry found for side type"); - - sqlx::query!( - " - UPDATE mods - SET server_side = $1 - WHERE (id = $2) - ", - side_type_id as db_models::SideTypeId, - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(license) = &new_project.license_id { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the license of this project!" - .to_string(), - )); - } - - let mut license = license.clone(); - - if license.to_lowercase() == "arr" { - license = models::projects::DEFAULT_LICENSE_ID.to_string(); - } - - spdx::Expression::parse(&license).map_err(|err| { - ApiError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) - })?; - - sqlx::query!( - " - UPDATE mods - SET license = $1 - WHERE (id = $2) - ", - license, - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - if let Some(donations) = &new_project.donation_urls { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the donation links of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - DELETE FROM mods_donations - WHERE joining_mod_id = $1 - ", - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - for donation in donations { - let platform_id = db_models::categories::DonationPlatform::get_id( - &donation.id, - &mut *transaction, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Platform {} does not exist.", - donation.id.clone() - )) - })?; - - sqlx::query!( - " - INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url) - VALUES ($1, $2, $3) - ", - id as db_ids::ProjectId, - platform_id as db_ids::DonationPlatformId, - donation.url - ) - .execute(&mut *transaction) - .await?; - } - } - - if let Some(moderation_message) = &new_project.moderation_message { - if !user.role.is_mod() - && (!project_item.inner.status.is_approved() || moderation_message.is_some()) - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the moderation message of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message = $1 - WHERE (id = $2) - ", - moderation_message.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(moderation_message_body) = &new_project.moderation_message_body { - if !user.role.is_mod() - && (!project_item.inner.status.is_approved() - || moderation_message_body.is_some()) - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the moderation message body of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message_body = $1 - WHERE (id = $2) - ", - moderation_message_body.as_deref(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(body) = &new_project.body { - if !perms.contains(ProjectPermissions::EDIT_BODY) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the body of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET body = $1 - WHERE (id = $2) - ", - body, - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(monetization_status) = &new_project.monetization_status { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the monetization status of this project!" - .to_string(), - )); - } - - if (*monetization_status == MonetizationStatus::ForceDemonetized - || project_item.inner.monetization_status - == MonetizationStatus::ForceDemonetized) - && !user.role.is_mod() - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the monetization status of this project!" - .to_string(), - )); - } + // TODO: Should call v3 route + let self_addr = dotenvy::var("SELF_ADDR")?; + let url = format!("{self_addr}/v3/project/{id}", id = info.0); - sqlx::query!( - " - UPDATE mods - SET monetization_status = $1 - WHERE (id = $2) - ", - monetization_status.as_str(), - id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } + let response = v2_reroute::reroute_patch(&url, req, serde_json::to_value(new_project)?).await?; - // check new description and body for links to associated images - // if they no longer exist in the description or body, delete them - let checkable_strings: Vec<&str> = vec![&new_project.description, &new_project.body] - .into_iter() - .filter_map(|x| x.as_ref().map(|y| y.as_str())) - .collect(); + let response = HttpResponse::build(response.status()) + .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) + .body(response.bytes().await.unwrap_or_default()); - let context = ImageContext::Project { - project_id: Some(id.into()), - }; - - img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; - db_models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this project!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + // TODO: Convert response to V2 format + Ok(response) } + #[derive(derive_new::new)] pub struct CategoryChanges<'a> { pub categories: &'a Option>, @@ -1537,34 +811,6 @@ pub async fn bulk_edit_project_categories( Ok(()) } -pub async fn edit_project_categories( - categories: &Vec, - perms: &ProjectPermissions, - project_id: db_ids::ProjectId, - additional: bool, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result<(), ApiError> { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - let additional_str = if additional { "additional " } else { "" }; - return Err(ApiError::CustomAuthentication(format!( - "You do not have the permissions to edit the {additional_str}categories of this project!" - ))); - } - - let mut mod_categories = Vec::new(); - for category in categories { - let category_id = db_models::categories::Category::get_id(category, &mut *transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) - })?; - mod_categories.push(ModCategory::new(project_id, category_id, additional)); - } - ModCategory::insert_many(mod_categories, &mut *transaction).await?; - - Ok(()) -} - #[derive(Deserialize)] pub struct SchedulingData { pub time: DateTime, @@ -2544,17 +1790,3 @@ pub async fn project_unfollow( } } -pub async fn delete_from_index( - id: ProjectId, - config: web::Data, -) -> Result<(), meilisearch_sdk::errors::Error> { - let client = meilisearch_sdk::client::Client::new(&*config.address, &*config.key); - - let indexes: IndexesResults = client.get_indexes().await?; - - for index in indexes.results { - index.delete_document(id.to_string()).await?; - } - - Ok(()) -} diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 56ffaac5..cc0ca69c 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -1,10 +1,11 @@ use super::ApiError; use crate::database::models; -use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType, SideType}; +use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType}; +use crate::database::models::loader_fields::{Loader, GameVersion}; use crate::database::redis::RedisPool; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; -use models::categories::{Category, GameVersion, Loader}; +use models::categories::{Category}; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -35,18 +36,9 @@ pub async fn category_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = Category::list(&**pool, &redis) - .await? - .into_iter() - .map(|x| CategoryData { - icon: x.icon, - name: x.category, - project_type: x.project_type, - header: x.header, - }) - .collect::>(); - - Ok(HttpResponse::Ok().json(results)) + // TODO: should cvall v3 + + Ok(HttpResponse::Ok().json("")) } #[derive(serde::Serialize, serde::Deserialize)] @@ -61,19 +53,9 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let mut results = Loader::list(&**pool, &redis) - .await? - .into_iter() - .map(|x| LoaderData { - icon: x.icon, - name: x.loader, - supported_project_types: x.supported_project_types, - }) - .collect::>(); - - results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - - Ok(HttpResponse::Ok().json(results)) + // TODO: should cvall v3 + + Ok(HttpResponse::Ok().json("")) } #[derive(serde::Serialize)] @@ -97,21 +79,9 @@ pub async fn game_version_list( query: web::Query, redis: web::Data, ) -> Result { - let results: Vec = if query.type_.is_some() || query.major.is_some() { - GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool, &redis).await? - } else { - GameVersion::list(&**pool, &redis).await? - } - .into_iter() - .map(|x| GameVersionQueryData { - version: x.version, - version_type: x.type_, - date: x.created, - major: x.major, - }) - .collect(); - - Ok(HttpResponse::Ok().json(results)) + // TODO: should cvall v3 + + Ok(HttpResponse::Ok().json("`")) } #[derive(serde::Serialize)] @@ -209,6 +179,7 @@ pub async fn side_type_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = SideType::list(&**pool, &redis).await?; - Ok(HttpResponse::Ok().json(results)) + // TODO: should call v3 + + Ok(HttpResponse::Ok().json("")) } diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 19961671..6a12c9a8 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -1,4 +1,3 @@ -use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ @@ -17,6 +16,7 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use crate::validate::{validate_file, ValidationResult}; @@ -94,411 +94,8 @@ pub async fn version_create( file_host: Data>, session_queue: Data, ) -> Result { - let mut transaction = client.begin().await?; - let mut uploaded_files = Vec::new(); - - let result = version_create_inner( - req, - &mut payload, - &mut transaction, - &redis, - &***file_host, - &mut uploaded_files, - &client, - &session_queue, - ) - .await; - - if result.is_err() { - let undo_result = - super::project_creation::undo_uploads(&***file_host, &uploaded_files).await; - let rollback_result = transaction.rollback().await; - - undo_result?; - if let Err(e) = rollback_result { - return Err(e.into()); - } - } else { - transaction.commit().await?; - } - - result -} - -#[allow(clippy::too_many_arguments)] -async fn version_create_inner( - req: HttpRequest, - payload: &mut Multipart, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - redis: &RedisPool, - file_host: &dyn FileHost, - uploaded_files: &mut Vec, - pool: &PgPool, - session_queue: &AuthQueue, -) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; - - let mut initial_version_data = None; - let mut version_builder = None; - - let all_game_versions = models::categories::GameVersion::list(&mut *transaction, redis).await?; - let all_loaders = models::categories::Loader::list(&mut *transaction, redis).await?; - - let user = get_user_from_headers( - &req, - pool, - redis, - session_queue, - Some(&[Scopes::VERSION_CREATE]), - ) - .await? - .1; - - let mut error = None; - while let Some(item) = payload.next().await { - let mut field: Field = item?; - - if error.is_some() { - continue; - } - - let result = async { - let content_disposition = field.content_disposition().clone(); - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::MissingValueError("Missing content name".to_string()) - })?; - - if name == "data" { - let mut data = Vec::new(); - while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk?); - } - - let version_create_data: InitialVersionData = serde_json::from_slice(&data)?; - initial_version_data = Some(version_create_data); - let version_create_data = initial_version_data.as_ref().unwrap(); - if version_create_data.project_id.is_none() { - return Err(CreateError::MissingValueError( - "Missing project id".to_string(), - )); - } - - version_create_data.validate().map_err(|err| { - CreateError::ValidationError(validation_errors_to_string(err, None)) - })?; - - if !version_create_data.status.can_be_requested() { - return Err(CreateError::InvalidInput( - "Status specified cannot be requested".to_string(), - )); - } - - let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); - - // Ensure that the project this version is being added to exists - let results = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", - project_id as models::ProjectId - ) - .fetch_one(&mut *transaction) - .await?; - - if !results.exists.unwrap_or(false) { - return Err(CreateError::InvalidInput( - "An invalid project id was supplied".to_string(), - )); - } - - // Check that the user creating this version is a team member - // of the project the version is being added to. - let team_member = models::TeamMember::get_from_user_id_project( - project_id, - user.id.into(), - &mut *transaction, - ) - .await?; - - // Get organization attached, if exists, and the member project permissions - let organization = models::Organization::get_associated_organization_project_id( - project_id, - &mut *transaction, - ) - .await?; - - let organization_team_member = if let Some(organization) = &organization { - models::TeamMember::get_from_user_id( - organization.team_id, - user.id.into(), - &mut *transaction, - ) - .await? - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { - return Err(CreateError::CustomAuthenticationError( - "You don't have permission to upload this version!".to_string(), - )); - } - - let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - project_id as models::ProjectId, - ) - .fetch_one(&mut *transaction) - .await? - .name; - - let game_versions = version_create_data - .game_versions - .iter() - .map(|x| { - all_game_versions - .iter() - .find(|y| y.version == x.0) - .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let loaders = version_create_data - .loaders - .iter() - .map(|x| { - all_loaders - .iter() - .find(|y| { - y.loader == x.0 && y.supported_project_types.contains(&project_type) - }) - .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let dependencies = version_create_data - .dependencies - .iter() - .map(|d| models::version_item::DependencyBuilder { - version_id: d.version_id.map(|x| x.into()), - project_id: d.project_id.map(|x| x.into()), - dependency_type: d.dependency_type.to_string(), - file_name: None, - }) - .collect::>(); - - version_builder = Some(VersionBuilder { - version_id: version_id.into(), - project_id, - author_id: user.id.into(), - name: version_create_data.version_title.clone(), - version_number: version_create_data.version_number.clone(), - changelog: version_create_data.version_body.clone().unwrap_or_default(), - files: Vec::new(), - dependencies, - game_versions, - loaders, - version_type: version_create_data.release_channel.to_string(), - featured: version_create_data.featured, - status: version_create_data.status, - requested_status: None, - }); - - return Ok(()); - } - - let version = version_builder.as_mut().ok_or_else(|| { - CreateError::InvalidInput(String::from("`data` field must come before file fields")) - })?; - - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - version.project_id as models::ProjectId, - ) - .fetch_one(&mut *transaction) - .await? - .name; - - let version_data = initial_version_data - .clone() - .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; - - upload_file( - &mut field, - file_host, - version_data.file_parts.len(), - uploaded_files, - &mut version.files, - &mut version.dependencies, - &cdn_url, - &content_disposition, - version.project_id.into(), - version.version_id.into(), - &project_type, - version_data.loaders, - version_data.game_versions, - all_game_versions.clone(), - version_data.primary_file.is_some(), - version_data.primary_file.as_deref() == Some(name), - version_data.file_types.get(name).copied().flatten(), - transaction, - ) - .await?; - - Ok(()) - } - .await; - - if result.is_err() { - error = result.err(); - } - } - - if let Some(error) = error { - return Err(error); - } - - let version_data = initial_version_data - .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; - let builder = version_builder - .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; - - if builder.files.is_empty() { - return Err(CreateError::InvalidInput( - "Versions must have at least one file uploaded to them".to_string(), - )); - } - - use futures::stream::TryStreamExt; - - let users = sqlx::query!( - " - SELECT follower_id FROM mod_follows - WHERE mod_id = $1 - ", - builder.project_id as crate::database::models::ids::ProjectId - ) - .fetch_many(&mut *transaction) - .try_filter_map(|e| async { Ok(e.right().map(|m| models::ids::UserId(m.follower_id))) }) - .try_collect::>() - .await?; - - let project_id: ProjectId = builder.project_id.into(); - let version_id: VersionId = builder.version_id.into(); - - NotificationBuilder { - body: NotificationBody::ProjectUpdate { - project_id, - version_id, - }, - } - .insert_many(users, &mut *transaction, redis) - .await?; - - let response = Version { - id: builder.version_id.into(), - project_id: builder.project_id.into(), - author_id: user.id, - featured: builder.featured, - name: builder.name.clone(), - version_number: builder.version_number.clone(), - changelog: builder.changelog.clone(), - changelog_url: None, - date_published: Utc::now(), - downloads: 0, - version_type: version_data.release_channel, - status: builder.status, - requested_status: builder.requested_status, - files: builder - .files - .iter() - .map(|file| VersionFile { - hashes: file - .hashes - .iter() - .map(|hash| { - ( - hash.algorithm.clone(), - // This is a hack since the hashes are currently stored as ASCII - // in the database, but represented here as a Vec. At some - // point we need to change the hash to be the real bytes in the - // database and add more processing here. - String::from_utf8(hash.hash.clone()).unwrap(), - ) - }) - .collect(), - url: file.url.clone(), - filename: file.filename.clone(), - primary: file.primary, - size: file.size, - file_type: file.file_type, - }) - .collect::>(), - dependencies: version_data.dependencies, - game_versions: version_data.game_versions, - loaders: version_data.loaders, - }; - - let project_id = builder.project_id; - builder.insert(transaction).await?; - - for image_id in version_data.uploaded_images { - if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut *transaction, redis).await? - { - let image: Image = db_image.into(); - if !matches!(image.context, ImageContext::Report { .. }) - || image.context.inner_id().is_some() - { - return Err(CreateError::InvalidInput(format!( - "Image {} is not unused and in the 'version' context", - image_id - ))); - } - - sqlx::query!( - " - UPDATE uploaded_images - SET version_id = $1 - WHERE id = $2 - ", - version_id.0 as i64, - image_id.0 as i64 - ) - .execute(&mut *transaction) - .await?; - - image_item::Image::clear_cache(image.id.into(), redis).await?; - } else { - return Err(CreateError::InvalidInput(format!( - "Image {} does not exist", - image_id - ))); - } - } - - models::Project::update_game_versions(project_id, &mut *transaction).await?; - models::Project::update_loaders(project_id, &mut *transaction).await?; - models::Project::clear_cache(project_id, None, Some(true), redis).await?; - - Ok(HttpResponse::Ok().json(response)) + // TODO: should call this from the v3 + Ok(HttpResponse::NoContent().body("")) } // under /api/v1/version/{version_id} @@ -512,446 +109,6 @@ pub async fn upload_file_to_version( file_host: Data>, session_queue: web::Data, ) -> Result { - let mut transaction = client.begin().await?; - let mut uploaded_files = Vec::new(); - - let version_id = models::VersionId::from(url_data.into_inner().0); - - let result = upload_file_to_version_inner( - req, - &mut payload, - client, - &mut transaction, - redis, - &***file_host, - &mut uploaded_files, - version_id, - &session_queue, - ) - .await; - - if result.is_err() { - let undo_result = - super::project_creation::undo_uploads(&***file_host, &uploaded_files).await; - let rollback_result = transaction.rollback().await; - - undo_result?; - if let Err(e) = rollback_result { - return Err(e.into()); - } - } else { - transaction.commit().await?; - } - - result -} - -#[allow(clippy::too_many_arguments)] -async fn upload_file_to_version_inner( - req: HttpRequest, - payload: &mut Multipart, - client: Data, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - redis: Data, - file_host: &dyn FileHost, - uploaded_files: &mut Vec, - version_id: models::VersionId, - session_queue: &AuthQueue, -) -> Result { - let cdn_url = dotenvy::var("CDN_URL")?; - - let mut initial_file_data: Option = None; - let mut file_builders: Vec = Vec::new(); - - let user = get_user_from_headers( - &req, - &**client, - &redis, - session_queue, - Some(&[Scopes::VERSION_WRITE]), - ) - .await? - .1; - - let result = models::Version::get(version_id, &**client, &redis).await?; - - let version = match result { - Some(v) => v, - None => { - return Err(CreateError::InvalidInput( - "An invalid version id was supplied".to_string(), - )); - } - }; - - if !user.role.is_admin() { - let team_member = models::TeamMember::get_from_user_id_project( - version.inner.project_id, - user.id.into(), - &mut *transaction, - ) - .await?; - - let organization = Organization::get_associated_organization_project_id( - version.inner.project_id, - &**client, - ) - .await?; - - let organization_team_member = if let Some(organization) = &organization { - models::TeamMember::get_from_user_id( - organization.team_id, - user.id.into(), - &mut *transaction, - ) - .await? - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { - return Err(CreateError::CustomAuthenticationError( - "You don't have permission to upload files to this version!".to_string(), - )); - } - } - - let project_id = ProjectId(version.inner.project_id.0 as u64); - - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - version.inner.project_id as models::ProjectId, - ) - .fetch_one(&mut *transaction) - .await? - .name; - - let all_game_versions = - models::categories::GameVersion::list(&mut *transaction, &redis).await?; - - let mut error = None; - while let Some(item) = payload.next().await { - let mut field: Field = item?; - - if error.is_some() { - continue; - } - - let result = async { - let content_disposition = field.content_disposition().clone(); - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::MissingValueError("Missing content name".to_string()) - })?; - - if name == "data" { - let mut data = Vec::new(); - while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk?); - } - let file_data: InitialFileData = serde_json::from_slice(&data)?; - - initial_file_data = Some(file_data); - return Ok(()); - } - - let file_data = initial_file_data.as_ref().ok_or_else(|| { - CreateError::InvalidInput(String::from("`data` field must come before file fields")) - })?; - - let mut dependencies = version - .dependencies - .iter() - .map(|x| DependencyBuilder { - project_id: x.project_id, - version_id: x.version_id, - file_name: x.file_name.clone(), - dependency_type: x.dependency_type.clone(), - }) - .collect(); - - upload_file( - &mut field, - file_host, - 0, - uploaded_files, - &mut file_builders, - &mut dependencies, - &cdn_url, - &content_disposition, - project_id, - version_id.into(), - &project_type, - version.loaders.clone().into_iter().map(Loader).collect(), - version - .game_versions - .clone() - .into_iter() - .map(GameVersion) - .collect(), - all_game_versions.clone(), - true, - false, - file_data.file_types.get(name).copied().flatten(), - transaction, - ) - .await?; - - Ok(()) - } - .await; - - if result.is_err() { - error = result.err(); - } - } - - if let Some(error) = error { - return Err(error); - } - - if file_builders.is_empty() { - return Err(CreateError::InvalidInput( - "At least one file must be specified".to_string(), - )); - } else { - VersionFileBuilder::insert_many(file_builders, version_id, &mut *transaction).await?; - } - - // Clear version cache - models::Version::clear_cache(&version, &redis).await?; - + // TODO: should call this from the v3 Ok(HttpResponse::NoContent().body("")) -} - -// This function is used for adding a file to a version, uploading the initial -// files for a version, and for uploading the initial version files for a project -#[allow(clippy::too_many_arguments)] -pub async fn upload_file( - field: &mut Field, - file_host: &dyn FileHost, - total_files_len: usize, - uploaded_files: &mut Vec, - version_files: &mut Vec, - dependencies: &mut Vec, - cdn_url: &str, - content_disposition: &actix_web::http::header::ContentDisposition, - project_id: ProjectId, - version_id: VersionId, - project_type: &str, - loaders: Vec, - game_versions: Vec, - all_game_versions: Vec, - ignore_primary: bool, - force_primary: bool, - file_type: Option, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result<(), CreateError> { - let (file_name, file_extension) = get_name_ext(content_disposition)?; - - if file_name.contains('/') { - return Err(CreateError::InvalidInput( - "File names must not contain slashes!".to_string(), - )); - } - - let content_type = crate::util::ext::project_file_type(file_extension) - .ok_or_else(|| CreateError::InvalidFileType(file_extension.to_string()))?; - - let data = read_from_field( - field, 500 * (1 << 20), - "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." - ).await?; - - let hash = sha1::Sha1::from(&data).hexdigest(); - let exists = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM hashes h - INNER JOIN files f ON f.id = h.file_id - INNER JOIN versions v ON v.id = f.version_id - WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3) - ", - hash.as_bytes(), - "sha1", - project_id.0 as i64 - ) - .fetch_one(&mut *transaction) - .await? - .exists - .unwrap_or(false); - - if exists { - return Err(CreateError::InvalidInput( - "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), - )); - } - - let validation_result = validate_file( - data.clone().into(), - file_extension.to_string(), - project_type.to_string(), - loaders.clone(), - game_versions.clone(), - all_game_versions.clone(), - file_type, - ) - .await?; - - if let ValidationResult::PassWithPackDataAndFiles { - ref format, - ref files, - } = validation_result - { - if dependencies.is_empty() { - let hashes: Vec> = format - .files - .iter() - .filter_map(|x| x.hashes.get(&PackFileHash::Sha1)) - .map(|x| x.as_bytes().to_vec()) - .collect(); - - let res = sqlx::query!( - " - SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h - INNER JOIN files f on h.file_id = f.id - INNER JOIN versions v on f.version_id = v.id - WHERE h.algorithm = 'sha1' AND h.hash = ANY($1) - ", - &*hashes - ) - .fetch_all(&mut *transaction) - .await?; - - for file in &format.files { - if let Some(dep) = res.iter().find(|x| { - Some(&*x.hash) == file.hashes.get(&PackFileHash::Sha1).map(|x| x.as_bytes()) - }) { - dependencies.push(DependencyBuilder { - project_id: Some(models::ProjectId(dep.project_id)), - version_id: Some(models::VersionId(dep.version_id)), - file_name: None, - dependency_type: DependencyType::Embedded.to_string(), - }); - } else if let Some(first_download) = file.downloads.first() { - dependencies.push(DependencyBuilder { - project_id: None, - version_id: None, - file_name: Some( - first_download - .rsplit('/') - .next() - .unwrap_or(first_download) - .to_string(), - ), - dependency_type: DependencyType::Embedded.to_string(), - }); - } - } - - for file in files { - if !file.is_empty() { - dependencies.push(DependencyBuilder { - project_id: None, - version_id: None, - file_name: Some(file.to_string()), - dependency_type: DependencyType::Embedded.to_string(), - }); - } - } - } - } - - let data = data.freeze(); - - let primary = (validation_result.is_passed() - && version_files.iter().all(|x| !x.primary) - && !ignore_primary) - || force_primary - || total_files_len == 1; - - let file_path_encode = format!( - "data/{}/versions/{}/{}", - project_id, - version_id, - urlencoding::encode(file_name) - ); - let file_path = format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); - - let upload_data = file_host - .upload_file(content_type, &file_path, data) - .await?; - - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: file_path, - }); - - let sha1_bytes = upload_data.content_sha1.into_bytes(); - let sha512_bytes = upload_data.content_sha512.into_bytes(); - - if version_files.iter().any(|x| { - x.hashes - .iter() - .any(|y| y.hash == sha1_bytes || y.hash == sha512_bytes) - }) { - return Err(CreateError::InvalidInput( - "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), - )); - } - - if let ValidationResult::Warning(msg) = validation_result { - if primary { - return Err(CreateError::InvalidInput(msg.to_string())); - } - } - - version_files.push(VersionFileBuilder { - filename: file_name.to_string(), - url: format!("{cdn_url}/{file_path_encode}"), - hashes: vec![ - models::version_item::HashBuilder { - algorithm: "sha1".to_string(), - // This is an invalid cast - the database expects the hash's - // bytes, but this is the string version. - hash: sha1_bytes, - }, - models::version_item::HashBuilder { - algorithm: "sha512".to_string(), - // This is an invalid cast - the database expects the hash's - // bytes, but this is the string version. - hash: sha512_bytes, - }, - ], - primary, - size: upload_data.content_length, - file_type, - }); - - Ok(()) -} - -pub fn get_name_ext( - content_disposition: &actix_web::http::header::ContentDisposition, -) -> Result<(&str, &str), CreateError> { - let file_name = content_disposition - .get_filename() - .ok_or_else(|| CreateError::MissingValueError("Missing content file name".to_string()))?; - let file_extension = if let Some(last_period) = file_name.rfind('.') { - file_name.get((last_period + 1)..).unwrap_or("") - } else { - return Err(CreateError::MissingValueError( - "Missing content file extension".to_string(), - )); - }; - Ok((file_name, file_extension)) -} +} \ No newline at end of file diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 171788b1..170b7b8f 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -278,64 +278,7 @@ pub async fn get_update_from_hash( update_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - let hash = info.into_inner().0.to_lowercase(); - - if let Some(file) = database::models::Version::get_file_from_hash( - hash_query.algorithm.clone(), - hash, - hash_query.version_id.map(|x| x.into()), - &**pool, - &redis, - ) - .await? - { - if let Some(project) = - database::models::Project::get_id(file.project_id, &**pool, &redis).await? - { - let mut versions = - database::models::Version::get_many(&project.versions, &**pool, &redis) - .await? - .into_iter() - .filter(|x| { - let mut bool = true; - - if let Some(version_types) = &update_data.version_types { - bool &= version_types - .iter() - .any(|y| y.as_str() == x.inner.version_type); - } - if let Some(loaders) = &update_data.loaders { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(game_versions) = &update_data.game_versions { - bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - } - - bool - }) - .sorted_by(|a, b| a.inner.date_published.cmp(&b.inner.date_published)) - .collect::>(); - - if let Some(first) = versions.pop() { - if !is_authorized_version(&first.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - return Ok(HttpResponse::Ok().json(models::projects::Version::from(first))); - } - } - } - + // TODO: should call v3 Ok(HttpResponse::NotFound().body("")) } @@ -463,82 +406,8 @@ pub async fn update_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let files = database::models::Version::get_files_from_hash( - update_data.algorithm.clone(), - &update_data.hashes, - &**pool, - &redis, - ) - .await?; - - let projects = database::models::Project::get_many_ids( - &files.iter().map(|x| x.project_id).collect::>(), - &**pool, - &redis, - ) - .await?; - let all_versions = database::models::Version::get_many( - &projects - .iter() - .flat_map(|x| x.versions.clone()) - .collect::>(), - &**pool, - &redis, - ) - .await?; - - let mut response = HashMap::new(); - - for project in projects { - for file in files.iter().filter(|x| x.project_id == project.inner.id) { - let version = all_versions - .iter() - .filter(|x| x.inner.project_id == file.project_id) - .filter(|x| { - let mut bool = true; - - if let Some(version_types) = &update_data.version_types { - bool &= version_types - .iter() - .any(|y| y.as_str() == x.inner.version_type); - } - if let Some(loaders) = &update_data.loaders { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(game_versions) = &update_data.game_versions { - bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - } - - bool - }) - .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) - .next(); - - if let Some(version) = version { - if is_authorized_version(&version.inner, &user_option, &pool).await? { - if let Some(hash) = file.hashes.get(&update_data.algorithm) { - response.insert( - hash.clone(), - models::projects::Version::from(version.clone()), - ); - } - } - } - } - } - - Ok(HttpResponse::Ok().json(response)) + // TODO: should call v3 + Ok(HttpResponse::Ok().json("")) } #[derive(Deserialize)] @@ -564,86 +433,5 @@ pub async fn update_individual_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let files = database::models::Version::get_files_from_hash( - update_data.algorithm.clone(), - &update_data - .hashes - .iter() - .map(|x| x.hash.clone()) - .collect::>(), - &**pool, - &redis, - ) - .await?; - - let projects = database::models::Project::get_many_ids( - &files.iter().map(|x| x.project_id).collect::>(), - &**pool, - &redis, - ) - .await?; - let all_versions = database::models::Version::get_many( - &projects - .iter() - .flat_map(|x| x.versions.clone()) - .collect::>(), - &**pool, - &redis, - ) - .await?; - - let mut response = HashMap::new(); - - for project in projects { - for file in files.iter().filter(|x| x.project_id == project.inner.id) { - if let Some(hash) = file.hashes.get(&update_data.algorithm) { - if let Some(query_file) = update_data.hashes.iter().find(|x| &x.hash == hash) { - let version = all_versions - .iter() - .filter(|x| x.inner.project_id == file.project_id) - .filter(|x| { - let mut bool = true; - - if let Some(version_types) = &query_file.version_types { - bool &= version_types - .iter() - .any(|y| y.as_str() == x.inner.version_type); - } - if let Some(loaders) = &query_file.loaders { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(game_versions) = &query_file.game_versions { - bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - } - - bool - }) - .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) - .next(); - - if let Some(version) = version { - if is_authorized_version(&version.inner, &user_option, &pool).await? { - response.insert( - hash.clone(), - models::projects::Version::from(version.clone()), - ); - } - } - } - } - } - } - - Ok(HttpResponse::Ok().json(response)) + Ok(HttpResponse::Ok().json("")) } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 218706e9..644576f0 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -3,7 +3,7 @@ use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; -use crate::database::models::version_item::{DependencyBuilder, LoaderVersion, VersionVersion}; +use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; @@ -54,116 +54,8 @@ pub async fn version_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - - let result = database::models::Project::get(&string, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(project) = result { - if !is_authorized(&project.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - let version_filters = filters - .game_versions - .as_ref() - .map(|x| serde_json::from_str::>(x).unwrap_or_default()); - let loader_filters = filters - .loaders - .as_ref() - .map(|x| serde_json::from_str::>(x).unwrap_or_default()); - let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis) - .await? - .into_iter() - .skip(filters.offset.unwrap_or(0)) - .take(filters.limit.unwrap_or(usize::MAX)) - .filter(|x| { - let mut bool = true; - - if let Some(version_type) = filters.version_type { - bool &= &*x.inner.version_type == version_type.as_str(); - } - if let Some(loaders) = &loader_filters { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(game_versions) = &version_filters { - bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - } - - bool - }) - .collect::>(); - - let mut response = versions - .iter() - .filter(|version| { - filters - .featured - .map(|featured| featured == version.inner.featured) - .unwrap_or(true) - }) - .cloned() - .collect::>(); - - versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); - - // Attempt to populate versions with "auto featured" versions - if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { - let (loaders, game_versions) = futures::future::try_join( - database::models::categories::Loader::list(&**pool, &redis), - database::models::categories::GameVersion::list_filter( - None, - Some(true), - &**pool, - &redis, - ), - ) - .await?; - - let mut joined_filters = Vec::new(); - for game_version in &game_versions { - for loader in &loaders { - joined_filters.push((game_version, loader)) - } - } - - joined_filters.into_iter().for_each(|filter| { - versions - .iter() - .find(|version| { - version.game_versions.contains(&filter.0.version) - && version.loaders.contains(&filter.1.loader) - }) - .map(|version| response.push(version.clone())) - .unwrap_or(()); - }); - - if response.is_empty() { - versions - .into_iter() - .for_each(|version| response.push(version)); - } - } - - response.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); - response.dedup_by(|a, b| a.inner.id == b.inner.id); - - let response = filter_authorized_versions(response, &user_option, &pool).await?; - - Ok(HttpResponse::Ok().json(response)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + // TODO: move route to v3 + Ok(HttpResponse::Ok().json("")) } // Given a project ID/slug and a version slug @@ -324,395 +216,8 @@ pub async fn version_edit( new_version: web::Json, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_WRITE]), - ) - .await? - .1; - - new_version - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let version_id = info.into_inner().0; - let id = version_id.into(); - - let result = database::models::Version::get(id, &**pool, &redis).await?; - - if let Some(version_item) = result { - let project_item = - database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) - .await?; - - let team_member = database::models::TeamMember::get_from_user_id_project( - version_item.inner.project_id, - user.id.into(), - &**pool, - ) - .await?; - - let organization = Organization::get_associated_organization_project_id( - version_item.inner.project_id, - &**pool, - ) - .await?; - - let organization_team_member = if let Some(organization) = &organization { - database::models::TeamMember::get_from_user_id( - organization.team_id, - user.id.into(), - &**pool, - ) - .await? - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ); - - if let Some(perms) = permissions { - if !perms.contains(ProjectPermissions::UPLOAD_VERSION) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit this version!".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - - if let Some(name) = &new_version.name { - sqlx::query!( - " - UPDATE versions - SET name = $1 - WHERE (id = $2) - ", - name.trim(), - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(number) = &new_version.version_number { - sqlx::query!( - " - UPDATE versions - SET version_number = $1 - WHERE (id = $2) - ", - number, - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(version_type) = &new_version.version_type { - sqlx::query!( - " - UPDATE versions - SET version_type = $1 - WHERE (id = $2) - ", - version_type.as_str(), - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(dependencies) = &new_version.dependencies { - if let Some(project) = project_item { - if project.project_type != "modpack" { - sqlx::query!( - " - DELETE FROM dependencies WHERE dependent_id = $1 - ", - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - let builders = dependencies - .iter() - .map(|x| database::models::version_item::DependencyBuilder { - project_id: x.project_id.map(|x| x.into()), - version_id: x.version_id.map(|x| x.into()), - file_name: x.file_name.clone(), - dependency_type: x.dependency_type.to_string(), - }) - .collect::>(); - - DependencyBuilder::insert_many( - builders, - version_item.inner.id, - &mut transaction, - ) - .await?; - } - } - } - - if let Some(game_versions) = &new_version.game_versions { - sqlx::query!( - " - DELETE FROM game_versions_versions WHERE joining_version_id = $1 - ", - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - let mut version_versions = Vec::new(); - for game_version in game_versions { - let game_version_id = database::models::categories::GameVersion::get_id( - &game_version.0, - &mut *transaction, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "No database entry for game version provided.".to_string(), - ) - })?; - - version_versions.push(VersionVersion::new(game_version_id, id)); - } - VersionVersion::insert_many(version_versions, &mut transaction).await?; - - database::models::Project::update_game_versions( - version_item.inner.project_id, - &mut transaction, - ) - .await?; - } - - if let Some(loaders) = &new_version.loaders { - sqlx::query!( - " - DELETE FROM loaders_versions WHERE version_id = $1 - ", - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - let mut loader_versions = Vec::new(); - for loader in loaders { - let loader_id = - database::models::categories::Loader::get_id(&loader.0, &mut *transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "No database entry for loader provided.".to_string(), - ) - })?; - loader_versions.push(LoaderVersion::new(loader_id, id)); - } - LoaderVersion::insert_many(loader_versions, &mut transaction).await?; - - database::models::Project::update_loaders( - version_item.inner.project_id, - &mut transaction, - ) - .await?; - } - - if let Some(featured) = &new_version.featured { - sqlx::query!( - " - UPDATE versions - SET featured = $1 - WHERE (id = $2) - ", - featured, - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(primary_file) = &new_version.primary_file { - let result = sqlx::query!( - " - SELECT f.id id FROM hashes h - INNER JOIN files f ON h.file_id = f.id - WHERE h.algorithm = $2 AND h.hash = $1 - ", - primary_file.1.as_bytes(), - primary_file.0 - ) - .fetch_optional(&**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Specified file with hash {} does not exist.", - primary_file.1.clone() - )) - })?; - - sqlx::query!( - " - UPDATE files - SET is_primary = FALSE - WHERE (version_id = $1) - ", - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - UPDATE files - SET is_primary = TRUE - WHERE (id = $1) - ", - result.id, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(body) = &new_version.changelog { - sqlx::query!( - " - UPDATE versions - SET changelog = $1 - WHERE (id = $2) - ", - body, - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(downloads) = &new_version.downloads { - if !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "You don't have permission to set the downloads of this mod".to_string(), - )); - } - - sqlx::query!( - " - UPDATE versions - SET downloads = $1 - WHERE (id = $2) - ", - *downloads as i32, - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - let diff = *downloads - (version_item.inner.downloads as u32); - - sqlx::query!( - " - UPDATE mods - SET downloads = downloads + $1 - WHERE (id = $2) - ", - diff as i32, - version_item.inner.project_id as database::models::ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(status) = &new_version.status { - if !status.can_be_requested() { - return Err(ApiError::InvalidInput( - "The requested status cannot be set!".to_string(), - )); - } - - sqlx::query!( - " - UPDATE versions - SET status = $1 - WHERE (id = $2) - ", - status.as_str(), - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(file_types) = &new_version.file_types { - for file_type in file_types { - let result = sqlx::query!( - " - SELECT f.id id FROM hashes h - INNER JOIN files f ON h.file_id = f.id - WHERE h.algorithm = $2 AND h.hash = $1 - ", - file_type.hash.as_bytes(), - file_type.algorithm - ) - .fetch_optional(&**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Specified file with hash {} does not exist.", - file_type.algorithm.clone() - )) - })?; - - sqlx::query!( - " - UPDATE files - SET file_type = $2 - WHERE (id = $1) - ", - result.id, - file_type.file_type.as_ref().map(|x| x.as_str()), - ) - .execute(&mut *transaction) - .await?; - } - } - - // delete any images no longer in the changelog - let checkable_strings: Vec<&str> = vec![&new_version.changelog] - .into_iter() - .filter_map(|x| x.as_ref().map(|y| y.as_str())) - .collect(); - let context = ImageContext::Version { - version_id: Some(version_item.inner.id.into()), - }; - - img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; - - database::models::Version::clear_cache(&version_item, &redis).await?; - database::models::Project::clear_cache( - version_item.inner.project_id, - None, - Some(true), - &redis, - ) - .await?; - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this version!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + // TODO: move route to v3 + Ok(HttpResponse::Ok().json("")) } #[derive(Deserialize)] diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs new file mode 100644 index 00000000..208a7299 --- /dev/null +++ b/src/routes/v2_reroute.rs @@ -0,0 +1,107 @@ +use actix_multipart::Multipart; +use actix_web::{web::Payload, HttpRequest}; +use futures::{TryStreamExt, StreamExt}; + +use super::{v3::project_creation::CreateError, ApiError}; + +// const for ignore_headers +const IGNORE_HEADERS: [&str; 3] = [ + "content-type", + "content-length", + "accept-encoding", +]; + +pub async fn reroute_patch(url : &str, req : HttpRequest, json : serde_json::Value) -> Result { + // Forwarding headers + let mut headers = reqwest::header::HeaderMap::new(); + for (key, value) in req.headers() { + if !IGNORE_HEADERS.contains(&key.as_str()) { + headers.insert(key.clone(), value.clone()); + } + } + + // Sending the request + let client = reqwest::Client::new(); + Ok(client.patch(url) + .headers(headers) + .json(&json) + .send() + .await?) +} + +pub async fn reroute_multipart(url : &str, req : HttpRequest, mut payload : Multipart, closure: impl Fn(&mut serde_json::Value)) -> Result { + println!("print 3!"); + + // Forwarding headers + let mut headers = reqwest::header::HeaderMap::new(); + for (key, value) in req.headers() { + if !IGNORE_HEADERS.contains(&key.as_str()) { + headers.insert(key.clone(), value.clone()); + } + } + println!("print 4!"); + + // Forwarding multipart data + let mut body = reqwest::multipart::Form::new(); + println!("print 5!"); + + // Data field + if let Ok(Some(mut field)) = payload.try_next().await { + // The first multipart field must be named "data" and contain a JSON + let content_disposition = field.content_disposition(); + let name = content_disposition + .get_name() + .ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?; + + if name != "data" { + return Err(CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + ))); + } + println!("print 7!"); + + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); + } + let mut data: serde_json::Value = serde_json::from_slice(&data)?; + + // Now that we have the json data, execute the closure + closure(&mut data); + + // Re-encode the json data and add it to the body + let data = serde_json::to_string(&data)?; + body = body.part("data", reqwest::multipart::Part::text(data)); + } + + // Forward every other field exactly as is + while let Ok(Some(field)) = payload.try_next().await { + let content_type = field.content_type().map(|ct| ct.to_string()).unwrap_or("text/plain".to_string()); + let field_name = field.name().to_string(); + let content_disposition = field.content_disposition().clone(); + let filename = content_disposition.get_filename().unwrap_or_default().to_string(); + + let bytes: Vec = field + .map(|chunk| chunk.unwrap().to_vec()) // Convert each chunk to Vec + .fold(Vec::new(), |mut acc, vec| { // Collect all chunks into one Vec + acc.extend(vec); + async move { acc } + }) + .await; + + let part = reqwest::multipart::Part::bytes(bytes) + .file_name(filename) + .mime_str(&content_type) + .unwrap(); + + body = body.part(field_name, part); + } + + // Sending the request + let client = reqwest::Client::new(); + Ok(client.post(url) + .headers(headers) + .multipart(body) + .send() + .await?) +} \ No newline at end of file diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index ddfb05e5..e2e593c3 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -3,11 +3,23 @@ use crate::util::cors::default_cors; use actix_web::{web, HttpResponse}; use serde_json::json; +pub mod projects; +pub mod project_creation; +pub mod tags; +pub mod versions; +pub mod version_creation; +pub mod version_file; + + pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("v3") .wrap(default_cors()) - .route("", web::get().to(hello_world)), + .configure(project_creation::config) + .configure(projects::config) + .configure(tags::config) + .configure(version_file::config) + .configure(versions::config), ); } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs new file mode 100644 index 00000000..ae956487 --- /dev/null +++ b/src/routes/v3/project_creation.rs @@ -0,0 +1,846 @@ +use super::version_creation::InitialVersionData; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue}; +use crate::database::models::thread_item::ThreadBuilder; +use crate::database::models::{self, image_item, User, DatabaseError}; +use crate::database::redis::RedisPool; +use crate::file_hosting::{FileHost, FileHostingError}; +use crate::models::error::ApiError; +use crate::models::ids::ImageId; +use crate::models::images::{Image, ImageContext}; +use crate::models::pats::Scopes; +use crate::models::projects::{ + DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, + VersionStatus, +}; +use crate::models::teams::ProjectPermissions; +use crate::models::threads::ThreadType; +use crate::models::users::UserId; +use crate::queue::session::AuthQueue; +use crate::search::indexing::IndexingError; +use crate::util::routes::read_from_field; +use crate::util::validate::validation_errors_to_string; +use actix_multipart::{Field, Multipart}; +use actix_web::http::StatusCode; +use actix_web::web::Data; +use actix_web::{post, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::stream::StreamExt; +use image::ImageError; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; +use std::sync::Arc; +use thiserror::Error; +use validator::Validate; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(project_create); +} + +#[derive(Error, Debug)] +pub enum CreateError { + #[error("Environment Error")] + EnvError(#[from] dotenvy::Error), + #[error("An unknown database error occurred")] + SqlxDatabaseError(#[from] sqlx::Error), + #[error("Database Error: {0}")] + DatabaseError(#[from] models::DatabaseError), + #[error("Indexing Error: {0}")] + IndexingError(#[from] IndexingError), + #[error("Error while parsing multipart payload: {0}")] + MultipartError(#[from] actix_multipart::MultipartError), + #[error("Error while parsing JSON: {0}")] + SerDeError(#[from] serde_json::Error), + #[error("Error while validating input: {0}")] + ValidationError(String), + #[error("Error while uploading file: {0}")] + FileHostingError(#[from] FileHostingError), + #[error("Error while validating uploaded file: {0}")] + FileValidationError(#[from] crate::validate::ValidationError), + #[error("{}", .0)] + MissingValueError(String), + #[error("Invalid format for image: {0}")] + InvalidIconFormat(String), + #[error("Error with multipart data: {0}")] + InvalidInput(String), + #[error("Invalid game version: {0}")] + InvalidGameVersion(String), + #[error("Invalid loader: {0}")] + InvalidLoader(String), + #[error("Invalid category: {0}")] + InvalidCategory(String), + #[error("Invalid file type for version file: {0}")] + InvalidFileType(String), + #[error("Slug collides with other project's id!")] + SlugCollision, + #[error("Authentication Error: {0}")] + Unauthorized(#[from] AuthenticationError), + #[error("Authentication Error: {0}")] + CustomAuthenticationError(String), + #[error("Image Parsing Error: {0}")] + ImageError(#[from] ImageError), + #[error("Reroute Error: {0}")] + RerouteError(#[from] reqwest::Error), +} + +impl actix_web::ResponseError for CreateError { + fn status_code(&self) -> StatusCode { + match self { + CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::SqlxDatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::FileHostingError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::SerDeError(..) => StatusCode::BAD_REQUEST, + CreateError::MultipartError(..) => StatusCode::BAD_REQUEST, + CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, + CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, + CreateError::CustomAuthenticationError(..) => StatusCode::UNAUTHORIZED, + CreateError::SlugCollision => StatusCode::BAD_REQUEST, + CreateError::ValidationError(..) => StatusCode::BAD_REQUEST, + CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST, + CreateError::ImageError(..) => StatusCode::BAD_REQUEST, + CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiError { + error: match self { + CreateError::EnvError(..) => "environment_error", + CreateError::SqlxDatabaseError(..) => "database_error", + CreateError::DatabaseError(..) => "database_error", + CreateError::IndexingError(..) => "indexing_error", + CreateError::FileHostingError(..) => "file_hosting_error", + CreateError::SerDeError(..) => "invalid_input", + CreateError::MultipartError(..) => "invalid_input", + CreateError::MissingValueError(..) => "invalid_input", + CreateError::InvalidIconFormat(..) => "invalid_input", + CreateError::InvalidInput(..) => "invalid_input", + CreateError::InvalidGameVersion(..) => "invalid_input", + CreateError::InvalidLoader(..) => "invalid_input", + CreateError::InvalidCategory(..) => "invalid_input", + CreateError::InvalidFileType(..) => "invalid_input", + CreateError::Unauthorized(..) => "unauthorized", + CreateError::CustomAuthenticationError(..) => "unauthorized", + CreateError::SlugCollision => "invalid_input", + CreateError::ValidationError(..) => "invalid_input", + CreateError::FileValidationError(..) => "invalid_input", + CreateError::ImageError(..) => "invalid_image", + CreateError::RerouteError(..) => "reroute_error", + }, + description: &self.to_string(), + }) + } +} + +fn default_project_type() -> String { + "mod".to_string() +} + +fn default_requested_status() -> ProjectStatus { + ProjectStatus::Approved +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +struct ProjectCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "mod_name")] + /// The title or name of the project. + pub title: String, + /// The name of the game that the project is for. + /// This must be a valid game name. + #[validate(length(min = 1, max = 64))] + pub game_name: String, + #[validate(length(min = 1, max = 64))] + #[serde(default = "default_project_type")] + /// The project type of this mod + pub project_type: String, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + #[serde(alias = "mod_slug")] + /// The slug of a project, used for vanity URLs + pub slug: String, + #[validate(length(min = 3, max = 255))] + #[serde(alias = "mod_description")] + /// A short description of the project. + pub description: String, + #[validate(length(max = 65536))] + #[serde(alias = "mod_body")] + /// A long description of the project, in markdown. + pub body: String, + + #[validate(length(max = 3))] + /// A list of the categories that the project is in. + pub categories: Vec, + #[validate(length(max = 256))] + #[serde(default = "Vec::new")] + /// A list of the categories that the project is in. + pub additional_categories: Vec, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to where to submit bugs or issues with the project. + pub issues_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the source code for the project. + pub source_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's wiki page or other relevant information. + pub wiki_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's license page + pub license_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's discord. + pub discord_url: Option, + /// An optional list of all donation links the project has\ + #[validate] + pub donation_urls: Option>, + + /// An optional boolean. If true, the project will be created as a draft. + pub is_draft: Option, + + /// The license id that the project follows + pub license_id: String, + + #[serde(default = "default_requested_status")] + /// The status of the mod to be set once it is approved + pub requested_status: ProjectStatus, + + // Associations to uploaded images in body/description + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + + /// The id of the organization to create the project in + pub organization_id: Option, +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct NewGalleryItem { + /// The name of the multipart item where the gallery media is located + pub item: String, + /// Whether the gallery item should show in search or not + pub featured: bool, + #[validate(length(min = 1, max = 2048))] + /// The title of the gallery item + pub title: Option, + #[validate(length(min = 1, max = 2048))] + /// The description of the gallery item + pub description: Option, + pub ordering: i64, +} + +pub struct UploadedFile { + pub file_id: String, + pub file_name: String, +} + +pub async fn undo_uploads( + file_host: &dyn FileHost, + uploaded_files: &[UploadedFile], +) -> Result<(), CreateError> { + for file in uploaded_files { + file_host + .delete_file_version(&file.file_id, &file.file_name) + .await?; + } + Ok(()) +} + +#[post("project")] +pub async fn project_create( + req: HttpRequest, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, +) -> Result { + println!("Received project create!"); + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let result = project_create_inner( + req, + &mut payload, + &mut transaction, + &***file_host, + &mut uploaded_files, + &client, + &redis, + &session_queue, + ) + .await; +println!("Out3!"); + + if result.is_err() { + let undo_result = undo_uploads(&***file_host, &uploaded_files).await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + println!("Out!"); + + result +} +/* + +Project Creation Steps: +Get logged in user + Must match the author in the version creation + +1. Data + - Gets "data" field from multipart form; must be first + - Verification: string lengths + - Create versions + - Some shared logic with version creation + - Create list of VersionBuilders + - Create ProjectBuilder + +2. Upload + - Icon: check file format & size + - Upload to backblaze & record URL + - Project files + - Check for matching version + - File size limits? + - Check file type + - Eventually, malware scan + - Upload to backblaze & create VersionFileBuilder + - + +3. Creation + - Database stuff + - Add project data to indexing queue +*/ + +#[allow(clippy::too_many_arguments)] +async fn project_create_inner( + req: HttpRequest, + payload: &mut Multipart, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + pool: &PgPool, + redis: &RedisPool, + session_queue: &AuthQueue, +) -> Result { + // The base URL for files uploaded to backblaze + let cdn_url = dotenvy::var("CDN_URL")?; + println!("in1!"); + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Some(&[Scopes::PROJECT_CREATE]), + ) + .await? + .1; +println!("in2!"); + + let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); + + let project_create_data; + { + // The first multipart field must be named "data" and contain a + // JSON `ProjectCreateData` object. + println!("in3"); + + let mut field = payload + .next() + .await + .map(|m| m.map_err(CreateError::MultipartError)) + .unwrap_or_else(|| { + Err(CreateError::MissingValueError(String::from( + "No `data` field in multipart upload", + ))) + })?; + println!("in4"); + + let content_disposition = field.content_disposition(); + let name = content_disposition + .get_name() + .ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?; + + if name != "data" { + return Err(CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + ))); + } + println!("in5"); + + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); + } + let create_data: ProjectCreateData = serde_json::from_slice(&data)?; + + create_data + .validate() + .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + + let slug_project_id_option: Option = + serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); + + if let Some(slug_project_id) = slug_project_id_option { + let slug_project_id: models::ids::ProjectId = slug_project_id.into(); + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) + ", + slug_project_id as models::ids::ProjectId + ) + .fetch_one(&mut *transaction) + .await + .map_err(|e| CreateError::DatabaseError(e.into()))?; + + if results.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + } + println!("in6"); + + { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + ", + create_data.slug + ) + .fetch_one(&mut *transaction) + .await + .map_err(|e| CreateError::DatabaseError(e.into()))?; + + if results.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + } + println!("in7"); + + project_create_data = create_data; + } + println!("in8"); + + let project_type_id = models::categories::ProjectType::get_id( + project_create_data.project_type.as_str(), + &mut *transaction, + ) + .await? + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Project Type {} does not exist.", + project_create_data.project_type.clone() + )) + })?; + + let mut icon_data = None; + println!("in9"); + + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError("Missing content name".to_string()) + })?; + + let (file_name, file_extension) = + super::version_creation::get_name_ext(&content_disposition)?; + + if name == "icon" { + if icon_data.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Projects can only have one icon", + ))); + } + // Upload the icon to the cdn + icon_data = Some( + process_icon_upload( + uploaded_files, + project_id.0, + file_extension, + file_host, + field, + &cdn_url, + ) + .await?, + ); + return Ok(()); + } + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + println!("in10"); + + { + + // Convert the list of category names to actual categories + let mut categories = Vec::with_capacity(project_create_data.categories.len()); + for category in &project_create_data.categories { + let id = models::categories::Category::get_id_project( + category, + project_type_id, + &mut *transaction, + ) + .await? + .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; + categories.push(id); + } + + let mut additional_categories = + Vec::with_capacity(project_create_data.additional_categories.len()); + for category in &project_create_data.additional_categories { + let id = models::categories::Category::get_id_project( + category, + project_type_id, + &mut *transaction, + ) + .await? + .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; + additional_categories.push(id); + } + + let team = models::team_item::TeamBuilder { + members: vec![models::team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + role: crate::models::teams::OWNER_ROLE.to_owned(), + // Allow all permissions for project creator, even if attached to a project + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }], + }; + println!("in11"); + + let team_id = team.insert(&mut *transaction).await?; + + let status = ProjectStatus::Draft; + if !project_create_data.requested_status.can_be_requested() { + return Err(CreateError::InvalidInput(String::from( + "Specified requested status is not allowed to be requested", + ))); + } + + let game_name = &project_create_data.game_name; + let game_id = models::loader_fields::Game::get_id( + &project_create_data.game_name, + &mut *transaction, + ).await?.ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; + + let license_id = + spdx::Expression::parse(&project_create_data.license_id).map_err(|err| { + CreateError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) + })?; + + let mut donation_urls = vec![]; + println!("in12"); + + if let Some(urls) = &project_create_data.donation_urls { + for url in urls { + let platform_id = + models::categories::DonationPlatform::get_id(&url.id, &mut *transaction) + .await? + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Donation platform {} does not exist.", + url.id.clone() + )) + })?; + + donation_urls.push(models::project_item::DonationUrl { + platform_id, + platform_short: "".to_string(), + platform_name: "".to_string(), + url: url.url.clone(), + }) + } + } + println!("in13"); + + let project_builder_actual = models::project_item::ProjectBuilder { + project_id: project_id.into(), + game_id, + project_type_id, + team_id, + organization_id: project_create_data.organization_id, + title: project_create_data.title, + description: project_create_data.description, + body: project_create_data.body, + icon_url: icon_data.clone().map(|x| x.0), + issues_url: project_create_data.issues_url, + source_url: project_create_data.source_url, + wiki_url: project_create_data.wiki_url, + + license_url: project_create_data.license_url, + discord_url: project_create_data.discord_url, + categories, + additional_categories, + status, + requested_status: Some(project_create_data.requested_status), + license: license_id.to_string(), + slug: Some(project_create_data.slug), + donation_urls, + color: icon_data.and_then(|x| x.1), + monetization_status: MonetizationStatus::Monetized, + }; + let project_builder = project_builder_actual.clone(); + + let now = Utc::now(); + + let id = project_builder_actual.insert(&mut *transaction).await?; + User::clear_project_cache(&[current_user.id.into()], redis).await?; + + for image_id in project_create_data.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Project { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'project' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET mod_id = $1 + WHERE id = $2 + ", + id as models::ids::ProjectId, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + + let thread_id = ThreadBuilder { + type_: ThreadType::Project, + members: vec![], + project_id: Some(id), + report_id: None, + } + .insert(&mut *transaction) + .await?; + + let response = crate::models::projects::Project { + id: project_id, + slug: project_builder.slug.clone(), + project_type: project_create_data.project_type.clone(), + team: team_id.into(), + organization: project_create_data.organization_id.map(|x| x.into()), + title: project_builder.title.clone(), + description: project_builder.description.clone(), + body: project_builder.body.clone(), + body_url: None, + published: now, + updated: now, + approved: None, + queued: None, + status, + requested_status: project_builder.requested_status, + moderator_message: None, + license: License { + id: project_create_data.license_id.clone(), + name: "".to_string(), + url: project_builder.license_url.clone(), + }, + downloads: 0, + followers: 0, + categories: project_create_data.categories, + additional_categories: project_create_data.additional_categories, + loaders: vec![], + versions: vec![], + gallery: vec![], + icon_url: project_builder.icon_url.clone(), + issues_url: project_builder.issues_url.clone(), + source_url: project_builder.source_url.clone(), + wiki_url: project_builder.wiki_url.clone(), + discord_url: project_builder.discord_url.clone(), + donation_urls: project_create_data.donation_urls.clone(), + color: project_builder.color, + thread_id: thread_id.into(), + monetization_status: MonetizationStatus::Monetized, + }; + + Ok(HttpResponse::Ok().json(response)) + } +} + +async fn create_initial_version( + version_data: &InitialVersionData, + project_id: ProjectId, + author: UserId, + all_game_versions: &[models::loader_fields::GameVersion], + all_loaders: &[models::loader_fields::Loader], + project_type: &str, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + if version_data.project_id.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Found project id in initial version for new project", + ))); + } + + version_data + .validate() + .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; + + // Randomly generate a new id to be used for the version + let version_id: VersionId = models::generate_version_id(transaction).await?.into(); + + // let game_versions = version_data + // .game_versions + // .iter() + // .map(|x| { + // all_game_versions + // .iter() + // .find(|y| y.version == x.0) + // .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) + // .map(|y| y.id) + // }) + // .collect::, CreateError>>()?; + + let loaders = version_data + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| { + y.loader == x.0 + && y.supported_project_types + .contains(&project_type.to_string()) + }) + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + .map(|y| y.id) + }) + .collect::, CreateError>>()?; + + let dependencies = version_data + .dependencies + .iter() + .map(|d| models::version_item::DependencyBuilder { + version_id: d.version_id.map(|x| x.into()), + project_id: d.project_id.map(|x| x.into()), + dependency_type: d.dependency_type.to_string(), + file_name: None, + }) + .collect::>(); + + let version = models::version_item::VersionBuilder { + version_id: version_id.into(), + project_id: project_id.into(), + author_id: author.into(), + name: version_data.version_title.clone(), + version_number: version_data.version_number.clone(), + changelog: version_data.version_body.clone().unwrap_or_default(), + files: Vec::new(), + dependencies, + loaders, + featured: version_data.featured, + status: VersionStatus::Listed, + version_type: version_data.release_channel.to_string(), + requested_status: None, + }; + + Ok(version) +} + +async fn process_icon_upload( + uploaded_files: &mut Vec, + id: u64, + file_extension: &str, + file_host: &dyn FileHost, + mut field: Field, + cdn_url: &str, +) -> Result<(String, Option), CreateError> { + if let Some(content_type) = crate::util::ext::get_image_content_type(file_extension) { + let data = read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&data)?; + + let hash = sha1::Sha1::from(&data).hexdigest(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{id}/{hash}.{file_extension}"), + data.freeze(), + ) + .await?; + + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: upload_data.file_name.clone(), + }); + + Ok((format!("{}/{}", cdn_url, upload_data.file_name), color)) + } else { + Err(CreateError::InvalidIconFormat(file_extension.to_string())) + } +} diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs new file mode 100644 index 00000000..46dc7f7b --- /dev/null +++ b/src/routes/v3/projects.rs @@ -0,0 +1,885 @@ +use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; +use crate::database::{self}; +use crate::database::models::image_item; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::project_item::{GalleryItem, ModCategory}; +use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::images::ImageContext; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::projects::{ + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, +}; +use crate::models::teams::ProjectPermissions; +use crate::models::threads::MessageBody; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::search::{search_for_project, SearchConfig, SearchError}; +use crate::util::img; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Utc}; +use futures::TryStreamExt; +use meilisearch_sdk::indexes::IndexesResults; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; +use crate::database::models as db_models; +use crate::database::models::ids as db_ids; + +pub fn config(cfg: &mut web::ServiceConfig) { + + cfg.service( + web::scope("project") + .service(project_edit) + ); +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditProject { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + #[validate(length(min = 3, max = 256))] + pub description: Option, + #[validate(length(max = 65536))] + pub body: Option, + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub issues_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub source_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub wiki_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub license_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub discord_url: Option>, + #[validate] + pub donation_urls: Option>, + pub license_id: Option, + pub client_side: Option, + pub server_side: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub slug: Option, + pub status: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub requested_status: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 2000))] + pub moderation_message: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 65536))] + pub moderation_message_body: Option>, + pub monetization_status: Option, +} + +#[patch("{id}")] +pub async fn project_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + config: web::Data, + new_project: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + new_project + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let string = info.into_inner().0; + let result = db_models::Project::get(&string, &**pool, &redis).await?; + + if let Some(project_item) = result { + let id = project_item.inner.id; + + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); + + if let Some(perms) = permissions { + let mut transaction = pool.begin().await?; + + if let Some(title) = &new_project.title { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the title of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET title = $1 + WHERE (id = $2) + ", + title.trim(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_project.description { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET description = $1 + WHERE (id = $2) + ", + description, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_project.status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the status of this project!" + .to_string(), + )); + } + + if !(user.role.is_mod() + || !project_item.inner.status.is_approved() + && status == &ProjectStatus::Processing + || project_item.inner.status.is_approved() && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } + + if status == &ProjectStatus::Processing { + if project_item.versions.is_empty() { + return Err(ApiError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() + WHERE (id = $1) + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE threads + SET show_in_mod_inbox = FALSE + WHERE id = $1 + ", + project_item.thread_id as db_ids::ThreadId, + ) + .execute(&mut *transaction) + .await?; + } + + if status.is_approved() && !project_item.inner.status.is_approved() { + sqlx::query!( + " + UPDATE mods + SET approved = NOW() + WHERE id = $1 AND approved IS NULL + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if status.is_searchable() && !project_item.inner.webhook_sent { + if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { + crate::util::webhook::send_discord_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + None, + ) + .await + .ok(); + + sqlx::query!( + " + UPDATE mods + SET webhook_sent = TRUE + WHERE id = $1 + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if user.role.is_mod() { + if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK") { + crate::util::webhook::send_discord_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + Some( + format!( + "**[{}]({}/user/{})** changed project status from **{}** to **{}**", + user.username, + dotenvy::var("SITE_URL")?, + user.username, + &project_item.inner.status.as_friendly_str(), + status.as_friendly_str(), + ) + .to_string(), + ), + ) + .await + .ok(); + } + } + + if team_member.map(|x| !x.accepted).unwrap_or(true) { + let notified_members = sqlx::query!( + " + SELECT tm.user_id id + FROM team_members tm + WHERE tm.team_id = $1 AND tm.accepted + ", + project_item.inner.team_id as db_ids::TeamId + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { Ok(e.right().map(|c| db_models::UserId(c.id))) }) + .try_collect::>() + .await?; + + NotificationBuilder { + body: NotificationBody::StatusChange { + project_id: project_item.inner.id.into(), + old_status: project_item.inner.status, + new_status: *status, + }, + } + .insert_many(notified_members, &mut transaction, &redis) + .await?; + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: MessageBody::StatusChange { + new_status: *status, + old_status: project_item.inner.status, + }, + thread_id: project_item.thread_id, + } + .insert(&mut transaction) + .await?; + + sqlx::query!( + " + UPDATE mods + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + if project_item.inner.status.is_searchable() && !status.is_searchable() { + delete_from_index(id.into(), config).await?; + } + } + + if let Some(requested_status) = &new_project.requested_status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the requested status of this project!" + .to_string(), + )); + } + + if !requested_status + .map(|x| x.can_be_requested()) + .unwrap_or(true) + { + return Err(ApiError::InvalidInput(String::from( + "Specified status cannot be requested!", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET requested_status = $1 + WHERE (id = $2) + ", + requested_status.map(|x| x.as_str()), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if perms.contains(ProjectPermissions::EDIT_DETAILS) { + if new_project.categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = FALSE + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if new_project.additional_categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = TRUE + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(categories) = &new_project.categories { + edit_project_categories( + categories, + &perms, + id as db_ids::ProjectId, + false, + &mut transaction, + ) + .await?; + } + + if let Some(categories) = &new_project.additional_categories { + edit_project_categories( + categories, + &perms, + id as db_ids::ProjectId, + true, + &mut transaction, + ) + .await?; + } + + if let Some(issues_url) = &new_project.issues_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the issues URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET issues_url = $1 + WHERE (id = $2) + ", + issues_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(source_url) = &new_project.source_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the source URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET source_url = $1 + WHERE (id = $2) + ", + source_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(wiki_url) = &new_project.wiki_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the wiki URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET wiki_url = $1 + WHERE (id = $2) + ", + wiki_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(license_url) = &new_project.license_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET license_url = $1 + WHERE (id = $2) + ", + license_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(discord_url) = &new_project.discord_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the discord URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET discord_url = $1 + WHERE (id = $2) + ", + discord_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(slug) = &new_project.slug { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the slug of this project!" + .to_string(), + )); + } + + let slug_project_id_option: Option = parse_base62(slug).ok(); + if let Some(slug_project_id) = slug_project_id_option { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) + ", + slug_project_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!".to_string(), + )); + } + } + + // Make sure the new slug is different from the old one + // We are able to unwrap here because the slug is always set + if !slug.eq(&project_item.inner.slug.clone().unwrap_or_default()) { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + ", + slug + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!".to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE mods + SET slug = LOWER($1) + WHERE (id = $2) + ", + Some(slug), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(license) = &new_project.license_id { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license of this project!" + .to_string(), + )); + } + + let mut license = license.clone(); + + if license.to_lowercase() == "arr" { + license = models::projects::DEFAULT_LICENSE_ID.to_string(); + } + + spdx::Expression::parse(&license).map_err(|err| { + ApiError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) + })?; + + sqlx::query!( + " + UPDATE mods + SET license = $1 + WHERE (id = $2) + ", + license, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + if let Some(donations) = &new_project.donation_urls { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the donation links of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + DELETE FROM mods_donations + WHERE joining_mod_id = $1 + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + for donation in donations { + let platform_id = db_models::categories::DonationPlatform::get_id( + &donation.id, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Platform {} does not exist.", + donation.id.clone() + )) + })?; + + sqlx::query!( + " + INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url) + VALUES ($1, $2, $3) + ", + id as db_ids::ProjectId, + platform_id as db_ids::DonationPlatformId, + donation.url + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(moderation_message) = &new_project.moderation_message { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() || moderation_message.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = $1 + WHERE (id = $2) + ", + moderation_message.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(moderation_message_body) = &new_project.moderation_message_body { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() + || moderation_message_body.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message body of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message_body = $1 + WHERE (id = $2) + ", + moderation_message_body.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(body) = &new_project.body { + if !perms.contains(ProjectPermissions::EDIT_BODY) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the body of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET body = $1 + WHERE (id = $2) + ", + body, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(monetization_status) = &new_project.monetization_status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + if (*monetization_status == MonetizationStatus::ForceDemonetized + || project_item.inner.monetization_status + == MonetizationStatus::ForceDemonetized) + && !user.role.is_mod() + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET monetization_status = $1 + WHERE (id = $2) + ", + monetization_status.as_str(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + // check new description and body for links to associated images + // if they no longer exist in the description or body, delete them + let checkable_strings: Vec<&str> = vec![&new_project.description, &new_project.body] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + + let context = ImageContext::Project { + project_id: Some(id.into()), + }; + + img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this project!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn edit_project_categories( + categories: &Vec, + perms: &ProjectPermissions, + project_id: db_ids::ProjectId, + additional: bool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), ApiError> { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + let additional_str = if additional { "additional " } else { "" }; + return Err(ApiError::CustomAuthentication(format!( + "You do not have the permissions to edit the {additional_str}categories of this project!" + ))); + } + + let mut mod_categories = Vec::new(); + for category in categories { + let category_id = db_models::categories::Category::get_id(category, &mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) + })?; + mod_categories.push(ModCategory::new(project_id, category_id, additional)); + } + ModCategory::insert_many(mod_categories, &mut *transaction).await?; + + Ok(()) +} + +pub async fn delete_from_index( + id: ProjectId, + config: web::Data, +) -> Result<(), meilisearch_sdk::errors::Error> { + let client = meilisearch_sdk::client::Client::new(&*config.address, &*config.key); + + let indexes: IndexesResults = client.get_indexes().await?; + + for index in indexes.results { + index.delete_document(id.to_string()).await?; + } + + Ok(()) +} diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs new file mode 100644 index 00000000..6ca90321 --- /dev/null +++ b/src/routes/v3/tags.rs @@ -0,0 +1,131 @@ +use super::ApiError; +use crate::database::models; +use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType}; +use crate::database::models::loader_fields::{Loader, GameVersion}; +use crate::database::redis::RedisPool; +use actix_web::{get, web, HttpResponse}; +use chrono::{DateTime, Utc}; +use models::categories::{Category}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("tag") + .service(category_list) + // .service(loader_list) + // .service(game_version_list) + // .service(side_type_list), + ); +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct CategoryData { + icon: String, + name: String, + project_type: String, + header: String, +} + +#[get("category")] +pub async fn category_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = Category::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| CategoryData { + icon: x.icon, + name: x.category, + project_type: x.project_type, + header: x.header, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(results)) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LoaderData { + icon: String, + name: String, + supported_project_types: Vec, +} + +// #[derive(serde::Deserialize)] +// struct LoaderList { +// game: String +// } +// #[get("loader")] +// pub async fn loader_list( +// data: web::Query, +// pool: web::Data, +// redis: web::Data, +// ) -> Result { +// let mut results = Loader::list(&data.game,&**pool, &redis) +// .await? +// .into_iter() +// .map(|x| LoaderData { +// icon: x.icon, +// name: x.loader, +// supported_project_types: x.supported_project_types, +// }) +// .collect::>(); + +// results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + +// Ok(HttpResponse::Ok().json(results)) +// } + +#[derive(serde::Serialize)] +pub struct GameVersionQueryData { + pub version: String, + pub version_type: String, + pub date: DateTime, + pub major: bool, +} + +#[derive(serde::Deserialize)] +pub struct GameVersionQuery { + #[serde(rename = "type")] + type_: Option, + major: Option, +} + +// #[get("game_version")] +// pub async fn game_version_list( +// pool: web::Data, +// query: web::Query, +// redis: web::Data, +// ) -> Result { +// let results: Vec = if query.type_.is_some() || query.major.is_some() { +// GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool, &redis).await? +// } else { +// GameVersion::list(&**pool, &redis).await? +// } +// .into_iter() +// .map(|x| GameVersionQueryData { +// version: x.version, +// version_type: x.type_, +// date: x.created, +// major: x.major, +// }) +// .collect(); + +// Ok(HttpResponse::Ok().json(results)) +// } + +#[derive(serde::Serialize)] +pub struct License { + short: String, + name: String, +} + +// #[get("side_type")] +// pub async fn side_type_list( +// pool: web::Data, +// redis: web::Data, +// ) -> Result { +// let results = SideType::list(&**pool, &redis).await?; +// Ok(HttpResponse::Ok().json(results)) +// } diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs new file mode 100644 index 00000000..c47063fa --- /dev/null +++ b/src/routes/v3/version_creation.rs @@ -0,0 +1,929 @@ +use super::project_creation::{CreateError, UploadedFile}; +use crate::auth::get_user_from_headers; +use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue, LoaderFieldEnum}; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::version_item::{ + DependencyBuilder, VersionBuilder, VersionFileBuilder, +}; +use crate::database::models::{self, image_item, Organization, DatabaseError}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::images::{Image, ImageContext, ImageId}; +use crate::models::notifications::NotificationBody; +use crate::models::pack::PackFileHash; +use crate::models::pats::Scopes; +use crate::models::projects::{ + Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile, + VersionId, VersionStatus, VersionType, +}; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::util::routes::read_from_field; +use crate::util::validate::validation_errors_to_string; +use crate::validate::{validate_file, ValidationResult}; +use actix_multipart::{Field, Multipart}; +use actix_web::web::Data; +use actix_web::{post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::stream::StreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; + +fn default_requested_status() -> VersionStatus { + VersionStatus::Listed +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct InitialVersionData { + #[serde(alias = "mod_id")] + pub project_id: Option, + #[validate(length(min = 1, max = 256))] + pub file_parts: Vec, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: String, + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "name")] + pub version_title: String, + #[validate(length(max = 65536))] + #[serde(alias = "changelog")] + pub version_body: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Vec, + #[serde(alias = "version_type")] + pub release_channel: VersionType, + #[validate(length(min = 1))] + pub loaders: Vec, + pub featured: bool, + pub primary_file: Option, + #[serde(default = "default_requested_status")] + pub status: VersionStatus, + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, + // Associations to uploaded images in changelog + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +struct InitialFileData { + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, +} + +// under `/api/v1/version` +#[post("version")] +pub async fn version_create( + req: HttpRequest, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let result = version_create_inner( + req, + &mut payload, + &mut transaction, + &redis, + &***file_host, + &mut uploaded_files, + &client, + &session_queue, + ) + .await; + + if result.is_err() { + let undo_result = + super::project_creation::undo_uploads(&***file_host, &uploaded_files).await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn version_create_inner( + req: HttpRequest, + payload: &mut Multipart, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + pool: &PgPool, + session_queue: &AuthQueue, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + + let mut initial_version_data = None; + let mut version_builder = None; + + // let all_game_versions = models::loader_fields::GameVersion::list(&mut *transaction, redis).await?; + let all_loaders = models::loader_fields::Loader::list(Game::MinecraftJava.name(),&mut *transaction, redis).await?; + + let user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Some(&[Scopes::VERSION_CREATE]), + ) + .await? + .1; + + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError("Missing content name".to_string()) + })?; + + if name == "data" { + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk?); + } + + let version_create_data: InitialVersionData = serde_json::from_slice(&data)?; + initial_version_data = Some(version_create_data); + let version_create_data = initial_version_data.as_ref().unwrap(); + if version_create_data.project_id.is_none() { + return Err(CreateError::MissingValueError( + "Missing project id".to_string(), + )); + } + + version_create_data.validate().map_err(|err| { + CreateError::ValidationError(validation_errors_to_string(err, None)) + })?; + + if !version_create_data.status.can_be_requested() { + return Err(CreateError::InvalidInput( + "Status specified cannot be requested".to_string(), + )); + } + + let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); + + // Ensure that the project this version is being added to exists + let results = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", + project_id as models::ProjectId + ) + .fetch_one(&mut *transaction) + .await?; + + if !results.exists.unwrap_or(false) { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } + + // Check that the user creating this version is a team member + // of the project the version is being added to. + let team_member = models::TeamMember::get_from_user_id_project( + project_id, + user.id.into(), + &mut *transaction, + ) + .await?; + + // Get organization attached, if exists, and the member project permissions + let organization = models::Organization::get_associated_organization_project_id( + project_id, + &mut *transaction, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut *transaction, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to upload this version!".to_string(), + )); + } + + let version_id: VersionId = models::generate_version_id(transaction).await?.into(); + + let project_type = sqlx::query!( + " + SELECT name FROM project_types pt + INNER JOIN mods ON mods.project_type = pt.id + WHERE mods.id = $1 + ", + project_id as models::ProjectId, + ) + .fetch_one(&mut *transaction) + .await? + .name; + + let loaders = version_create_data + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| { + y.loader == x.0 && y.supported_project_types.contains(&project_type) + }) + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + .map(|y| y.id) + }) + .collect::, CreateError>>()?; + + let dependencies = version_create_data + .dependencies + .iter() + .map(|d| models::version_item::DependencyBuilder { + version_id: d.version_id.map(|x| x.into()), + project_id: d.project_id.map(|x| x.into()), + dependency_type: d.dependency_type.to_string(), + file_name: None, + }) + .collect::>(); + + version_builder = Some(VersionBuilder { + version_id: version_id.into(), + project_id, + author_id: user.id.into(), + name: version_create_data.version_title.clone(), + version_number: version_create_data.version_number.clone(), + changelog: version_create_data.version_body.clone().unwrap_or_default(), + files: Vec::new(), + dependencies, + loaders, + version_type: version_create_data.release_channel.to_string(), + featured: version_create_data.featured, + status: version_create_data.status, + requested_status: None, + }); + + return Ok(()); + } + + let version = version_builder.as_mut().ok_or_else(|| { + CreateError::InvalidInput(String::from("`data` field must come before file fields")) + })?; + + let project_type = sqlx::query!( + " + SELECT name FROM project_types pt + INNER JOIN mods ON mods.project_type = pt.id + WHERE mods.id = $1 + ", + version.project_id as models::ProjectId, + ) + .fetch_one(&mut *transaction) + .await? + .name; + + let version_data = initial_version_data + .clone() + .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; + + upload_file( + &mut field, + file_host, + version_data.file_parts.len(), + uploaded_files, + &mut version.files, + &mut version.dependencies, + &cdn_url, + &content_disposition, + version.project_id.into(), + version.version_id.into(), + &project_type, + version_data.loaders, + version_data.primary_file.is_some(), + version_data.primary_file.as_deref() == Some(name), + version_data.file_types.get(name).copied().flatten(), + transaction, + ) + .await?; + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + + let version_data = initial_version_data + .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; + let builder = version_builder + .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; + + if builder.files.is_empty() { + return Err(CreateError::InvalidInput( + "Versions must have at least one file uploaded to them".to_string(), + )); + } + + use futures::stream::TryStreamExt; + + let users = sqlx::query!( + " + SELECT follower_id FROM mod_follows + WHERE mod_id = $1 + ", + builder.project_id as crate::database::models::ids::ProjectId + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { Ok(e.right().map(|m| models::ids::UserId(m.follower_id))) }) + .try_collect::>() + .await?; + + let project_id: ProjectId = builder.project_id.into(); + let version_id: VersionId = builder.version_id.into(); + + NotificationBuilder { + body: NotificationBody::ProjectUpdate { + project_id, + version_id, + }, + } + .insert_many(users, &mut *transaction, redis) + .await?; + + let response = Version { + id: builder.version_id.into(), + project_id: builder.project_id.into(), + author_id: user.id, + featured: builder.featured, + name: builder.name.clone(), + version_number: builder.version_number.clone(), + changelog: builder.changelog.clone(), + changelog_url: None, + date_published: Utc::now(), + downloads: 0, + version_type: version_data.release_channel, + status: builder.status, + requested_status: builder.requested_status, + files: builder + .files + .iter() + .map(|file| VersionFile { + hashes: file + .hashes + .iter() + .map(|hash| { + ( + hash.algorithm.clone(), + // This is a hack since the hashes are currently stored as ASCII + // in the database, but represented here as a Vec. At some + // point we need to change the hash to be the real bytes in the + // database and add more processing here. + String::from_utf8(hash.hash.clone()).unwrap(), + ) + }) + .collect(), + url: file.url.clone(), + filename: file.filename.clone(), + primary: file.primary, + size: file.size, + file_type: file.file_type, + }) + .collect::>(), + dependencies: version_data.dependencies, + loaders: version_data.loaders, + }; + + let project_id = builder.project_id; + builder.insert(transaction).await?; + + for image_id in version_data.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'version' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET version_id = $1 + WHERE id = $2 + ", + version_id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + + models::Project::update_loaders(project_id, &mut *transaction).await?; + models::Project::clear_cache(project_id, None, Some(true), redis).await?; + + Ok(HttpResponse::Ok().json(response)) +} + +// under /api/v1/version/{version_id} +#[post("{version_id}/file")] +pub async fn upload_file_to_version( + req: HttpRequest, + url_data: web::Path<(VersionId,)>, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: web::Data, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let version_id = models::VersionId::from(url_data.into_inner().0); + + let result = upload_file_to_version_inner( + req, + &mut payload, + client, + &mut transaction, + redis, + &***file_host, + &mut uploaded_files, + version_id, + &session_queue, + ) + .await; + + if result.is_err() { + let undo_result = + super::project_creation::undo_uploads(&***file_host, &uploaded_files).await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn upload_file_to_version_inner( + req: HttpRequest, + payload: &mut Multipart, + client: Data, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: Data, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + version_id: models::VersionId, + session_queue: &AuthQueue, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + + let mut initial_file_data: Option = None; + let mut file_builders: Vec = Vec::new(); + + let user = get_user_from_headers( + &req, + &**client, + &redis, + session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + let result = models::Version::get(version_id, &**client, &redis).await?; + + let version = match result { + Some(v) => v, + None => { + return Err(CreateError::InvalidInput( + "An invalid version id was supplied".to_string(), + )); + } + }; + + if !user.role.is_admin() { + let team_member = models::TeamMember::get_from_user_id_project( + version.inner.project_id, + user.id.into(), + &mut *transaction, + ) + .await?; + + let organization = Organization::get_associated_organization_project_id( + version.inner.project_id, + &**client, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut *transaction, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to upload files to this version!".to_string(), + )); + } + } + + let project_id = ProjectId(version.inner.project_id.0 as u64); + + let project_type = sqlx::query!( + " + SELECT name FROM project_types pt + INNER JOIN mods ON mods.project_type = pt.id + WHERE mods.id = $1 + ", + version.inner.project_id as models::ProjectId, + ) + .fetch_one(&mut *transaction) + .await? + .name; + + let game_name = Game::MinecraftJava.name(); + let game_version_enum = LoaderFieldEnum::get("game_versions", game_name, &mut *transaction, &redis).await?.ok_or_else(|| DatabaseError::SchemaError("Could not find game version enum".to_string()))?; + let all_game_versions = LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, &redis).await?; + + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError("Missing content name".to_string()) + })?; + + if name == "data" { + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk?); + } + let file_data: InitialFileData = serde_json::from_slice(&data)?; + + initial_file_data = Some(file_data); + return Ok(()); + } + + let file_data = initial_file_data.as_ref().ok_or_else(|| { + CreateError::InvalidInput(String::from("`data` field must come before file fields")) + })?; + + let mut dependencies = version + .dependencies + .iter() + .map(|x| DependencyBuilder { + project_id: x.project_id, + version_id: x.version_id, + file_name: x.file_name.clone(), + dependency_type: x.dependency_type.clone(), + }) + .collect(); + + upload_file( + &mut field, + file_host, + 0, + uploaded_files, + &mut file_builders, + &mut dependencies, + &cdn_url, + &content_disposition, + project_id, + version_id.into(), + &project_type, + version.loaders.clone().into_iter().map(Loader).collect(), + true, + false, + file_data.file_types.get(name).copied().flatten(), + transaction, + ) + .await?; + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + + if file_builders.is_empty() { + return Err(CreateError::InvalidInput( + "At least one file must be specified".to_string(), + )); + } else { + VersionFileBuilder::insert_many(file_builders, version_id, &mut *transaction).await?; + } + + // Clear version cache + models::Version::clear_cache(&version, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +// This function is used for adding a file to a version, uploading the initial +// files for a version, and for uploading the initial version files for a project +#[allow(clippy::too_many_arguments)] +pub async fn upload_file( + field: &mut Field, + file_host: &dyn FileHost, + total_files_len: usize, + uploaded_files: &mut Vec, + version_files: &mut Vec, + dependencies: &mut Vec, + cdn_url: &str, + content_disposition: &actix_web::http::header::ContentDisposition, + project_id: ProjectId, + version_id: VersionId, + project_type: &str, + loaders: Vec, + ignore_primary: bool, + force_primary: bool, + file_type: Option, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), CreateError> { + let (file_name, file_extension) = get_name_ext(content_disposition)?; + + if file_name.contains('/') { + return Err(CreateError::InvalidInput( + "File names must not contain slashes!".to_string(), + )); + } + + let content_type = crate::util::ext::project_file_type(file_extension) + .ok_or_else(|| CreateError::InvalidFileType(file_extension.to_string()))?; + + let data = read_from_field( + field, 500 * (1 << 20), + "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." + ).await?; + + let hash = sha1::Sha1::from(&data).hexdigest(); + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM hashes h + INNER JOIN files f ON f.id = h.file_id + INNER JOIN versions v ON v.id = f.version_id + WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3) + ", + hash.as_bytes(), + "sha1", + project_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await? + .exists + .unwrap_or(false); + + if exists { + return Err(CreateError::InvalidInput( + "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), + )); + } + + let validation_result = validate_file( + data.clone().into(), + file_extension.to_string(), + project_type.to_string(), + loaders.clone(), + file_type, + ) + .await?; + + if let ValidationResult::PassWithPackDataAndFiles { + ref format, + ref files, + } = validation_result + { + if dependencies.is_empty() { + let hashes: Vec> = format + .files + .iter() + .filter_map(|x| x.hashes.get(&PackFileHash::Sha1)) + .map(|x| x.as_bytes().to_vec()) + .collect(); + + let res = sqlx::query!( + " + SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h + INNER JOIN files f on h.file_id = f.id + INNER JOIN versions v on f.version_id = v.id + WHERE h.algorithm = 'sha1' AND h.hash = ANY($1) + ", + &*hashes + ) + .fetch_all(&mut *transaction) + .await?; + + for file in &format.files { + if let Some(dep) = res.iter().find(|x| { + Some(&*x.hash) == file.hashes.get(&PackFileHash::Sha1).map(|x| x.as_bytes()) + }) { + dependencies.push(DependencyBuilder { + project_id: Some(models::ProjectId(dep.project_id)), + version_id: Some(models::VersionId(dep.version_id)), + file_name: None, + dependency_type: DependencyType::Embedded.to_string(), + }); + } else if let Some(first_download) = file.downloads.first() { + dependencies.push(DependencyBuilder { + project_id: None, + version_id: None, + file_name: Some( + first_download + .rsplit('/') + .next() + .unwrap_or(first_download) + .to_string(), + ), + dependency_type: DependencyType::Embedded.to_string(), + }); + } + } + + for file in files { + if !file.is_empty() { + dependencies.push(DependencyBuilder { + project_id: None, + version_id: None, + file_name: Some(file.to_string()), + dependency_type: DependencyType::Embedded.to_string(), + }); + } + } + } + } + + let data = data.freeze(); + + let primary = (validation_result.is_passed() + && version_files.iter().all(|x| !x.primary) + && !ignore_primary) + || force_primary + || total_files_len == 1; + + let file_path_encode = format!( + "data/{}/versions/{}/{}", + project_id, + version_id, + urlencoding::encode(file_name) + ); + let file_path = format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); + + let upload_data = file_host + .upload_file(content_type, &file_path, data) + .await?; + + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: file_path, + }); + + let sha1_bytes = upload_data.content_sha1.into_bytes(); + let sha512_bytes = upload_data.content_sha512.into_bytes(); + + if version_files.iter().any(|x| { + x.hashes + .iter() + .any(|y| y.hash == sha1_bytes || y.hash == sha512_bytes) + }) { + return Err(CreateError::InvalidInput( + "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), + )); + } + + if let ValidationResult::Warning(msg) = validation_result { + if primary { + return Err(CreateError::InvalidInput(msg.to_string())); + } + } + + version_files.push(VersionFileBuilder { + filename: file_name.to_string(), + url: format!("{cdn_url}/{file_path_encode}"), + hashes: vec![ + models::version_item::HashBuilder { + algorithm: "sha1".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha1_bytes, + }, + models::version_item::HashBuilder { + algorithm: "sha512".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha512_bytes, + }, + ], + primary, + size: upload_data.content_length, + file_type, + }); + + Ok(()) +} + +pub fn get_name_ext( + content_disposition: &actix_web::http::header::ContentDisposition, +) -> Result<(&str, &str), CreateError> { + let file_name = content_disposition + .get_filename() + .ok_or_else(|| CreateError::MissingValueError("Missing content file name".to_string()))?; + let file_extension = if let Some(last_period) = file_name.rfind('.') { + file_name.get((last_period + 1)..).unwrap_or("") + } else { + return Err(CreateError::MissingValueError( + "Missing content file extension".to_string(), + )); + }; + Ok((file_name, file_extension)) +} diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs new file mode 100644 index 00000000..5e62dd25 --- /dev/null +++ b/src/routes/v3/version_file.rs @@ -0,0 +1,318 @@ +use super::ApiError; +use crate::auth::{ + filter_authorized_projects, filter_authorized_versions, get_user_from_headers, + is_authorized_version, +}; +use crate::database::redis::RedisPool; +use crate::models::ids::VersionId; +use crate::models::pats::Scopes; +use crate::models::projects::VersionType; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::{database, models}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("version_file") + .service(get_update_from_hash) + ); + + cfg.service( + web::scope("version_files") + .service(update_files) + .service(update_individual_files), + ); +} + +#[derive(Serialize, Deserialize)] +pub struct HashQuery { + #[serde(default = "default_algorithm")] + pub algorithm: String, + pub version_id: Option, +} + +fn default_algorithm() -> String { + "sha1".into() +} + +#[derive(Deserialize)] +pub struct UpdateData { + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} + +#[post("{version_id}/update")] +pub async fn get_update_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let hash = info.into_inner().0.to_lowercase(); + + if let Some(file) = database::models::Version::get_file_from_hash( + hash_query.algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await? + { + if let Some(project) = + database::models::Project::get_id(file.project_id, &**pool, &redis).await? + { + let mut versions = + database::models::Version::get_many(&project.versions, &**pool, &redis) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + + if let Some(version_types) = &update_data.version_types { + bool &= version_types + .iter() + .any(|y| y.as_str() == x.inner.version_type); + } + if let Some(loaders) = &update_data.loaders { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + // if let Some(game_versions) = &update_data.game_versions { + // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); + // } + + bool + }) + .sorted_by(|a, b| a.inner.date_published.cmp(&b.inner.date_published)) + .collect::>(); + + if let Some(first) = versions.pop() { + if !is_authorized_version(&first.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + return Ok(HttpResponse::Ok().json(models::projects::Version::from(first))); + } + } + } + + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize)] +pub struct ManyUpdateData { + #[serde(default = "default_algorithm")] + pub algorithm: String, + pub hashes: Vec, + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} +#[post("update")] +pub async fn update_files( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let files = database::models::Version::get_files_from_hash( + update_data.algorithm.clone(), + &update_data.hashes, + &**pool, + &redis, + ) + .await?; + + let projects = database::models::Project::get_many_ids( + &files.iter().map(|x| x.project_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let all_versions = database::models::Version::get_many( + &projects + .iter() + .flat_map(|x| x.versions.clone()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut response = HashMap::new(); + + for project in projects { + for file in files.iter().filter(|x| x.project_id == project.inner.id) { + let version = all_versions + .iter() + .filter(|x| x.inner.project_id == file.project_id) + .filter(|x| { + let mut bool = true; + + if let Some(version_types) = &update_data.version_types { + bool &= version_types + .iter() + .any(|y| y.as_str() == x.inner.version_type); + } + if let Some(loaders) = &update_data.loaders { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + // if let Some(game_versions) = &update_data.game_versions { + // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); + // } + + bool + }) + .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) + .next(); + + if let Some(version) = version { + if is_authorized_version(&version.inner, &user_option, &pool).await? { + if let Some(hash) = file.hashes.get(&update_data.algorithm) { + response.insert( + hash.clone(), + models::projects::Version::from(version.clone()), + ); + } + } + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Deserialize)] +pub struct FileUpdateData { + pub hash: String, + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} + +#[derive(Deserialize)] +pub struct ManyFileUpdateData { + #[serde(default = "default_algorithm")] + pub algorithm: String, + pub hashes: Vec, +} + +#[post("update_individual")] +pub async fn update_individual_files( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let files = database::models::Version::get_files_from_hash( + update_data.algorithm.clone(), + &update_data + .hashes + .iter() + .map(|x| x.hash.clone()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let projects = database::models::Project::get_many_ids( + &files.iter().map(|x| x.project_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let all_versions = database::models::Version::get_many( + &projects + .iter() + .flat_map(|x| x.versions.clone()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut response = HashMap::new(); + + for project in projects { + for file in files.iter().filter(|x| x.project_id == project.inner.id) { + if let Some(hash) = file.hashes.get(&update_data.algorithm) { + if let Some(query_file) = update_data.hashes.iter().find(|x| &x.hash == hash) { + let version = all_versions + .iter() + .filter(|x| x.inner.project_id == file.project_id) + .filter(|x| { + let mut bool = true; + + if let Some(version_types) = &query_file.version_types { + bool &= version_types + .iter() + .any(|y| y.as_str() == x.inner.version_type); + } + if let Some(loaders) = &query_file.loaders { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + + bool + }) + .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) + .next(); + + if let Some(version) = version { + if is_authorized_version(&version.inner, &user_option, &pool).await? { + response.insert( + hash.clone(), + models::projects::Version::from(version.clone()), + ); + } + } + } + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs new file mode 100644 index 00000000..052a3ca5 --- /dev/null +++ b/src/routes/v3/versions.rs @@ -0,0 +1,597 @@ +use super::ApiError; +use crate::auth::{ + filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, +}; +use crate::database; +use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; +use crate::database::models::{image_item, Organization}; +use crate::database::redis::RedisPool; +use crate::models; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::images::ImageContext; +use crate::models::pats::Scopes; +use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::util::img; +use crate::util::validate::validation_errors_to_string; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(super::version_creation::version_create); + + cfg.service( + web::scope("version") + .service(version_edit) + .service(super::version_creation::upload_file_to_version), + ); +} +#[derive(Serialize, Deserialize, Validate)] +pub struct EditVersion { + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: Option, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: Option, + #[validate(length(max = 65536))] + pub changelog: Option, + pub version_type: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Option>, + pub game_versions: Option>, + pub loaders: Option>, + pub featured: Option, + pub primary_file: Option<(String, String)>, + pub downloads: Option, + pub status: Option, + pub file_types: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct EditVersionFileType { + pub algorithm: String, + pub hash: String, + pub file_type: Option, +} + +#[patch("{id}")] +pub async fn version_edit( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + new_version: web::Json, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + new_version + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let version_id = info.into_inner().0; + let id = version_id.into(); + + let result = database::models::Version::get(id, &**pool, &redis).await?; + + if let Some(version_item) = result { + let project_item = + database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) + .await?; + + let team_member = database::models::TeamMember::get_from_user_id_project( + version_item.inner.project_id, + user.id.into(), + &**pool, + ) + .await?; + + let organization = Organization::get_associated_organization_project_id( + version_item.inner.project_id, + &**pool, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); + + if let Some(perms) = permissions { + if !perms.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit this version!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + if let Some(name) = &new_version.name { + sqlx::query!( + " + UPDATE versions + SET name = $1 + WHERE (id = $2) + ", + name.trim(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(number) = &new_version.version_number { + sqlx::query!( + " + UPDATE versions + SET version_number = $1 + WHERE (id = $2) + ", + number, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(version_type) = &new_version.version_type { + sqlx::query!( + " + UPDATE versions + SET version_type = $1 + WHERE (id = $2) + ", + version_type.as_str(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(dependencies) = &new_version.dependencies { + if let Some(project) = project_item { + if project.project_type != "modpack" { + sqlx::query!( + " + DELETE FROM dependencies WHERE dependent_id = $1 + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let builders = dependencies + .iter() + .map(|x| database::models::version_item::DependencyBuilder { + project_id: x.project_id.map(|x| x.into()), + version_id: x.version_id.map(|x| x.into()), + file_name: x.file_name.clone(), + dependency_type: x.dependency_type.to_string(), + }) + .collect::>(); + + DependencyBuilder::insert_many( + builders, + version_item.inner.id, + &mut transaction, + ) + .await?; + } + } + } + + // if let Some(game_versions) = &new_version.game_versions { + // sqlx::query!( + // " + // DELETE FROM game_versions_versions WHERE joining_version_id = $1 + // ", + // id as database::models::ids::VersionId, + // ) + // .execute(&mut *transaction) + // .await?; + + // let mut version_versions = Vec::new(); + // for game_version in game_versions { + // let game_version_id = database::models::categories::GameVersion::get_id( + // &game_version.0, + // &mut *transaction, + // ) + // .await? + // .ok_or_else(|| { + // ApiError::InvalidInput( + // "No database entry for game version provided.".to_string(), + // ) + // })?; + + // version_versions.push(VersionVersion::new(game_version_id, id)); + // } + // VersionVersion::insert_many(version_versions, &mut transaction).await?; + + // database::models::Project::update_game_versions( + // version_item.inner.project_id, + // &mut transaction, + // ) + // .await?; + // } + + if let Some(loaders) = &new_version.loaders { + sqlx::query!( + " + DELETE FROM loaders_versions WHERE version_id = $1 + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let mut loader_versions = Vec::new(); + for loader in loaders { + let loader_id = + database::models::loader_fields::Loader::get_id(&loader.0, &mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "No database entry for loader provided.".to_string(), + ) + })?; + loader_versions.push(LoaderVersion::new(loader_id, id)); + } + LoaderVersion::insert_many(loader_versions, &mut transaction).await?; + + database::models::Project::update_loaders( + version_item.inner.project_id, + &mut transaction, + ) + .await?; + } + + if let Some(featured) = &new_version.featured { + sqlx::query!( + " + UPDATE versions + SET featured = $1 + WHERE (id = $2) + ", + featured, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(primary_file) = &new_version.primary_file { + let result = sqlx::query!( + " + SELECT f.id id FROM hashes h + INNER JOIN files f ON h.file_id = f.id + WHERE h.algorithm = $2 AND h.hash = $1 + ", + primary_file.1.as_bytes(), + primary_file.0 + ) + .fetch_optional(&**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Specified file with hash {} does not exist.", + primary_file.1.clone() + )) + })?; + + sqlx::query!( + " + UPDATE files + SET is_primary = FALSE + WHERE (version_id = $1) + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE files + SET is_primary = TRUE + WHERE (id = $1) + ", + result.id, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(body) = &new_version.changelog { + sqlx::query!( + " + UPDATE versions + SET changelog = $1 + WHERE (id = $2) + ", + body, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(downloads) = &new_version.downloads { + if !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set the downloads of this mod".to_string(), + )); + } + + sqlx::query!( + " + UPDATE versions + SET downloads = $1 + WHERE (id = $2) + ", + *downloads as i32, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let diff = *downloads - (version_item.inner.downloads as u32); + + sqlx::query!( + " + UPDATE mods + SET downloads = downloads + $1 + WHERE (id = $2) + ", + diff as i32, + version_item.inner.project_id as database::models::ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_version.status { + if !status.can_be_requested() { + return Err(ApiError::InvalidInput( + "The requested status cannot be set!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE versions + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(file_types) = &new_version.file_types { + for file_type in file_types { + let result = sqlx::query!( + " + SELECT f.id id FROM hashes h + INNER JOIN files f ON h.file_id = f.id + WHERE h.algorithm = $2 AND h.hash = $1 + ", + file_type.hash.as_bytes(), + file_type.algorithm + ) + .fetch_optional(&**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Specified file with hash {} does not exist.", + file_type.algorithm.clone() + )) + })?; + + sqlx::query!( + " + UPDATE files + SET file_type = $2 + WHERE (id = $1) + ", + result.id, + file_type.file_type.as_ref().map(|x| x.as_str()), + ) + .execute(&mut *transaction) + .await?; + } + } + + // delete any images no longer in the changelog + let checkable_strings: Vec<&str> = vec![&new_version.changelog] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + let context = ImageContext::Version { + version_id: Some(version_item.inner.id.into()), + }; + + img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; + + database::models::Version::clear_cache(&version_item, &redis).await?; + database::models::Project::clear_cache( + version_item.inner.project_id, + None, + Some(true), + &redis, + ) + .await?; + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this version!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub struct VersionListFilters { + pub game_versions: Option, + pub loaders: Option, + pub featured: Option, + pub version_type: Option, + pub limit: Option, + pub offset: Option, +} + +// #[get("version")] +// pub async fn version_list( +// req: HttpRequest, +// info: web::Path<(String,)>, +// web::Query(filters): web::Query, +// pool: web::Data, +// redis: web::Data, +// session_queue: web::Data, +// ) -> Result { +// let string = info.into_inner().0; + +// let result = database::models::Project::get(&string, &**pool, &redis).await?; + +// let user_option = get_user_from_headers( +// &req, +// &**pool, +// &redis, +// &session_queue, +// Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), +// ) +// .await +// .map(|x| x.1) +// .ok(); + +// if let Some(project) = result { +// if !is_authorized(&project.inner, &user_option, &pool).await? { +// return Ok(HttpResponse::NotFound().body("")); +// } + +// let version_filters = filters +// .game_versions +// .as_ref() +// .map(|x| serde_json::from_str::>(x).unwrap_or_default()); +// let loader_filters = filters +// .loaders +// .as_ref() +// .map(|x| serde_json::from_str::>(x).unwrap_or_default()); +// let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis) +// .await? +// .into_iter() +// .skip(filters.offset.unwrap_or(0)) +// .take(filters.limit.unwrap_or(usize::MAX)) +// .filter(|x| { +// let mut bool = true; + +// if let Some(version_type) = filters.version_type { +// bool &= &*x.inner.version_type == version_type.as_str(); +// } +// if let Some(loaders) = &loader_filters { +// bool &= x.loaders.iter().any(|y| loaders.contains(y)); +// } +// if let Some(game_versions) = &version_filters { +// bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); +// } + +// bool +// }) +// .collect::>(); + +// let mut response = versions +// .iter() +// .filter(|version| { +// filters +// .featured +// .map(|featured| featured == version.inner.featured) +// .unwrap_or(true) +// }) +// .cloned() +// .collect::>(); + +// versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); + +// // Attempt to populate versions with "auto featured" versions +// if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { +// let (loaders, game_versions) = futures::future::try_join( +// database::models::loader_fields::Loader::list(&**pool, &redis), +// database::models::loader_fields::GameVersion::list_filter( +// None, +// Some(true), +// &**pool, +// &redis, +// ), +// ) +// .await?; + +// let mut joined_filters = Vec::new(); +// for game_version in &game_versions { +// for loader in &loaders { +// joined_filters.push((game_version, loader)) +// } +// } + +// joined_filters.into_iter().for_each(|filter| { +// versions +// .iter() +// .find(|version| { +// version.game_versions.contains(&filter.0.version) +// && version.loaders.contains(&filter.1.loader) +// }) +// .map(|version| response.push(version.clone())) +// .unwrap_or(()); +// }); + +// if response.is_empty() { +// versions +// .into_iter() +// .for_each(|version| response.push(version)); +// } +// } + +// response.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); +// response.dedup_by(|a, b| a.inner.id == b.inner.id); + +// let response = filter_authorized_versions(response, &user_option, &pool).await?; + +// Ok(HttpResponse::Ok().json(response)) +// } else { +// Ok(HttpResponse::NotFound().body("")) +// } +// } diff --git a/src/scheduler.rs b/src/scheduler.rs index 055601c3..4d2dd026 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -38,15 +38,16 @@ impl Drop for Scheduler { use log::{info, warn}; -pub fn schedule_versions(scheduler: &mut Scheduler, pool: sqlx::Pool) { +pub fn schedule_versions(scheduler: &mut Scheduler, pool: sqlx::Pool, redis : RedisPool) { let version_index_interval = std::time::Duration::from_secs(parse_var("VERSION_INDEX_INTERVAL").unwrap_or(1800)); scheduler.run(version_index_interval, move || { let pool_ref = pool.clone(); + let redis = redis.clone(); async move { info!("Indexing game versions list from Mojang"); - let result = update_versions(&pool_ref).await; + let result = update_versions(&pool_ref, &redis).await; if let Err(e) = result { warn!("Version update failed: {}", e); } @@ -65,7 +66,7 @@ pub enum VersionIndexingError { DatabaseError(#[from] crate::database::models::DatabaseError), } -use crate::util::env::parse_var; +use crate::{util::env::parse_var, database::redis::RedisPool}; use chrono::{DateTime, Utc}; use serde::Deserialize; use tokio_stream::wrappers::IntervalStream; @@ -84,7 +85,7 @@ struct VersionFormat<'a> { release_time: DateTime, } -async fn update_versions(pool: &sqlx::Pool) -> Result<(), VersionIndexingError> { +async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) -> Result<(), VersionIndexingError> { let input = reqwest::get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json") .await? .json::() @@ -144,45 +145,45 @@ async fn update_versions(pool: &sqlx::Pool) -> Result<(), Versio ]; } - for version in input.versions.into_iter() { - let mut name = version.id; - if !name - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) - { - if let Some((_, alternate)) = HALL_OF_SHAME.iter().find(|(version, _)| name == *version) - { - name = String::from(*alternate); - } else { - // We'll deal with these manually - skipped_versions_count += 1; - continue; - } - } - - let type_ = match &*version.type_ { - "release" => "release", - "snapshot" => "snapshot", - "old_alpha" => "alpha", - "old_beta" => "beta", - _ => "other", - }; - - crate::database::models::categories::GameVersion::builder() - .version(&name)? - .version_type(type_)? - .created( - if let Some((_, alternate)) = - HALL_OF_SHAME_2.iter().find(|(version, _)| name == *version) - { - alternate - } else { - &version.release_time - }, - ) - .insert(pool) - .await?; - } + // for version in input.versions.into_iter() { + // let mut name = version.id; + // if !name + // .chars() + // .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + // { + // if let Some((_, alternate)) = HALL_OF_SHAME.iter().find(|(version, _)| name == *version) + // { + // name = String::from(*alternate); + // } else { + // // We'll deal with these manually + // skipped_versions_count += 1; + // continue; + // } + // } + + // let type_ = match &*version.type_ { + // "release" => "release", + // "snapshot" => "snapshot", + // "old_alpha" => "alpha", + // "old_beta" => "beta", + // _ => "other", + // }; + + // crate::database::models::loader_fields::GameVersion::builder() + // .version(&name)? + // .version_type(type_)? + // .created( + // if let Some((_, alternate)) = + // HALL_OF_SHAME_2.iter().find(|(version, _)| name == *version) + // { + // alternate + // } else { + // &version.release_time + // }, + // ) + // .insert(pool, redis) + // .await?; + // } if skipped_versions_count > 0 { // This will currently always trigger due to 1.14 pre releases diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index ca8b0e54..c9df8af0 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -15,29 +15,41 @@ pub async fn index_local(pool: PgPool) -> Result, Index SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.published published, m.approved approved, m.updated updated, m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color, - cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username, + pt.name project_type_name, u.username username, 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 lo.loader) filter (where lo.loader is not null) loaders, - ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery, - ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery + ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'id', vf.id, + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value, + 'field', lf.field, + 'field_type', lf.field_type, + 'enum_type', lf.enum_type, + 'enum_name', lfe.enum_name + ) + ) version_fields + FROM versions v INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2) LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id - LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id - LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id INNER JOIN project_types pt ON pt.id = m.project_type - INNER JOIN side_types cs ON m.client_side = cs.id - INNER JOIN side_types ss ON m.server_side = ss.id INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE INNER JOIN users u ON tm.user_id = u.id + LEFT OUTER JOIN version_fields vf on v.id = vf.version_id + LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id + LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id WHERE v.status != ANY($1) - GROUP BY v.id, m.id, cs.id, ss.id, pt.id, u.id; + GROUP BY v.id, m.id, pt.id, u.id; ", &*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::>(), &*crate::models::projects::ProjectStatus::iterator().filter(|x| x.is_searchable()).map(|x| x.to_string()).collect::>(), @@ -54,8 +66,6 @@ pub async fn index_local(pool: PgPool) -> Result, Index let display_categories = categories.clone(); categories.append(&mut additional_categories); - let versions = m.versions.unwrap_or_default(); - let project_id: crate::models::projects::ProjectId = ProjectId(m.id).into(); let version_id: crate::models::projects::ProjectId = ProjectId(m.version_id).into(); @@ -83,11 +93,7 @@ pub async fn index_local(pool: PgPool) -> Result, Index created_timestamp: m.approved.unwrap_or(m.published).timestamp(), date_modified: m.updated, modified_timestamp: m.updated.timestamp(), - latest_version: versions.last().cloned().unwrap_or_else(|| "None".to_string()), - versions, license, - client_side: m.client_side_type, - server_side: m.server_side_type, slug: m.slug, project_type: m.project_type_name, gallery: m.gallery.unwrap_or_default(), diff --git a/src/search/mod.rs b/src/search/mod.rs index 84dcc1c7..333c6fa7 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -80,14 +80,10 @@ pub struct UploadSearchProject { pub description: String, pub categories: Vec, pub display_categories: Vec, - pub versions: Vec, pub follows: i32, pub downloads: i32, pub icon_url: String, - pub latest_version: String, pub license: String, - pub client_side: String, - pub server_side: String, pub gallery: Vec, pub featured_gallery: Option, /// RFC 3339 formatted creation date of the project diff --git a/src/util/routes.rs b/src/util/routes.rs index 79512f72..00bb288e 100644 --- a/src/util/routes.rs +++ b/src/util/routes.rs @@ -1,4 +1,4 @@ -use crate::routes::v2::project_creation::CreateError; +use crate::routes::v3::project_creation::CreateError; use crate::routes::ApiError; use actix_multipart::Field; use actix_web::web::Payload; diff --git a/src/util/webhook.rs b/src/util/webhook.rs index 8b5b5a65..d82c305d 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -1,4 +1,5 @@ -use crate::database::models::categories::GameVersion; +use crate::database; +use crate::database::models::{loader_fields::GameVersion, GameId}; use crate::database::redis::RedisPool; use crate::models::projects::ProjectId; use crate::routes::ApiError; @@ -77,35 +78,47 @@ pub async fn send_discord_webhook( webhook_url: String, message: Option, ) -> Result<(), ApiError> { - let all_game_versions = GameVersion::list(pool, redis).await?; + // let all_game_versions = GameVersion::list(pool, redis).await?; let row = sqlx::query!( " SELECT m.id id, m.title title, m.description description, m.color color, - m.icon_url icon_url, m.slug slug, cs.name client_side_type, ss.name server_side_type, + m.icon_url icon_url, m.slug slug, pt.name project_type, u.username username, u.avatar_url avatar_url, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories, ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders, - JSONB_AGG(DISTINCT jsonb_build_object('id', gv.id, 'version', gv.version, 'type', gv.type, 'created', gv.created, 'major', gv.major)) filter (where gv.version is not null) versions, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery, - ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery + ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'id', vf.id, + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value, + 'field', lf.field, + 'field_type', lf.field_type, + 'enum_type', lf.enum_type, + 'enum_name', lfe.enum_name + ) + ) version_fields FROM mods m LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2) - LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id - LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id INNER JOIN project_types pt ON pt.id = m.project_type - INNER JOIN side_types cs ON m.client_side = cs.id - INNER JOIN side_types ss ON m.server_side = ss.id INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE INNER JOIN users u ON tm.user_id = u.id + LEFT OUTER JOIN version_fields vf on v.id = vf.version_id + LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id + LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id + WHERE m.id = $1 - GROUP BY m.id, cs.id, ss.id, pt.id, u.id; + GROUP BY m.id, pt.id, u.id; ", project_id.0 as i64, &*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::>(), @@ -120,10 +133,10 @@ pub async fn send_discord_webhook( let categories = project.categories.unwrap_or_default(); let loaders = project.loaders.unwrap_or_default(); - let versions: Vec = - serde_json::from_value(project.versions.unwrap_or_default()) - .ok() - .unwrap_or_default(); + // let versions: Vec = + // serde_json::from_value(project.versions.unwrap_or_default()) + // .ok() + // .unwrap_or_default(); if !categories.is_empty() { fields.push(DiscordEmbedField { @@ -187,15 +200,15 @@ pub async fn send_discord_webhook( }); } - if !versions.is_empty() { - let formatted_game_versions: String = get_gv_range(versions, all_game_versions); + // if !versions.is_empty() { + // let formatted_game_versions: String = get_gv_range(versions, all_game_versions); - fields.push(DiscordEmbedField { - name: "Versions", - value: formatted_game_versions, - inline: true, - }); - } + // fields.push(DiscordEmbedField { + // name: "Versions", + // value: formatted_game_versions, + // inline: true, + // }); + // } let mut project_type = project.project_type; diff --git a/src/validate/mod.rs b/src/validate/mod.rs index b0ed3394..016dc867 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -107,8 +107,8 @@ pub async fn validate_file( file_extension: String, mut project_type: String, mut loaders: Vec, - game_versions: Vec, - all_game_versions: Vec, + // game_versions: Vec, + // all_game_versions: Vec, file_type: Option, ) -> Result { actix_web::web::block(move || { @@ -131,11 +131,11 @@ pub async fn validate_file( && loaders .iter() .any(|x| validator.get_supported_loaders().contains(&&*x.0)) - && game_version_supported( - &game_versions, - &all_game_versions, - validator.get_supported_game_versions(), - ) + // && game_version_supported( + // &game_versions, + // &all_game_versions, + // validator.get_supported_game_versions(), + // ) { if validator.get_file_extensions().contains(&&*file_extension) { return validator.validate(&mut zip); @@ -164,7 +164,7 @@ pub async fn validate_file( fn game_version_supported( game_versions: &[GameVersion], - all_game_versions: &[crate::database::models::categories::GameVersion], + all_game_versions: &[crate::database::models::loader_fields::GameVersion], supported_game_versions: SupportedGameVersions, ) -> bool { match supported_game_versions { diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index ae1b4aa2..f4cae7de 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -361,6 +361,10 @@ pub async fn get_project_alpha(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; + println!("Got out and got through!"); + println!("S {:?}", resp.status()); + println!("H {:?}", resp.headers()); + println!("B {:?}", resp.response().body()); let project: Project = test::read_body_json(resp).await; // Get project's versions diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 334baebe..a1aa59c9 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -19,22 +19,35 @@ INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); --- -- Sample game versions, loaders, categories -INSERT INTO game_versions (id, version, type, created) -VALUES (20000, '1.20.1', 'release', timezone('utc', now())); -INSERT INTO game_versions (id, version, type, created) -VALUES (20001, '1.20.2', 'release', timezone('utc', now())); -INSERT INTO game_versions (id, version, type, created) -VALUES (20002, '1.20.3', 'release', timezone('utc', now())); -INSERT INTO game_versions (id, version, type, created) -VALUES (20003, '1.20.4', 'release', timezone('utc', now())); -INSERT INTO game_versions (id, version, type, created) -VALUES (20004, '1.20.5', 'release', timezone('utc', now())); - -INSERT INTO loaders (id, loader) VALUES (1, 'fabric'); +INSERT INTO loaders (id, loader, game_id) VALUES (1, 'fabric', 1); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,1); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,2); +-- Sample game versions, loaders, categories +-- Game versions is '2' +INSERT INTO loader_field_enum_values(enum_id, value, metadata) +VALUES (2, '1.20.1', '{"type":"release","major":false}'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata) +VALUES (2, '1.20.2', '{"type":"release","major":false}'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata) +VALUES (2, '1.20.3', '{"type":"release","major":false}'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata) +VALUES (2, '1.20.4', '{"type":"release","major":false}'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata) +VALUES (2, '1.20.5', '{"type":"release","major":false}'); + +-- INSERT INTO game_versions (id, version, type, created) +-- VALUES (20000, '1.20.1', 'release', timezone('utc', now())); +-- INSERT INTO game_versions (id, version, type, created) +-- VALUES (20001, '1.20.2', 'release', timezone('utc', now())); +-- INSERT INTO game_versions (id, version, type, created) +-- VALUES (20002, '1.20.3', 'release', timezone('utc', now())); +-- INSERT INTO game_versions (id, version, type, created) +-- VALUES (20003, '1.20.4', 'release', timezone('utc', now())); +-- INSERT INTO game_versions (id, version, type, created) +-- VALUES (20004, '1.20.5', 'release', timezone('utc', now())); + + INSERT INTO categories (id, category, project_type) VALUES (1, 'combat', 1), (2, 'decoration', 1), diff --git a/tests/project.rs b/tests/project.rs index 2a34dae1..7015e315 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -426,19 +426,20 @@ pub async fn test_patch_project() { assert_eq!(resp.status(), 404); // New slug does work - let project = api.get_project_deserialized("newslug", USER_USER_PAT).await; - assert_eq!(project.slug, Some("newslug".to_string())); - assert_eq!(project.title, "New successful title"); - assert_eq!(project.description, "New successful description"); - assert_eq!(project.body, "New successful body"); - assert_eq!(project.categories, vec![DUMMY_CATEGORIES[0]]); - assert_eq!(project.license.id, "MIT"); - assert_eq!(project.issues_url, Some("https://github.com".to_string())); - assert_eq!(project.discord_url, Some("https://discord.gg".to_string())); - assert_eq!(project.wiki_url, Some("https://wiki.com".to_string())); - assert_eq!(project.client_side.to_string(), "optional"); - assert_eq!(project.server_side.to_string(), "required"); - assert_eq!(project.donation_urls.unwrap()[0].url, "https://patreon.com"); + let resp = api.get_project("newslug", USER_USER_PAT).await; + let project: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(project["slug"], json!(Some("newslug".to_string()))); + assert_eq!(project["title"], "New successful title"); + assert_eq!(project["description"], "New successful description"); + assert_eq!(project["body"], "New successful body"); + assert_eq!(project["categories"], json!(vec![DUMMY_CATEGORIES[0]])); + assert_eq!(project["license"]["id"], "MIT"); + assert_eq!(project["issues_url"], json!(Some("https://github.com".to_string()))); + assert_eq!(project["discord_url"], json!(Some("https://discord.gg".to_string()))); + assert_eq!(project["wiki_url"], json!(Some("https://wiki.com".to_string()))); + assert_eq!(project["client_side"].to_string(), "optional"); + assert_eq!(project["server_side"].to_string(), "required"); + assert_eq!(project["donation_urls"][0]["url"], "https://patreon.com"); // Cleanup test db test_env.cleanup().await; diff --git a/tests/search.rs b/tests/search.rs index f0dfa114..bad7dbf0 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -1,17 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; -use actix_web::test; use common::dummy_data::TestFile; use common::request_data; use labrinth::models::ids::base62_impl::parse_base62; -use labrinth::models::projects::Project; -use labrinth::search::SearchResults; use serde_json::json; use futures::stream::StreamExt; use crate::common::database::*; use crate::common::dummy_data::DUMMY_CATEGORIES; use crate::common::request_data::ProjectCreationRequestData; -use crate::common::{actix::AppendsMultipart, environment::TestEnvironment}; +use crate::common::environment::TestEnvironment; // importing common module. mod common; From 21ae78064b2d79d2b4276ed148da8ccf962ef270 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Thu, 19 Oct 2023 11:12:47 -0700 Subject: [PATCH 06/31] Working multipat reroute backup --- src/routes/v2/project_creation.rs | 34 ++-- src/routes/v2/projects.rs | 31 +++- src/routes/v2/versions.rs | 16 +- src/routes/v2_reroute.rs | 249 ++++++++++++++++++----------- src/routes/v3/project_creation.rs | 5 +- src/routes/v3/projects.rs | 7 +- src/routes/v3/tags.rs | 4 +- src/routes/v3/version_creation.rs | 3 - src/routes/v3/version_file.rs | 12 +- src/routes/v3/versions.rs | 250 +++++++++++++++--------------- tests/common/actix.rs | 2 +- 11 files changed, 363 insertions(+), 250 deletions(-) diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index d34b2179..6507c463 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -15,6 +15,7 @@ use crate::models::projects::{ }; use actix_web::http::header::HeaderValue; + use crate::models::teams::ProjectPermissions; use crate::models::threads::ThreadType; use crate::models::users::UserId; @@ -48,19 +49,34 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { #[post("project")] pub async fn project_create( req: HttpRequest, - payload: Multipart, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, ) -> Result { - // Redirects to V3 route - let self_addr = dotenvy::var("SELF_ADDR")?; - let url = format!("{self_addr}/v3/project"); - let response = v2_reroute::reroute_multipart(&url, req, payload, |json | { + let (headers, payload) = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { // Convert input data to V3 format json["game_name"] = json!("minecraft_java"); - }).await?; - let response = HttpResponse::build(response.status()) - .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) - .body(response.bytes().await.unwrap_or_default()); + }).await; + // for (key, value) in headers.iter() { + // req.headers().append(key.clone(), value.clone()); + // } + let response= v3::project_creation::project_create(req, payload, client, redis, file_host, session_queue).await?; + + + + // Redirects to V3 route + // let self_addr = dotenvy::var("SELF_ADDR")?; + // let url = format!("{self_addr}/v3/project"); + // let response = v2_reroute::reroute_multipart(&url, req, payload, |json | { + // // Convert input data to V3 format + // json["game_name"] = json!("minecraft_java"); + // }).await?; + // let response = HttpResponse::build(response.status()) + // .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) + // .body(response.bytes().await.unwrap_or_default()); // TODO: Convert response to V2 format Ok(response) diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index da4f81d7..d839fd5d 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -17,7 +17,7 @@ use crate::models::projects::{ use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v2_reroute}; +use crate::routes::{ApiError, v2_reroute, v3}; use crate::routes::v3::projects::delete_from_index; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::img; @@ -393,13 +393,34 @@ pub async fn project_edit( let self_addr = dotenvy::var("SELF_ADDR")?; let url = format!("{self_addr}/v3/project/{id}", id = info.0); - let response = v2_reroute::reroute_patch(&url, req, serde_json::to_value(new_project)?).await?; + let new_project = new_project.into_inner(); + let new_project = v3::projects::EditProject { + title: new_project.title, + description: new_project.description, + body: new_project.body, + categories: new_project.categories, + additional_categories: new_project.additional_categories, + issues_url: new_project.issues_url, + source_url: new_project.source_url, + wiki_url: new_project.wiki_url, + license_url: new_project.license_url, + discord_url: new_project.discord_url, + donation_urls: new_project.donation_urls, + license_id: new_project.license_id, + client_side: new_project.client_side, + server_side: new_project.server_side, + slug: new_project.slug, + status: new_project.status, + requested_status: new_project.requested_status, + moderation_message: new_project.moderation_message, + moderation_message_body: new_project.moderation_message_body, + monetization_status: new_project.monetization_status, + }; - let response = HttpResponse::build(response.status()) - .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) - .body(response.bytes().await.unwrap_or_default()); + let response = v3::projects::project_edit(req, info, pool, config, web::Json(new_project), redis, session_queue).await?; // TODO: Convert response to V2 format + Ok(response) } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 644576f0..1b372d25 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -13,6 +13,7 @@ use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::v3; use crate::util::img; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; @@ -55,7 +56,20 @@ pub async fn version_list( session_queue: web::Data, ) -> Result { // TODO: move route to v3 - Ok(HttpResponse::Ok().json("")) + + let filters = v3::versions::VersionListFilters { + game_versions: filters.game_versions, + loaders: filters.loaders, + featured: filters.featured, + version_type: filters.version_type, + limit: filters.limit, + offset: filters.offset, + }; + + let response = v3::versions::version_list(req, info, web::Query(filters), pool, redis, session_queue).await?; + + //TODO: Convert response to V2 format + Ok(response) } // Given a project ID/slug and a version slug diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 208a7299..56165c50 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -1,107 +1,172 @@ use actix_multipart::Multipart; -use actix_web::{web::Payload, HttpRequest}; -use futures::{TryStreamExt, StreamExt}; - -use super::{v3::project_creation::CreateError, ApiError}; - -// const for ignore_headers -const IGNORE_HEADERS: [&str; 3] = [ - "content-type", - "content-length", - "accept-encoding", -]; - -pub async fn reroute_patch(url : &str, req : HttpRequest, json : serde_json::Value) -> Result { - // Forwarding headers - let mut headers = reqwest::header::HeaderMap::new(); - for (key, value) in req.headers() { - if !IGNORE_HEADERS.contains(&key.as_str()) { - headers.insert(key.clone(), value.clone()); - } - } +use actix_web::test::TestRequest; +use bytes::{Bytes, BytesMut}; +use actix_web::http::header::{TryIntoHeaderPair,HeaderMap, HeaderName}; +use futures::{StreamExt, stream}; +use serde_json::{Value, json}; - // Sending the request - let client = reqwest::Client::new(); - Ok(client.patch(url) - .headers(headers) - .json(&json) - .send() - .await?) -} +pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, closure: impl Fn(&mut serde_json::Value)) -> (HeaderMap, Multipart) { + let mut segments: Vec = Vec::new(); -pub async fn reroute_multipart(url : &str, req : HttpRequest, mut payload : Multipart, closure: impl Fn(&mut serde_json::Value)) -> Result { - println!("print 3!"); + if let Some(mut field) = multipart.next().await { + let mut field = field.unwrap(); + let content_disposition = field.content_disposition().clone(); // This unwrap is okay because we expect every field to have content disposition + let field_name = content_disposition.get_name().unwrap_or(""); // replace unwrap_or as you see fit + let field_filename = content_disposition.get_filename(); + let field_content_type = field.content_type(); + let field_content_type = field_content_type.map(|ct| ct.to_string()); - // Forwarding headers - let mut headers = reqwest::header::HeaderMap::new(); - for (key, value) in req.headers() { - if !IGNORE_HEADERS.contains(&key.as_str()) { - headers.insert(key.clone(), value.clone()); + let mut buffer = Vec::new(); + while let Some(chunk) = field.next().await { + // let data = chunk.map_err(|e| ApiError::from(e))?; + let data = chunk.unwrap();//.map_err(|e| ApiError::from(e))?; + buffer.extend_from_slice(&data); } - } - println!("print 4!"); - - // Forwarding multipart data - let mut body = reqwest::multipart::Form::new(); - println!("print 5!"); - - // Data field - if let Ok(Some(mut field)) = payload.try_next().await { - // The first multipart field must be named "data" and contain a JSON - let content_disposition = field.content_disposition(); - let name = content_disposition - .get_name() - .ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?; - - if name != "data" { - return Err(CreateError::InvalidInput(String::from( - "`data` field must come before file fields", - ))); + + { + let mut json_value: Value = serde_json::from_slice(&buffer).unwrap(); + json_value["game_name"] = json!("minecraft-java"); + buffer = serde_json::to_vec(&json_value).unwrap(); } - println!("print 7!"); - let mut data = Vec::new(); + segments.push(MultipartSegment { name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(buffer) + }) + + } + + while let Some(mut field) = multipart.next().await { + let mut field = field.unwrap(); + let content_disposition = field.content_disposition().clone(); // This unwrap is okay because we expect every field to have content disposition + let field_name = content_disposition.get_name().unwrap_or(""); // replace unwrap_or as you see fit + let field_filename = content_disposition.get_filename(); + let field_content_type = field.content_type(); + let field_content_type = field_content_type.map(|ct| ct.to_string()); + + let mut buffer = Vec::new(); while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); + // let data = chunk.map_err(|e| ApiError::from(e))?; + let data = chunk.unwrap();//.map_err(|e| ApiError::from(e))?; + buffer.extend_from_slice(&data); } - let mut data: serde_json::Value = serde_json::from_slice(&data)?; - // Now that we have the json data, execute the closure - closure(&mut data); + // if /* this is the JSON part */ { + // let mut json_value: Value = serde_json::from_slice(&buffer)?; + // json_value["new_key"] = json!("new_value"); + // buffer = serde_json::to_vec(&json_value)?; + // } + + segments.push(MultipartSegment { name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(buffer) + }) - // Re-encode the json data and add it to the body - let data = serde_json::to_string(&data)?; - body = body.part("data", reqwest::multipart::Part::text(data)); } - // Forward every other field exactly as is - while let Ok(Some(field)) = payload.try_next().await { - let content_type = field.content_type().map(|ct| ct.to_string()).unwrap_or("text/plain".to_string()); - let field_name = field.name().to_string(); - let content_disposition = field.content_disposition().clone(); - let filename = content_disposition.get_filename().unwrap_or_default().to_string(); - - let bytes: Vec = field - .map(|chunk| chunk.unwrap().to_vec()) // Convert each chunk to Vec - .fold(Vec::new(), |mut acc, vec| { // Collect all chunks into one Vec - acc.extend(vec); - async move { acc } - }) - .await; - - let part = reqwest::multipart::Part::bytes(bytes) - .file_name(filename) - .mime_str(&content_type) - .unwrap(); - - body = body.part(field_name, part); + // let modified_stream = iter(modified_parts.into_iter().map(Result::::Ok)); + + // let modified_stream = ModifiedStream { inner: modified_stream }; + + // let new_multipart = Multipart::new(&headers, modified_stream); + + let (boundary, payload) = generate_multipart(segments); + + let header = match ("Content-Type", format!("multipart/form-data; boundary={}", boundary).as_str()).try_into_pair() { + Ok((key, value)) => { + headers.insert(key, value); + } + Err(err) => { + panic!("Error inserting test header: {:?}.", err); + } + }; + + let new_multipart = Multipart::new(&headers, stream::once(async { Ok(payload) })); + + (headers, new_multipart) +} + + + + +// Multipart functionality (actix-test does not innately support multipart) +#[derive(Debug, Clone)] +pub struct MultipartSegment { + pub name: String, + pub filename: Option, + pub content_type: Option, + pub data: MultipartSegmentData, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum MultipartSegmentData { + Text(String), + Binary(Vec), +} + +pub trait AppendsMultipart { + fn set_multipart(self, data: impl IntoIterator) -> Self; +} + +impl AppendsMultipart for TestRequest { + fn set_multipart(self, data: impl IntoIterator) -> Self { + let (boundary, payload) = generate_multipart(data); + self.append_header(( + "Content-Type", + format!("multipart/form-data; boundary={}", boundary), + )) + .set_payload(payload) + } +} + +fn generate_multipart(data: impl IntoIterator) -> (String, Bytes) { + let mut boundary: String = String::from("----WebKitFormBoundary"); + boundary.push_str(&rand::random::().to_string()); + boundary.push_str(&rand::random::().to_string()); + boundary.push_str(&rand::random::().to_string()); + + let mut payload = BytesMut::new(); + + for segment in data { + payload.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"", + boundary = boundary, + name = segment.name + ) + .as_bytes(), + ); + + if let Some(filename) = &segment.filename { + payload.extend_from_slice( + format!("; filename=\"{filename}\"", filename = filename).as_bytes(), + ); + } + if let Some(content_type) = &segment.content_type { + payload.extend_from_slice( + format!( + "\r\nContent-Type: {content_type}", + content_type = content_type + ) + .as_bytes(), + ); + } + payload.extend_from_slice(b"\r\n\r\n"); + + match &segment.data { + MultipartSegmentData::Text(text) => { + payload.extend_from_slice(text.as_bytes()); + } + MultipartSegmentData::Binary(binary) => { + payload.extend_from_slice(binary); + } + } + payload.extend_from_slice(b"\r\n"); } + payload.extend_from_slice(format!("--{boundary}--\r\n", boundary = boundary).as_bytes()); - // Sending the request - let client = reqwest::Client::new(); - Ok(client.post(url) - .headers(headers) - .multipart(body) - .send() - .await?) -} \ No newline at end of file + (boundary, Bytes::from(payload)) +} diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index ae956487..2d675b24 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -22,7 +22,7 @@ use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; -use actix_web::web::Data; +use actix_web::web::{Data, self}; use actix_web::{post, HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; @@ -35,7 +35,7 @@ use thiserror::Error; use validator::Validate; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(project_create); + cfg.route("create", web::post().to(project_create)); } #[derive(Error, Debug)] @@ -275,7 +275,6 @@ pub async fn undo_uploads( Ok(()) } -#[post("project")] pub async fn project_create( req: HttpRequest, mut payload: Multipart, diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 46dc7f7b..947f54ca 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -38,7 +38,11 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("project") - .service(project_edit) + .route("{id}", web::patch().to(project_edit)) + .service( + web::scope("{project_id}") + .route("versions", web::get().to(super::versions::version_list)) + ) ); } @@ -141,7 +145,6 @@ pub struct EditProject { pub monetization_status: Option, } -#[patch("{id}")] pub async fn project_edit( req: HttpRequest, info: web::Path<(String,)>, diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 6ca90321..2bfb065c 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -11,11 +11,10 @@ use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("tag") - .service(category_list) + .route("category", web::get().to(category_list))); // .service(loader_list) // .service(game_version_list) // .service(side_type_list), - ); } #[derive(serde::Serialize, serde::Deserialize)] @@ -26,7 +25,6 @@ pub struct CategoryData { header: String, } -#[get("category")] pub async fn category_list( pool: web::Data, redis: web::Data, diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index c47063fa..be997e38 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -84,7 +84,6 @@ struct InitialFileData { } // under `/api/v1/version` -#[post("version")] pub async fn version_create( req: HttpRequest, mut payload: Multipart, @@ -483,8 +482,6 @@ async fn version_create_inner( Ok(HttpResponse::Ok().json(response)) } -// under /api/v1/version/{version_id} -#[post("{version_id}/file")] pub async fn upload_file_to_version( req: HttpRequest, url_data: web::Path<(VersionId,)>, diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 5e62dd25..f958db51 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -17,16 +17,17 @@ use sqlx::PgPool; use std::collections::HashMap; pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( web::scope("version_file") - .service(get_update_from_hash) + .route("{version_id}/update",web::post().to(get_update_from_hash)) ); - cfg.service( web::scope("version_files") - .service(update_files) - .service(update_individual_files), + .route("update",web::post().to(update_files)) + .route("update_individual",web::post().to(update_individual_files)) ); + } #[derive(Serialize, Deserialize)] @@ -47,7 +48,6 @@ pub struct UpdateData { pub version_types: Option>, } -#[post("{version_id}/update")] pub async fn get_update_from_hash( req: HttpRequest, info: web::Path<(String,)>, @@ -127,7 +127,6 @@ pub struct ManyUpdateData { pub game_versions: Option>, pub version_types: Option>, } -#[post("update")] pub async fn update_files( req: HttpRequest, pool: web::Data, @@ -228,7 +227,6 @@ pub struct ManyFileUpdateData { pub hashes: Vec, } -#[post("update_individual")] pub async fn update_individual_files( req: HttpRequest, pool: web::Data, diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 052a3ca5..60ece6b2 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -22,12 +22,14 @@ use sqlx::PgPool; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(super::version_creation::version_create); + + cfg.route("version", web::post().to(super::version_creation::version_create)); + cfg.route("{id}", web::post().to(super::version_creation::version_create)); cfg.service( web::scope("version") - .service(version_edit) - .service(super::version_creation::upload_file_to_version), + .route("{id}", web::patch().to(version_edit)) + .route("{version_id}/file", web::post().to(super::version_creation::upload_file_to_version)) ); } #[derive(Serialize, Deserialize, Validate)] @@ -66,7 +68,6 @@ pub struct EditVersionFileType { pub file_type: Option, } -#[patch("{id}")] pub async fn version_edit( req: HttpRequest, info: web::Path<(models::ids::VersionId,)>, @@ -466,6 +467,7 @@ pub async fn version_edit( } } +#[derive(Deserialize)] pub struct VersionListFilters { pub game_versions: Option, pub loaders: Option, @@ -475,123 +477,123 @@ pub struct VersionListFilters { pub offset: Option, } -// #[get("version")] -// pub async fn version_list( -// req: HttpRequest, -// info: web::Path<(String,)>, -// web::Query(filters): web::Query, -// pool: web::Data, -// redis: web::Data, -// session_queue: web::Data, -// ) -> Result { -// let string = info.into_inner().0; - -// let result = database::models::Project::get(&string, &**pool, &redis).await?; - -// let user_option = get_user_from_headers( -// &req, -// &**pool, -// &redis, -// &session_queue, -// Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), -// ) -// .await -// .map(|x| x.1) -// .ok(); - -// if let Some(project) = result { -// if !is_authorized(&project.inner, &user_option, &pool).await? { -// return Ok(HttpResponse::NotFound().body("")); -// } - -// let version_filters = filters -// .game_versions -// .as_ref() -// .map(|x| serde_json::from_str::>(x).unwrap_or_default()); -// let loader_filters = filters -// .loaders -// .as_ref() -// .map(|x| serde_json::from_str::>(x).unwrap_or_default()); -// let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis) -// .await? -// .into_iter() -// .skip(filters.offset.unwrap_or(0)) -// .take(filters.limit.unwrap_or(usize::MAX)) -// .filter(|x| { -// let mut bool = true; - -// if let Some(version_type) = filters.version_type { -// bool &= &*x.inner.version_type == version_type.as_str(); -// } -// if let Some(loaders) = &loader_filters { -// bool &= x.loaders.iter().any(|y| loaders.contains(y)); -// } -// if let Some(game_versions) = &version_filters { -// bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); -// } - -// bool -// }) -// .collect::>(); - -// let mut response = versions -// .iter() -// .filter(|version| { -// filters -// .featured -// .map(|featured| featured == version.inner.featured) -// .unwrap_or(true) -// }) -// .cloned() -// .collect::>(); - -// versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); - -// // Attempt to populate versions with "auto featured" versions -// if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { -// let (loaders, game_versions) = futures::future::try_join( -// database::models::loader_fields::Loader::list(&**pool, &redis), -// database::models::loader_fields::GameVersion::list_filter( -// None, -// Some(true), -// &**pool, -// &redis, -// ), -// ) -// .await?; - -// let mut joined_filters = Vec::new(); -// for game_version in &game_versions { -// for loader in &loaders { -// joined_filters.push((game_version, loader)) -// } -// } - -// joined_filters.into_iter().for_each(|filter| { -// versions -// .iter() -// .find(|version| { -// version.game_versions.contains(&filter.0.version) -// && version.loaders.contains(&filter.1.loader) -// }) -// .map(|version| response.push(version.clone())) -// .unwrap_or(()); -// }); - -// if response.is_empty() { -// versions -// .into_iter() -// .for_each(|version| response.push(version)); -// } -// } - -// response.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); -// response.dedup_by(|a, b| a.inner.id == b.inner.id); - -// let response = filter_authorized_versions(response, &user_option, &pool).await?; - -// Ok(HttpResponse::Ok().json(response)) -// } else { -// Ok(HttpResponse::NotFound().body("")) -// } -// } +pub async fn version_list( + req: HttpRequest, + info: web::Path<(String,)>, + web::Query(filters): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let result = database::models::Project::get(&string, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_authorized(&project.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let version_filters = filters + .game_versions + .as_ref() + .map(|x| serde_json::from_str::>(x).unwrap_or_default()); + let loader_filters = filters + .loaders + .as_ref() + .map(|x| serde_json::from_str::>(x).unwrap_or_default()); + let mut versions = database::models::Version::get_many(&project.versions, &**pool, &redis) + .await? + .into_iter() + .skip(filters.offset.unwrap_or(0)) + .take(filters.limit.unwrap_or(usize::MAX)) + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = filters.version_type { + bool &= &*x.inner.version_type == version_type.as_str(); + } + if let Some(loaders) = &loader_filters { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + // if let Some(game_versions) = &version_filters { + // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); + // } + + bool + }) + .collect::>(); + + let mut response = versions + .iter() + .filter(|version| { + filters + .featured + .map(|featured| featured == version.inner.featured) + .unwrap_or(true) + }) + .cloned() + .collect::>(); + + versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); + + // Attempt to populate versions with "auto featured" versions + if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { + // let (loaders, game_versions) = futures::future::try_join( + // database::models::loader_fields::Loader::list(&**pool, &redis), + // database::models::loader_fields::GameVersion::list_filter( + // None, + // Some(true), + // &**pool, + // &redis, + // ), + // ) + // .await?; + + // let mut joined_filters = Vec::new(); + // for game_version in &game_versions { + // for loader in &loaders { + // joined_filters.push((game_version, loader)) + // } + // } + + // joined_filters.into_iter().for_each(|filter| { + // versions + // .iter() + // .find(|version| { + // // version.game_versions.contains(&filter.0.version) + // // && + // version.loaders.contains(&filter.1.loader) + // }) + // .map(|version| response.push(version.clone())) + // .unwrap_or(()); + // }); + + if response.is_empty() { + versions + .into_iter() + .for_each(|version| response.push(version)); + } + } + + response.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); + response.dedup_by(|a, b| a.inner.id == b.inner.id); + + let response = filter_authorized_versions(response, &user_option, &pool).await?; + + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/tests/common/actix.rs b/tests/common/actix.rs index 11759d7f..cc63b5f0 100644 --- a/tests/common/actix.rs +++ b/tests/common/actix.rs @@ -33,7 +33,7 @@ impl AppendsMultipart for TestRequest { } fn generate_multipart(data: impl IntoIterator) -> (String, Bytes) { - let mut boundary = String::from("----WebKitFormBoundary"); + let mut boundary: String = String::from("----WebKitFormBoundary"); boundary.push_str(&rand::random::().to_string()); boundary.push_str(&rand::random::().to_string()); boundary.push_str(&rand::random::().to_string()); From 36bcd2fe6cee304a7a51a05047bb6850351d4974 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Thu, 19 Oct 2023 18:13:51 -0700 Subject: [PATCH 07/31] working rough draft v3 --- migrations/20231005230721_dynamic-fields.sql | 4 +- src/database/models/loader_fields.rs | 260 ++++++++++++++++++- src/database/models/project_item.rs | 10 + src/database/models/version_item.rs | 125 +++++++-- src/models/projects.rs | 36 ++- src/routes/v2/project_creation.rs | 57 ++-- src/routes/v2/projects.rs | 2 - src/routes/v2/versions.rs | 34 ++- src/routes/v2_reroute.rs | 20 +- src/routes/v3/project_creation.rs | 239 ++++++++++++++--- src/routes/v3/version_creation.rs | 72 ++--- src/search/indexing/local_import.rs | 1 - src/util/webhook.rs | 1 - tests/common/api_v2/project.rs | 14 + tests/files/dummy_data.sql | 19 +- tests/project.rs | 9 + 16 files changed, 753 insertions(+), 150 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index eb561e16..aa5bd3f6 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -51,13 +51,13 @@ CREATE TABLE loader_fields ( ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; CREATE TABLE version_fields ( - id bigint PRIMARY KEY, version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, field_id integer REFERENCES loader_fields ON UPDATE CASCADE NOT NULL, -- for int/bool values int_value integer NULL, enum_value integer REFERENCES loader_field_enum_values ON UPDATE CASCADE NULL, - string_value text NULL + string_value text NULL, + PRIMARY KEY (version_id, field_id) ); -- Convert side_types diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index b8a93d6f..b9c16324 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -1,10 +1,14 @@ use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::parse_base62; +use crate::routes::ApiError; +use crate::routes::v3::project_creation::CreateError; use super::ids::*; use super::DatabaseError; use chrono::DateTime; use chrono::Utc; use futures::TryStreamExt; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -23,8 +27,8 @@ pub enum Game { impl Game { pub fn name(&self) -> &'static str { match self { - Game::MinecraftJava => "minecraft_java", - Game::MinecraftBedrock => "minecraft_bedrock" + Game::MinecraftJava => "minecraft-java", + Game::MinecraftBedrock => "minecraft-bedrock" } } @@ -73,22 +77,22 @@ impl Loader { Ok(result.map(|r| LoaderId(r.id))) } - pub async fn list<'a, E>(game_name : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn list<'a, E>(game_name_or_id : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let result = sqlx::query!( " SELECT l.id id, l.loader loader, l.icon icon, ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types FROM loaders l + INNER JOIN games g ON l.game_id = g.id LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - WHERE l.loader = $1 + WHERE g.name = $1 GROUP BY l.id; ", - game_name + game_name_or_id, ) .fetch_many(exec) .try_filter_map(|e| async { @@ -106,7 +110,44 @@ impl Loader { }) .try_collect::>() .await?; + println!("Just collected loaders for game {}, got {} loaders", game_name_or_id, result.len()); + Ok(result) + } + pub async fn list_id<'a, E>(game_id : GameId , exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT l.id id, l.loader loader, l.icon icon, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types + FROM loaders l + INNER JOIN games g ON l.game_id = g.id + LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id + LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id + WHERE g.id = $1 + GROUP BY l.id; + ", + game_id.0, + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|x| Loader { + id: LoaderId(x.id), + loader: x.loader, + icon: x.icon, + supported_project_types: x + .project_types + .unwrap_or_default() + .iter() + .map(|x| x.to_string()) + .collect(), + })) + }) + .try_collect::>() + .await?; + println!("Just collected loaders for game {}, got {} loaders", game_id.0, result.len()); Ok(result) } } @@ -116,6 +157,7 @@ impl Loader { pub struct LoaderField { pub id: LoaderFieldId, pub loader_id: LoaderId, + pub loader_name : String, pub field: String, pub field_type: LoaderFieldType, pub optional: bool, @@ -189,15 +231,97 @@ pub struct LoaderFieldEnumValue { pub struct VersionField { pub version_id: VersionId, pub field_id: LoaderFieldId, + pub loader_name: String, + pub field_name: String, pub value: VersionFieldValue, } impl VersionField { + pub async fn insert_many( + items: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let mut query_version_fields = vec![]; + for item in items { + let base = QueryVersionField { + version_id: item.version_id, + field_id: item.field_id, + int_value: None, + enum_value: None, + string_value: None, + }; + + match item.value { + VersionFieldValue::Integer(i) => query_version_fields.push(base.clone().with_int_value(i)), + VersionFieldValue::Text(s) => query_version_fields.push(base.clone().with_string_value(s)), + VersionFieldValue::Boolean(b) => query_version_fields.push(base.clone().with_int_value(if b { 1 } else { 0 })), + VersionFieldValue::ArrayInteger(v) => { + for i in v { + query_version_fields.push(base.clone().with_int_value(i)); + } + } + VersionFieldValue::ArrayText(v) => { + for s in v { + query_version_fields.push(base.clone().with_string_value(s)); + } + } + VersionFieldValue::ArrayBoolean(v) => { + for b in v { + query_version_fields.push(base.clone().with_int_value(if b { 1 } else { 0 })); + } + } + VersionFieldValue::Enum(_, v) => query_version_fields.push(base.clone().with_enum_value(v)), + VersionFieldValue::ArrayEnum(_, v) => { + for ev in v { + query_version_fields.push(base.clone().with_enum_value(ev)); + } + } + VersionFieldValue::Unknown => {} + }; + } + + let (field_ids, version_ids, int_values, enum_values, string_values): (Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>) = query_version_fields + .iter() + .map(|l| (l.field_id.0, l.version_id.0, l.int_value, l.enum_value.as_ref().map(|e|e.id.0), l.string_value.clone())) + .multiunzip(); + + sqlx::query!( + " + INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value) + SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[]) + ", + &field_ids[..], + &version_ids[..], + &int_values[..] as &[Option], + &string_values[..] as &[Option], + &enum_values[..] as &[Option] + ) + .execute(&mut *transaction) + .await?; + + Ok(()) + } + + pub async fn check_parse<'a, E>(version_id : VersionId, loader_field : LoaderField, key : &str, value : serde_json::Value, exec : E, redis : &RedisPool) -> Result + where E : sqlx::Executor<'a, Database = sqlx::Postgres> + { + let value = VersionFieldValue::parse(&loader_field, value, exec, &redis).await?; + + Ok(VersionField { + version_id, + field_id: loader_field.id, + loader_name: loader_field.loader_name, + field_name: loader_field.field, + value + }) + } + pub fn build(loader_field : LoaderField, version_id : VersionId, query_version_fields : Vec) -> Result { - let value = VersionFieldValue::build(&loader_field.field_type, query_version_fields)?; Ok(VersionField { version_id, field_id: loader_field.id, + loader_name: loader_field.loader_name, + field_name: loader_field.field, value }) } @@ -205,17 +329,90 @@ impl VersionField { #[derive(Clone, Serialize, Deserialize, Debug)] pub enum VersionFieldValue { - Integer(i64), + Integer(i32), Text(String), Enum(LoaderFieldEnumId, LoaderFieldEnumValue), Boolean(bool), - ArrayInteger(Vec), + ArrayInteger(Vec), ArrayText(Vec), ArrayEnum(LoaderFieldEnumId, Vec), ArrayBoolean(Vec), Unknown } impl VersionFieldValue { + // TODO: this could be combined with build + pub async fn parse<'a, E>(loader_field: &LoaderField, value : serde_json::Value, exec : E, redis : &RedisPool) -> Result + where E : sqlx::Executor<'a, Database = sqlx::Postgres> + + { + let field_name = &loader_field.field; + let field_type = &loader_field.field_type; + + let incorrect_type_error = |field_type : &str| CreateError::InvalidInput( + format!("Provided value for {field_name} could not be parsed to {field_type} ")); + + // Todo more efficient? + let enum_array = if let LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) = field_type { + LoaderFieldEnumValue::list(*id, exec, redis).await? + } else { + vec![] + }; + + Ok(match field_type { + LoaderFieldType::Integer => VersionFieldValue::Integer( + serde_json::from_value(value).map_err(|_| incorrect_type_error("integer"))? + ), + LoaderFieldType::Text => VersionFieldValue::Text( + value.as_str().ok_or_else(|| incorrect_type_error("string"))?.to_string() + ), + LoaderFieldType::Boolean => VersionFieldValue::Boolean( + value.as_bool().ok_or_else(|| incorrect_type_error("boolean"))? + ), + LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger( +{ + let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of integers"))?; + array_values.into_iter().map(|v| v).collect() +} ), + LoaderFieldType::ArrayText => VersionFieldValue::ArrayText( + { + let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of strings"))?; + array_values.into_iter().map(|v| v.to_string()).collect() + } + ), + LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean( + { + let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of booleans"))?; + array_values.into_iter().map(|v| v != 0).collect() + } + ), + LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, + { + let enum_value = value.as_str().ok_or_else(|| incorrect_type_error("enum"))?; + if let Some(ev) = enum_array.into_iter().find(|v| v.value == enum_value) { + ev + } else { + return Err(CreateError::InvalidInput(format!("Provided value '{enum_value}' is not a valid variant for {field_name}"))); + } + } + ), + LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum(*id, + { + let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of enums"))?; + let mut enum_values = vec![]; + for av in array_values { + if let Some(ev) = enum_array.iter().find(|v| v.value == av) { + enum_values.push(ev.clone()); + } else { + return Err(CreateError::InvalidInput(format!("Provided value '{av}' is not a valid variant for {field_name}"))); + } + } + enum_values + } + ), + LoaderFieldType::Unknown => VersionFieldValue::Unknown + }) + } + pub fn build(field_type : &LoaderFieldType, qvfs : Vec) -> Result { let field_name = field_type.to_str(); // TODO: should not use numbers , should use id with tostring @@ -246,7 +443,7 @@ impl VersionFieldValue { ), LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger( qvfs.into_iter().map(|qvf| - Ok::(qvf.int_value.ok_or(did_not_exist_error(field_name, "int_value"))?)).collect::>()? + Ok::(qvf.int_value.ok_or(did_not_exist_error(field_name, "int_value"))?)).collect::>()? ), LoaderFieldType::ArrayText => VersionFieldValue::ArrayText( qvfs.into_iter().map(|qvf| @@ -268,6 +465,20 @@ impl VersionFieldValue { }) } + pub fn serialize_internal(&self) -> serde_json::Value { + // Serialize to internal value + match self { + VersionFieldValue::Integer(i) => serde_json::Value::Number((*i).into()), + VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), + VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), + VersionFieldValue::ArrayInteger(v) => serde_json::Value::Array(v.iter().map(|i| serde_json::Value::Number((*i).into())).collect()), + VersionFieldValue::ArrayText(v) => serde_json::Value::Array(v.iter().map(|s| serde_json::Value::String(s.clone())).collect()), + VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array(v.iter().map(|b| serde_json::Value::Bool(*b)).collect()), + VersionFieldValue::Enum(_, v) => serde_json::Value::String(v.value.clone()), + VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array(v.iter().map(|v| serde_json::Value::String(v.value.clone())).collect()), + VersionFieldValue::Unknown => serde_json::Value::Null + } + } } @@ -275,11 +486,28 @@ impl VersionFieldValue { pub struct QueryVersionField { pub version_id: VersionId, pub field_id: LoaderFieldId, - pub int_value: Option, + pub int_value: Option, pub enum_value: Option, pub string_value: Option, } +impl QueryVersionField { + pub fn with_int_value(mut self, int_value: i32) -> Self { + self.int_value = Some(int_value); + self + } + + pub fn with_enum_value(mut self, enum_value: LoaderFieldEnumValue) -> Self { + self.enum_value = Some(enum_value); + self + } + + pub fn with_string_value(mut self, string_value: String) -> Self { + self.string_value = Some(string_value); + self + } +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub struct SideType { @@ -339,8 +567,9 @@ impl LoaderField { { let result = sqlx::query!( " - SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type + SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader FROM loader_fields lf + INNER JOIN loaders l ON lf.loader_id = l.id WHERE loader_id = ANY($1) AND field = $2 ", &loader_ids.into_iter().map(|l|l.0).collect::>(), @@ -353,6 +582,7 @@ impl LoaderField { loader_id: LoaderId(r.loader_id), field: r.field, field_type: LoaderFieldType::build(&r.field_type, r.enum_type), + loader_name: r.loader, optional: r.optional, min_val: r.min_val, max_val: r.max_val @@ -403,7 +633,9 @@ impl LoaderFieldEnumValue { let result = sqlx::query!( " SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values - " + WHERE enum_id = $1 + ", + loader_field_enum_id.0 ) .fetch_many(exec) .try_filter_map(|e| async { Ok(e.right().map(|c| @@ -496,7 +728,7 @@ impl<'a> GameVersionBuilder<'a> { where E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy { - // TODO: this is hardcoded for minecraft_java + // TODO: this is hardcoded for minecraft-java let game_name = Game::MinecraftJava.name(); let game_versions_enum = LoaderFieldEnum::get("game_versions", game_name, exec, redis).await? .ok_or(DatabaseError::SchemaError("Missing loaders field: 'game_versions'".to_string()))?; diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index e8aa9711..71022433 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -156,11 +156,13 @@ pub struct ProjectBuilder { pub discord_url: Option, pub categories: Vec, pub additional_categories: Vec, + pub initial_versions: Vec, pub status: ProjectStatus, pub requested_status: Option, pub license: String, pub slug: Option, pub donation_urls: Vec, + pub gallery_items: Vec, pub color: Option, pub monetization_status: MonetizationStatus, } @@ -211,14 +213,22 @@ impl ProjectBuilder { let ProjectBuilder { donation_urls, + gallery_items, categories, additional_categories, .. } = self; + for mut version in self.initial_versions { + version.project_id = self.project_id; + version.insert(&mut *transaction).await?; + } + DonationUrl::insert_many_projects(donation_urls, self.project_id, &mut *transaction) .await?; + GalleryItem::insert_many(gallery_items, self.project_id, &mut *transaction).await?; + let project_id = self.project_id; let mod_categories = categories .into_iter() diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 32381d44..ee647315 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -2,6 +2,7 @@ use super::ids::*; use super::DatabaseError; use super::loader_fields::LoaderField; use super::loader_fields::VersionField; +use crate::database::models::loader_fields::LoaderFieldEnumValue; use crate::database::models::loader_fields::LoaderFieldType; use crate::database::models::loader_fields::QueryVersionField; use crate::database::redis::RedisPool; @@ -27,6 +28,7 @@ pub struct VersionBuilder { pub files: Vec, pub dependencies: Vec, pub loaders: Vec, + pub version_fields: Vec, pub version_type: String, pub featured: bool, pub status: VersionStatus, @@ -242,13 +244,15 @@ impl VersionBuilder { VersionFileBuilder::insert_many(files, self.version_id, transaction).await?; DependencyBuilder::insert_many(dependencies, self.version_id, transaction).await?; - + let loader_versions = loaders .iter() .map(|l| LoaderVersion::new(*l, version_id)) .collect_vec(); LoaderVersion::insert_many(loader_versions, &mut *transaction).await?; + VersionField::insert_many(self.version_fields, transaction).await?; + Ok(self.version_id) } } @@ -523,15 +527,17 @@ impl Version { JSONB_AGG( DISTINCT jsonb_build_object( - 'values', jsonb_build_object( - 'vf_id', vf.id, - 'field_id', vf.field_id, - 'int_value', vf.int_value, - 'enum_value', vf.enum_value, - 'string_value', vf.string_value - ), + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value + ) + ) version_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( 'lf_id', lf.id, 'l_id', lf.loader_id, + 'loader_name', l.loader, 'field', lf.field, 'field_type', lf.field_type, 'enum_type', lf.enum_type, @@ -541,7 +547,17 @@ impl Version { 'enum_name', lfe.enum_name ) - ) version_fields + ) loader_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'id', lfev.id, + 'enum_id', lfev.enum_id, + 'value', lfev.value, + 'ordering', lfev.ordering, + 'created', lfev.created, + 'metadata', lfev.metadata + ) + ) loader_field_enum_values FROM versions v LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id @@ -552,6 +568,7 @@ impl Version { LEFT OUTER JOIN version_fields vf on v.id = vf.version_id LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id + LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id WHERE v.id = ANY($1) GROUP BY v.id @@ -645,13 +662,12 @@ impl Version { files }, version_fields: { - #[derive(Deserialize)] - struct QueryVersionFieldCombined { - values: Vec, - + #[derive(Deserialize, Debug)] + struct JsonLoaderField { lf_id: i32, l_id: i32, field: String, + loader_name: String, field_type: String, enum_type: Option, min_val: Option, @@ -661,26 +677,91 @@ impl Version { enum_name: Option, } - let query_version_field_combined: Vec = serde_json::from_value( - v.version_fields.unwrap_or_default()).unwrap_or_default(); + #[derive(Deserialize, Debug)] + struct JsonVersionField { + field_id: i32, + int_value: Option, + enum_value: Option, + string_value: Option, + } + + #[derive(Deserialize, Debug)] + struct JsonLoaderFieldEnumValue { + id: i32, + enum_id: i32, + value: String, + ordering: Option, + created: DateTime, + metadata: Option, + } + + + println!("Value: {:#?}", serde_json::to_string(&v.loader_fields)); + + let query_loader_fields: Vec = serde_json::from_value( + v.loader_fields.unwrap_or_default()).unwrap(); + + + println!("Value: {:#?}", serde_json::to_string(&v.version_fields)); + + let query_version_field_combined: Vec = serde_json::from_value( + v.version_fields.unwrap_or_default()).unwrap(); + println!("Query version field combined: {:#?}", query_version_field_combined); + + println!("Value: {:#?}", serde_json::to_string(&v.loader_field_enum_values)); + let query_loader_field_enum_values: Vec = serde_json::from_value( + v.loader_field_enum_values.unwrap_or_default()).unwrap(); + + println!("Query loader field enum values: {:#?}", query_loader_field_enum_values); + let version_id = VersionId(v.id); - query_version_field_combined.into_iter().filter_map( |q| { + query_loader_fields.into_iter().filter_map( |q| { + println!("Query version field: {:#?}", q); let loader_field = LoaderField { id: LoaderFieldId(q.lf_id), - loader_id: LoaderId(q.l_id), - field: q.field, + loader_id: LoaderId(q.l_id), + field: q.field.clone(), + loader_name: q.loader_name.clone(), field_type: LoaderFieldType::build(&q.field_type, q.enum_type), optional: q.optional, min_val: q.min_val, max_val: q.max_val }; - VersionField::build( + + let values = query_version_field_combined.iter().filter_map(|qvf| { + if qvf.field_id == q.lf_id { + let lfev = query_loader_field_enum_values.iter().find(|x| Some(x.id) == qvf.enum_value); + + Some(QueryVersionField { + version_id, + field_id: LoaderFieldId(qvf.field_id), + int_value: qvf.int_value, + enum_value: lfev.map(|lfev| LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(lfev.id), + enum_id: LoaderFieldEnumId(lfev.enum_id), + value: lfev.value.clone(), + ordering: lfev.ordering, + created: lfev.created, + metadata: serde_json::from_str(&lfev.metadata.clone().unwrap_or_default()).unwrap_or_default() + }), + string_value: qvf.string_value.clone() + }) + } else { + None + } + }).collect::>(); + + + + println!("Loader field: {:#?}", loader_field); + let v = VersionField::build( loader_field, version_id, - - q.values - ).ok() + values + ).ok(); + println!("Version field: {:#?}", v); + v }).collect() }, loaders: v.loaders.unwrap_or_default(), diff --git a/src/models/projects.rs b/src/models/projects.rs index bf8bfaba..eef00b22 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -1,6 +1,9 @@ +use std::collections::HashMap; + use super::ids::{Base62Id, OrganizationId}; use super::teams::TeamId; use super::users::UserId; +use crate::database::models::loader_fields::VersionField as DBVersionField; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::QueryVersion; use crate::models::threads::ThreadId; @@ -478,13 +481,42 @@ pub struct Version { /// A list of projects that this version depends on. pub dependencies: Vec, /// The loaders that this version works on - pub loaders: Vec, + pub loaders: Vec, + +} + +// A loader and its associated loader VersionFields +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct LoaderStruct { + pub loader : Loader, + + // All other fields are loader-specific VersionFields + #[serde(flatten)] + pub fields : HashMap, } impl From for Version { fn from(data: QueryVersion) -> Version { let v = data.inner; + let loader_names : Vec = data.loaders.into_iter().map(Loader).collect(); + let mut loaders : HashMap = HashMap::new(); + for loader in loader_names { + loaders.insert(loader.0.clone(), LoaderStruct { + loader, + fields: HashMap::new(), + }); + } + for version_field in data.version_fields { + if let Some(loader_struct) = loaders.get_mut(&version_field.loader_name) { + // Only add the internal component of the field for display + // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) + loader_struct.fields.insert(version_field.field_name, version_field.value.serialize_internal()); + } + } + let loaders = loaders.into_iter().map(|(_, v)| v).collect(); + + Version { id: v.id.into(), project_id: v.project_id.into(), @@ -528,7 +560,7 @@ impl From for Version { dependency_type: DependencyType::from_string(d.dependency_type.as_str()), }) .collect(), - loaders: data.loaders.into_iter().map(Loader).collect(), + loaders, } } } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 6507c463..aa5a97cb 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -56,27 +56,50 @@ pub async fn project_create( session_queue: Data, ) -> Result { - let (headers, payload) = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { + // Convert V2 multipart payload to V3 multipart payload + let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { // Convert input data to V3 format - json["game_name"] = json!("minecraft_java"); - }).await; - // for (key, value) in headers.iter() { - // req.headers().append(key.clone(), value.clone()); - // } - let response= v3::project_creation::project_create(req, payload, client, redis, file_host, session_queue).await?; + // Set game name (all v2 projects are minecraft-java) + json["game_name"] = json!("minecraft-java"); + + // Loader fields are now a struct, containing all versionfields + // loaders: ["fabric"] + // game_versions: ["1.16.5", "1.17"] + // -> becomes -> + // loaders: [{"loader": "fabric", "game_versions": ["1.16.5", "1.17"]}] + // Side types will be applied to each version + let client_side = json["client_side"].as_str().unwrap_or("required").to_string(); + let server_side = json["server_side"].as_str().unwrap_or("required").to_string(); + json["client_side"] = json!(null); + json["server_side"] = json!(null); - // Redirects to V3 route - // let self_addr = dotenvy::var("SELF_ADDR")?; - // let url = format!("{self_addr}/v3/project"); - // let response = v2_reroute::reroute_multipart(&url, req, payload, |json | { - // // Convert input data to V3 format - // json["game_name"] = json!("minecraft_java"); - // }).await?; - // let response = HttpResponse::build(response.status()) - // .content_type(response.headers().get("content-type").and_then(|h| h.to_str().ok()).unwrap_or_default()) - // .body(response.bytes().await.unwrap_or_default()); + if let Some(versions) = json["initial_versions"].as_array_mut() { + for version in versions { + // Construct loader object with version fields + // V2 fields becoming loader fields are: + // - client_side + // - server_side + // - game_versions + let mut loaders = vec![]; + for loader in version["loaders"].as_array().unwrap_or(&Vec::new()) { + let loader = loader.as_str().unwrap_or(""); + loaders.push(json!({ + "loader": loader, + "game_versions": version["game_versions"].as_array(), + "client_side": client_side, + "server_side": server_side, + })); + } + version["loaders"] = json!(loaders); + } + } + + }).await; + + // Call V3 project creation + let response= v3::project_creation::project_create(req, payload, client, redis, file_host, session_queue).await?; // TODO: Convert response to V2 format Ok(response) diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index d839fd5d..570dc539 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -390,8 +390,6 @@ pub async fn project_edit( session_queue: web::Data, ) -> Result { // TODO: Should call v3 route - let self_addr = dotenvy::var("SELF_ADDR")?; - let url = format!("{self_addr}/v3/project/{id}", id = info.0); let new_project = new_project.into_inner(); let new_project = v3::projects::EditProject { diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 1b372d25..a083de60 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -230,8 +230,38 @@ pub async fn version_edit( new_version: web::Json, session_queue: web::Data, ) -> Result { - // TODO: move route to v3 - Ok(HttpResponse::Ok().json("")) + // TODO: Should call v3 route + + let new_version = new_version.into_inner(); + let new_version = v3::versions::EditVersion { + name: new_version.name, + version_number: new_version.version_number, + changelog: new_version.changelog, + version_type: new_version.version_type, + dependencies: new_version.dependencies, + game_versions: new_version.game_versions, + loaders: new_version.loaders, + featured: new_version.featured, + primary_file: new_version.primary_file, + downloads: new_version.downloads, + status: new_version.status, + file_types: new_version.file_types.map(|v| + v.into_iter().map(|evft| + v3::versions::EditVersionFileType { + algorithm: evft.algorithm, + hash: evft.hash, + file_type: evft.file_type, + }).collect::>() + ) + }; + + let response = v3::versions::version_edit(req, info, pool, redis, web::Json(new_version), session_queue).await?; + + println!("Interecepting patch: {:?}", response); + // TODO: Convert response to V2 format + + + Ok(response) } #[derive(Deserialize)] diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 56165c50..8c27df39 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -5,7 +5,7 @@ use actix_web::http::header::{TryIntoHeaderPair,HeaderMap, HeaderName}; use futures::{StreamExt, stream}; use serde_json::{Value, json}; -pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, closure: impl Fn(&mut serde_json::Value)) -> (HeaderMap, Multipart) { +pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, closure: impl Fn(&mut serde_json::Value)) -> Multipart { let mut segments: Vec = Vec::new(); if let Some(mut field) = multipart.next().await { @@ -25,7 +25,7 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header { let mut json_value: Value = serde_json::from_slice(&buffer).unwrap(); - json_value["game_name"] = json!("minecraft-java"); + closure(&mut json_value); buffer = serde_json::to_vec(&json_value).unwrap(); } @@ -52,12 +52,6 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header buffer.extend_from_slice(&data); } - // if /* this is the JSON part */ { - // let mut json_value: Value = serde_json::from_slice(&buffer)?; - // json_value["new_key"] = json!("new_value"); - // buffer = serde_json::to_vec(&json_value)?; - // } - segments.push(MultipartSegment { name: field_name.to_string(), filename: field_filename.map(|s| s.to_string()), content_type: field_content_type, @@ -66,15 +60,9 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header } - // let modified_stream = iter(modified_parts.into_iter().map(Result::::Ok)); - - // let modified_stream = ModifiedStream { inner: modified_stream }; - - // let new_multipart = Multipart::new(&headers, modified_stream); - let (boundary, payload) = generate_multipart(segments); - let header = match ("Content-Type", format!("multipart/form-data; boundary={}", boundary).as_str()).try_into_pair() { + match ("Content-Type", format!("multipart/form-data; boundary={}", boundary).as_str()).try_into_pair() { Ok((key, value)) => { headers.insert(key, value); } @@ -85,7 +73,7 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header let new_multipart = Multipart::new(&headers, stream::once(async { Ok(payload) })); - (headers, new_multipart) + new_multipart } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 2d675b24..0269d10a 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -1,6 +1,6 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; -use crate::database::models::loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue}; +use crate::database::models::loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue, VersionField, LoaderField}; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, image_item, User, DatabaseError}; use crate::database::redis::RedisPool; @@ -182,6 +182,10 @@ struct ProjectCreateData { /// A long description of the project, in markdown. pub body: String, + #[validate(length(max = 32))] + #[validate] + /// A list of initial versions to upload with the created project + pub initial_versions: Vec, #[validate(length(max = 3))] /// A list of the categories that the project is in. pub categories: Vec, @@ -230,6 +234,10 @@ struct ProjectCreateData { /// The license id that the project follows pub license_id: String, + #[validate(length(max = 64))] + #[validate] + /// The multipart names of the gallery items to upload + pub gallery_items: Option>, #[serde(default = "default_requested_status")] /// The status of the mod to be set once it is approved pub requested_status: ProjectStatus, @@ -375,6 +383,10 @@ println!("in2!"); let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); let project_create_data; + let game_id; + let mut versions; + let mut versions_map = std::collections::HashMap::new(); + let mut gallery_urls = Vec::new(); { // The first multipart field must be named "data" and contain a // JSON `ProjectCreateData` object. @@ -413,6 +425,8 @@ println!("in2!"); .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + println!("CREATING PROJECT: {}", serde_json::to_string_pretty(&create_data).unwrap()); + let slug_project_id_option: Option = serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); @@ -451,6 +465,40 @@ println!("in2!"); } println!("in7"); + // Check game exists, and get loaders for it + let game_name = &create_data.game_name; + game_id = models::loader_fields::Game::get_id( + &create_data.game_name, + &mut *transaction, + ).await?.ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; + let all_loaders = models::loader_fields::Loader::list(&game_name, &mut *transaction, redis).await?; + + // Create VersionBuilders for the versions specified in `initial_versions` + versions = Vec::with_capacity(create_data.initial_versions.len()); + for (i, data) in create_data.initial_versions.iter().enumerate() { + // Create a map of multipart field names to version indices + for name in &data.file_parts { + if versions_map.insert(name.to_owned(), i).is_some() { + // If the name is already used + return Err(CreateError::InvalidInput(String::from( + "Duplicate multipart field name", + ))); + } + } + versions.push( + create_initial_version( + data, + project_id, + current_user.id, + &all_loaders, + &create_data.project_type, + transaction, + redis + ) + .await?, + ); + } + project_create_data = create_data; } println!("in8"); @@ -508,6 +556,75 @@ println!("in2!"); ); return Ok(()); } + if let Some(gallery_items) = &project_create_data.gallery_items { + if gallery_items.iter().filter(|a| a.featured).count() > 1 { + return Err(CreateError::InvalidInput(String::from( + "Only one gallery image can be featured.", + ))); + } + if let Some(item) = gallery_items.iter().find(|x| x.item == name) { + let data = read_from_field( + &mut field, + 5 * (1 << 20), + "Gallery image exceeds the maximum of 5MiB.", + ) + .await?; + let hash = sha1::Sha1::from(&data).hexdigest(); + let (_, file_extension) = + super::version_creation::get_name_ext(&content_disposition)?; + let content_type = crate::util::ext::get_image_content_type(file_extension) + .ok_or_else(|| { + CreateError::InvalidIconFormat(file_extension.to_string()) + })?; + let url = format!("data/{project_id}/images/{hash}.{file_extension}"); + let upload_data = file_host + .upload_file(content_type, &url, data.freeze()) + .await?; + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: upload_data.file_name, + }); + gallery_urls.push(crate::models::projects::GalleryItem { + url: format!("{cdn_url}/{url}"), + featured: item.featured, + title: item.title.clone(), + description: item.description.clone(), + created: Utc::now(), + ordering: item.ordering, + }); + return Ok(()); + } + } + let index = if let Some(i) = versions_map.get(name) { + *i + } else { + return Err(CreateError::InvalidInput(format!( + "File `{file_name}` (field {name}) isn't specified in the versions data" + ))); + }; + // `index` is always valid for these lists + let created_version = versions.get_mut(index).unwrap(); + let version_data = project_create_data.initial_versions.get(index).unwrap(); + // Upload the new jar file + super::version_creation::upload_file( + &mut field, + file_host, + version_data.file_parts.len(), + uploaded_files, + &mut created_version.files, + &mut created_version.dependencies, + &cdn_url, + &content_disposition, + project_id, + created_version.version_id.into(), + &project_create_data.project_type, + version_data.loaders.clone().into_iter().map(|l|l.loader).collect(), + version_data.primary_file.is_some(), + version_data.primary_file.as_deref() == Some(name), + None, + transaction, + ) + .await?; Ok(()) } @@ -524,7 +641,20 @@ println!("in2!"); println!("in10"); { + // Check to make sure that all specified files were uploaded + for (version_data, builder) in project_create_data + .initial_versions + .iter() + .zip(versions.iter()) + { + if version_data.file_parts.len() != builder.files.len() { + return Err(CreateError::InvalidInput(String::from( + "Some files were specified in initial_versions but not uploaded", + ))); + } + } + // Convert the list of category names to actual categories let mut categories = Vec::with_capacity(project_create_data.categories.len()); for category in &project_create_data.categories { @@ -567,19 +697,18 @@ println!("in2!"); let team_id = team.insert(&mut *transaction).await?; - let status = ProjectStatus::Draft; - if !project_create_data.requested_status.can_be_requested() { - return Err(CreateError::InvalidInput(String::from( - "Specified requested status is not allowed to be requested", - ))); + let status; + if project_create_data.is_draft.unwrap_or(false) { + status = ProjectStatus::Draft; + } else { + status = ProjectStatus::Processing; + if project_create_data.initial_versions.is_empty() { + return Err(CreateError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } } - let game_name = &project_create_data.game_name; - let game_id = models::loader_fields::Game::get_id( - &project_create_data.game_name, - &mut *transaction, - ).await?.ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; - let license_id = spdx::Expression::parse(&project_create_data.license_id).map_err(|err| { CreateError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) @@ -628,11 +757,22 @@ println!("in2!"); discord_url: project_create_data.discord_url, categories, additional_categories, + initial_versions: versions, status, requested_status: Some(project_create_data.requested_status), license: license_id.to_string(), slug: Some(project_create_data.slug), donation_urls, + gallery_items: gallery_urls.iter() + .map(|x| models::project_item::GalleryItem { + image_url: x.url.clone(), + featured: x.featured, + title: x.title.clone(), + description: x.description.clone(), + created: x.created, + ordering: x.ordering, + }) + .collect(), color: icon_data.and_then(|x| x.1), monetization_status: MonetizationStatus::Monetized, }; @@ -714,14 +854,18 @@ println!("in2!"); categories: project_create_data.categories, additional_categories: project_create_data.additional_categories, loaders: vec![], - versions: vec![], - gallery: vec![], + versions: project_builder + .initial_versions + .iter() + .map(|v| v.version_id.into()) + .collect::>(), icon_url: project_builder.icon_url.clone(), issues_url: project_builder.issues_url.clone(), source_url: project_builder.source_url.clone(), wiki_url: project_builder.wiki_url.clone(), discord_url: project_builder.discord_url.clone(), donation_urls: project_create_data.donation_urls.clone(), + gallery: gallery_urls, color: project_builder.color, thread_id: thread_id.into(), monetization_status: MonetizationStatus::Monetized, @@ -735,10 +879,10 @@ async fn create_initial_version( version_data: &InitialVersionData, project_id: ProjectId, author: UserId, - all_game_versions: &[models::loader_fields::GameVersion], all_loaders: &[models::loader_fields::Loader], - project_type: &str, + project_type: &String, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, ) -> Result { if version_data.project_id.is_some() { return Err(CreateError::InvalidInput(String::from( @@ -765,22 +909,56 @@ async fn create_initial_version( // }) // .collect::, CreateError>>()?; - let loaders = version_data - .loaders + println!("This one!!!!!"); + println!("Loaders: {:?}", serde_json::to_string(&version_data.loaders).unwrap()); + println!("All loaders: {:?}", serde_json::to_string(&all_loaders).unwrap()); + println!("Supported project types: {:?}", all_loaders.iter().map(|x| x.supported_project_types.clone()).collect::>()); + + let mut loader_ids = vec![]; + let mut loaders = vec![]; + let mut version_fields = vec![]; + for loader_create in version_data.loaders.iter() { + let loader_name = loader_create.loader.0.clone(); + println!("ADding loader: {}", loader_name); + // Confirm loader from list of loaders + let loader_id = all_loaders .iter() - .map(|x| { - all_loaders - .iter() - .find(|y| { - y.loader == x.0 - && y.supported_project_types - .contains(&project_type.to_string()) - }) - .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) + .find(|y| { + y.loader == loader_name && y.supported_project_types.contains(project_type) }) - .collect::, CreateError>>()?; + .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) + .map(|y| y.id)?; + + loader_ids.push(loader_id); + loaders.push(loader_create.loader.clone()); + + for (key, value) in loader_create.fields .iter() { + println!("ADding loader field: {} {}", key, value); + // TODO: more efficient, multiselect + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + })?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), &mut *transaction, redis).await?; + version_fields.push(vf); + } + } + // let loaders = version_data + // .loaders + // .iter() + // .map(|x| { + // all_loaders + // .iter() + // .find(|y| { + // y.loader == x.0 + // && y.supported_project_types + // .contains(&project_type.to_string()) + // }) + // .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + // .map(|y| y.id) + // }) + // .collect::, CreateError>>()?; + println!("past..."); let dependencies = version_data .dependencies .iter() @@ -801,7 +979,8 @@ async fn create_initial_version( changelog: version_data.version_body.clone().unwrap_or_default(), files: Vec::new(), dependencies, - loaders, + loaders: loader_ids, + version_fields, featured: version_data.featured, status: VersionStatus::Listed, version_type: version_data.release_channel.to_string(), diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index be997e38..6eb08d4f 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,6 +1,6 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue, LoaderFieldEnum}; +use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue, LoaderFieldEnum, VersionField, LoaderField}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, @@ -14,7 +14,7 @@ use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; use crate::models::projects::{ Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile, - VersionId, VersionStatus, VersionType, + VersionId, VersionStatus, VersionType, LoaderStruct, }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; @@ -64,7 +64,7 @@ pub struct InitialVersionData { #[serde(alias = "version_type")] pub release_channel: VersionType, #[validate(length(min = 1))] - pub loaders: Vec, + pub loaders: Vec, pub featured: bool, pub primary_file: Option, #[serde(default = "default_requested_status")] @@ -139,9 +139,6 @@ async fn version_create_inner( let mut initial_version_data = None; let mut version_builder = None; - // let all_game_versions = models::loader_fields::GameVersion::list(&mut *transaction, redis).await?; - let all_loaders = models::loader_fields::Loader::list(Game::MinecraftJava.name(),&mut *transaction, redis).await?; - let user = get_user_from_headers( &req, pool, @@ -194,18 +191,8 @@ async fn version_create_inner( let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); // Ensure that the project this version is being added to exists - let results = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", - project_id as models::ProjectId - ) - .fetch_one(&mut *transaction) - .await?; - - if !results.exists.unwrap_or(false) { - return Err(CreateError::InvalidInput( - "An invalid project id was supplied".to_string(), - )); - } + let project = models::Project::get_id(project_id, &mut *transaction, redis).await? + .ok_or_else(|| CreateError::InvalidInput("An invalid project id was supplied".to_string()))?; // Check that the user creating this version is a team member // of the project the version is being added to. @@ -261,20 +248,42 @@ async fn version_create_inner( .await? .name; - let loaders = version_create_data - .loaders + let game_id = project.inner.game_id; + let all_loaders = models::loader_fields::Loader::list_id(game_id,&mut *transaction, redis).await?; + + println!("Loaders: {:?}", serde_json::to_string(&version_create_data.loaders).unwrap()); + println!("All loaders: {:?}", serde_json::to_string(&all_loaders).unwrap()); + println!("Supported project types: {:?}", all_loaders.iter().map(|x| x.supported_project_types.clone()).collect::>()); + + let mut loader_ids = vec![]; + let mut loaders = vec![]; + let mut version_fields = vec![]; + for loader_create in version_create_data.loaders.iter() { + let loader_name = loader_create.loader.0.clone(); + + // Confirm loader from list of loaders + let loader_id = all_loaders .iter() - .map(|x| { - all_loaders - .iter() - .find(|y| { - y.loader == x.0 && y.supported_project_types.contains(&project_type) - }) - .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) + .find(|y| { + y.loader == loader_name && y.supported_project_types.contains(&project_type) }) - .collect::, CreateError>>()?; + .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) + .map(|y| y.id)?; + + loader_ids.push(loader_id); + loaders.push(loader_create.loader.clone()); + + for (key, value) in loader_create.fields .iter() { + // TODO: more efficient, multiselect + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + })?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), &mut *transaction, redis).await?; + version_fields.push(vf); + } + } + println!("Got past this part"); let dependencies = version_create_data .dependencies .iter() @@ -295,7 +304,8 @@ async fn version_create_inner( changelog: version_create_data.version_body.clone().unwrap_or_default(), files: Vec::new(), dependencies, - loaders, + loaders: loader_ids, + version_fields, version_type: version_create_data.release_channel.to_string(), featured: version_create_data.featured, status: version_create_data.status, @@ -337,7 +347,7 @@ async fn version_create_inner( version.project_id.into(), version.version_id.into(), &project_type, - version_data.loaders, + version_data.loaders.into_iter().map(|l|l.loader).collect(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), version_data.file_types.get(name).copied().flatten(), diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index c9df8af0..631b1163 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -23,7 +23,6 @@ pub async fn index_local(pool: PgPool) -> Result, Index ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( DISTINCT jsonb_build_object( - 'id', vf.id, 'field_id', vf.field_id, 'int_value', vf.int_value, 'enum_value', vf.enum_value, diff --git a/src/util/webhook.rs b/src/util/webhook.rs index d82c305d..00cd620b 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -92,7 +92,6 @@ pub async fn send_discord_webhook( ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( DISTINCT jsonb_build_object( - 'id', vf.id, 'field_id', vf.field_id, 'int_value', vf.int_value, 'enum_value', vf.enum_value, diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 2a014767..89fbe92b 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -82,6 +82,20 @@ impl ApiV2 { test::read_body_json(resp).await } + pub async fn get_version(&self, id : &str, pat: &str) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v2/version/{id}")) + .append_header(("Authorization", pat)) + .to_request(); + self.call(req).await + } + + pub async fn get_version_deserialized(&self, id : &str, pat: &str) -> Version { + let resp = self.get_version(id, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + pub async fn get_user_projects_deserialized( &self, user_id_or_username: &str, diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index a1aa59c9..1cc30937 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -36,16 +36,15 @@ VALUES (2, '1.20.4', '{"type":"release","major":false}'); INSERT INTO loader_field_enum_values(enum_id, value, metadata) VALUES (2, '1.20.5', '{"type":"release","major":false}'); --- INSERT INTO game_versions (id, version, type, created) --- VALUES (20000, '1.20.1', 'release', timezone('utc', now())); --- INSERT INTO game_versions (id, version, type, created) --- VALUES (20001, '1.20.2', 'release', timezone('utc', now())); --- INSERT INTO game_versions (id, version, type, created) --- VALUES (20002, '1.20.3', 'release', timezone('utc', now())); --- INSERT INTO game_versions (id, version, type, created) --- VALUES (20003, '1.20.4', 'release', timezone('utc', now())); --- INSERT INTO game_versions (id, version, type, created) --- VALUES (20004, '1.20.5', 'release', timezone('utc', now())); +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (1, 'game_versions', 'array(enum)', 2); + +-- Side-types +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (1, 'client_side', 'enum', 1); + +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (1, 'server_side', 'enum', 1); INSERT INTO categories (id, category, project_type) VALUES diff --git a/tests/project.rs b/tests/project.rs index 7015e315..e42219f7 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -291,6 +291,7 @@ pub async fn test_patch_project() { let alpha_project_slug = &test_env.dummy.as_ref().unwrap().project_alpha.project_slug; let beta_project_slug = &test_env.dummy.as_ref().unwrap().project_beta.project_slug; + // First, we do some patch requests that should fail. // Failure because the user is not authorized. let resp = api @@ -428,6 +429,14 @@ pub async fn test_patch_project() { // New slug does work let resp = api.get_project("newslug", USER_USER_PAT).await; let project: serde_json::Value = test::read_body_json(resp).await; + + // TODO DELETE ME + // Get and check versions real quick + let version = api.get_version_deserialized(project["versions"][0].as_str().unwrap(), USER_USER_PAT).await; + // print json + println!("Serialiezd version: {}", serde_json::to_string_pretty(&version).unwrap()); + + println!("Serialiezd project: {}", serde_json::to_string_pretty(&project).unwrap()); assert_eq!(project["slug"], json!(Some("newslug".to_string()))); assert_eq!(project["title"], "New successful title"); assert_eq!(project["description"], "New successful description"); From efbea3e60c0419e79a03a8d841fe0f12151f0ed1 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Fri, 20 Oct 2023 16:41:19 -0700 Subject: [PATCH 08/31] most tests passing --- src/database/models/loader_fields.rs | 46 ++++--- src/database/models/project_item.rs | 10 +- src/database/models/version_item.rs | 9 +- src/models/projects.rs | 25 +++- src/routes/v2/project_creation.rs | 18 ++- src/routes/v2/projects.rs | 175 ++++++++++++++++----------- src/routes/v2/versions.rs | 15 ++- src/routes/v2_reroute.rs | 47 ++++++- src/routes/v3/project_creation.rs | 11 +- src/routes/v3/projects.rs | 97 +++++++++++++-- src/routes/v3/version_creation.rs | 3 +- src/routes/v3/versions.rs | 65 ++++++++-- tests/common/dummy_data.rs | 6 +- tests/common/permissions.rs | 9 +- tests/project.rs | 15 +-- 15 files changed, 405 insertions(+), 146 deletions(-) diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index b9c16324..ba4ce6b4 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -301,11 +301,9 @@ impl VersionField { Ok(()) } - pub async fn check_parse<'a, E>(version_id : VersionId, loader_field : LoaderField, key : &str, value : serde_json::Value, exec : E, redis : &RedisPool) -> Result - where E : sqlx::Executor<'a, Database = sqlx::Postgres> + pub fn check_parse(version_id : VersionId, loader_field : LoaderField, key : &str, value : serde_json::Value, enum_variants: Vec) -> Result { - let value = VersionFieldValue::parse(&loader_field, value, exec, &redis).await?; - + let value = VersionFieldValue::parse(&loader_field, value, enum_variants)?; Ok(VersionField { version_id, field_id: loader_field.id, @@ -341,22 +339,18 @@ pub enum VersionFieldValue { } impl VersionFieldValue { // TODO: this could be combined with build - pub async fn parse<'a, E>(loader_field: &LoaderField, value : serde_json::Value, exec : E, redis : &RedisPool) -> Result - where E : sqlx::Executor<'a, Database = sqlx::Postgres> + pub fn parse(loader_field: &LoaderField, value : serde_json::Value, enum_array: Vec) -> Result { + println!("Parsing field {} with type {:?} and value {:?}", loader_field.field, loader_field.field_type, value); + println!("Enum array {:?}", enum_array); + let field_name = &loader_field.field; let field_type = &loader_field.field_type; - let incorrect_type_error = |field_type : &str| CreateError::InvalidInput( - format!("Provided value for {field_name} could not be parsed to {field_type} ")); - - // Todo more efficient? - let enum_array = if let LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) = field_type { - LoaderFieldEnumValue::list(*id, exec, redis).await? - } else { - vec![] - }; + let error_value = value.clone(); + let incorrect_type_error = |field_type : &str| + format!("Provided value '{v}' for {field_name} could not be parsed to {field_type} ", v = serde_json::to_string(&error_value).unwrap_or_default()); Ok(match field_type { LoaderFieldType::Integer => VersionFieldValue::Integer( @@ -391,7 +385,7 @@ impl VersionFieldValue { if let Some(ev) = enum_array.into_iter().find(|v| v.value == enum_value) { ev } else { - return Err(CreateError::InvalidInput(format!("Provided value '{enum_value}' is not a valid variant for {field_name}"))); + return Err((format!("Provided value '{enum_value}' is not a valid variant for {field_name}"))); } } ), @@ -403,7 +397,7 @@ impl VersionFieldValue { if let Some(ev) = enum_array.iter().find(|v| v.value == av) { enum_values.push(ev.clone()); } else { - return Err(CreateError::InvalidInput(format!("Provided value '{av}' is not a valid variant for {field_name}"))); + return Err((format!("Provided value '{av}' is not a valid variant for {field_name}"))); } } enum_values @@ -467,7 +461,7 @@ impl VersionFieldValue { pub fn serialize_internal(&self) -> serde_json::Value { // Serialize to internal value - match self { + let a = match self { VersionFieldValue::Integer(i) => serde_json::Value::Number((*i).into()), VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), @@ -477,7 +471,9 @@ impl VersionFieldValue { VersionFieldValue::Enum(_, v) => serde_json::Value::String(v.value.clone()), VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array(v.iter().map(|v| serde_json::Value::String(v.value.clone())).collect()), VersionFieldValue::Unknown => serde_json::Value::Null - } + }; + println!("Serializing internal: {:?} to {:?}", self, a); + a } } @@ -625,6 +621,18 @@ impl LoaderFieldEnum { } impl LoaderFieldEnumValue { + + pub async fn list_optional<'a, E>(list_optional : &LoaderFieldType, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where E: sqlx::Executor<'a, Database = sqlx::Postgres> + { + match list_optional { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + LoaderFieldEnumValue::list(*id, exec, redis).await + } + _ => Ok(vec![]) + } + } + pub async fn list<'a, E>(loader_field_enum_id : LoaderFieldEnumId, exec: E, redis: &RedisPool) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 71022433..d362a059 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -544,7 +544,7 @@ impl Project { .flatten() .collect(), ); - + println!("Project ids: {:?}", project_ids); if !project_ids.is_empty() { let projects = redis .multi_get::(PROJECTS_NAMESPACE, project_ids) @@ -570,6 +570,9 @@ impl Project { .flat_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); + + println!("Project ids parsed: {:?}", project_ids_parsed); + println!("Remaining strings: {:?}", &remaining_strings.iter().map(|x| x.to_string().to_lowercase()).collect::>()); let db_projects: Vec = sqlx::query!( " SELECT m.id id, m.game_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, @@ -604,7 +607,7 @@ impl Project { .try_filter_map(|e| async { Ok(e.right().map(|m| { let id = m.id; - + println!("FOUND SOMETHING!"); QueryProject { inner: Project { id: ProjectId(id), @@ -649,6 +652,7 @@ impl Project { categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), versions: { + println!("Calculating versions..."); #[derive(Deserialize)] struct Version { pub id: VersionId, @@ -662,7 +666,7 @@ impl Project { .unwrap_or_default(); versions.sort_by(|a, b| a.date_published.cmp(&b.date_published)); - + println!("No bueno..."); versions.into_iter().map(|x| x.id).collect() }, gallery_items: { diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index ee647315..0e15fe96 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -698,20 +698,17 @@ impl Version { println!("Value: {:#?}", serde_json::to_string(&v.loader_fields)); - let query_loader_fields: Vec = serde_json::from_value( - v.loader_fields.unwrap_or_default()).unwrap(); + let query_loader_fields : Vec = v.loader_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); println!("Value: {:#?}", serde_json::to_string(&v.version_fields)); - let query_version_field_combined: Vec = serde_json::from_value( - v.version_fields.unwrap_or_default()).unwrap(); + let query_version_field_combined : Vec = v.version_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); println!("Query version field combined: {:#?}", query_version_field_combined); println!("Value: {:#?}", serde_json::to_string(&v.loader_field_enum_values)); - let query_loader_field_enum_values: Vec = serde_json::from_value( - v.loader_field_enum_values.unwrap_or_default()).unwrap(); + let query_loader_field_enum_values: Vec = v.loader_field_enum_values.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); println!("Query loader field enum values: {:#?}", query_loader_field_enum_values); diff --git a/src/models/projects.rs b/src/models/projects.rs index eef00b22..43753bfc 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -486,15 +486,28 @@ pub struct Version { } // A loader and its associated loader VersionFields -#[derive(Serialize, Deserialize, Validate, Clone)] +#[derive(Serialize, Deserialize, Validate, Clone, Debug)] pub struct LoaderStruct { pub loader : Loader, // All other fields are loader-specific VersionFields + #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields : HashMap, } +fn skip_nulls<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let mut map = HashMap::deserialize(deserializer)?; + println!("Loaded hashmap {:?}", map); + map.retain(|_, v : &mut serde_json::Value | !v.is_null()); + println!("Loaded hashmap2 {:?}", map); + Ok(map) +} + + impl From for Version { fn from(data: QueryVersion) -> Version { let v = data.inner; @@ -674,7 +687,7 @@ pub struct VersionFile { /// A dendency which describes what versions are required, break support, or are optional to the /// version's functionality -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Dependency { /// The specific version id that the dependency uses pub version_id: Option, @@ -686,7 +699,7 @@ pub struct Dependency { pub dependency_type: DependencyType, } -#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] #[serde(rename_all = "lowercase")] pub enum VersionType { Release, @@ -711,7 +724,7 @@ impl VersionType { } } -#[derive(Serialize, Deserialize, Copy, Clone)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "lowercase")] pub enum DependencyType { Required, @@ -783,12 +796,12 @@ impl FileType { } /// A specific version of Minecraft -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] #[serde(transparent)] pub struct GameVersion(pub String); /// A project loader -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(transparent)] pub struct Loader(pub String); diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index aa5a97cb..538720b5 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -2,7 +2,7 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::loader_fields::Game; use crate::database::models::thread_item::ThreadBuilder; -use crate::database::models::{self, image_item, User}; +use crate::database::models::{self, image_item, User, version_item}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; @@ -57,9 +57,13 @@ pub async fn project_create( ) -> Result { // Convert V2 multipart payload to V3 multipart payload + let mut saved_slug = None; let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { // Convert input data to V3 format + // Save slug for out of closure + saved_slug = Some(json["slug"].as_str().unwrap_or("").to_string()); + // Set game name (all v2 projects are minecraft-java) json["game_name"] = json!("minecraft-java"); @@ -99,9 +103,17 @@ pub async fn project_create( }).await; // Call V3 project creation - let response= v3::project_creation::project_create(req, payload, client, redis, file_host, session_queue).await?; + let response= v3::project_creation::project_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + + // Convert response to V2 forma + match v2_reroute::extract_ok_json(response).await { + Ok(mut json) => { + v2_reroute::set_side_types_from_versions(&mut json, &**client, &redis).await?; + Ok(HttpResponse::Ok().json(json)) + }, + Err(response) => Ok(response) + } // TODO: Convert response to V2 format - Ok(response) } diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 570dc539..7936d04d 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,35 +1,37 @@ use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; use crate::database; -use crate::database::models::image_item; +use crate::database::models::{image_item, version_item, project_item}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{GalleryItem, ModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; +use crate::models::ids::VersionId; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, LoaderStruct, Loader, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; use crate::routes::{ApiError, v2_reroute, v3}; -use crate::routes::v3::projects::delete_from_index; +use crate::routes::v3::projects::{delete_from_index, ProjectIds}; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::img; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse, App}; use chrono::{DateTime, Utc}; use futures::TryStreamExt; use meilisearch_sdk::indexes::IndexesResults; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; +use std::borrow::BorrowMut; use std::sync::Arc; use validator::Validate; @@ -115,11 +117,6 @@ pub async fn random_projects_get( Ok(HttpResponse::Ok().json(projects_data)) } -#[derive(Serialize, Deserialize)] -pub struct ProjectIds { - pub ids: String, -} - #[get("projects")] pub async fn projects_get( req: HttpRequest, @@ -128,23 +125,24 @@ pub async fn projects_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; - let projects_data = db_models::Project::get_many(&ids, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let projects = filter_authorized_projects(projects_data, &user_option, &pool).await?; - - Ok(HttpResponse::Ok().json(projects)) + // Convert V2 data to V3 data + + // Call V3 project creation + let response= v3::projects::projects_get(req, web::Query(ids), pool.clone(), redis.clone(), session_queue).await?; + + // Convert response to V2 forma + match v2_reroute::extract_ok_json(response).await { + Ok(mut json) => { + if let Some(projects) = json.as_array_mut() { + for project in projects { + // We need versions + v2_reroute::set_side_types_from_versions(project, &**pool, &redis).await?; + } + } + Ok(HttpResponse::Ok().json(json)) + }, + Err(response) => Ok(response) + } } #[get("{id}")] @@ -155,26 +153,20 @@ pub async fn project_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - - let project_data = db_models::Project::get(&string, &**pool, &redis).await?; - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(data) = project_data { - if is_authorized(&data.inner, &user_option, &pool).await? { - return Ok(HttpResponse::Ok().json(Project::from(data))); - } + // Convert V2 data to V3 data + + // Call V3 project creation + let response= v3::projects::project_get(req, info, pool.clone(), redis.clone(), session_queue).await?; + + // Convert response to V2 forma + match v2_reroute::extract_ok_json(response).await { + Ok(mut json) => { + v2_reroute::set_side_types_from_versions(&mut json, &**pool, &redis).await?; + + Ok(HttpResponse::Ok().json(json)) + }, + Err(response) => Ok(response) } - Ok(HttpResponse::NotFound().body("")) } //checks the validity of a project id or slug @@ -390,36 +382,81 @@ pub async fn project_edit( session_queue: web::Data, ) -> Result { // TODO: Should call v3 route - - let new_project = new_project.into_inner(); + println!("\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-Starting New Project Edit Call"); + println!("New Project: {:?}", serde_json::to_string(&new_project)?); + let v2_new_project = new_project.into_inner(); + let client_side = v2_new_project.client_side.clone(); + let server_side = v2_new_project.server_side.clone(); + let new_slug = v2_new_project.slug.clone(); + println!("Client Side: {:?}", client_side); + println!("Server Side: {:?}", server_side); let new_project = v3::projects::EditProject { - title: new_project.title, - description: new_project.description, - body: new_project.body, - categories: new_project.categories, - additional_categories: new_project.additional_categories, - issues_url: new_project.issues_url, - source_url: new_project.source_url, - wiki_url: new_project.wiki_url, - license_url: new_project.license_url, - discord_url: new_project.discord_url, - donation_urls: new_project.donation_urls, - license_id: new_project.license_id, - client_side: new_project.client_side, - server_side: new_project.server_side, - slug: new_project.slug, - status: new_project.status, - requested_status: new_project.requested_status, - moderation_message: new_project.moderation_message, - moderation_message_body: new_project.moderation_message_body, - monetization_status: new_project.monetization_status, + title: v2_new_project.title, + description: v2_new_project.description, + body: v2_new_project.body, + categories: v2_new_project.categories, + additional_categories: v2_new_project.additional_categories, + issues_url: v2_new_project.issues_url, + source_url: v2_new_project.source_url, + wiki_url: v2_new_project.wiki_url, + license_url: v2_new_project.license_url, + discord_url: v2_new_project.discord_url, + donation_urls: v2_new_project.donation_urls, + license_id: v2_new_project.license_id, + // client_side: new_project.client_side, + // server_side: new_project.server_side, + slug: v2_new_project.slug, + status: v2_new_project.status, + requested_status: v2_new_project.requested_status, + moderation_message: v2_new_project.moderation_message, + moderation_message_body: v2_new_project.moderation_message_body, + monetization_status: v2_new_project.monetization_status, }; + // TODO: client_side and server_side + + + // 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(req.clone(), info, pool.clone(), config, web::Json(new_project), redis.clone(), session_queue.clone()).await?; + + // If client and server side were set, we will call + // the version setting route for each version to set the side types for each of them. + if response.status().is_success() { + println!("\nWas successful!"); + println!("Project ID: {:?}", project_id); + if client_side.is_some() || server_side.is_some() { + let project_item = project_item::Project::get(&new_slug.unwrap_or(project_id), &**pool, &redis).await?; + println!("a successful: {:?}", serde_json::to_string(&project_item)?); + let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); + println!("as successful: {:?}", version_ids); + let versions = version_item::Version::get_many(&version_ids, &**pool, &redis).await?; + println!("Versions: {:?}", serde_json::to_string(&versions)?); + for version in versions { + let loaders : Result, _> = version.loaders.into_iter().map(|l| serde_json::from_value(json!({ + + "loader": Loader(l), + "client_side": client_side, + "server_side": server_side, + }))).collect(); + + println!("SUBMITTING JSON\n\n\n\n\n: {:?}", json!({ + "client_side": client_side, + "server_side": server_side, + })); + response = v3::versions::version_edit_helper(req.clone(), (version.inner.id.into(),), pool.clone(), redis.clone(), v3::versions::EditVersion { + loaders: Some(loaders?), + ..Default::default() + }, session_queue.clone()).await?; + } + } - let response = v3::projects::project_edit(req, info, pool, config, web::Json(new_project), redis, session_queue).await?; - // TODO: Convert response to V2 format + } Ok(response) + + // TODO: Convert response to V2 format + } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index a083de60..890036cc 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::ApiError; use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, @@ -7,10 +9,11 @@ use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; +use crate::models::ids::VersionId; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; +use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, LoaderStruct}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::v3; @@ -224,7 +227,7 @@ pub struct EditVersionFileType { #[patch("{id}")] pub async fn version_edit( req: HttpRequest, - info: web::Path<(models::ids::VersionId,)>, + info: web::Path<(VersionId,)>, pool: web::Data, redis: web::Data, new_version: web::Json, @@ -240,7 +243,10 @@ pub async fn version_edit( version_type: new_version.version_type, dependencies: new_version.dependencies, game_versions: new_version.game_versions, - loaders: new_version.loaders, + loaders: new_version.loaders.map(|l| l.into_iter().map(|l| LoaderStruct { + loader: l, + fields: HashMap::new(), + }).collect::>()), featured: new_version.featured, primary_file: new_version.primary_file, downloads: new_version.downloads, @@ -254,8 +260,9 @@ pub async fn version_edit( }).collect::>() ) }; + // TODO: maybe should allow client server in loaders field? but probably not needed here - let response = v3::versions::version_edit(req, info, pool, redis, web::Json(new_version), session_queue).await?; + let response = v3::versions::version_edit(req, info, pool, redis, web::Json(serde_json::to_value(new_version)?), session_queue).await?; println!("Interecepting patch: {:?}", response); // TODO: Convert response to V2 format diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 8c27df39..e0698166 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -1,11 +1,52 @@ +use std::borrow::BorrowMut; use actix_multipart::Multipart; -use actix_web::test::TestRequest; +use actix_web::{test::TestRequest, HttpResponse}; use bytes::{Bytes, BytesMut}; use actix_web::http::header::{TryIntoHeaderPair,HeaderMap, HeaderName}; -use futures::{StreamExt, stream}; +use futures::{StreamExt, stream, Future}; use serde_json::{Value, json}; +use actix_web::test; + +use crate::{database::{models::{version_item, DatabaseError}, redis::RedisPool}, models::ids::VersionId}; + +use super::ApiError; + + +pub async fn set_side_types_from_versions<'a, E>(json : &mut serde_json::Value, exec: E, redis: &RedisPool) -> Result<(), DatabaseError> +where E : sqlx::Executor<'a, Database = sqlx::Postgres> +{ + json["client_side"] = json!("required"); // default to required + json["server_side"] = json!("required"); + let version_id = json["versions"].as_array().and_then(|a| a.iter().next()); + if let Some(version_id) = version_id { + let version_id = serde_json::from_value::(version_id.clone())?; + let versions_item = version_item::Version::get(version_id.into(), exec, &redis).await?; + println!("Got versions item: {:?}", serde_json::to_string(&versions_item)); + if let Some(versions_item) = versions_item { + println!("Got versions item: {:?}", serde_json::to_string(&versions_item)); + json["client_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "client_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("required")); + json["server_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "server_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("server_side")); + } + } + Ok(()) +} + + +// TODO: this is not an ideal way to do this, but it works for now +pub async fn extract_ok_json(mut response : HttpResponse) -> Result { + if response.status() == actix_web::http::StatusCode::OK { + // Takes json out of HttpResponse, mutates it, then regenerates the HttpResponse + // actix client + let body = response.into_body(); + let bytes = actix_web::body::to_bytes(body).await.unwrap(); + let mut json_value: Value = serde_json::from_slice(&bytes).unwrap(); + Ok(json_value) + } else { + Err(response) + } +} -pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, closure: impl Fn(&mut serde_json::Value)) -> Multipart { +pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, mut closure: impl FnMut(&mut serde_json::Value)) -> Multipart { let mut segments: Vec = Vec::new(); if let Some(mut field) = multipart.next().await { diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 0269d10a..45a44288 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -306,7 +306,6 @@ pub async fn project_create( &session_queue, ) .await; -println!("Out3!"); if result.is_err() { let undo_result = undo_uploads(&***file_host, &uploaded_files).await; @@ -319,7 +318,6 @@ println!("Out3!"); } else { transaction.commit().await?; } - println!("Out!"); result } @@ -366,7 +364,6 @@ async fn project_create_inner( ) -> Result { // The base URL for files uploaded to backblaze let cdn_url = dotenvy::var("CDN_URL")?; - println!("in1!"); // The currently logged in user let current_user = get_user_from_headers( @@ -378,7 +375,6 @@ async fn project_create_inner( ) .await? .1; -println!("in2!"); let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); @@ -390,7 +386,6 @@ println!("in2!"); { // The first multipart field must be named "data" and contain a // JSON `ProjectCreateData` object. - println!("in3"); let mut field = payload .next() @@ -401,7 +396,6 @@ println!("in2!"); "No `data` field in multipart upload", ))) })?; - println!("in4"); let content_disposition = field.content_disposition(); let name = content_disposition @@ -909,6 +903,7 @@ async fn create_initial_version( // }) // .collect::, CreateError>>()?; + println!("\n\n\n\n\n\n\n\n\n\n\n----\n\n\n"); println!("This one!!!!!"); println!("Loaders: {:?}", serde_json::to_string(&version_data.loaders).unwrap()); println!("All loaders: {:?}", serde_json::to_string(&all_loaders).unwrap()); @@ -932,13 +927,15 @@ async fn create_initial_version( loader_ids.push(loader_id); loaders.push(loader_create.loader.clone()); + println!("Loader_create fields: {:?}",loader_create.fields); for (key, value) in loader_create.fields .iter() { println!("ADding loader field: {} {}", key, value); // TODO: more efficient, multiselect let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) })?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), &mut *transaction, redis).await?; + let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; version_fields.push(vf); } } diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 947f54ca..315a73f9 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -12,7 +12,7 @@ use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, LoaderStruct, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; @@ -38,6 +38,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("project") + .route("{id}", web::get().to(project_get)) + .route("projects", web::get().to(projects_get)) .route("{id}", web::patch().to(project_edit)) .service( web::scope("{project_id}") @@ -46,6 +48,67 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); } +#[derive(Serialize, Deserialize)] +pub struct ProjectIds { + pub ids: String, +} + +pub async fn projects_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let projects_data = db_models::Project::get_many(&ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let projects = filter_authorized_projects(projects_data, &user_option, &pool).await?; + + Ok(HttpResponse::Ok().json(projects)) +} + +pub async fn project_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let project_data = db_models::Project::get(&string, &**pool, &redis).await?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = project_data { + if is_authorized(&data.inner, &user_option, &pool).await? { + return Ok(HttpResponse::Ok().json(Project::from(data))); + } + } + Ok(HttpResponse::NotFound().body("")) +} + + #[derive(Serialize, Deserialize, Validate)] pub struct EditProject { #[validate( @@ -114,8 +177,6 @@ pub struct EditProject { #[validate] pub donation_urls: Option>, pub license_id: Option, - pub client_side: Option, - pub server_side: Option, #[validate( length(min = 3, max = 64), regex = "crate::util::validate::RE_URL_SAFE" @@ -154,6 +215,7 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { + println!("project_edit"); let user = get_user_from_headers( &req, &**pool, @@ -163,6 +225,7 @@ pub async fn project_edit( ) .await? .1; +println!("user: {:?}", user); new_project .validate() @@ -170,10 +233,11 @@ pub async fn project_edit( let string = info.into_inner().0; let result = db_models::Project::get(&string, &**pool, &redis).await?; - + println!("result: {:?}", result); if let Some(project_item) = result { let id = project_item.inner.id; + println!("id: {:?}", id); let (team_member, organization_team_member) = db_models::TeamMember::get_for_project_permissions( &project_item.inner, @@ -181,12 +245,13 @@ pub async fn project_edit( &**pool, ) .await?; - + println!("team_member: {:?}", team_member); let permissions = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ); + println!("permissions: {:?}", permissions); if let Some(perms) = permissions { let mut transaction = pool.begin().await?; @@ -234,6 +299,7 @@ pub async fn project_edit( } if let Some(status) = &new_project.status { + println!("Status: {:?}", status); if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the status of this project!" @@ -241,6 +307,8 @@ pub async fn project_edit( )); } + println!("Got thru"); + if !(user.role.is_mod() || !project_item.inner.status.is_approved() && status == &ProjectStatus::Processing @@ -251,7 +319,9 @@ pub async fn project_edit( )); } + println!("Got thru 2"); if status == &ProjectStatus::Processing { + println!("Got thru 3"); if project_item.versions.is_empty() { return Err(ApiError::InvalidInput(String::from( "Project submitted for review with no initial versions", @@ -268,7 +338,7 @@ pub async fn project_edit( ) .execute(&mut *transaction) .await?; - + println!("Got thru 4"); sqlx::query!( " UPDATE threads @@ -280,8 +350,9 @@ pub async fn project_edit( .execute(&mut *transaction) .await?; } - + println!("Got thru 5"); if status.is_approved() && !project_item.inner.status.is_approved() { + println!("Got thru 6"); sqlx::query!( " UPDATE mods @@ -293,9 +364,11 @@ pub async fn project_edit( .execute(&mut *transaction) .await?; } - + println!("Got thru 7"); if status.is_searchable() && !project_item.inner.webhook_sent { + println!("Got thru 8"); if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { + println!("Got thru 9"); crate::util::webhook::send_discord_webhook( project_item.inner.id.into(), &pool, @@ -318,9 +391,13 @@ pub async fn project_edit( .await?; } } + println!("Got thru 10"); if user.role.is_mod() { + println!("Got thru 11"); + if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK") { + println!("Got thru 12"); crate::util::webhook::send_discord_webhook( project_item.inner.id.into(), &pool, @@ -342,6 +419,7 @@ pub async fn project_edit( .ok(); } } + println!("Got thru 13"); if team_member.map(|x| !x.accepted).unwrap_or(true) { let notified_members = sqlx::query!( @@ -367,6 +445,7 @@ pub async fn project_edit( .insert_many(notified_members, &mut transaction, &redis) .await?; } + println!("Got thru 14"); ThreadMessageBuilder { author_id: Some(user.id.into()), @@ -378,6 +457,7 @@ pub async fn project_edit( } .insert(&mut transaction) .await?; + println!("Got thru 15"); sqlx::query!( " @@ -425,6 +505,7 @@ pub async fn project_edit( .execute(&mut *transaction) .await?; } + if perms.contains(ProjectPermissions::EDIT_DETAILS) { if new_project.categories.is_some() { diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 6eb08d4f..0eb639d3 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -278,7 +278,8 @@ async fn version_create_inner( let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) })?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), &mut *transaction, redis).await?; + let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; version_fields.push(vf); } } diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 60ece6b2..bfc7d4c5 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -3,14 +3,16 @@ use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; +use crate::database::models::loader_fields::{LoaderField, VersionField, LoaderFieldEnumValue}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; +use crate::models::ids::VersionId; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; +use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, LoaderStruct}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::img; @@ -32,7 +34,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("{version_id}/file", web::post().to(super::version_creation::upload_file_to_version)) ); } -#[derive(Serialize, Deserialize, Validate)] +#[derive(Serialize, Deserialize, Validate, Default, Debug)] pub struct EditVersion { #[validate( length(min = 1, max = 64), @@ -53,7 +55,7 @@ pub struct EditVersion { )] pub dependencies: Option>, pub game_versions: Option>, - pub loaders: Option>, + pub loaders: Option>, pub featured: Option, pub primary_file: Option<(String, String)>, pub downloads: Option, @@ -61,21 +63,41 @@ pub struct EditVersion { pub file_types: Option>, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct EditVersionFileType { pub algorithm: String, pub hash: String, pub file_type: Option, } +// TODO: Avoid this pattern pub async fn version_edit( req: HttpRequest, - info: web::Path<(models::ids::VersionId,)>, + info: web::Path<(VersionId,)>, pool: web::Data, redis: web::Data, - new_version: web::Json, + new_version: web::Json, session_queue: web::Data, ) -> Result { + println!("HERE: {:?}", new_version); + + let new_version : EditVersion = serde_json::from_value(new_version.into_inner())?; + println!("HERE: {:?}", new_version); + + version_edit_helper(req, info.into_inner(), pool, redis, new_version, session_queue).await +} +pub async fn version_edit_helper( + req: HttpRequest, + info: (VersionId,), + pool: web::Data, + redis: web::Data, + new_version: EditVersion, + session_queue: web::Data, + +) -> Result { + + println!("in version edit"); + println!("VVV - new_version: {:?}", new_version); let user = get_user_from_headers( &req, &**pool, @@ -86,16 +108,19 @@ pub async fn version_edit( .await? .1; + new_version .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - let version_id = info.into_inner().0; + let version_id = info.0; let id = version_id.into(); let result = database::models::Version::get(id, &**pool, &redis).await?; if let Some(version_item) = result { + println!("VVV - VERSOIN: {:?}", serde_json::to_string(&version_item)); + let project_item = database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) .await?; @@ -257,10 +282,23 @@ pub async fn version_edit( .execute(&mut *transaction) .await?; + sqlx::query!( + " + DELETE FROM version_fields WHERE version_id = $1 + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + // TODO: This should check if loader supports project type like the version_creation route let mut loader_versions = Vec::new(); + let mut version_fields = Vec::new(); for loader in loaders { + let loader_name = loader.loader.0.clone(); + let loader_id = - database::models::loader_fields::Loader::get_id(&loader.0, &mut *transaction) + database::models::loader_fields::Loader::get_id(&loader_name, &mut *transaction) .await? .ok_or_else(|| { ApiError::InvalidInput( @@ -268,8 +306,19 @@ pub async fn version_edit( ) })?; loader_versions.push(LoaderVersion::new(loader_id, id)); + println!("VVV - Loader fields: {:?}", loader.fields); + for (key, value) in loader.fields .iter() { + // TODO: more efficient, multiselect + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + })?; + let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| ApiError::InvalidInput(s))?; + version_fields.push(vf); + } } LoaderVersion::insert_many(loader_versions, &mut transaction).await?; + VersionField::insert_many(version_fields, &mut transaction).await?; database::models::Project::update_loaders( version_item.inner.project_id, diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index f4cae7de..e31b4617 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -386,7 +386,11 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - let project: Project = test::read_body_json(resp).await; + println!("Got out and got through123!"); + let project: serde_json::Value = test::read_body_json(resp).await; + println!("here"); + println!("Serde json value: {}", serde_json::to_string(&project).unwrap()); + let project: Project = serde_json::from_value(project).unwrap(); // Get project's versions let req = TestRequest::get() diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 4ab33900..80f7bb38 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -164,6 +164,8 @@ impl<'a> PermissionsTest<'a> { ) .await; + println!("PATCHING..."); + // Failure test let request = req_gen(&PermissionsTestContext { project_id: Some(&project_id), @@ -172,6 +174,7 @@ impl<'a> PermissionsTest<'a> { }) .append_header(("Authorization", self.user_pat)) .to_request(); + println!("PATCHING2..."); let resp = test_env.call(request).await; if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { @@ -183,7 +186,8 @@ impl<'a> PermissionsTest<'a> { .join(","), resp.status().as_u16() )); - } + } println!("PATCHING3..."); + // Patch user's permissions to success permissions modify_user_team_permissions( @@ -194,6 +198,7 @@ impl<'a> PermissionsTest<'a> { test_env, ) .await; + println!("PATCHING..4."); // Successful test let request = req_gen(&PermissionsTestContext { @@ -203,6 +208,7 @@ impl<'a> PermissionsTest<'a> { }) .append_header(("Authorization", self.user_pat)) .to_request(); + println!("PATCHING..5."); let resp = test_env.call(request).await; if !resp.status().is_success() { @@ -211,6 +217,7 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } + println!("PATCHING.6.."); // If the remove_user flag is set, remove the user from the project // Relevant for existing projects/users diff --git a/tests/project.rs b/tests/project.rs index e42219f7..a03dfca9 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -446,8 +446,8 @@ pub async fn test_patch_project() { assert_eq!(project["issues_url"], json!(Some("https://github.com".to_string()))); assert_eq!(project["discord_url"], json!(Some("https://discord.gg".to_string()))); assert_eq!(project["wiki_url"], json!(Some("https://wiki.com".to_string()))); - assert_eq!(project["client_side"].to_string(), "optional"); - assert_eq!(project["server_side"].to_string(), "required"); + assert_eq!(project["client_side"], json!("optional")); + assert_eq!(project["server_side"], json!("required")); assert_eq!(project["donation_urls"][0]["url"], "https://patreon.com"); // Cleanup test db @@ -509,8 +509,8 @@ async fn permissions_patch_project() { ("title", json!("randomname")), ("description", json!("randomdescription")), ("categories", json!(["combat", "economy"])), - ("client_side", json!("unsupported")), - ("server_side", json!("unsupported")), + // ("client_side", json!("unsupported")), + // ("server_side", json!("unsupported")), ("additional_categories", json!(["decoration"])), ("issues_url", json!("https://issues.com")), ("source_url", json!("https://source.com")), @@ -542,17 +542,17 @@ async fn permissions_patch_project() { }, })) }; - + println!("Testing {}", key); PermissionsTest::new(&test_env) .simple_project_permissions_test(edit_details, req_gen) - .await - .unwrap(); + .await.into_iter(); } }) .buffer_unordered(4) .collect::>() .await; + println!("HERE!"); // Test with status and requested_status // This requires a project with a version, so we use alpha_project_id let req_gen = |ctx: &PermissionsTestContext| { @@ -569,6 +569,7 @@ async fn permissions_patch_project() { .simple_project_permissions_test(edit_details, req_gen) .await .unwrap(); + println!("HERE!2"); // Bulk patch projects let req_gen = |ctx: &PermissionsTestContext| { From 4b6f3496aad594a68690cda90fb6c1b954b18670 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 20 Oct 2023 22:24:29 -0700 Subject: [PATCH 09/31] works --- migrations/20231005230721_dynamic-fields.sql | 6 +- src/database/models/loader_fields.rs | 129 +++++++++++++++++-- src/database/models/version_item.rs | 101 +-------------- src/routes/v2/project_creation.rs | 8 +- src/routes/v2/projects.rs | 1 + src/routes/v2/version_creation.rs | 63 ++++++++- src/routes/v2/versions.rs | 4 + src/routes/v3/projects.rs | 11 ++ src/search/indexing/local_import.rs | 68 ++++++++-- src/search/indexing/mod.rs | 31 +++-- src/search/mod.rs | 41 +++++- tests/common/api_v2/project.rs | 1 + tests/files/dummy_data.sql | 2 +- tests/search.rs | 8 +- 14 files changed, 319 insertions(+), 155 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index aa5bd3f6..6b0f5d9f 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -35,8 +35,8 @@ CREATE TABLE loader_fields ( id serial PRIMARY KEY, loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, field varchar(64) NOT NULL, - -- "int", "text", "enum", "bool", - -- "array(int)", "array(text)", "array(enum)", "array(bool)" + -- "integer", "text", "enum", "bool", + -- "array_integer", "array_text", "array_enum", "array_bool" field_type varchar(64) NOT NULL, -- only for enum enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, @@ -90,7 +90,7 @@ INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (2, 'game_version INSERT INTO loader_field_enum_values (original_id, enum_id, value, created, metadata) SELECT id, 2, version, created, json_build_object('type', type, 'major', major) FROM game_versions; -INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val) SELECT l.id, 'game_versions', 'enum', 2, false, 1 FROM loaders l; +INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val) SELECT l.id, 'game_versions', 'array_enum', 2, false, 1 FROM loaders l; INSERT INTO version_fields(version_id, field_id, enum_value) SELECT gvv.joining_version_id, 2, lfev.id diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index ba4ce6b4..5b3be19a 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -175,11 +175,11 @@ pub enum LoaderFieldType { ArrayText, ArrayEnum(LoaderFieldEnumId), ArrayBoolean, - Unknown } impl LoaderFieldType { - pub fn build(field_name : &str, loader_field_enum : Option) -> LoaderFieldType { - match (field_name, loader_field_enum) { + pub fn build(field_type_name : &str, loader_field_enum : Option) -> Option { + println!("Building field type for {} with enum {:?}", field_type_name, loader_field_enum); + Some(match (field_type_name, loader_field_enum) { ("integer", _) => LoaderFieldType::Integer, ("text", _) => LoaderFieldType::Text, ("boolean", _) => LoaderFieldType::Boolean, @@ -188,8 +188,8 @@ impl LoaderFieldType { ("array_boolean", _) => LoaderFieldType::ArrayBoolean, ("enum", Some(id)) => LoaderFieldType::Enum(LoaderFieldEnumId(id)), ("array_enum", Some(id)) => LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)), - _ => LoaderFieldType::Unknown - } + _ => return None + }) } pub fn to_str(&self) -> &'static str { @@ -202,7 +202,6 @@ impl LoaderFieldType { LoaderFieldType::ArrayBoolean => "array_boolean", LoaderFieldType::Enum(_) => "enum", LoaderFieldType::ArrayEnum(_) => "array_enum", - LoaderFieldType::Unknown => "unknown" } } } @@ -240,6 +239,8 @@ impl VersionField { items: Vec, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { + println!("Inserting version fields!"); + println!("Verion fields: {:?}", items); let mut query_version_fields = vec![]; for item in items { let base = QueryVersionField { @@ -275,7 +276,6 @@ impl VersionField { query_version_fields.push(base.clone().with_enum_value(ev)); } } - VersionFieldValue::Unknown => {} }; } @@ -313,6 +313,99 @@ impl VersionField { }) } + pub fn from_query_json(version_id : i64, loader_fields : Option, version_fields : Option, loader_field_enum_values : Option) -> Vec { + #[derive(Deserialize, Debug)] + struct JsonLoaderField { + lf_id: i32, + l_id: i32, + field: String, + loader_name: String, + field_type: String, + enum_type: Option, + min_val: Option, + max_val: Option, + optional: bool, + + enum_name: Option, + } + + #[derive(Deserialize, Debug)] + struct JsonVersionField { + field_id: i32, + int_value: Option, + enum_value: Option, + string_value: Option, + } + + #[derive(Deserialize, Debug)] + struct JsonLoaderFieldEnumValue { + id: i32, + enum_id: i32, + value: String, + ordering: Option, + created: DateTime, + metadata: Option, + } + + let query_loader_fields : Vec = loader_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); + println!("QLF: {:?}", query_loader_fields); + + let query_version_field_combined : Vec = version_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); + println!("QVFC: {:?}", query_version_field_combined); + println!("QVFEV PRE: {:?}", serde_json::to_string(&loader_field_enum_values).unwrap()); + let query_loader_field_enum_values: Vec = loader_field_enum_values.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); + println!("QVFEV: {:?}", query_loader_field_enum_values); + let version_id = VersionId(version_id); + query_loader_fields.into_iter().filter_map( |q| { + let loader_field_type = match LoaderFieldType::build(&q.field_type, q.enum_type) { + Some(lft) => lft, + None => return None + }; + let loader_field = LoaderField { + id: LoaderFieldId(q.lf_id), + loader_id: LoaderId(q.l_id), + field: q.field.clone(), + loader_name: q.loader_name.clone(), + field_type: loader_field_type, + optional: q.optional, + min_val: q.min_val, + max_val: q.max_val + }; + println!("calculating values for field {} with query {:?}", loader_field.field, query_version_field_combined); + let values = query_version_field_combined.iter().filter_map(|qvf| { + if qvf.field_id == q.lf_id { + let lfev = query_loader_field_enum_values.iter().find(|x| Some(x.id) == qvf.enum_value); + + Some(QueryVersionField { + version_id, + field_id: LoaderFieldId(qvf.field_id), + int_value: qvf.int_value, + enum_value: lfev.map(|lfev| LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(lfev.id), + enum_id: LoaderFieldEnumId(lfev.enum_id), + value: lfev.value.clone(), + ordering: lfev.ordering, + created: lfev.created, + metadata: lfev.metadata.clone().unwrap_or_default() + }), + string_value: qvf.string_value.clone() + }) + } else { + None + } + }).collect::>(); + + + println!("End of loop for field {} with values {:?}", loader_field.field, values); + let v = VersionField::build( + loader_field, + version_id, + values + ).ok(); + v + }).collect() + } + pub fn build(loader_field : LoaderField, version_id : VersionId, query_version_fields : Vec) -> Result { let value = VersionFieldValue::build(&loader_field.field_type, query_version_fields)?; Ok(VersionField { @@ -335,7 +428,6 @@ pub enum VersionFieldValue { ArrayText(Vec), ArrayEnum(LoaderFieldEnumId, Vec), ArrayBoolean(Vec), - Unknown } impl VersionFieldValue { // TODO: this could be combined with build @@ -403,7 +495,6 @@ impl VersionFieldValue { enum_values } ), - LoaderFieldType::Unknown => VersionFieldValue::Unknown }) } @@ -455,7 +546,6 @@ impl VersionFieldValue { qvfs.into_iter().map(|qvf| Ok::(qvf.enum_value.ok_or(did_not_exist_error( field_name, "enum_value"))?)).collect::>()? ), - LoaderFieldType::Unknown => VersionFieldValue::Unknown }) } @@ -470,11 +560,23 @@ impl VersionFieldValue { VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array(v.iter().map(|b| serde_json::Value::Bool(*b)).collect()), VersionFieldValue::Enum(_, v) => serde_json::Value::String(v.value.clone()), VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array(v.iter().map(|v| serde_json::Value::String(v.value.clone())).collect()), - VersionFieldValue::Unknown => serde_json::Value::Null }; println!("Serializing internal: {:?} to {:?}", self, a); a } + + pub fn as_search_strings(&self) -> Vec { + match self { + VersionFieldValue::Integer(i) => vec![i.to_string()], + VersionFieldValue::Text(s) => vec![s.clone()], + VersionFieldValue::Boolean(b) => vec![b.to_string()], + VersionFieldValue::ArrayInteger(v) => v.iter().map(|i| i.to_string()).collect(), + VersionFieldValue::ArrayText(v) => v.clone(), + VersionFieldValue::ArrayBoolean(v) => v.iter().map(|b| b.to_string()).collect(), + VersionFieldValue::Enum(_, v) => vec![v.value.clone()], + VersionFieldValue::ArrayEnum(_, v) => v.iter().map(|v| v.value.clone()).collect(), + } + } } @@ -572,12 +674,13 @@ impl LoaderField { field ) .fetch_many(exec) - .try_filter_map(|e| async { Ok(e.right().map(|r| + .try_filter_map(|e| async { + Ok(e.right().map(|r| LoaderField { id: LoaderFieldId(r.id), loader_id: LoaderId(r.loader_id), + field_type: LoaderFieldType::build(&r.field_type, r.enum_type).ok_or_else(|| DatabaseError::SchemaError(format!("Could not parse field type {}",r.field_type))).unwrap(), field: r.field, - field_type: LoaderFieldType::build(&r.field_type, r.enum_type), loader_name: r.loader, optional: r.optional, min_val: r.min_val, diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 0e15fe96..a5423049 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -661,106 +661,7 @@ impl Version { files }, - version_fields: { - #[derive(Deserialize, Debug)] - struct JsonLoaderField { - lf_id: i32, - l_id: i32, - field: String, - loader_name: String, - field_type: String, - enum_type: Option, - min_val: Option, - max_val: Option, - optional: bool, - - enum_name: Option, - } - - #[derive(Deserialize, Debug)] - struct JsonVersionField { - field_id: i32, - int_value: Option, - enum_value: Option, - string_value: Option, - } - - #[derive(Deserialize, Debug)] - struct JsonLoaderFieldEnumValue { - id: i32, - enum_id: i32, - value: String, - ordering: Option, - created: DateTime, - metadata: Option, - } - - - println!("Value: {:#?}", serde_json::to_string(&v.loader_fields)); - - let query_loader_fields : Vec = v.loader_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - - - println!("Value: {:#?}", serde_json::to_string(&v.version_fields)); - - let query_version_field_combined : Vec = v.version_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - - println!("Query version field combined: {:#?}", query_version_field_combined); - - println!("Value: {:#?}", serde_json::to_string(&v.loader_field_enum_values)); - let query_loader_field_enum_values: Vec = v.loader_field_enum_values.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - - println!("Query loader field enum values: {:#?}", query_loader_field_enum_values); - - let version_id = VersionId(v.id); - query_loader_fields.into_iter().filter_map( |q| { - println!("Query version field: {:#?}", q); - let loader_field = LoaderField { - id: LoaderFieldId(q.lf_id), - loader_id: LoaderId(q.l_id), - field: q.field.clone(), - loader_name: q.loader_name.clone(), - field_type: LoaderFieldType::build(&q.field_type, q.enum_type), - optional: q.optional, - min_val: q.min_val, - max_val: q.max_val - }; - - let values = query_version_field_combined.iter().filter_map(|qvf| { - if qvf.field_id == q.lf_id { - let lfev = query_loader_field_enum_values.iter().find(|x| Some(x.id) == qvf.enum_value); - - Some(QueryVersionField { - version_id, - field_id: LoaderFieldId(qvf.field_id), - int_value: qvf.int_value, - enum_value: lfev.map(|lfev| LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(lfev.id), - enum_id: LoaderFieldEnumId(lfev.enum_id), - value: lfev.value.clone(), - ordering: lfev.ordering, - created: lfev.created, - metadata: serde_json::from_str(&lfev.metadata.clone().unwrap_or_default()).unwrap_or_default() - }), - string_value: qvf.string_value.clone() - }) - } else { - None - } - }).collect::>(); - - - - println!("Loader field: {:#?}", loader_field); - let v = VersionField::build( - loader_field, - version_id, - values - ).ok(); - println!("Version field: {:#?}", v); - v - }).collect() - }, + version_fields: VersionField::from_query_json(v.id, v.loader_fields, v.version_fields, v.loader_field_enum_values), loaders: v.loaders.unwrap_or_default(), dependencies: serde_json::from_value( v.dependencies.unwrap_or_default(), diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 538720b5..9dcf1a89 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -49,7 +49,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { #[post("project")] pub async fn project_create( req: HttpRequest, - mut payload: Multipart, + payload: Multipart, client: Data, redis: Data, file_host: Data>, @@ -60,7 +60,7 @@ pub async fn project_create( let mut saved_slug = None; let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { // Convert input data to V3 format - + println!("ABOUT TO ALTER ACTIX MULTIPART {}", json.to_string()); // Save slug for out of closure saved_slug = Some(json["slug"].as_str().unwrap_or("").to_string()); @@ -99,7 +99,9 @@ pub async fn project_create( version["loaders"] = json!(loaders); } } - + println!("JUST ALTER ACTIX MULTIPART {}", json.to_string()); + println!("Done;"); + }).await; // Call V3 project creation diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 7936d04d..67b8b9cb 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -73,6 +73,7 @@ pub async fn project_search( web::Query(info): web::Query, config: web::Data, ) -> Result { + // TODO: redirect to v3 let results = search_for_project(&info, &config).await?; Ok(HttpResponse::Ok().json(results)) } diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 6a12c9a8..964cf1a0 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -16,6 +16,7 @@ use crate::models::projects::{ }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3}; use crate::routes::v3::project_creation::CreateError; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -28,6 +29,7 @@ use futures::stream::StreamExt; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::collections::HashMap; +use serde_json::json; use std::sync::Arc; use validator::Validate; @@ -95,7 +97,58 @@ pub async fn version_create( session_queue: Data, ) -> Result { // TODO: should call this from the v3 - Ok(HttpResponse::NoContent().body("")) + let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { + // Convert input data to V3 format + + // Loader fields are now a struct, containing all versionfields + // loaders: ["fabric"] + // game_versions: ["1.16.5", "1.17"] + // -> becomes -> + // loaders: [{"loader": "fabric", "game_versions": ["1.16.5", "1.17"]}] + let mut loaders = vec![]; + for loader in json["loaders"].as_array().unwrap_or(&Vec::new()) { + let loader = loader.as_str().unwrap_or(""); + loaders.push(json!({ + "loader": loader, + "game_versions": json["game_versions"].as_array(), + })); + } + json["loaders"] = json!(loaders); + + + }).await; + + // Call V3 project creation + let response= v3::version_creation::version_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + + // Convert response to V2 forma + match v2_reroute::extract_ok_json(response).await { + Ok(mut json) => { + // Get game_versions out of loaders, and flatten loadedrs + let mut game_versions = Vec::new(); + let mut loaders = Vec::new(); + if let Some(loaders_json) = json["loaders"].as_array() { + for loader_json in loaders_json { + if let Some(loader) = loader_json["loader"].as_str() { + loaders.push(loader.to_string()); + } + if let Some(game_versions_json) = loader_json["game_versions"].as_array() { + for game_version_json in game_versions_json { + if let Some(game_version) = game_version_json.as_str() { + game_versions.push(game_version.to_string()); + } + } + } + } + } + json["game_versions"] = json!(game_versions); + json["loaders"] = json!(loaders); + + println!("Completed version creation: {:?}", json); + Ok(HttpResponse::Ok().json(json)) + }, + Err(response) => Ok(response) + } } // under /api/v1/version/{version_id} @@ -109,6 +162,10 @@ pub async fn upload_file_to_version( file_host: Data>, session_queue: web::Data, ) -> Result { - // TODO: should call this from the v3 - Ok(HttpResponse::NoContent().body("")) + // TODO: do we need to modify this? + + let response= v3::version_creation::upload_file_to_version(req, url_data, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + + + Ok(response) } \ No newline at end of file diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 890036cc..b85ee498 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -181,10 +181,14 @@ pub async fn version_get( if let Some(data) = version_data { if is_authorized_version(&data.inner, &user_option, &pool).await? { + println!("Got version: {:?}", serde_json::to_value(&data)?); + panic!(); + return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); } } + Ok(HttpResponse::NotFound().body("")) } diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 315a73f9..d41255ad 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -953,6 +953,16 @@ pub async fn edit_project_categories( Ok(()) } +#[get("search")] +pub async fn project_search( + web::Query(info): web::Query, + config: web::Data, +) -> Result { + let results = search_for_project(&info, &config).await?; + Ok(HttpResponse::Ok().json(results)) +} + + pub async fn delete_from_index( id: ProjectId, config: web::Data, @@ -967,3 +977,4 @@ pub async fn delete_from_index( Ok(()) } + diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 631b1163..d7723933 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -1,15 +1,21 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use dashmap::DashSet; use futures::TryStreamExt; use log::info; +use serde::Deserialize; use super::IndexingError; use crate::database::models::ProjectId; +use crate::database::models::loader_fields::{VersionFieldValue, LoaderFieldType, VersionField}; use crate::search::UploadSearchProject; use sqlx::postgres::PgPool; -pub async fn index_local(pool: PgPool) -> Result, IndexingError> { +pub async fn index_local(pool: PgPool) -> Result<(Vec, Vec), IndexingError> { info!("Indexing local projects!"); - - Ok( + let loader_field_keys : Arc> = Arc::new(DashSet::new()); + let uploads = sqlx::query!( " SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, @@ -23,17 +29,39 @@ pub async fn index_local(pool: PgPool) -> Result, Index ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( DISTINCT jsonb_build_object( - 'field_id', vf.field_id, - 'int_value', vf.int_value, - 'enum_value', vf.enum_value, - 'string_value', vf.string_value, + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value + ) + ) version_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'lf_id', lf.id, + 'l_id', lf.loader_id, + 'loader_name', lo.loader, 'field', lf.field, 'field_type', lf.field_type, 'enum_type', lf.enum_type, + 'min_val', lf.min_val, + 'max_val', lf.max_val, + 'optional', lf.optional, + 'enum_name', lfe.enum_name ) - ) version_fields + ) loader_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'id', lfev.id, + 'enum_id', lfev.enum_id, + 'value', lfev.value, + 'ordering', lfev.ordering, + 'created', lfev.created, + 'metadata', lfev.metadata + ) + ) loader_field_enum_values + FROM versions v INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2) LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id @@ -47,6 +75,7 @@ pub async fn index_local(pool: PgPool) -> Result, Index LEFT OUTER JOIN version_fields vf on v.id = vf.version_id LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id + LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id WHERE v.status != ANY($1) GROUP BY v.id, m.id, pt.id, u.id; ", @@ -55,7 +84,9 @@ pub async fn index_local(pool: PgPool) -> Result, Index crate::models::teams::OWNER_ROLE, ) .fetch_many(&pool) - .try_filter_map(|e| async { + .try_filter_map(|e| { + let loader_field_keys = loader_field_keys.clone(); + async move { Ok(e.right().map(|m| { let mut additional_categories = m.additional_categories.unwrap_or_default(); let mut categories = m.categories.unwrap_or_default(); @@ -65,6 +96,18 @@ pub async fn index_local(pool: PgPool) -> Result, Index let display_categories = categories.clone(); categories.append(&mut additional_categories); + let version_fields = VersionField::from_query_json(m.id, m.loader_fields, m.version_fields, m.loader_field_enum_values); + println!("Got version fields: {:?}", version_fields); + let loader_fields : HashMap> = version_fields.into_iter().map(|vf| { + let key = format!("{}_{}", vf.loader_name, vf.field_name); + let value = vf.value.as_search_strings(); + (key, value) + }).collect(); + println!("Got loader fields: {:?}", loader_fields); + for v in loader_fields.keys().cloned() { + loader_field_keys.insert(v); + } + let project_id: crate::models::projects::ProjectId = ProjectId(m.id).into(); let version_id: crate::models::projects::ProjectId = ProjectId(m.version_id).into(); @@ -100,10 +143,11 @@ pub async fn index_local(pool: PgPool) -> Result, Index open_source, color: m.color.map(|x| x as u32), featured_gallery: m.featured_gallery.unwrap_or_default().first().cloned(), + loader_fields } })) - }) +}}) .try_collect::>() - .await? - ) + .await?; + Ok((uploads, Arc::try_unwrap(loader_field_keys).unwrap_or_default().into_iter().collect())) } diff --git a/src/search/indexing/mod.rs b/src/search/indexing/mod.rs index bcb5abd5..df075d99 100644 --- a/src/search/indexing/mod.rs +++ b/src/search/indexing/mod.rs @@ -2,6 +2,7 @@ pub mod local_import; use crate::search::{SearchConfig, UploadSearchProject}; +use dashmap::DashSet; use local_import::index_local; use meilisearch_sdk::client::Client; use meilisearch_sdk::indexes::Index; @@ -32,11 +33,15 @@ const MEILISEARCH_CHUNK_SIZE: usize = 10000; pub async fn index_projects(pool: PgPool, config: &SearchConfig) -> Result<(), IndexingError> { let mut docs_to_add: Vec = vec![]; + let mut additional_fields: Vec = vec![]; - docs_to_add.append(&mut index_local(pool.clone()).await?); + let (mut uploads, mut loader_fields) = index_local(pool.clone()).await?; + docs_to_add.append(&mut uploads); + additional_fields.append(&mut loader_fields); + println!("Additional fields: {:?}", additional_fields); // Write Indices - add_projects(docs_to_add, config).await?; + add_projects(docs_to_add, additional_fields, config).await?; Ok(()) } @@ -46,6 +51,7 @@ async fn create_index( name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result { + println!("Creatingg index: {}", name); client .delete_index(name) .await? @@ -114,25 +120,36 @@ async fn add_to_index( async fn create_and_add_to_index( client: &Client, projects: &[UploadSearchProject], + additional_fields: &[String], name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result<(), IndexingError> { - let index = create_index(client, name, custom_rules).await?; + println!("Creating and adding to index: {}, {}", name, projects.len()); + let index = create_index(client, name, custom_rules).await?; + + let mut new_filterable_attributes = index.get_filterable_attributes().await?; + new_filterable_attributes.extend(additional_fields.iter().map(|s| s.to_string())); + index.set_filterable_attributes(new_filterable_attributes).await?; + + println!("Current filterable attributes: {:?}", index.get_filterable_attributes().await?); + add_to_index(client, index, projects).await?; Ok(()) } pub async fn add_projects( projects: Vec, + additional_fields: Vec, config: &SearchConfig, ) -> Result<(), IndexingError> { let client = config.make_client(); - create_and_add_to_index(&client, &projects, "projects", None).await?; + create_and_add_to_index(&client, &projects, &additional_fields, "projects", None).await?; create_and_add_to_index( &client, &projects, + &additional_fields, "projects_filtered", Some(&[ "sort", @@ -170,7 +187,6 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ "description", "categories", "display_categories", - "versions", "downloads", "follows", "icon_url", @@ -178,8 +194,6 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ "date_modified", "latest_version", "license", - "client_side", - "server_side", "gallery", "featured_gallery", "color", @@ -189,10 +203,7 @@ const DEFAULT_SEARCHABLE_ATTRIBUTES: &[&str] = &["title", "description", "author const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[ "categories", - "versions", "license", - "client_side", - "server_side", "project_type", "downloads", "follows", diff --git a/src/search/mod.rs b/src/search/mod.rs index 333c6fa7..b2be5f49 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -7,6 +7,7 @@ use meilisearch_sdk::client::Client; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::cmp::min; +use std::collections::HashMap; use std::fmt::Write; use thiserror::Error; @@ -96,6 +97,20 @@ pub struct UploadSearchProject { pub modified_timestamp: i64, pub open_source: bool, pub color: Option, + + #[serde(flatten)] + /* + Version fields are stored as: + "loader_field": ["value1", "value2", ...] + By convention, first underline separates the loader from the field name, + and any subsequent underlines may be part of the field name. + eg: + "fabric_game_versions": ["1.21", "1.22"] + "fabric_client_side": ["required"] + "fabric_server_side": ["optional"] + */ + pub loader_fields: HashMap> + } #[derive(Serialize, Deserialize, Debug)] @@ -117,7 +132,6 @@ pub struct ResultSearchProject { pub description: String, pub categories: Vec, pub display_categories: Vec, - pub versions: Vec, pub downloads: i32, pub follows: i32, pub icon_url: String, @@ -125,13 +139,13 @@ pub struct ResultSearchProject { pub date_created: String, /// RFC 3339 formatted modification date of the project pub date_modified: String, - pub latest_version: String, pub license: String, - pub client_side: String, - pub server_side: String, pub gallery: Vec, pub featured_gallery: Option, pub color: Option, + + #[serde(flatten)] + pub loader_fields: HashMap> } pub async fn search_for_project( @@ -210,16 +224,31 @@ pub async fn search_for_project( filter_string.push_str(&filters); } + println!("Filter string: {}", filter_string); + if !filter_string.is_empty() { query.with_filter(&filter_string); } } - query.execute::().await? + // query.execute::().await? + let v = query.execute::().await?; + println!("Got results from MeiliSearch"); + println!("Value: {:?}", v); + v }; + println!("Finished filtering"); Ok(SearchResults { - hits: results.hits.into_iter().map(|r| r.result).collect(), + hits: results + .hits + .into_iter() + .map(|r| { + let mut result: ResultSearchProject = serde_json::from_value(r.result).unwrap(); + result + }) + .collect(), + // hits: results.hits.into_iter().map(|r| r.result).collect(), offset: results.offset.unwrap_or_default(), limit: results.limit.unwrap_or_default(), total_hits: results.estimated_total_hits.unwrap_or_default(), diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 89fbe92b..cc716072 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -221,6 +221,7 @@ impl ApiV2 { .to_request(); let resp = self.call(req).await; let status = resp.status(); + println!("Body: {:?}", resp.response().body()); assert_eq!(status, 200); test::read_body_json(resp).await } diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 1cc30937..ecd79341 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -37,7 +37,7 @@ INSERT INTO loader_field_enum_values(enum_id, value, metadata) VALUES (2, '1.20.5', '{"type":"release","major":false}'); INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (1, 'game_versions', 'array(enum)', 2); +VALUES (1, 'game_versions', 'array_enum', 2); -- Side-types INSERT INTO loader_fields(loader_id, field, field_type, enum_type) diff --git a/tests/search.rs b/tests/search.rs index bad7dbf0..16786307 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -150,10 +150,10 @@ async fn search_projects() { ["project_type:modpack"] ]), vec![4]), (json!([ - ["client_side:required"] + ["fabric_client_side:required"] ]), vec![0,2,3]), (json!([ - ["server_side:required"] + ["fabric_server_side:required"] ]), vec![0,2,3,6]), (json!([ ["open_source:true"] @@ -168,7 +168,7 @@ async fn search_projects() { ["author:user"] ]), vec![0,1,2,4,5]), (json!([ - ["versions:1.20.5"] + ["fabric_game_versions:1.20.5"] ]), vec![4,5]), ]; // TODO: versions, game versions @@ -184,7 +184,7 @@ async fn search_projects() { // Test searches let stream = futures::stream::iter(pairs); - stream.for_each_concurrent(10, |(facets, mut expected_project_ids)| { + stream.for_each_concurrent(1, |(facets, mut expected_project_ids)| { let id_conversion = id_conversion.clone(); let test_name = test_name.clone(); async move { From 0f61721f0aee267f638a49730c8229fc2d23ef68 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 20 Oct 2023 23:08:46 -0700 Subject: [PATCH 10/31] search v2 conversion --- src/models/projects.rs | 3 +- src/routes/v2/projects.rs | 72 ++++++++++++++++++++++++++++++++++++++- src/search/mod.rs | 20 ++++++----- tests/search.rs | 6 ++-- 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/models/projects.rs b/src/models/projects.rs index 43753bfc..8f42dd52 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -814,9 +814,10 @@ pub struct SearchRequest { pub index: Option, pub limit: Option, + // TODO: implement fully with explanation pub new_filters: Option, - // Deprecated values below. WILL BE REMOVED V3! + // TODO: Deprecated values below. WILL BE REMOVED V3! pub facets: Option, pub filters: Option, pub version: Option, diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 67b8b9cb..fd38b51d 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,5 +1,5 @@ use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; -use crate::database; +use crate::{database, search}; use crate::database::models::{image_item, version_item, project_item}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{GalleryItem, ModCategory}; @@ -74,6 +74,76 @@ pub async fn project_search( config: web::Data, ) -> Result { // TODO: redirect to v3 + println!("info: {:?}", serde_json::to_string(&info)); + + + + // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields + // Loader fields are: + // (loader)_(field):(value) + // The first _ by convention is used to separate the loader from the field + // For each v2 loader, we create a loader field facet for each of these fields that are now loader fields + let facets : Option>> = if let Some(facets) = info.facets { + let facets = serde_json::from_str::>>(&facets)?; + + // "versions:x" => "fabric_game_versions:x", "forge_game_versions:x" ... + // They are put in the same array- considered to be 'or' + let mut v2_loaders = vec!["fabric", "forge"]; // TODO: populate + { + let client = meilisearch_sdk::Client::new(&*config.address, &*config.key); + let index = info.index.as_deref().unwrap_or("relevance"); + let meilisearch_index = client.get_index(search::get_sort_index(index)?.0).await?; + let filterable_fields = meilisearch_index.get_filterable_attributes().await?; + // Only keep v2 loaders that are filterable + v2_loaders = v2_loaders.into_iter().filter(|x| filterable_fields.iter().any(|f| f.starts_with(&format!("{}_game_versions", x)))).collect(); + println!("Post-analysis v2_loaders: {:?}", v2_loaders); + + } + Some(facets.into_iter().map(|facet| { + facet + .into_iter() + .map(|facet| { + let version = match facet.split(":").nth(1) { + Some(version) => version, + None => return vec![facet.to_string()], + }; + println!("Analyzing facet: {:?}", facet); + + let f = if facet.starts_with("versions:") { + v2_loaders + .iter() + .map(|loader| format!("{}_game_versions:{}", loader, version)) + .collect::>() + } else if facet.starts_with("client_side:") { + v2_loaders + .iter() + .map(|loader| format!("{}_client_side:{}", loader, version)) + .collect::>() + } else if facet.starts_with("server_side:") { + v2_loaders + .iter() + .map(|loader| format!("{}_server_side:{}", loader, version)) + .collect::>() + } else { + vec![facet.to_string()] + }; + println!("Post-analysis facet: {:?}", f); + f + }) + .flatten() + .collect::>() + }).collect()) + } else { + None + }; + + println!("Post-analysis facets: {:?}", facets); + + let info = SearchRequest { + facets : facets.and_then(|x| serde_json::to_string(&x).ok()), + ..info + }; + let results = search_for_project(&info, &config).await?; Ok(HttpResponse::Ok().json(results)) } diff --git a/src/search/mod.rs b/src/search/mod.rs index b2be5f49..98a167ff 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -148,6 +148,17 @@ pub struct ResultSearchProject { pub loader_fields: HashMap> } +pub fn get_sort_index(index: &str) -> Result<(&str, [&str; 1]), SearchError> { + Ok(match index { + "relevance" => ("projects", ["downloads:desc"]), + "downloads" => ("projects_filtered", ["downloads:desc"]), + "follows" => ("projects", ["follows:desc"]), + "updated" => ("projects", ["date_modified:desc"]), + "newest" => ("projects", ["date_created:desc"]), + i => return Err(SearchError::InvalidIndex(i.to_string())), + }) +} + pub async fn search_for_project( info: &SearchRequest, config: &SearchConfig, @@ -158,14 +169,7 @@ pub async fn search_for_project( let index = info.index.as_deref().unwrap_or("relevance"); let limit = info.limit.as_deref().unwrap_or("10").parse()?; - let sort = match index { - "relevance" => ("projects", ["downloads:desc"]), - "downloads" => ("projects_filtered", ["downloads:desc"]), - "follows" => ("projects", ["follows:desc"]), - "updated" => ("projects", ["date_modified:desc"]), - "newest" => ("projects", ["date_created:desc"]), - i => return Err(SearchError::InvalidIndex(i.to_string())), - }; + let sort = get_sort_index(index)?; let meilisearch_index = client.get_index(sort.0).await?; diff --git a/tests/search.rs b/tests/search.rs index 16786307..493ad774 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -150,10 +150,10 @@ async fn search_projects() { ["project_type:modpack"] ]), vec![4]), (json!([ - ["fabric_client_side:required"] + ["client_side:required"] ]), vec![0,2,3]), (json!([ - ["fabric_server_side:required"] + ["server_side:required"] ]), vec![0,2,3,6]), (json!([ ["open_source:true"] @@ -168,7 +168,7 @@ async fn search_projects() { ["author:user"] ]), vec![0,1,2,4,5]), (json!([ - ["fabric_game_versions:1.20.5"] + ["versions:1.20.5"] ]), vec![4,5]), ]; // TODO: versions, game versions From d8740ec49efa4eaf5d798c877b5564a65d768d1e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Sat, 21 Oct 2023 01:06:29 -0700 Subject: [PATCH 11/31] added some tags.rs v2 conversions --- src/database/models/categories.rs | 2 - src/database/models/loader_fields.rs | 23 ++--- src/database/models/version_item.rs | 4 - src/routes/v2/project_creation.rs | 35 +------ src/routes/v2/projects.rs | 15 +-- src/routes/v2/tags.rs | 55 ++++++++--- src/routes/v2/version_creation.rs | 28 +----- src/routes/v2/version_file.rs | 41 +++++--- src/routes/v2/versions.rs | 3 - src/routes/v2_reroute.rs | 83 +--------------- src/routes/v3/tags.rs | 140 +++++++++------------------ src/routes/v3/version_file.rs | 24 +++-- {tests/common => src/util}/actix.rs | 10 +- src/util/mod.rs | 1 + src/util/webhook.rs | 3 +- tests/common/api_v2/project.rs | 3 +- tests/common/dummy_data.rs | 4 +- tests/common/mod.rs | 1 - tests/common/permissions.rs | 1 + tests/common/request_data.rs | 7 +- tests/project.rs | 37 ++++--- tests/scopes.rs | 26 ++--- 22 files changed, 194 insertions(+), 352 deletions(-) rename {tests/common => src/util}/actix.rs (89%) diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs index 2d1943a6..8150d61d 100644 --- a/src/database/models/categories.rs +++ b/src/database/models/categories.rs @@ -2,8 +2,6 @@ use crate::database::redis::RedisPool; use super::ids::*; use super::DatabaseError; -use chrono::DateTime; -use chrono::Utc; use futures::TryStreamExt; use serde::{Deserialize, Serialize}; diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 5b3be19a..4ade51a8 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -1,8 +1,6 @@ -use crate::database::redis::RedisPool; -use crate::models::ids::base62_impl::parse_base62; -use crate::routes::ApiError; -use crate::routes::v3::project_creation::CreateError; +use std::collections::HashMap; +use crate::database::redis::RedisPool; use super::ids::*; use super::DatabaseError; use chrono::DateTime; @@ -12,12 +10,6 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; -const GAME_LOADERS_NAMESPACE: &str = "game_loaders"; -const LOADER_FIELD_ID_NAMESPACE: &str = "loader_field_ids"; // from str to id -const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; -const LOADER_FIELD_ENUMS_NAMESPACE: &str = "loader_field_enums"; -const VERSION_FIELDS_NAMESPACE: &str = "version_fields_enums"; - #[derive(Clone, Serialize, Deserialize, Debug)] pub enum Game { MinecraftJava, @@ -223,6 +215,7 @@ pub struct LoaderFieldEnumValue { pub value: String, pub ordering: Option, pub created: DateTime, + #[serde(flatten)] pub metadata: serde_json::Value, } @@ -477,7 +470,7 @@ impl VersionFieldValue { if let Some(ev) = enum_array.into_iter().find(|v| v.value == enum_value) { ev } else { - return Err((format!("Provided value '{enum_value}' is not a valid variant for {field_name}"))); + return Err(format!("Provided value '{enum_value}' is not a valid variant for {field_name}")); } } ), @@ -489,7 +482,7 @@ impl VersionFieldValue { if let Some(ev) = enum_array.iter().find(|v| v.value == av) { enum_values.push(ev.clone()); } else { - return Err((format!("Provided value '{av}' is not a valid variant for {field_name}"))); + return Err(format!("Provided value '{av}' is not a valid variant for {field_name}")); } } enum_values @@ -768,22 +761,20 @@ impl LoaderFieldEnumValue { // Matches filter against metadata of enum values pub async fn list_filter<'a, E>( loader_field_enum_id : LoaderFieldEnumId, - filter : serde_json::Value, + filter : HashMap, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - - let filter = filter.as_object().ok_or(DatabaseError::SchemaError("Filter must be an object".to_string()))?; let result = Self::list(loader_field_enum_id, exec, redis) .await? .into_iter() .filter(|x| { let mut bool = true; - for (key, value) in filter { + for (key, value) in filter.iter() { if let Some(metadata_value) = x.metadata.get(key) { bool &= metadata_value == value; } else { diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index a5423049..40243be1 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,10 +1,6 @@ use super::ids::*; use super::DatabaseError; -use super::loader_fields::LoaderField; use super::loader_fields::VersionField; -use crate::database::models::loader_fields::LoaderFieldEnumValue; -use crate::database::models::loader_fields::LoaderFieldType; -use crate::database::models::loader_fields::QueryVersionField; use crate::database::redis::RedisPool; use crate::models::projects::{FileType, VersionStatus}; use chrono::{DateTime, Utc}; diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 9dcf1a89..d38f0111 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -1,45 +1,14 @@ -use super::version_creation::InitialVersionData; -use crate::auth::{get_user_from_headers, AuthenticationError}; -use crate::database::models::loader_fields::Game; -use crate::database::models::thread_item::ThreadBuilder; -use crate::database::models::{self, image_item, User, version_item}; use crate::database::redis::RedisPool; -use crate::file_hosting::{FileHost, FileHostingError}; -use crate::models::error::ApiError; -use crate::models::ids::ImageId; -use crate::models::images::{Image, ImageContext}; -use crate::models::pats::Scopes; -use crate::models::projects::{ - DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, - VersionStatus, -}; -use actix_web::http::header::HeaderValue; +use crate::file_hosting::FileHost; - -use crate::models::teams::ProjectPermissions; -use crate::models::threads::ThreadType; -use crate::models::users::UserId; use crate::queue::session::AuthQueue; use crate::routes::{v3, v2_reroute}; use crate::routes::v3::project_creation::CreateError; -use crate::search::indexing::IndexingError; -use crate::util::routes::read_from_field; -use crate::util::validate::validation_errors_to_string; -use actix_multipart::{Field, Multipart}; -use actix_web::http::StatusCode; +use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; -use bytes::Bytes; -use chrono::Utc; -use futures::TryStreamExt; -use futures::stream::StreamExt; -use image::ImageError; -use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::sync::Arc; -use thiserror::Error; -use validator::Validate; use serde_json::json; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index fd38b51d..af7655ee 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,37 +1,28 @@ -use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; +use crate::auth::{get_user_from_headers, is_authorized}; use crate::{database, search}; use crate::database::models::{image_item, version_item, project_item}; -use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::{GalleryItem, ModCategory}; -use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; -use crate::models::ids::VersionId; -use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; -use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, LoaderStruct, Loader, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, Loader, }; use crate::models::teams::ProjectPermissions; -use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; use crate::routes::{ApiError, v2_reroute, v3}; use crate::routes::v3::projects::{delete_from_index, ProjectIds}; use crate::search::{search_for_project, SearchConfig, SearchError}; -use crate::util::img; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse, App}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use futures::TryStreamExt; -use meilisearch_sdk::indexes::IndexesResults; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; -use std::borrow::BorrowMut; use std::sync::Arc; use validator::Validate; diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index cc0ca69c..7694c49e 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -1,11 +1,13 @@ +use std::collections::HashMap; + use super::ApiError; -use crate::database::models; -use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType}; -use crate::database::models::loader_fields::{Loader, GameVersion}; +use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType, Category}; +use crate::database::models::loader_fields::Game; use crate::database::redis::RedisPool; +use crate::routes::v3; +use crate::routes::v3::tags::{LoaderList, LoaderFieldsEnumQuery}; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; -use models::categories::{Category}; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -36,9 +38,18 @@ pub async fn category_list( pool: web::Data, redis: web::Data, ) -> Result { - // TODO: should cvall v3 - - Ok(HttpResponse::Ok().json("")) + let results = Category::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| CategoryData { + icon: x.icon, + name: x.category, + project_type: x.project_type, + header: x.header, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(results)) } #[derive(serde::Serialize, serde::Deserialize)] @@ -53,9 +64,8 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - // TODO: should cvall v3 - - Ok(HttpResponse::Ok().json("")) + let response = v3::tags::loader_list(web::Query(LoaderList {game: Game::MinecraftJava.name().to_string()}), pool, redis).await?; + Ok(response) } #[derive(serde::Serialize)] @@ -80,8 +90,20 @@ pub async fn game_version_list( redis: web::Data, ) -> Result { // TODO: should cvall v3 + let mut filters = HashMap::new(); + if let Some(type_) = &query.type_ { + filters.insert("type".to_string(), serde_json::json!(type_)); + } + if let Some(major) = query.major { + filters.insert("major".to_string(), serde_json::json!(major)); + } + let response = v3::tags::loader_fields_list(pool, web::Query(LoaderFieldsEnumQuery { + game: Game::MinecraftJava.name().to_string(), + field: "game_version".to_string(), + filters: Some(filters), + }), redis).await?; - Ok(HttpResponse::Ok().json("`")) + Ok(response) } #[derive(serde::Serialize)] @@ -179,7 +201,12 @@ pub async fn side_type_list( pool: web::Data, redis: web::Data, ) -> Result { - // TODO: should call v3 - - Ok(HttpResponse::Ok().json("")) + // TODO: should cvall v3 + let response = v3::tags::loader_fields_list(pool, web::Query(LoaderFieldsEnumQuery { + game: Game::MinecraftJava.name().to_string(), + field: "client_type".to_string(), // same as server_type + filters: None, + }), redis).await?; + + Ok(response) } diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 964cf1a0..4f600de0 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -1,31 +1,16 @@ -use crate::auth::get_user_from_headers; -use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::version_item::{ - DependencyBuilder, VersionBuilder, VersionFileBuilder, -}; -use crate::database::models::{self, image_item, Organization}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::images::{Image, ImageContext, ImageId}; -use crate::models::notifications::NotificationBody; -use crate::models::pack::PackFileHash; -use crate::models::pats::Scopes; +use crate::models::ids::ImageId; use crate::models::projects::{ - Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile, + Dependency, FileType, GameVersion, Loader, ProjectId, VersionId, VersionStatus, VersionType, }; -use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::{v2_reroute, v3}; use crate::routes::v3::project_creation::CreateError; -use crate::util::routes::read_from_field; -use crate::util::validate::validation_errors_to_string; -use crate::validate::{validate_file, ValidationResult}; -use actix_multipart::{Field, Multipart}; +use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, web, HttpRequest, HttpResponse}; -use chrono::Utc; -use futures::stream::StreamExt; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::collections::HashMap; @@ -90,7 +75,7 @@ struct InitialFileData { #[post("version")] pub async fn version_create( req: HttpRequest, - mut payload: Multipart, + payload: Multipart, client: Data, redis: Data, file_host: Data>, @@ -156,16 +141,13 @@ pub async fn version_create( pub async fn upload_file_to_version( req: HttpRequest, url_data: web::Path<(VersionId,)>, - mut payload: Multipart, + payload: Multipart, client: Data, redis: Data, file_host: Data>, session_queue: web::Data, ) -> Result { // TODO: do we need to modify this? - let response= v3::version_creation::upload_file_to_version(req, url_data, payload, client.clone(), redis.clone(), file_host, session_queue).await?; - - Ok(response) } \ No newline at end of file diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 170b7b8f..e03072cf 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -9,9 +9,10 @@ use crate::models::pats::Scopes; use crate::models::projects::VersionType; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::v3; +use crate::routes::v3::version_file::{HashQuery, default_algorithm}; use crate::{database, models}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; -use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; @@ -34,17 +35,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); } -#[derive(Serialize, Deserialize)] -pub struct HashQuery { - #[serde(default = "default_algorithm")] - pub algorithm: String, - pub version_id: Option, -} - -fn default_algorithm() -> String { - "sha1".into() -} - // under /api/v1/version_file/{hash} #[get("{version_id}")] pub async fn get_version_from_hash( @@ -268,6 +258,7 @@ pub struct UpdateData { pub version_types: Option>, } +// TODO: this being left as empty was not caught by tests, so write tests for this #[post("{version_id}/update")] pub async fn get_update_from_hash( req: HttpRequest, @@ -278,8 +269,30 @@ pub async fn get_update_from_hash( update_data: web::Json, session_queue: web::Data, ) -> Result { - // TODO: should call v3 - Ok(HttpResponse::NotFound().body("")) + let update_data = update_data.into_inner(); + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in update_data.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + let update_data = v3::version_file::UpdateData { + loaders: update_data.loaders.clone(), + version_types: update_data.version_types.clone(), + loader_fields: Some(loader_fields), + }; + + let response = v3::version_file::get_update_from_hash( + req, + info, + pool, + redis, + hash_query, + web::Json(update_data), + session_queue, + ).await?; + + Ok(response) } // Requests above with multiple versions below diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index b85ee498..44901f88 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -181,9 +181,6 @@ pub async fn version_get( if let Some(data) = version_data { if is_authorized_version(&data.inner, &user_option, &pool).await? { - println!("Got version: {:?}", serde_json::to_value(&data)?); - panic!(); - return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); } } diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index e0698166..9bed33ba 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -7,7 +7,7 @@ use futures::{StreamExt, stream, Future}; use serde_json::{Value, json}; use actix_web::test; -use crate::{database::{models::{version_item, DatabaseError}, redis::RedisPool}, models::ids::VersionId}; +use crate::{database::{models::{version_item, DatabaseError}, redis::RedisPool}, models::ids::VersionId, util::actix::{MultipartSegment, MultipartSegmentData, generate_multipart}}; use super::ApiError; @@ -118,84 +118,3 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header } - - -// Multipart functionality (actix-test does not innately support multipart) -#[derive(Debug, Clone)] -pub struct MultipartSegment { - pub name: String, - pub filename: Option, - pub content_type: Option, - pub data: MultipartSegmentData, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum MultipartSegmentData { - Text(String), - Binary(Vec), -} - -pub trait AppendsMultipart { - fn set_multipart(self, data: impl IntoIterator) -> Self; -} - -impl AppendsMultipart for TestRequest { - fn set_multipart(self, data: impl IntoIterator) -> Self { - let (boundary, payload) = generate_multipart(data); - self.append_header(( - "Content-Type", - format!("multipart/form-data; boundary={}", boundary), - )) - .set_payload(payload) - } -} - -fn generate_multipart(data: impl IntoIterator) -> (String, Bytes) { - let mut boundary: String = String::from("----WebKitFormBoundary"); - boundary.push_str(&rand::random::().to_string()); - boundary.push_str(&rand::random::().to_string()); - boundary.push_str(&rand::random::().to_string()); - - let mut payload = BytesMut::new(); - - for segment in data { - payload.extend_from_slice( - format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"", - boundary = boundary, - name = segment.name - ) - .as_bytes(), - ); - - if let Some(filename) = &segment.filename { - payload.extend_from_slice( - format!("; filename=\"{filename}\"", filename = filename).as_bytes(), - ); - } - if let Some(content_type) = &segment.content_type { - payload.extend_from_slice( - format!( - "\r\nContent-Type: {content_type}", - content_type = content_type - ) - .as_bytes(), - ); - } - payload.extend_from_slice(b"\r\n\r\n"); - - match &segment.data { - MultipartSegmentData::Text(text) => { - payload.extend_from_slice(text.as_bytes()); - } - MultipartSegmentData::Binary(binary) => { - payload.extend_from_slice(binary); - } - } - payload.extend_from_slice(b"\r\n"); - } - payload.extend_from_slice(format!("--{boundary}--\r\n", boundary = boundary).as_bytes()); - - (boundary, Bytes::from(payload)) -} diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 2bfb065c..2843d8ec 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,129 +1,77 @@ +use std::collections::HashMap; + use super::ApiError; use crate::database::models; -use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType}; -use crate::database::models::loader_fields::{Loader, GameVersion}; +use crate::database::models::loader_fields::{Loader, LoaderFieldEnumValue, LoaderFieldEnum}; use crate::database::redis::RedisPool; -use actix_web::{get, web, HttpResponse}; -use chrono::{DateTime, Utc}; -use models::categories::{Category}; +use actix_web::{web, HttpResponse}; +use serde_json::Value; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("tag") - .route("category", web::get().to(category_list))); - // .service(loader_list) - // .service(game_version_list) - // .service(side_type_list), + .route("loader", web::get().to(loader_list))) + .route("loader_fields", web::get().to(loader_fields_list)); } + #[derive(serde::Serialize, serde::Deserialize)] -pub struct CategoryData { +pub struct LoaderData { icon: String, name: String, - project_type: String, - header: String, + supported_project_types: Vec, +} + +#[derive(serde::Deserialize)] +pub struct LoaderList { + pub game: String } -pub async fn category_list( +pub async fn loader_list( + data: web::Query, pool: web::Data, redis: web::Data, ) -> Result { - let results = Category::list(&**pool, &redis) + let mut results = Loader::list(&data.game,&**pool, &redis) .await? .into_iter() - .map(|x| CategoryData { + .map(|x| LoaderData { icon: x.icon, - name: x.category, - project_type: x.project_type, - header: x.header, + name: x.loader, + supported_project_types: x.supported_project_types, }) .collect::>(); - Ok(HttpResponse::Ok().json(results)) -} - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct LoaderData { - icon: String, - name: String, - supported_project_types: Vec, -} - -// #[derive(serde::Deserialize)] -// struct LoaderList { -// game: String -// } -// #[get("loader")] -// pub async fn loader_list( -// data: web::Query, -// pool: web::Data, -// redis: web::Data, -// ) -> Result { -// let mut results = Loader::list(&data.game,&**pool, &redis) -// .await? -// .into_iter() -// .map(|x| LoaderData { -// icon: x.icon, -// name: x.loader, -// supported_project_types: x.supported_project_types, -// }) -// .collect::>(); + results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); -// results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - -// Ok(HttpResponse::Ok().json(results)) -// } - -#[derive(serde::Serialize)] -pub struct GameVersionQueryData { - pub version: String, - pub version_type: String, - pub date: DateTime, - pub major: bool, + Ok(HttpResponse::Ok().json(results)) } -#[derive(serde::Deserialize)] -pub struct GameVersionQuery { - #[serde(rename = "type")] - type_: Option, - major: Option, +// TODO: write tests for this and all other v3/tags and v2/tags +#[derive(serde::Deserialize, serde::Serialize)] +pub struct LoaderFieldsEnumQuery { + pub game: String, + pub field: String, + pub filters : Option> // For metadata } -// #[get("game_version")] -// pub async fn game_version_list( -// pool: web::Data, -// query: web::Query, -// redis: web::Data, -// ) -> Result { -// let results: Vec = if query.type_.is_some() || query.major.is_some() { -// GameVersion::list_filter(query.type_.as_deref(), query.major, &**pool, &redis).await? -// } else { -// GameVersion::list(&**pool, &redis).await? -// } -// .into_iter() -// .map(|x| GameVersionQueryData { -// version: x.version, -// version_type: x.type_, -// date: x.created, -// major: x.major, -// }) -// .collect(); +pub async fn loader_fields_list( + pool: web::Data, + query: web::Query, + redis: web::Data, +) -> Result { -// Ok(HttpResponse::Ok().json(results)) -// } + let query = query.into_inner(); + let loader_field_enum = LoaderFieldEnum::get(&query.field, &query.game, &**pool, &redis).await? + .ok_or_else(|| ApiError::InvalidInput(format!("'{}' was not a valid enumerable loader field for game {}.", query.field, query.game)))?; -#[derive(serde::Serialize)] -pub struct License { - short: String, - name: String, + let results: Vec<_> = if let Some(filters) = query.filters { + LoaderFieldEnumValue::list_filter(loader_field_enum.id, filters, &**pool, &redis).await? + } else { + LoaderFieldEnumValue::list(loader_field_enum.id, &**pool, &redis).await? + }; + + Ok(HttpResponse::Ok().json(results)) } -// #[get("side_type")] -// pub async fn side_type_list( -// pool: web::Data, -// redis: web::Data, -// ) -> Result { -// let results = SideType::list(&**pool, &redis).await?; -// Ok(HttpResponse::Ok().json(results)) -// } diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index f958db51..0577b757 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -37,17 +37,25 @@ pub struct HashQuery { pub version_id: Option, } -fn default_algorithm() -> String { +pub fn default_algorithm() -> String { "sha1".into() } #[derive(Deserialize)] pub struct UpdateData { pub loaders: Option>, - pub game_versions: Option>, pub version_types: Option>, + /* + Loader fields to filter with: + "game_versions": ["1.16.5", "1.17"] + + Returns if it matches any of the values + */ + pub loader_fields: Option>>, + } +// TODO: write tests for this pub async fn get_update_from_hash( req: HttpRequest, info: web::Path<(String,)>, @@ -96,10 +104,14 @@ pub async fn get_update_from_hash( if let Some(loaders) = &update_data.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - // if let Some(game_versions) = &update_data.game_versions { - // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - // } - + + if let Some(loader_fields) = &update_data.loader_fields { + for (key, value) in loader_fields { + bool &= x.version_fields.iter().any(|y| { + y.field_name == *key && value.contains(&y.value.serialize_internal()) + }); + } + } bool }) .sorted_by(|a, b| a.inner.date_published.cmp(&b.inner.date_published)) diff --git a/tests/common/actix.rs b/src/util/actix.rs similarity index 89% rename from tests/common/actix.rs rename to src/util/actix.rs index cc63b5f0..6cc1c50e 100644 --- a/tests/common/actix.rs +++ b/src/util/actix.rs @@ -1,7 +1,11 @@ +use bytes::{BytesMut, Bytes}; use actix_web::test::TestRequest; -use bytes::{Bytes, BytesMut}; -// Multipart functionality (actix-test does not innately support multipart) + + +// Multipart functionality for actix +// Primarily for testing or some implementations of route-redirection +// (actix-test does not innately support multipart) #[derive(Debug, Clone)] pub struct MultipartSegment { pub name: String, @@ -32,7 +36,7 @@ impl AppendsMultipart for TestRequest { } } -fn generate_multipart(data: impl IntoIterator) -> (String, Bytes) { +pub fn generate_multipart(data: impl IntoIterator) -> (String, Bytes) { let mut boundary: String = String::from("----WebKitFormBoundary"); boundary.push_str(&rand::random::().to_string()); boundary.push_str(&rand::random::().to_string()); diff --git a/src/util/mod.rs b/src/util/mod.rs index 74588dd7..5729d570 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ +pub mod actix; pub mod bitflag; pub mod captcha; pub mod cors; diff --git a/src/util/webhook.rs b/src/util/webhook.rs index 00cd620b..9c903768 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -1,5 +1,4 @@ -use crate::database; -use crate::database::models::{loader_fields::GameVersion, GameId}; +use crate::database::models::loader_fields::GameVersion; use crate::database::redis::RedisPool; use crate::models::projects::ProjectId; use crate::routes::ApiError; diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index cc716072..3818ca96 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -4,11 +4,10 @@ use actix_web::{ test::{self, TestRequest}, }; use bytes::Bytes; -use labrinth::{models::projects::{Project, Version}, search::SearchResults}; +use labrinth::{models::projects::{Project, Version}, search::SearchResults, util::actix::AppendsMultipart}; use serde_json::json; use crate::common::{ - actix::AppendsMultipart, asserts::assert_status, database::MOD_USER_PAT, request_data::{ImageData, ProjectCreationRequestData}, diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index e31b4617..f058ed5e 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -10,10 +10,10 @@ use serde_json::json; use sqlx::Executor; use zip::{write::FileOptions, ZipWriter, CompressionMethod}; -use crate::common::{actix::AppendsMultipart, database::USER_USER_PAT}; +use crate::common::database::USER_USER_PAT; +use labrinth::util::actix::{MultipartSegmentData,MultipartSegment,AppendsMultipart}; use super::{ - actix::{MultipartSegment, MultipartSegmentData}, environment::TestEnvironment, request_data::get_public_project_creation_data, }; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 39b3305a..3ec3b803 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use self::database::TemporaryDatabase; -pub mod actix; pub mod api_v2; pub mod asserts; pub mod database; diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 80f7bb38..f2e84583 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -310,6 +310,7 @@ impl<'a> PermissionsTest<'a> { .to_request(); let resp = test_env.call(request).await; + println!("RESP: {:?}", resp.response().body()); if !resp.status().is_success() { return Err(format!( "Success permissions test failed. Expected success, got {}", diff --git a/tests/common/request_data.rs b/tests/common/request_data.rs index 6e795a47..6ae751c7 100644 --- a/tests/common/request_data.rs +++ b/tests/common/request_data.rs @@ -1,11 +1,8 @@ #![allow(dead_code)] use serde_json::json; -use super::{ - actix::MultipartSegment, - dummy_data::{DummyImage, TestFile}, -}; -use crate::common::actix::MultipartSegmentData; +use super::dummy_data::{DummyImage, TestFile}; +use labrinth::util::actix::{MultipartSegmentData,MultipartSegment}; pub struct ProjectCreationRequestData { pub slug: String, diff --git a/tests/project.rs b/tests/project.rs index a03dfca9..df16cd94 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -2,8 +2,8 @@ use actix_http::StatusCode; use actix_web::test; use bytes::Bytes; use chrono::{Duration, Utc}; -use common::actix::MultipartSegment; -use common::environment::with_test_environment; +use labrinth::util::actix::{MultipartSegmentData,MultipartSegment, AppendsMultipart}; +use common::environment::{with_test_environment, TestEnvironment}; use common::permissions::{PermissionsTest, PermissionsTestContext}; use futures::StreamExt; use labrinth::database::models::project_item::{PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE}; @@ -14,7 +14,6 @@ use serde_json::json; use crate::common::database::*; use crate::common::dummy_data::DUMMY_CATEGORIES; -use crate::common::{actix::AppendsMultipart, environment::TestEnvironment}; // importing common module. mod common; @@ -130,54 +129,54 @@ async fn test_add_remove_project() { ); // Basic json - let json_segment = common::actix::MultipartSegment { + let json_segment = MultipartSegment { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), }; // Basic json, with a different file json_data["initial_versions"][0]["file_parts"][0] = json!("basic-mod-different.jar"); - let json_diff_file_segment = common::actix::MultipartSegment { - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + let json_diff_file_segment = MultipartSegment { + data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), ..json_segment.clone() }; // Basic json, with a different file, and a different slug json_data["slug"] = json!("new_demo"); json_data["initial_versions"][0]["file_parts"][0] = json!("basic-mod-different.jar"); - let json_diff_slug_file_segment = common::actix::MultipartSegment { - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + let json_diff_slug_file_segment = MultipartSegment { + data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), ..json_segment.clone() }; // Basic file - let file_segment = common::actix::MultipartSegment { + let file_segment = MultipartSegment { name: "basic-mod.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod.jar").to_vec(), ), }; // Differently named file, with the same content (for hash testing) - let file_diff_name_segment = common::actix::MultipartSegment { + let file_diff_name_segment = MultipartSegment { name: "basic-mod-different.jar".to_string(), filename: Some("basic-mod-different.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod.jar").to_vec(), ), }; // Differently named file, with different content - let file_diff_name_content_segment = common::actix::MultipartSegment { + let file_diff_name_content_segment = MultipartSegment { name: "basic-mod-different.jar".to_string(), filename: Some("basic-mod-different.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod-different.jar").to_vec(), ), }; @@ -755,7 +754,7 @@ async fn permissions_upload_version() { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text( + data: MultipartSegmentData::Text( serde_json::to_string(&json!({ "project_id": ctx.project_id.unwrap(), "file_parts": ["basic-mod.jar"], @@ -775,7 +774,7 @@ async fn permissions_upload_version() { name: "basic-mod.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod.jar").to_vec(), ), }, @@ -796,7 +795,7 @@ async fn permissions_upload_version() { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text( + data: MultipartSegmentData::Text( serde_json::to_string(&json!({ "file_parts": ["basic-mod-different.jar"], })) @@ -807,7 +806,7 @@ async fn permissions_upload_version() { name: "basic-mod-different.jar".to_string(), filename: Some("basic-mod-different.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod-different.jar").to_vec(), ), }, diff --git a/tests/scopes.rs b/tests/scopes.rs index 7c11aa79..09767901 100644 --- a/tests/scopes.rs +++ b/tests/scopes.rs @@ -1,7 +1,7 @@ use actix_web::test::{self, TestRequest}; use bytes::Bytes; use chrono::{Duration, Utc}; -use common::actix::AppendsMultipart; +use labrinth::util::actix::{MultipartSegmentData,MultipartSegment, AppendsMultipart}; use labrinth::models::pats::Scopes; use serde_json::json; @@ -225,17 +225,17 @@ pub async fn project_version_create_scopes() { "license_id": "MIT" } ); - let json_segment = common::actix::MultipartSegment { + let json_segment = MultipartSegment { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), }; - let file_segment = common::actix::MultipartSegment { + let file_segment = MultipartSegment { name: "basic-mod.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod.jar").to_vec(), ), }; @@ -266,17 +266,17 @@ pub async fn project_version_create_scopes() { "featured": true } ); - let json_segment = common::actix::MultipartSegment { + let json_segment = MultipartSegment { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), + data: MultipartSegmentData::Text(serde_json::to_string(&json_data).unwrap()), }; - let file_segment = common::actix::MultipartSegment { + let file_segment = MultipartSegment { name: "basic-mod-different.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/basic-mod-different.jar").to_vec(), ), }; @@ -819,11 +819,11 @@ pub async fn version_write_scopes() { // Generate test project data. // Basic json - let json_segment = common::actix::MultipartSegment { + let json_segment = MultipartSegment { name: "data".to_string(), filename: None, content_type: Some("application/json".to_string()), - data: common::actix::MultipartSegmentData::Text( + data: MultipartSegmentData::Text( serde_json::to_string(&json!( { "file_types": { @@ -836,11 +836,11 @@ pub async fn version_write_scopes() { }; // Differently named file, with different content - let content_segment = common::actix::MultipartSegment { + let content_segment = MultipartSegment { name: "simple-zip.zip".to_string(), filename: Some("simple-zip.zip".to_string()), content_type: Some("application/zip".to_string()), - data: common::actix::MultipartSegmentData::Binary( + data: MultipartSegmentData::Binary( include_bytes!("../tests/files/simple-zip.zip").to_vec(), ), }; From 3eb68aee8202a18a7dcc8a0f3f666e190bb27de5 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Sun, 22 Oct 2023 21:01:44 -0700 Subject: [PATCH 12/31] Worked through warnings, unwraps, prints --- src/database/models/loader_fields.rs | 614 +++++++++++++++------------ src/database/models/project_item.rs | 6 - src/database/models/version_item.rs | 4 +- src/models/projects.rs | 3 - src/routes/maven.rs | 11 +- src/routes/updates.rs | 46 +- src/routes/v2/project_creation.rs | 6 +- src/routes/v2/projects.rs | 27 +- src/routes/v2/version_creation.rs | 3 +- src/routes/v2/version_file.rs | 56 ++- src/routes/v2/versions.rs | 25 +- src/routes/v2_reroute.rs | 51 +-- src/routes/v3/project_creation.rs | 56 +-- src/routes/v3/projects.rs | 41 +- src/routes/v3/tags.rs | 1 - src/routes/v3/version_creation.rs | 50 ++- src/routes/v3/version_file.rs | 31 +- src/routes/v3/versions.rs | 51 +-- src/scheduler.rs | 80 ++-- src/search/indexing/local_import.rs | 13 +- src/search/indexing/mod.rs | 7 +- src/search/mod.rs | 19 +- src/util/webhook.rs | 61 ++- src/validate/mod.rs | 59 ++- tests/common/api_v2/project.rs | 1 - tests/common/dummy_data.rs | 7 - tests/common/permissions.rs | 9 +- tests/project.rs | 11 +- 28 files changed, 728 insertions(+), 621 deletions(-) diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 4ade51a8..de617156 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -10,20 +10,39 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; -#[derive(Clone, Serialize, Deserialize, Debug)] +const LOADERS_LIST_NAMESPACE: &str = "loaders"; + +const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; + +const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums"; +const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = "loader_field_enum_values"; + +#[derive(Clone, Serialize, Deserialize, Debug, Copy)] pub enum Game { MinecraftJava, - MinecraftBedrock + // MinecraftBedrock + // Future games } impl Game { pub fn name(&self) -> &'static str { match self { Game::MinecraftJava => "minecraft-java", - Game::MinecraftBedrock => "minecraft-bedrock" + // Game::MinecraftBedrock => "minecraft-bedrock" + // Future games } } + pub fn from_name(name: &str) -> Option { + match name { + "minecraft-java" => Some(Game::MinecraftJava), + // "minecraft-bedrock" => Some(Game::MinecraftBedrock) + // Future games + _ => None, + } + } + + // TODO is this needed? pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, @@ -41,6 +60,24 @@ impl Game { Ok(result.map(|r| GameId(r.id))) } + // TODO is this needed? + pub async fn from_id<'a, E>(id: GameId, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT name FROM games + WHERE id = $1 + ", + id.0 + ) + .fetch_optional(exec) + .await?; + + Ok(result.and_then(|r| r.name).and_then(|n| Game::from_name(&n))) + } + } #[derive(Serialize, Deserialize)] @@ -69,10 +106,16 @@ impl Loader { Ok(result.map(|r| LoaderId(r.id))) } - pub async fn list<'a, E>(game_name_or_id : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn list<'a, E>(game_name : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { + + let cached_loaders : Option> = redis.get_deserialized_from_json(LOADERS_LIST_NAMESPACE, game_name).await?; + if let Some(cached_loaders) = cached_loaders { + return Ok(cached_loaders); + } + let result = sqlx::query!( " SELECT l.id id, l.loader loader, l.icon icon, @@ -84,7 +127,7 @@ impl Loader { WHERE g.name = $1 GROUP BY l.id; ", - game_name_or_id, + game_name, ) .fetch_many(exec) .try_filter_map(|e| async { @@ -102,11 +145,13 @@ impl Loader { }) .try_collect::>() .await?; - println!("Just collected loaders for game {}, got {} loaders", game_name_or_id, result.len()); + + redis.set_serialized_to_json(LOADERS_LIST_NAMESPACE, game_name, &result, None).await?; + Ok(result) } - pub async fn list_id<'a, E>(game_id : GameId , exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn list_id<'a, E>(game_id : GameId , exec: E) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { @@ -139,12 +184,10 @@ impl Loader { }) .try_collect::>() .await?; - println!("Just collected loaders for game {}, got {} loaders", game_id.0, result.len()); Ok(result) } } - #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderField { pub id: LoaderFieldId, @@ -170,7 +213,6 @@ pub enum LoaderFieldType { } impl LoaderFieldType { pub fn build(field_type_name : &str, loader_field_enum : Option) -> Option { - println!("Building field type for {} with enum {:?}", field_type_name, loader_field_enum); Some(match (field_type_name, loader_field_enum) { ("integer", _) => LoaderFieldType::Integer, ("text", _) => LoaderFieldType::Text, @@ -207,7 +249,6 @@ pub struct LoaderFieldEnum { pub hidable: bool, } - #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderFieldEnumValue { pub id: LoaderFieldEnumValueId, @@ -227,13 +268,308 @@ pub struct VersionField { pub field_name: String, pub value: VersionFieldValue, } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum VersionFieldValue { + Integer(i32), + Text(String), + Enum(LoaderFieldEnumId, LoaderFieldEnumValue), + Boolean(bool), + ArrayInteger(Vec), + ArrayText(Vec), + ArrayEnum(LoaderFieldEnumId, Vec), + ArrayBoolean(Vec), +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QueryVersionField { + pub version_id: VersionId, + pub field_id: LoaderFieldId, + pub int_value: Option, + pub enum_value: Option, + pub string_value: Option, +} + +impl QueryVersionField { + pub fn with_int_value(mut self, int_value: i32) -> Self { + self.int_value = Some(int_value); + self + } + + pub fn with_enum_value(mut self, enum_value: LoaderFieldEnumValue) -> Self { + self.enum_value = Some(enum_value); + self + } + + pub fn with_string_value(mut self, string_value: String) -> Self { + self.string_value = Some(string_value); + self + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SideType { + pub id: SideTypeId, + pub name: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct GameVersion { + pub id: LoaderFieldEnumValueId, + pub version: String, + #[serde(rename = "type")] + pub type_: String, + pub created: DateTime, + pub major: bool, +} + +// game version from loaderfieldenumvalue +// TODO: remove, after moving gameversion to legacy minecraft +impl GameVersion { + // The name under which this legacy field is stored as a LoaderField + pub const LEGACY_FIELD_NAME : &'static str = "game_versions"; + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool + ) + -> Result, DatabaseError> + where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy + { + let game_name = Game::MinecraftJava.name(); + let game_version_enum = LoaderFieldEnum::get(Self::LEGACY_FIELD_NAME, game_name, exec, &redis).await?.ok_or_else(|| DatabaseError::SchemaError(format!("Could not find game version enum for '{game_name}'")))?; + let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, exec, &redis).await?; + Ok(game_version_enum_values.into_iter().map(|x| GameVersion::from_enum_value(x)).collect()) + } + + // TODO: remove this + pub async fn list_transaction( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool + ) + -> Result, DatabaseError> + { + let game_name = Game::MinecraftJava.name(); + let game_version_enum = LoaderFieldEnum::get(Self::LEGACY_FIELD_NAME, game_name, &mut *transaction, &redis).await?.ok_or_else(|| DatabaseError::SchemaError(format!("Could not find game version enum for '{game_name}'")))?; + let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, &redis).await?; + Ok(game_version_enum_values.into_iter().map(|x| GameVersion::from_enum_value(x)).collect()) + } + + // Tries to create a GameVersion from a VersionField + // Clones on success + pub fn try_from_version_field(version_field: &VersionField) -> Result, DatabaseError> { + if version_field.field_name !=Self::LEGACY_FIELD_NAME { + // TODO: should this be an error? + return Err(DatabaseError::SchemaError(format!("Field name {} is not {}", version_field.field_name, Self::LEGACY_FIELD_NAME))); + } + let game_versions = match version_field.clone() { + VersionField { value: VersionFieldValue::ArrayEnum(_, values ), ..} => { + values.into_iter().map(|x| Self::from_enum_value(x)).collect() + }, + VersionField { value: VersionFieldValue::Enum(_, value ), ..} => { + vec![Self::from_enum_value(value)] + } + // TODO: should this be an error? + _ => vec![] + }; + Ok(game_versions) + } + + pub fn from_enum_value(loader_field_enum_value : LoaderFieldEnumValue) -> GameVersion { + GameVersion { + id: loader_field_enum_value.id, + version: loader_field_enum_value.value, + created: loader_field_enum_value.created, + type_: loader_field_enum_value.metadata.get("type").and_then(|x| x.as_str()).map(|x| x.to_string()).unwrap_or_default(), + major: loader_field_enum_value.metadata.get("major").and_then(|x| x.as_bool()).unwrap_or_default(), + } + } +} + +impl LoaderField { + + pub async fn get_field<'a, E>( + field : &str, + loader_id: LoaderId, + exec: E, + redis: &RedisPool + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let fields = Self::get_fields( loader_id, exec, &redis).await?; + Ok(fields.into_iter().find(|f| f.field == field)) + } + + // Gets all fields for a given loader + // Returns all as this there are probably relatively few fields per loader + pub async fn get_fields<'a, E>( + loader_id : LoaderId, + exec: E, + redis: &RedisPool + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let cached_fields = redis.get_deserialized_from_json(LOADER_FIELDS_NAMESPACE, &loader_id.0).await?; + if let Some(cached_fields) = cached_fields { + return Ok(cached_fields); + } + + let result = sqlx::query!( + " + SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader + FROM loader_fields lf + INNER JOIN loaders l ON lf.loader_id = l.id + WHERE loader_id = $1 + ", + loader_id.0, + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().and_then( + |r| + Some(LoaderField { + id: LoaderFieldId(r.id), + loader_id: LoaderId(r.loader_id), + field_type: LoaderFieldType::build(&r.field_type, r.enum_type)?, + field: r.field, + loader_name: r.loader, + optional: r.optional, + min_val: r.min_val, + max_val: r.max_val + }))) + }) + .try_collect::>() + .await?; + + redis.set_serialized_to_json(LOADER_FIELDS_NAMESPACE, &loader_id.0, &result, None).await?; + + Ok(result) + } +} + +// TODO: this could maybe return variants? +impl LoaderFieldEnum { + pub async fn get<'a, E>(enum_name : &str, game_name : &str, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let cached_enum = redis.get_deserialized_from_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, format!("{}_{}", game_name, enum_name)).await?; + if let Some(cached_enum) = cached_enum { + return Ok(cached_enum); + } + + let result = sqlx::query!( + " + SELECT lfe.id, lfe.game_id, lfe.enum_name, lfe.ordering, lfe.hidable + FROM loader_field_enums lfe + INNER JOIN games g ON lfe.game_id = g.id + WHERE g.name = $1 AND lfe.enum_name = $2 + ", + game_name, + enum_name + ) + .fetch_optional(exec).await?.map(|l| LoaderFieldEnum { + id: LoaderFieldEnumId(l.id), + game_id: GameId(l.game_id), + enum_name: l.enum_name, + ordering: l.ordering, + hidable: l.hidable, + }); + + redis.set_serialized_to_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, format!("{}_{}", game_name, enum_name), &result, None).await?; + + Ok(result) + } +} + +impl LoaderFieldEnumValue { + + pub async fn list_optional<'a, E>(list_optional : &LoaderFieldType, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where E: sqlx::Executor<'a, Database = sqlx::Postgres> + { + match list_optional { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + LoaderFieldEnumValue::list(*id, exec, redis).await + } + _ => Ok(vec![]) + } + } + + pub async fn list<'a, E>(loader_field_enum_id : LoaderFieldEnumId, exec: E, redis: &RedisPool) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let cached_enum_values = redis.get_deserialized_from_json(LOADER_FIELD_ENUM_VALUES_NAMESPACE, &loader_field_enum_id.0).await?; + if let Some(cached_enum_values) = cached_enum_values { + return Ok(cached_enum_values); + } + + let result = sqlx::query!( + " + SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values + WHERE enum_id = $1 + ", + loader_field_enum_id.0 + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| + LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(c.id), + enum_id: LoaderFieldEnumId(c.enum_id), + value: c.value, + ordering: c.ordering, + created: c.created, + metadata: c.metadata.unwrap_or_default() + } + )) }) + .try_collect::>() + .await?; + + redis.set_serialized_to_json(LOADER_FIELD_ENUM_VALUES_NAMESPACE, &loader_field_enum_id.0, &result, None).await?; + + Ok(result) + } + + // Matches filter against metadata of enum values + pub async fn list_filter<'a, E>( + loader_field_enum_id : LoaderFieldEnumId, + filter : HashMap, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + + let result = Self::list(loader_field_enum_id, exec, redis) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + for (key, value) in filter.iter() { + if let Some(metadata_value) = x.metadata.get(key) { + bool &= metadata_value == value; + } else { + bool = false; + } + } + bool + }) + .collect(); + + Ok(result) + } + +} + impl VersionField { pub async fn insert_many( items: Vec, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { - println!("Inserting version fields!"); - println!("Verion fields: {:?}", items); let mut query_version_fields = vec![]; for item in items { let base = QueryVersionField { @@ -294,7 +630,7 @@ impl VersionField { Ok(()) } - pub fn check_parse(version_id : VersionId, loader_field : LoaderField, key : &str, value : serde_json::Value, enum_variants: Vec) -> Result + pub fn check_parse(version_id : VersionId, loader_field : LoaderField, value : serde_json::Value, enum_variants: Vec) -> Result { let value = VersionFieldValue::parse(&loader_field, value, enum_variants)?; Ok(VersionField { @@ -318,8 +654,6 @@ impl VersionField { min_val: Option, max_val: Option, optional: bool, - - enum_name: Option, } #[derive(Deserialize, Debug)] @@ -341,13 +675,8 @@ impl VersionField { } let query_loader_fields : Vec = loader_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - println!("QLF: {:?}", query_loader_fields); - let query_version_field_combined : Vec = version_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - println!("QVFC: {:?}", query_version_field_combined); - println!("QVFEV PRE: {:?}", serde_json::to_string(&loader_field_enum_values).unwrap()); let query_loader_field_enum_values: Vec = loader_field_enum_values.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - println!("QVFEV: {:?}", query_loader_field_enum_values); let version_id = VersionId(version_id); query_loader_fields.into_iter().filter_map( |q| { let loader_field_type = match LoaderFieldType::build(&q.field_type, q.enum_type) { @@ -364,7 +693,6 @@ impl VersionField { min_val: q.min_val, max_val: q.max_val }; - println!("calculating values for field {} with query {:?}", loader_field.field, query_version_field_combined); let values = query_version_field_combined.iter().filter_map(|qvf| { if qvf.field_id == q.lf_id { let lfev = query_loader_field_enum_values.iter().find(|x| Some(x.id) == qvf.enum_value); @@ -389,7 +717,6 @@ impl VersionField { }).collect::>(); - println!("End of loop for field {} with values {:?}", loader_field.field, values); let v = VersionField::build( loader_field, version_id, @@ -411,25 +738,11 @@ impl VersionField { } } -#[derive(Clone, Serialize, Deserialize, Debug)] -pub enum VersionFieldValue { - Integer(i32), - Text(String), - Enum(LoaderFieldEnumId, LoaderFieldEnumValue), - Boolean(bool), - ArrayInteger(Vec), - ArrayText(Vec), - ArrayEnum(LoaderFieldEnumId, Vec), - ArrayBoolean(Vec), -} impl VersionFieldValue { // TODO: this could be combined with build pub fn parse(loader_field: &LoaderField, value : serde_json::Value, enum_array: Vec) -> Result { - println!("Parsing field {} with type {:?} and value {:?}", loader_field.field, loader_field.field_type, value); - println!("Enum array {:?}", enum_array); - let field_name = &loader_field.field; let field_type = &loader_field.field_type; @@ -544,7 +857,7 @@ impl VersionFieldValue { pub fn serialize_internal(&self) -> serde_json::Value { // Serialize to internal value - let a = match self { + match self { VersionFieldValue::Integer(i) => serde_json::Value::Number((*i).into()), VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), @@ -553,9 +866,7 @@ impl VersionFieldValue { VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array(v.iter().map(|b| serde_json::Value::Bool(*b)).collect()), VersionFieldValue::Enum(_, v) => serde_json::Value::String(v.value.clone()), VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array(v.iter().map(|v| serde_json::Value::String(v.value.clone())).collect()), - }; - println!("Serializing internal: {:?} to {:?}", self, a); - a + } } pub fn as_search_strings(&self) -> Vec { @@ -572,224 +883,6 @@ impl VersionFieldValue { } } - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct QueryVersionField { - pub version_id: VersionId, - pub field_id: LoaderFieldId, - pub int_value: Option, - pub enum_value: Option, - pub string_value: Option, -} - -impl QueryVersionField { - pub fn with_int_value(mut self, int_value: i32) -> Self { - self.int_value = Some(int_value); - self - } - - pub fn with_enum_value(mut self, enum_value: LoaderFieldEnumValue) -> Self { - self.enum_value = Some(enum_value); - self - } - - pub fn with_string_value(mut self, string_value: String) -> Self { - self.string_value = Some(string_value); - self - } -} - - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct SideType { - pub id: SideTypeId, - pub name: String, -} - -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct GameVersion { - pub id: LoaderFieldEnumValueId, - pub version: String, - #[serde(rename = "type")] - pub type_: String, - pub created: DateTime, - pub major: bool, -} - -// game version from loaderfieldenumvalue -// TODO: remove, after moving gameversion to legacy minecraft -impl GameVersion { - fn from(game_version: LoaderFieldEnumValue) -> Result { - // TODO: should not use numbers , should use id with tostring - let version_type = game_version.metadata.get("type").map(|x| x.as_str()).flatten().ok_or_else(|| format!("Could not read GameVersion {}: Missing version type", game_version.id.0)).unwrap_or_default().to_string(); - let major = game_version.metadata.get("major").map(|x| x.as_bool()).flatten().ok_or_else(|| format!("Could not read GameVersion {}: Missing version major", game_version.id.0)).unwrap_or_default(); - - Ok(Self { - id: game_version.id, - version: game_version.value, - type_: version_type, - created: game_version.created, - major, - }) - } -} - -impl LoaderField { - - pub async fn get_field<'a, E>( - field : &str, - loader_id: LoaderId, - exec: E, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let fields = Self::get_fields(field, &[loader_id], exec).await?; - Ok(fields.into_iter().next()) - } - - pub async fn get_fields<'a, E>( - field : &str, - loader_ids : &[LoaderId], - exec: E, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader - FROM loader_fields lf - INNER JOIN loaders l ON lf.loader_id = l.id - WHERE loader_id = ANY($1) AND field = $2 - ", - &loader_ids.into_iter().map(|l|l.0).collect::>(), - field - ) - .fetch_many(exec) - .try_filter_map(|e| async { - Ok(e.right().map(|r| - LoaderField { - id: LoaderFieldId(r.id), - loader_id: LoaderId(r.loader_id), - field_type: LoaderFieldType::build(&r.field_type, r.enum_type).ok_or_else(|| DatabaseError::SchemaError(format!("Could not parse field type {}",r.field_type))).unwrap(), - field: r.field, - loader_name: r.loader, - optional: r.optional, - min_val: r.min_val, - max_val: r.max_val - } - )) }) - .try_collect::>() - .await?; - - Ok(result) - } -} - -impl LoaderFieldEnum { - pub async fn get<'a, E>(enum_name : &str, game_name : &str, exec: E, redis: &RedisPool) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT lfe.id, lfe.game_id, lfe.enum_name, lfe.ordering, lfe.hidable - FROM loader_field_enums lfe - INNER JOIN games g ON lfe.game_id = g.id - WHERE g.name = $1 AND lfe.enum_name = $2 - ", - game_name, - enum_name - ) - .fetch_optional(exec).await?; - - - Ok(result.map(|l| LoaderFieldEnum { - id: LoaderFieldEnumId(l.id), - game_id: GameId(l.game_id), - enum_name: l.enum_name, - ordering: l.ordering, - hidable: l.hidable, - } - )) - } -} - -impl LoaderFieldEnumValue { - - pub async fn list_optional<'a, E>(list_optional : &LoaderFieldType, exec: E, redis: &RedisPool) -> Result, DatabaseError> - where E: sqlx::Executor<'a, Database = sqlx::Postgres> - { - match list_optional { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - LoaderFieldEnumValue::list(*id, exec, redis).await - } - _ => Ok(vec![]) - } - } - - pub async fn list<'a, E>(loader_field_enum_id : LoaderFieldEnumId, exec: E, redis: &RedisPool) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - - let result = sqlx::query!( - " - SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values - WHERE enum_id = $1 - ", - loader_field_enum_id.0 - ) - .fetch_many(exec) - .try_filter_map(|e| async { Ok(e.right().map(|c| - LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(c.id), - enum_id: LoaderFieldEnumId(c.enum_id), - value: c.value, - ordering: c.ordering, - created: c.created, - metadata: c.metadata.unwrap_or_default() - } - )) }) - .try_collect::>() - .await?; - - Ok(result) - } - - // Matches filter against metadata of enum values - pub async fn list_filter<'a, E>( - loader_field_enum_id : LoaderFieldEnumId, - filter : HashMap, - exec: E, - redis: &RedisPool, - ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - - let result = Self::list(loader_field_enum_id, exec, redis) - .await? - .into_iter() - .filter(|x| { - let mut bool = true; - for (key, value) in filter.iter() { - if let Some(metadata_value) = x.metadata.get(key) { - bool &= metadata_value == value; - } else { - bool = false; - } - } - bool - }) - .collect(); - - Ok(result) - } - -} - #[derive(Default)] pub struct GameVersionBuilder<'a> { pub version: Option<&'a str>, @@ -945,5 +1038,4 @@ impl GameVersion { // Ok(result) // } -} - +} \ No newline at end of file diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index d362a059..2a10b5e5 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -544,7 +544,6 @@ impl Project { .flatten() .collect(), ); - println!("Project ids: {:?}", project_ids); if !project_ids.is_empty() { let projects = redis .multi_get::(PROJECTS_NAMESPACE, project_ids) @@ -571,8 +570,6 @@ impl Project { .map(|x| x as i64) .collect(); - println!("Project ids parsed: {:?}", project_ids_parsed); - println!("Remaining strings: {:?}", &remaining_strings.iter().map(|x| x.to_string().to_lowercase()).collect::>()); let db_projects: Vec = sqlx::query!( " SELECT m.id id, m.game_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, @@ -607,7 +604,6 @@ impl Project { .try_filter_map(|e| async { Ok(e.right().map(|m| { let id = m.id; - println!("FOUND SOMETHING!"); QueryProject { inner: Project { id: ProjectId(id), @@ -652,7 +648,6 @@ impl Project { categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), versions: { - println!("Calculating versions..."); #[derive(Deserialize)] struct Version { pub id: VersionId, @@ -666,7 +661,6 @@ impl Project { .unwrap_or_default(); versions.sort_by(|a, b| a.date_published.cmp(&b.date_published)); - println!("No bueno..."); versions.into_iter().map(|x| x.id).collect() }, gallery_items: { diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 40243be1..45c8bcc2 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -539,9 +539,7 @@ impl Version { 'enum_type', lf.enum_type, 'min_val', lf.min_val, 'max_val', lf.max_val, - 'optional', lf.optional, - - 'enum_name', lfe.enum_name + 'optional', lf.optional ) ) loader_fields, JSONB_AGG( diff --git a/src/models/projects.rs b/src/models/projects.rs index 8f42dd52..c9707bc1 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use super::ids::{Base62Id, OrganizationId}; use super::teams::TeamId; use super::users::UserId; -use crate::database::models::loader_fields::VersionField as DBVersionField; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::QueryVersion; use crate::models::threads::ThreadId; @@ -501,9 +500,7 @@ where D: serde::Deserializer<'de>, { let mut map = HashMap::deserialize(deserializer)?; - println!("Loaded hashmap {:?}", map); map.retain(|_, v : &mut serde_json::Value | !v.is_null()); - println!("Loaded hashmap2 {:?}", map); Ok(map) } diff --git a/src/routes/maven.rs b/src/routes/maven.rs index 55d981d7..d92a1cca 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -1,4 +1,4 @@ -use crate::database::models::loader_fields::{Loader, Game}; +use crate::database::models::loader_fields::{Loader, Game, GameVersion}; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::{QueryFile, QueryVersion}; use crate::database::redis::RedisPool; @@ -199,6 +199,15 @@ async fn find_version( bool &= x.loaders.iter().any(|y| loaders.contains(y)); } + // For maven in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive + // TODO: should this also be changed to loader_fields? + if !game_versions.is_empty() { + let version_game_versions = x.version_fields.clone().into_iter().find_map(|v| GameVersion::try_from_version_field(&v).ok()); + if let Some(version_game_versions) = version_game_versions { + bool &= version_game_versions.iter().any(|y| game_versions.contains(&y.version)); + } + } + bool }) .collect::>(); diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 66e03197..517a73ca 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -6,6 +6,7 @@ use sqlx::PgPool; use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized}; use crate::database; +use crate::database::models::loader_fields::GameVersion; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; use crate::models::projects::VersionType; @@ -94,23 +95,34 @@ pub async fn forge_updates( promos: HashMap::new(), }; - // for version in versions { - // if version.version_type == VersionType::Release { - // for game_version in &version.game_versions { - // response - // .promos - // .entry(format!("{}-recommended", game_version.0)) - // .or_insert_with(|| version.version_number.clone()); - // } - // } - - // for game_version in &version.game_versions { - // response - // .promos - // .entry(format!("{}-latest", game_version.0)) - // .or_insert_with(|| version.version_number.clone()); - // } - // } + for version in versions { + // For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive + // TODO: should this also be changed to loader_fields? + // Will have duplicates between game_versions (for non-forge loaders), but that's okay as + // before v3 this was stored to the project and not the version + let game_version_values : Vec = version + .loaders + .iter() + .filter_map(|x| x.fields.get(GameVersion::LEGACY_FIELD_NAME).cloned()).collect(); + let game_versions : Vec = + game_version_values.into_iter().filter_map(|v| serde_json::from_value::>(v).ok()).flatten().collect(); + + if version.version_type == VersionType::Release { + for game_version in &game_versions { + response + .promos + .entry(format!("{}-recommended", game_version)) + .or_insert_with(|| version.version_number.clone()); + } + } + + for game_version in &game_versions { + response + .promos + .entry(format!("{}-latest", game_version)) + .or_insert_with(|| version.version_number.clone()); + } + } Ok(HttpResponse::Ok().json(response)) } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index d38f0111..5b25de7b 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -28,8 +28,6 @@ pub async fn project_create( // Convert V2 multipart payload to V3 multipart payload let mut saved_slug = None; let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { - // Convert input data to V3 format - println!("ABOUT TO ALTER ACTIX MULTIPART {}", json.to_string()); // Save slug for out of closure saved_slug = Some(json["slug"].as_str().unwrap_or("").to_string()); @@ -68,10 +66,8 @@ pub async fn project_create( version["loaders"] = json!(loaders); } } - println!("JUST ALTER ACTIX MULTIPART {}", json.to_string()); - println!("Done;"); - }).await; + }).await?; // Call V3 project creation let response= v3::project_creation::project_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index af7655ee..59149cfa 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -65,10 +65,6 @@ pub async fn project_search( config: web::Data, ) -> Result { // TODO: redirect to v3 - println!("info: {:?}", serde_json::to_string(&info)); - - - // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields // Loader fields are: // (loader)_(field):(value) @@ -87,7 +83,6 @@ pub async fn project_search( let filterable_fields = meilisearch_index.get_filterable_attributes().await?; // Only keep v2 loaders that are filterable v2_loaders = v2_loaders.into_iter().filter(|x| filterable_fields.iter().any(|f| f.starts_with(&format!("{}_game_versions", x)))).collect(); - println!("Post-analysis v2_loaders: {:?}", v2_loaders); } Some(facets.into_iter().map(|facet| { @@ -98,9 +93,8 @@ pub async fn project_search( Some(version) => version, None => return vec![facet.to_string()], }; - println!("Analyzing facet: {:?}", facet); - let f = if facet.starts_with("versions:") { + if facet.starts_with("versions:") { v2_loaders .iter() .map(|loader| format!("{}_game_versions:{}", loader, version)) @@ -117,9 +111,7 @@ pub async fn project_search( .collect::>() } else { vec![facet.to_string()] - }; - println!("Post-analysis facet: {:?}", f); - f + } }) .flatten() .collect::>() @@ -127,8 +119,6 @@ pub async fn project_search( } else { None }; - - println!("Post-analysis facets: {:?}", facets); let info = SearchRequest { facets : facets.and_then(|x| serde_json::to_string(&x).ok()), @@ -444,14 +434,10 @@ pub async fn project_edit( session_queue: web::Data, ) -> Result { // TODO: Should call v3 route - println!("\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-Starting New Project Edit Call"); - println!("New Project: {:?}", serde_json::to_string(&new_project)?); let v2_new_project = new_project.into_inner(); let client_side = v2_new_project.client_side.clone(); let server_side = v2_new_project.server_side.clone(); let new_slug = v2_new_project.slug.clone(); - println!("Client Side: {:?}", client_side); - println!("Server Side: {:?}", server_side); let new_project = v3::projects::EditProject { title: v2_new_project.title, description: v2_new_project.description, @@ -484,15 +470,10 @@ pub async fn project_edit( // If client and server side were set, we will call // the version setting route for each version to set the side types for each of them. if response.status().is_success() { - println!("\nWas successful!"); - println!("Project ID: {:?}", project_id); if client_side.is_some() || server_side.is_some() { let project_item = project_item::Project::get(&new_slug.unwrap_or(project_id), &**pool, &redis).await?; - println!("a successful: {:?}", serde_json::to_string(&project_item)?); let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); - println!("as successful: {:?}", version_ids); let versions = version_item::Version::get_many(&version_ids, &**pool, &redis).await?; - println!("Versions: {:?}", serde_json::to_string(&versions)?); for version in versions { let loaders : Result, _> = version.loaders.into_iter().map(|l| serde_json::from_value(json!({ @@ -501,10 +482,6 @@ pub async fn project_edit( "server_side": server_side, }))).collect(); - println!("SUBMITTING JSON\n\n\n\n\n: {:?}", json!({ - "client_side": client_side, - "server_side": server_side, - })); response = v3::versions::version_edit_helper(req.clone(), (version.inner.id.into(),), pool.clone(), redis.clone(), v3::versions::EditVersion { loaders: Some(loaders?), ..Default::default() diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 4f600de0..ed1a2754 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -101,7 +101,7 @@ pub async fn version_create( json["loaders"] = json!(loaders); - }).await; + }).await?; // Call V3 project creation let response= v3::version_creation::version_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; @@ -129,7 +129,6 @@ pub async fn version_create( json["game_versions"] = json!(game_versions); json["loaders"] = json!(loaders); - println!("Completed version creation: {:?}", json); Ok(HttpResponse::Ok().json(json)) }, Err(response) => Ok(response) diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index e03072cf..b10b50a1 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -4,7 +4,6 @@ use crate::auth::{ is_authorized_version, }; use crate::database::redis::RedisPool; -use crate::models::ids::VersionId; use crate::models::pats::Scopes; use crate::models::projects::VersionType; use crate::models::teams::ProjectPermissions; @@ -419,8 +418,30 @@ pub async fn update_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - // TODO: should call v3 - Ok(HttpResponse::Ok().json("")) + // TODO: write tests for this, it didnt get caught by cargo test + let update_data = update_data.into_inner(); + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in update_data.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + let update_data = v3::version_file::ManyUpdateData { + loaders: update_data.loaders.clone(), + version_types: update_data.version_types.clone(), + loader_fields: Some(loader_fields), + algorithm: update_data.algorithm, + hashes: update_data.hashes, + }; + + let response = v3::version_file::update_files( + req, + pool, + redis, + web::Json(update_data), + session_queue, + ).await?; + Ok(response) } #[derive(Deserialize)] @@ -446,5 +467,32 @@ pub async fn update_individual_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - Ok(HttpResponse::Ok().json("")) + // TODO: write tests for this, it didnt get caught by cargo test + let update_data = update_data.into_inner(); + let update_data = v3::version_file::ManyFileUpdateData { + algorithm: update_data.algorithm, + hashes: update_data.hashes.into_iter().map(|x| { + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in x.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + v3::version_file::FileUpdateData { + hash: x.hash.clone(), + loaders: x.loaders.clone(), + loader_fields: Some(loader_fields), + version_types: x.version_types.clone(), + }}).collect(), + }; + + let response = v3::version_file::update_individual_files( + req, + pool, + redis, + web::Json(update_data), + session_queue, + ).await?; + + Ok(response) } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 44901f88..eae65dc6 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -5,7 +5,6 @@ use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; -use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; @@ -17,8 +16,6 @@ use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::v3; -use crate::util::img; -use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -59,9 +56,24 @@ pub async fn version_list( session_queue: web::Data, ) -> Result { // TODO: move route to v3 + // TODO: write tests for this, it didnt get caught by cargo test + let loader_fields = if let Some(game_versions) = filters.game_versions { + // TODO: extract this logic which is similar to the other v2->v3 version_file functions + let mut loader_fields = HashMap::new(); + serde_json::from_str::>(&game_versions).ok().and_then(|versions| { + let mut game_versions: Vec = vec![]; + for gv in versions { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + serde_json::to_string(&loader_fields).ok() + }) + } else { + None + }; let filters = v3::versions::VersionListFilters { - game_versions: filters.game_versions, + loader_fields, loaders: filters.loaders, featured: filters.featured, version_type: filters.version_type, @@ -265,10 +277,7 @@ pub async fn version_edit( let response = v3::versions::version_edit(req, info, pool, redis, web::Json(serde_json::to_value(new_version)?), session_queue).await?; - println!("Interecepting patch: {:?}", response); - // TODO: Convert response to V2 format - - + // TODO: Convert response to V2 format Ok(response) } diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 9bed33ba..6493889d 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -1,16 +1,10 @@ -use std::borrow::BorrowMut; use actix_multipart::Multipart; -use actix_web::{test::TestRequest, HttpResponse}; -use bytes::{Bytes, BytesMut}; -use actix_web::http::header::{TryIntoHeaderPair,HeaderMap, HeaderName}; -use futures::{StreamExt, stream, Future}; +use actix_web::HttpResponse; +use actix_web::http::header::{TryIntoHeaderPair,HeaderMap}; +use futures::{StreamExt, stream}; use serde_json::{Value, json}; -use actix_web::test; - use crate::{database::{models::{version_item, DatabaseError}, redis::RedisPool}, models::ids::VersionId, util::actix::{MultipartSegment, MultipartSegmentData, generate_multipart}}; - -use super::ApiError; - +use super::v3::project_creation::CreateError; pub async fn set_side_types_from_versions<'a, E>(json : &mut serde_json::Value, exec: E, redis: &RedisPool) -> Result<(), DatabaseError> where E : sqlx::Executor<'a, Database = sqlx::Postgres> @@ -21,9 +15,7 @@ where E : sqlx::Executor<'a, Database = sqlx::Postgres> if let Some(version_id) = version_id { let version_id = serde_json::from_value::(version_id.clone())?; let versions_item = version_item::Version::get(version_id.into(), exec, &redis).await?; - println!("Got versions item: {:?}", serde_json::to_string(&versions_item)); if let Some(versions_item) = versions_item { - println!("Got versions item: {:?}", serde_json::to_string(&versions_item)); json["client_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "client_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("required")); json["server_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "server_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("server_side")); } @@ -33,41 +25,43 @@ where E : sqlx::Executor<'a, Database = sqlx::Postgres> // TODO: this is not an ideal way to do this, but it works for now -pub async fn extract_ok_json(mut response : HttpResponse) -> Result { +pub async fn extract_ok_json(response : HttpResponse) -> Result { if response.status() == actix_web::http::StatusCode::OK { + let failure_http_response = || HttpResponse::InternalServerError().json(json!({ + "error": "reroute_error", + "description": "Could not parse response from V2 redirection of route." + })); // Takes json out of HttpResponse, mutates it, then regenerates the HttpResponse - // actix client let body = response.into_body(); - let bytes = actix_web::body::to_bytes(body).await.unwrap(); - let mut json_value: Value = serde_json::from_slice(&bytes).unwrap(); + let bytes = actix_web::body::to_bytes(body).await.map_err(|_| failure_http_response())?; + let json_value: Value = serde_json::from_slice(&bytes).map_err(|_| failure_http_response())?; Ok(json_value) } else { Err(response) } } -pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, mut closure: impl FnMut(&mut serde_json::Value)) -> Multipart { +pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, mut closure: impl FnMut(&mut serde_json::Value)) -> Result { let mut segments: Vec = Vec::new(); - if let Some(mut field) = multipart.next().await { - let mut field = field.unwrap(); + if let Some(field) = multipart.next().await { + let mut field = field?; let content_disposition = field.content_disposition().clone(); // This unwrap is okay because we expect every field to have content disposition - let field_name = content_disposition.get_name().unwrap_or(""); // replace unwrap_or as you see fit + let field_name = content_disposition.get_name().unwrap_or(""); let field_filename = content_disposition.get_filename(); let field_content_type = field.content_type(); let field_content_type = field_content_type.map(|ct| ct.to_string()); let mut buffer = Vec::new(); while let Some(chunk) = field.next().await { - // let data = chunk.map_err(|e| ApiError::from(e))?; - let data = chunk.unwrap();//.map_err(|e| ApiError::from(e))?; + let data = chunk?; buffer.extend_from_slice(&data); } { - let mut json_value: Value = serde_json::from_slice(&buffer).unwrap(); + let mut json_value: Value = serde_json::from_slice(&buffer)?; closure(&mut json_value); - buffer = serde_json::to_vec(&json_value).unwrap(); + buffer = serde_json::to_vec(&json_value)?; } segments.push(MultipartSegment { name: field_name.to_string(), @@ -78,8 +72,8 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header } - while let Some(mut field) = multipart.next().await { - let mut field = field.unwrap(); + while let Some(field) = multipart.next().await { + let mut field = field?; let content_disposition = field.content_disposition().clone(); // This unwrap is okay because we expect every field to have content disposition let field_name = content_disposition.get_name().unwrap_or(""); // replace unwrap_or as you see fit let field_filename = content_disposition.get_filename(); @@ -88,8 +82,7 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header let mut buffer = Vec::new(); while let Some(chunk) = field.next().await { - // let data = chunk.map_err(|e| ApiError::from(e))?; - let data = chunk.unwrap();//.map_err(|e| ApiError::from(e))?; + let data = chunk?; buffer.extend_from_slice(&data); } @@ -114,7 +107,7 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header let new_multipart = Multipart::new(&headers, stream::once(async { Ok(payload) })); - new_multipart + Ok(new_multipart) } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 45a44288..ce7e0bbc 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -1,8 +1,8 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; -use crate::database::models::loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue, VersionField, LoaderField}; +use crate::database::models::{self, image_item, User}; +use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField, LoaderField, Game}; use crate::database::models::thread_item::ThreadBuilder; -use crate::database::models::{self, image_item, User, DatabaseError}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; @@ -10,7 +10,7 @@ use crate::models::ids::ImageId; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, + DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, VersionId, VersionStatus, }; use crate::models::teams::ProjectPermissions; @@ -23,7 +23,7 @@ use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::{Data, self}; -use actix_web::{post, HttpRequest, HttpResponse}; +use actix_web::{HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; use image::ImageError; @@ -291,7 +291,6 @@ pub async fn project_create( file_host: Data>, session_queue: Data, ) -> Result { - println!("Received project create!"); let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); @@ -378,8 +377,9 @@ async fn project_create_inner( let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); - let project_create_data; + let project_create_data: ProjectCreateData; let game_id; + let game; let mut versions; let mut versions_map = std::collections::HashMap::new(); let mut gallery_urls = Vec::new(); @@ -407,7 +407,6 @@ async fn project_create_inner( "`data` field must come before file fields", ))); } - println!("in5"); let mut data = Vec::new(); while let Some(chunk) = field.next().await { @@ -419,8 +418,6 @@ async fn project_create_inner( .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - println!("CREATING PROJECT: {}", serde_json::to_string_pretty(&create_data).unwrap()); - let slug_project_id_option: Option = serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); @@ -440,7 +437,6 @@ async fn project_create_inner( return Err(CreateError::SlugCollision); } } - println!("in6"); { let results = sqlx::query!( @@ -457,7 +453,6 @@ async fn project_create_inner( return Err(CreateError::SlugCollision); } } - println!("in7"); // Check game exists, and get loaders for it let game_name = &create_data.game_name; @@ -465,6 +460,7 @@ async fn project_create_inner( &create_data.game_name, &mut *transaction, ).await?.ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; + game = Game::from_name(&create_data.game_name).ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; let all_loaders = models::loader_fields::Loader::list(&game_name, &mut *transaction, redis).await?; // Create VersionBuilders for the versions specified in `initial_versions` @@ -495,7 +491,6 @@ async fn project_create_inner( project_create_data = create_data; } - println!("in8"); let project_type_id = models::categories::ProjectType::get_id( project_create_data.project_type.as_str(), @@ -510,7 +505,6 @@ async fn project_create_inner( })?; let mut icon_data = None; - println!("in9"); let mut error = None; while let Some(item) = payload.next().await { @@ -609,14 +603,17 @@ async fn project_create_inner( &mut created_version.dependencies, &cdn_url, &content_disposition, + game, project_id, created_version.version_id.into(), + &created_version.version_fields, &project_create_data.project_type, version_data.loaders.clone().into_iter().map(|l|l.loader).collect(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), None, transaction, + redis, ) .await?; @@ -632,7 +629,6 @@ async fn project_create_inner( if let Some(error) = error { return Err(error); } - println!("in10"); { // Check to make sure that all specified files were uploaded @@ -687,7 +683,6 @@ async fn project_create_inner( ordering: 0, }], }; - println!("in11"); let team_id = team.insert(&mut *transaction).await?; @@ -709,7 +704,6 @@ async fn project_create_inner( })?; let mut donation_urls = vec![]; - println!("in12"); if let Some(urls) = &project_create_data.donation_urls { for url in urls { @@ -731,7 +725,6 @@ async fn project_create_inner( }) } } - println!("in13"); let project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), @@ -902,19 +895,12 @@ async fn create_initial_version( // .map(|y| y.id) // }) // .collect::, CreateError>>()?; - - println!("\n\n\n\n\n\n\n\n\n\n\n----\n\n\n"); - println!("This one!!!!!"); - println!("Loaders: {:?}", serde_json::to_string(&version_data.loaders).unwrap()); - println!("All loaders: {:?}", serde_json::to_string(&all_loaders).unwrap()); - println!("Supported project types: {:?}", all_loaders.iter().map(|x| x.supported_project_types.clone()).collect::>()); let mut loader_ids = vec![]; let mut loaders = vec![]; let mut version_fields = vec![]; for loader_create in version_data.loaders.iter() { let loader_name = loader_create.loader.0.clone(); - println!("ADding loader: {}", loader_name); // Confirm loader from list of loaders let loader_id = all_loaders .iter() @@ -927,35 +913,17 @@ async fn create_initial_version( loader_ids.push(loader_id); loaders.push(loader_create.loader.clone()); - println!("Loader_create fields: {:?}",loader_create.fields); for (key, value) in loader_create.fields .iter() { - println!("ADding loader field: {} {}", key, value); // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) })?; let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; version_fields.push(vf); } } - // let loaders = version_data - // .loaders - // .iter() - // .map(|x| { - // all_loaders - // .iter() - // .find(|y| { - // y.loader == x.0 - // && y.supported_project_types - // .contains(&project_type.to_string()) - // }) - // .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - // .map(|y| y.id) - // }) - // .collect::, CreateError>>()?; - println!("past..."); let dependencies = version_data .dependencies .iter() diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index d41255ad..7fe47797 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -1,18 +1,15 @@ use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; -use crate::database::{self}; -use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::project_item::{GalleryItem, ModCategory}; +use crate::database::models::project_item::ModCategory; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, LoaderStruct, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; @@ -20,16 +17,12 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::img; -use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::{DateTime, Utc}; +use actix_web::{get, web, HttpRequest, HttpResponse}; use futures::TryStreamExt; use meilisearch_sdk::indexes::IndexesResults; use serde::{Deserialize, Serialize}; -use serde_json::json; use sqlx::PgPool; -use std::sync::Arc; use validator::Validate; use crate::database::models as db_models; use crate::database::models::ids as db_ids; @@ -215,7 +208,6 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - println!("project_edit"); let user = get_user_from_headers( &req, &**pool, @@ -225,7 +217,6 @@ pub async fn project_edit( ) .await? .1; -println!("user: {:?}", user); new_project .validate() @@ -233,11 +224,10 @@ println!("user: {:?}", user); let string = info.into_inner().0; let result = db_models::Project::get(&string, &**pool, &redis).await?; - println!("result: {:?}", result); + if let Some(project_item) = result { let id = project_item.inner.id; - println!("id: {:?}", id); let (team_member, organization_team_member) = db_models::TeamMember::get_for_project_permissions( &project_item.inner, @@ -245,13 +235,12 @@ println!("user: {:?}", user); &**pool, ) .await?; - println!("team_member: {:?}", team_member); + let permissions = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ); - println!("permissions: {:?}", permissions); if let Some(perms) = permissions { let mut transaction = pool.begin().await?; @@ -299,7 +288,6 @@ println!("user: {:?}", user); } if let Some(status) = &new_project.status { - println!("Status: {:?}", status); if !perms.contains(ProjectPermissions::EDIT_DETAILS) { return Err(ApiError::CustomAuthentication( "You do not have the permissions to edit the status of this project!" @@ -307,8 +295,6 @@ println!("user: {:?}", user); )); } - println!("Got thru"); - if !(user.role.is_mod() || !project_item.inner.status.is_approved() && status == &ProjectStatus::Processing @@ -319,9 +305,7 @@ println!("user: {:?}", user); )); } - println!("Got thru 2"); if status == &ProjectStatus::Processing { - println!("Got thru 3"); if project_item.versions.is_empty() { return Err(ApiError::InvalidInput(String::from( "Project submitted for review with no initial versions", @@ -338,7 +322,7 @@ println!("user: {:?}", user); ) .execute(&mut *transaction) .await?; - println!("Got thru 4"); + sqlx::query!( " UPDATE threads @@ -350,9 +334,8 @@ println!("user: {:?}", user); .execute(&mut *transaction) .await?; } - println!("Got thru 5"); + if status.is_approved() && !project_item.inner.status.is_approved() { - println!("Got thru 6"); sqlx::query!( " UPDATE mods @@ -364,11 +347,8 @@ println!("user: {:?}", user); .execute(&mut *transaction) .await?; } - println!("Got thru 7"); if status.is_searchable() && !project_item.inner.webhook_sent { - println!("Got thru 8"); if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { - println!("Got thru 9"); crate::util::webhook::send_discord_webhook( project_item.inner.id.into(), &pool, @@ -391,13 +371,9 @@ println!("user: {:?}", user); .await?; } } - println!("Got thru 10"); if user.role.is_mod() { - println!("Got thru 11"); - if let Ok(webhook_url) = dotenvy::var("MODERATION_DISCORD_WEBHOOK") { - println!("Got thru 12"); crate::util::webhook::send_discord_webhook( project_item.inner.id.into(), &pool, @@ -419,7 +395,6 @@ println!("user: {:?}", user); .ok(); } } - println!("Got thru 13"); if team_member.map(|x| !x.accepted).unwrap_or(true) { let notified_members = sqlx::query!( @@ -445,7 +420,6 @@ println!("user: {:?}", user); .insert_many(notified_members, &mut transaction, &redis) .await?; } - println!("Got thru 14"); ThreadMessageBuilder { author_id: Some(user.id.into()), @@ -457,7 +431,6 @@ println!("user: {:?}", user); } .insert(&mut transaction) .await?; - println!("Got thru 15"); sqlx::query!( " diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 2843d8ec..e4d6c917 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models; use crate::database::models::loader_fields::{Loader, LoaderFieldEnumValue, LoaderFieldEnum}; use crate::database::redis::RedisPool; use actix_web::{web, HttpResponse}; diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 0eb639d3..c9e4e1ae 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,11 +1,11 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue, LoaderFieldEnum, VersionField, LoaderField}; +use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField, LoaderField, Game}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, }; -use crate::database::models::{self, image_item, Organization, DatabaseError}; +use crate::database::models::{self, image_item, Organization}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::images::{Image, ImageContext, ImageId}; @@ -13,17 +13,17 @@ use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, DependencyType, FileType, GameVersion, Loader, ProjectId, Version, VersionFile, + Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus, VersionType, LoaderStruct, }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; -use crate::validate::{validate_file, ValidationResult}; +use crate::validate::{ValidationResult, validate_file}; use actix_multipart::{Field, Multipart}; use actix_web::web::Data; -use actix_web::{post, web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; use serde::{Deserialize, Serialize}; @@ -138,6 +138,7 @@ async fn version_create_inner( let mut initial_version_data = None; let mut version_builder = None; + let mut game = None; let user = get_user_from_headers( &req, @@ -249,11 +250,8 @@ async fn version_create_inner( .name; let game_id = project.inner.game_id; - let all_loaders = models::loader_fields::Loader::list_id(game_id,&mut *transaction, redis).await?; - - println!("Loaders: {:?}", serde_json::to_string(&version_create_data.loaders).unwrap()); - println!("All loaders: {:?}", serde_json::to_string(&all_loaders).unwrap()); - println!("Supported project types: {:?}", all_loaders.iter().map(|x| x.supported_project_types.clone()).collect::>()); + game = Game::from_id(game_id, &mut *transaction).await?; + let all_loaders = models::loader_fields::Loader::list_id(game_id,&mut *transaction).await?; let mut loader_ids = vec![]; let mut loaders = vec![]; @@ -275,16 +273,15 @@ async fn version_create_inner( for (key, value) in loader_create.fields .iter() { // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) })?; let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; version_fields.push(vf); } } - println!("Got past this part"); let dependencies = version_create_data .dependencies .iter() @@ -319,6 +316,9 @@ async fn version_create_inner( let version = version_builder.as_mut().ok_or_else(|| { CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; + let game = game.ok_or_else(|| { + CreateError::InvalidInput(String::from("`data` field must come before file fields")) + })?; let project_type = sqlx::query!( " @@ -345,14 +345,17 @@ async fn version_create_inner( &mut version.dependencies, &cdn_url, &content_disposition, + game, version.project_id.into(), version.version_id.into(), + &version.version_fields, &project_type, version_data.loaders.into_iter().map(|l|l.loader).collect(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), version_data.file_types.get(name).copied().flatten(), transaction, + redis, ) .await?; @@ -574,6 +577,9 @@ async fn upload_file_to_version_inner( } }; + let project = models::Project::get_id(version.inner.project_id, &mut *transaction, &redis).await? + .ok_or_else(|| CreateError::InvalidInput("Version contained an invalid project id".to_string()))?; + if !user.role.is_admin() { let team_member = models::TeamMember::get_from_user_id_project( version.inner.project_id, @@ -627,10 +633,6 @@ async fn upload_file_to_version_inner( .await? .name; - let game_name = Game::MinecraftJava.name(); - let game_version_enum = LoaderFieldEnum::get("game_versions", game_name, &mut *transaction, &redis).await?.ok_or_else(|| DatabaseError::SchemaError("Could not find game version enum".to_string()))?; - let all_game_versions = LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, &redis).await?; - let mut error = None; while let Some(item) = payload.next().await { let mut field: Field = item?; @@ -671,6 +673,8 @@ async fn upload_file_to_version_inner( }) .collect(); + let game = Game::from_id(project.inner.game_id, &mut *transaction).await?.ok_or_else(|| CreateError::InvalidInput("Version contained an invalid game id".to_string()))?; + upload_file( &mut field, file_host, @@ -680,14 +684,17 @@ async fn upload_file_to_version_inner( &mut dependencies, &cdn_url, &content_disposition, + game, project_id, version_id.into(), + &version.version_fields, &project_type, version.loaders.clone().into_iter().map(Loader).collect(), true, false, file_data.file_types.get(name).copied().flatten(), transaction, + &redis, ) .await?; @@ -730,14 +737,17 @@ pub async fn upload_file( dependencies: &mut Vec, cdn_url: &str, content_disposition: &actix_web::http::header::ContentDisposition, + game : Game, project_id: ProjectId, version_id: VersionId, + version_fields: &Vec, project_type: &str, loaders: Vec, ignore_primary: bool, force_primary: bool, file_type: Option, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool ) -> Result<(), CreateError> { let (file_name, file_extension) = get_name_ext(content_disposition)?; @@ -777,13 +787,17 @@ pub async fn upload_file( "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), )); } - + let validation_result = validate_file( + game, data.clone().into(), file_extension.to_string(), project_type.to_string(), loaders.clone(), file_type, + version_fields.clone(), + &mut *transaction, + &redis, ) .await?; diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 0577b757..3acc29ab 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -1,16 +1,15 @@ use super::ApiError; use crate::auth::{ - filter_authorized_projects, filter_authorized_versions, get_user_from_headers, + get_user_from_headers, is_authorized_version, }; use crate::database::redis::RedisPool; use crate::models::ids::VersionId; use crate::models::pats::Scopes; use crate::models::projects::VersionType; -use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::{database, models}; -use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest, HttpResponse}; use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -104,7 +103,7 @@ pub async fn get_update_from_hash( if let Some(loaders) = &update_data.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - + if let Some(loader_fields) = &update_data.loader_fields { for (key, value) in loader_fields { bool &= x.version_fields.iter().any(|y| { @@ -136,7 +135,7 @@ pub struct ManyUpdateData { pub algorithm: String, pub hashes: Vec, pub loaders: Option>, - pub game_versions: Option>, + pub loader_fields: Option>>, pub version_types: Option>, } pub async fn update_files( @@ -199,9 +198,13 @@ pub async fn update_files( if let Some(loaders) = &update_data.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - // if let Some(game_versions) = &update_data.game_versions { - // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - // } + if let Some(loader_fields) = &update_data.loader_fields { + for (key, value) in loader_fields { + bool &= x.version_fields.iter().any(|y| { + y.field_name == *key && value.contains(&y.value.serialize_internal()) + }); + } + } bool }) @@ -228,7 +231,7 @@ pub async fn update_files( pub struct FileUpdateData { pub hash: String, pub loaders: Option>, - pub game_versions: Option>, + pub loader_fields: Option>>, pub version_types: Option>, } @@ -305,7 +308,15 @@ pub async fn update_individual_files( if let Some(loaders) = &query_file.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - + if let Some(loader_fields) = &query_file.loader_fields { + for (key, value) in loader_fields { + bool &= x.version_fields.iter().any(|y| { + y.field_name == *key && value.contains(&y.value.serialize_internal()) + }); + } + } + + bool }) .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index bfc7d4c5..997bf904 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -1,15 +1,16 @@ +use std::collections::HashMap; + use super::ApiError; use crate::auth::{ - filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, + filter_authorized_versions, get_user_from_headers, is_authorized, }; use crate::database; use crate::database::models::loader_fields::{LoaderField, VersionField, LoaderFieldEnumValue}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; -use crate::database::models::{image_item, Organization}; +use crate::database::models::Organization; use crate::database::redis::RedisPool; use crate::models; use crate::models::ids::VersionId; -use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, LoaderStruct}; @@ -17,8 +18,7 @@ use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::img; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::{DateTime, Utc}; +use actix_web::{web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; @@ -79,11 +79,7 @@ pub async fn version_edit( new_version: web::Json, session_queue: web::Data, ) -> Result { - println!("HERE: {:?}", new_version); - let new_version : EditVersion = serde_json::from_value(new_version.into_inner())?; - println!("HERE: {:?}", new_version); - version_edit_helper(req, info.into_inner(), pool, redis, new_version, session_queue).await } pub async fn version_edit_helper( @@ -95,9 +91,6 @@ pub async fn version_edit_helper( session_queue: web::Data, ) -> Result { - - println!("in version edit"); - println!("VVV - new_version: {:?}", new_version); let user = get_user_from_headers( &req, &**pool, @@ -108,7 +101,6 @@ pub async fn version_edit_helper( .await? .1; - new_version .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; @@ -119,7 +111,6 @@ pub async fn version_edit_helper( let result = database::models::Version::get(id, &**pool, &redis).await?; if let Some(version_item) = result { - println!("VVV - VERSOIN: {:?}", serde_json::to_string(&version_item)); let project_item = database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) @@ -306,14 +297,13 @@ pub async fn version_edit_helper( ) })?; loader_versions.push(LoaderVersion::new(loader_id, id)); - println!("VVV - Loader fields: {:?}", loader.fields); for (key, value) in loader.fields .iter() { // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction).await?.ok_or_else(|| { + let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) })?; let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, &key, value.clone(), enum_variants).map_err(|s| ApiError::InvalidInput(s))?; + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| ApiError::InvalidInput(s))?; version_fields.push(vf); } } @@ -518,14 +508,21 @@ pub async fn version_edit_helper( #[derive(Deserialize)] pub struct VersionListFilters { - pub game_versions: Option, pub loaders: Option, pub featured: Option, pub version_type: Option, pub limit: Option, pub offset: Option, + /* + Loader fields to filter with: + "game_versions": ["1.16.5", "1.17"] + + Returns if it matches any of the values + */ + pub loader_fields: Option, } + pub async fn version_list( req: HttpRequest, info: web::Path<(String,)>, @@ -554,10 +551,10 @@ pub async fn version_list( return Ok(HttpResponse::NotFound().body("")); } - let version_filters = filters - .game_versions + let loader_field_filters = filters + .loader_fields .as_ref() - .map(|x| serde_json::from_str::>(x).unwrap_or_default()); + .map(|x| serde_json::from_str::>>(x).unwrap_or_default()); let loader_filters = filters .loaders .as_ref() @@ -570,15 +567,21 @@ pub async fn version_list( .filter(|x| { let mut bool = true; + // TODO: theres a lot of repeated logic here with the similar filterings in super::version_file if let Some(version_type) = filters.version_type { bool &= &*x.inner.version_type == version_type.as_str(); } if let Some(loaders) = &loader_filters { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - // if let Some(game_versions) = &version_filters { - // bool &= x.game_versions.iter().any(|y| game_versions.contains(y)); - // } + + if let Some(loader_fields) = &loader_field_filters { + for (key, value) in loader_fields { + bool &= x.version_fields.iter().any(|y| { + y.field_name == *key && value.contains(&y.value.serialize_internal()) + }); + } + } bool }) diff --git a/src/scheduler.rs b/src/scheduler.rs index 4d2dd026..d4059786 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -145,45 +145,45 @@ async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) ]; } - // for version in input.versions.into_iter() { - // let mut name = version.id; - // if !name - // .chars() - // .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) - // { - // if let Some((_, alternate)) = HALL_OF_SHAME.iter().find(|(version, _)| name == *version) - // { - // name = String::from(*alternate); - // } else { - // // We'll deal with these manually - // skipped_versions_count += 1; - // continue; - // } - // } - - // let type_ = match &*version.type_ { - // "release" => "release", - // "snapshot" => "snapshot", - // "old_alpha" => "alpha", - // "old_beta" => "beta", - // _ => "other", - // }; - - // crate::database::models::loader_fields::GameVersion::builder() - // .version(&name)? - // .version_type(type_)? - // .created( - // if let Some((_, alternate)) = - // HALL_OF_SHAME_2.iter().find(|(version, _)| name == *version) - // { - // alternate - // } else { - // &version.release_time - // }, - // ) - // .insert(pool, redis) - // .await?; - // } + for version in input.versions.into_iter() { + let mut name = version.id; + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + { + if let Some((_, alternate)) = HALL_OF_SHAME.iter().find(|(version, _)| name == *version) + { + name = String::from(*alternate); + } else { + // We'll deal with these manually + skipped_versions_count += 1; + continue; + } + } + + let type_ = match &*version.type_ { + "release" => "release", + "snapshot" => "snapshot", + "old_alpha" => "alpha", + "old_beta" => "beta", + _ => "other", + }; + + crate::database::models::loader_fields::GameVersion::builder() + .version(&name)? + .version_type(type_)? + .created( + if let Some((_, alternate)) = + HALL_OF_SHAME_2.iter().find(|(version, _)| name == *version) + { + alternate + } else { + &version.release_time + }, + ) + .insert(pool, &redis) + .await?; + } if skipped_versions_count > 0 { // This will currently always trigger due to 1.14 pre releases @@ -197,4 +197,4 @@ async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) } Ok(()) -} +} \ No newline at end of file diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index d7723933..36be7025 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -1,14 +1,13 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use dashmap::DashSet; use futures::TryStreamExt; use log::info; -use serde::Deserialize; use super::IndexingError; use crate::database::models::ProjectId; -use crate::database::models::loader_fields::{VersionFieldValue, LoaderFieldType, VersionField}; +use crate::database::models::loader_fields::VersionField; use crate::search::UploadSearchProject; use sqlx::postgres::PgPool; @@ -45,9 +44,7 @@ pub async fn index_local(pool: PgPool) -> Result<(Vec, Vec< 'enum_type', lf.enum_type, 'min_val', lf.min_val, 'max_val', lf.max_val, - 'optional', lf.optional, - - 'enum_name', lfe.enum_name + 'optional', lf.optional ) ) loader_fields, JSONB_AGG( @@ -97,13 +94,13 @@ pub async fn index_local(pool: PgPool) -> Result<(Vec, Vec< categories.append(&mut additional_categories); let version_fields = VersionField::from_query_json(m.id, m.loader_fields, m.version_fields, m.loader_field_enum_values); - println!("Got version fields: {:?}", version_fields); + let loader_fields : HashMap> = version_fields.into_iter().map(|vf| { let key = format!("{}_{}", vf.loader_name, vf.field_name); let value = vf.value.as_search_strings(); (key, value) }).collect(); - println!("Got loader fields: {:?}", loader_fields); + for v in loader_fields.keys().cloned() { loader_field_keys.insert(v); } diff --git a/src/search/indexing/mod.rs b/src/search/indexing/mod.rs index df075d99..93002f62 100644 --- a/src/search/indexing/mod.rs +++ b/src/search/indexing/mod.rs @@ -2,7 +2,6 @@ pub mod local_import; use crate::search::{SearchConfig, UploadSearchProject}; -use dashmap::DashSet; use local_import::index_local; use meilisearch_sdk::client::Client; use meilisearch_sdk::indexes::Index; @@ -39,7 +38,6 @@ pub async fn index_projects(pool: PgPool, config: &SearchConfig) -> Result<(), I docs_to_add.append(&mut uploads); additional_fields.append(&mut loader_fields); - println!("Additional fields: {:?}", additional_fields); // Write Indices add_projects(docs_to_add, additional_fields, config).await?; @@ -51,7 +49,7 @@ async fn create_index( name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result { - println!("Creatingg index: {}", name); + client .delete_index(name) .await? @@ -124,15 +122,12 @@ async fn create_and_add_to_index( name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result<(), IndexingError> { - println!("Creating and adding to index: {}, {}", name, projects.len()); let index = create_index(client, name, custom_rules).await?; let mut new_filterable_attributes = index.get_filterable_attributes().await?; new_filterable_attributes.extend(additional_fields.iter().map(|s| s.to_string())); index.set_filterable_attributes(new_filterable_attributes).await?; - println!("Current filterable attributes: {:?}", index.get_filterable_attributes().await?); - add_to_index(client, index, projects).await?; Ok(()) } diff --git a/src/search/mod.rs b/src/search/mod.rs index 98a167ff..9483b139 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -228,31 +228,16 @@ pub async fn search_for_project( filter_string.push_str(&filters); } - println!("Filter string: {}", filter_string); - if !filter_string.is_empty() { query.with_filter(&filter_string); } } - // query.execute::().await? - let v = query.execute::().await?; - println!("Got results from MeiliSearch"); - println!("Value: {:?}", v); - v + query.execute::().await? }; - println!("Finished filtering"); Ok(SearchResults { - hits: results - .hits - .into_iter() - .map(|r| { - let mut result: ResultSearchProject = serde_json::from_value(r.result).unwrap(); - result - }) - .collect(), - // hits: results.hits.into_iter().map(|r| r.result).collect(), + hits: results.hits.into_iter().map(|r| r.result).collect(), offset: results.offset.unwrap_or_default(), limit: results.limit.unwrap_or_default(), total_hits: results.estimated_total_hits.unwrap_or_default(), diff --git a/src/util/webhook.rs b/src/util/webhook.rs index 9c903768..dabd80ed 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -1,4 +1,4 @@ -use crate::database::models::loader_fields::GameVersion; +use crate::database::models::loader_fields::{GameVersion, VersionField}; use crate::database::redis::RedisPool; use crate::models::projects::ProjectId; use crate::routes::ApiError; @@ -77,7 +77,9 @@ pub async fn send_discord_webhook( webhook_url: String, message: Option, ) -> Result<(), ApiError> { - // let all_game_versions = GameVersion::list(pool, redis).await?; + // TODO: this currently uses Minecraft as it is a v2 webhook, and requires 'game_versions', a minecraft-java loader field. + // This should be updated to use the generic loader fields w/ discord from the project game + let all_game_versions = GameVersion::list(pool, redis).await?; let row = sqlx::query!( @@ -91,16 +93,35 @@ pub async fn send_discord_webhook( ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( DISTINCT jsonb_build_object( - 'field_id', vf.field_id, - 'int_value', vf.int_value, - 'enum_value', vf.enum_value, - 'string_value', vf.string_value, + 'field_id', vf.field_id, + 'int_value', vf.int_value, + 'enum_value', vf.enum_value, + 'string_value', vf.string_value + ) + ) version_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'lf_id', lf.id, + 'l_id', lf.loader_id, + 'loader_name', lo.loader, 'field', lf.field, 'field_type', lf.field_type, 'enum_type', lf.enum_type, - 'enum_name', lfe.enum_name + 'min_val', lf.min_val, + 'max_val', lf.max_val, + 'optional', lf.optional ) - ) version_fields + ) loader_fields, + JSONB_AGG( + DISTINCT jsonb_build_object( + 'id', lfev.id, + 'enum_id', lfev.enum_id, + 'value', lfev.value, + 'ordering', lfev.ordering, + 'created', lfev.created, + 'metadata', lfev.metadata + ) + ) loader_field_enum_values FROM mods m LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id @@ -114,7 +135,7 @@ pub async fn send_discord_webhook( LEFT OUTER JOIN version_fields vf on v.id = vf.version_id LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id - + LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id WHERE m.id = $1 GROUP BY m.id, pt.id, u.id; ", @@ -198,15 +219,19 @@ pub async fn send_discord_webhook( }); } - // if !versions.is_empty() { - // let formatted_game_versions: String = get_gv_range(versions, all_game_versions); - - // fields.push(DiscordEmbedField { - // name: "Versions", - // value: formatted_game_versions, - // inline: true, - // }); - // } + // TODO: Modified to keep "Versions" as a field as it may be hardcoded. Ideally, this pushes all loader fields to the embed for v3 + // TODO: This might need some work to manually test + let version_fields = VersionField::from_query_json(project.id, project.loader_fields, project.version_fields, project.loader_field_enum_values); + let versions = version_fields.into_iter().find_map(|vf| GameVersion::try_from_version_field(&vf).ok()).unwrap_or_default(); + + if versions.len() > 0 { + let formatted_game_versions: String = get_gv_range(versions, all_game_versions); + fields.push(DiscordEmbedField { + name: "Versions", + value: formatted_game_versions, + inline: true, + }); + } let mut project_type = project.project_type; diff --git a/src/validate/mod.rs b/src/validate/mod.rs index 016dc867..22eab2f3 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -1,5 +1,8 @@ +use crate::database::models::DatabaseError; +use crate::database::models::loader_fields::{Game, GameVersion, VersionField}; +use crate::database::redis::RedisPool; use crate::models::pack::PackFormat; -use crate::models::projects::{FileType, GameVersion, Loader}; +use crate::models::projects::{FileType, Loader}; use crate::validate::datapack::DataPackValidator; use crate::validate::fabric::FabricValidator; use crate::validate::forge::{ForgeValidator, LegacyForgeValidator}; @@ -36,6 +39,8 @@ pub enum ValidationError { InvalidInput(std::borrow::Cow<'static, str>), #[error("Error while managing threads")] Blocking(#[from] actix_web::error::BlockingError), + #[error("Error while querying database")] + Database(#[from] DatabaseError), } #[derive(Eq, PartialEq)] @@ -103,12 +108,41 @@ static VALIDATORS: &[&dyn Validator] = &[ /// The return value is whether this file should be marked as primary or not, based on the analysis of the file pub async fn validate_file( + game : Game, + data: bytes::Bytes, + file_extension: String, + project_type: String, + loaders: Vec, + file_type: Option, + version_fields : Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result + { + match game { + Game::MinecraftJava => { + let game_versions = version_fields.into_iter().find_map(|v| GameVersion::try_from_version_field(&v).ok()).unwrap_or_default(); + let all_game_versions = GameVersion::list_transaction(&mut *transaction, &redis).await?; + validate_minecraft_file( + data, + file_extension, + project_type, + loaders, + game_versions, + all_game_versions, + file_type, + ).await + } + } +} + +async fn validate_minecraft_file( data: bytes::Bytes, file_extension: String, mut project_type: String, mut loaders: Vec, - // game_versions: Vec, - // all_game_versions: Vec, + game_versions: Vec, // + all_game_versions: Vec, file_type: Option, ) -> Result { actix_web::web::block(move || { @@ -131,11 +165,11 @@ pub async fn validate_file( && loaders .iter() .any(|x| validator.get_supported_loaders().contains(&&*x.0)) - // && game_version_supported( - // &game_versions, - // &all_game_versions, - // validator.get_supported_game_versions(), - // ) + && game_version_supported( + &game_versions, + &all_game_versions, + validator.get_supported_game_versions(), + ) { if validator.get_file_extensions().contains(&&*file_extension) { return validator.validate(&mut zip); @@ -162,6 +196,7 @@ pub async fn validate_file( .await? } +// Write tests for this fn game_version_supported( game_versions: &[GameVersion], all_game_versions: &[crate::database::models::loader_fields::GameVersion], @@ -172,19 +207,21 @@ fn game_version_supported( SupportedGameVersions::PastDate(date) => game_versions.iter().any(|x| { all_game_versions .iter() - .find(|y| y.version == x.0) + .find(|y| y.version == x.version) .map(|x| x.created > date) .unwrap_or(false) }), SupportedGameVersions::Range(before, after) => game_versions.iter().any(|x| { all_game_versions .iter() - .find(|y| y.version == x.0) + .find(|y| y.version == x.version) .map(|x| x.created > before && x.created < after) .unwrap_or(false) }), SupportedGameVersions::Custom(versions) => { - versions.iter().any(|x| game_versions.contains(x)) + let version_ids = versions.iter().map(|gv| gv.id).collect::>(); + let game_version_ids = game_versions.iter().map(|gv| gv.id).collect::>(); + version_ids.iter().any(|x| game_version_ids.contains(x)) } } } diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 3818ca96..075dfe0d 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -220,7 +220,6 @@ impl ApiV2 { .to_request(); let resp = self.call(req).await; let status = resp.status(); - println!("Body: {:?}", resp.response().body()); assert_eq!(status, 200); test::read_body_json(resp).await } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index f058ed5e..34df6693 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -361,10 +361,6 @@ pub async fn get_project_alpha(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - println!("Got out and got through!"); - println!("S {:?}", resp.status()); - println!("H {:?}", resp.headers()); - println!("B {:?}", resp.response().body()); let project: Project = test::read_body_json(resp).await; // Get project's versions @@ -386,10 +382,7 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - println!("Got out and got through123!"); let project: serde_json::Value = test::read_body_json(resp).await; - println!("here"); - println!("Serde json value: {}", serde_json::to_string(&project).unwrap()); let project: Project = serde_json::from_value(project).unwrap(); // Get project's versions diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index f2e84583..43370905 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -164,8 +164,6 @@ impl<'a> PermissionsTest<'a> { ) .await; - println!("PATCHING..."); - // Failure test let request = req_gen(&PermissionsTestContext { project_id: Some(&project_id), @@ -174,7 +172,6 @@ impl<'a> PermissionsTest<'a> { }) .append_header(("Authorization", self.user_pat)) .to_request(); - println!("PATCHING2..."); let resp = test_env.call(request).await; if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { @@ -186,7 +183,7 @@ impl<'a> PermissionsTest<'a> { .join(","), resp.status().as_u16() )); - } println!("PATCHING3..."); + } // Patch user's permissions to success permissions @@ -198,7 +195,6 @@ impl<'a> PermissionsTest<'a> { test_env, ) .await; - println!("PATCHING..4."); // Successful test let request = req_gen(&PermissionsTestContext { @@ -208,7 +204,6 @@ impl<'a> PermissionsTest<'a> { }) .append_header(("Authorization", self.user_pat)) .to_request(); - println!("PATCHING..5."); let resp = test_env.call(request).await; if !resp.status().is_success() { @@ -217,7 +212,6 @@ impl<'a> PermissionsTest<'a> { resp.status().as_u16() )); } - println!("PATCHING.6.."); // If the remove_user flag is set, remove the user from the project // Relevant for existing projects/users @@ -310,7 +304,6 @@ impl<'a> PermissionsTest<'a> { .to_request(); let resp = test_env.call(request).await; - println!("RESP: {:?}", resp.response().body()); if !resp.status().is_success() { return Err(format!( "Success permissions test failed. Expected success, got {}", diff --git a/tests/project.rs b/tests/project.rs index df16cd94..842df6e3 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -429,13 +429,6 @@ pub async fn test_patch_project() { let resp = api.get_project("newslug", USER_USER_PAT).await; let project: serde_json::Value = test::read_body_json(resp).await; - // TODO DELETE ME - // Get and check versions real quick - let version = api.get_version_deserialized(project["versions"][0].as_str().unwrap(), USER_USER_PAT).await; - // print json - println!("Serialiezd version: {}", serde_json::to_string_pretty(&version).unwrap()); - - println!("Serialiezd project: {}", serde_json::to_string_pretty(&project).unwrap()); assert_eq!(project["slug"], json!(Some("newslug".to_string()))); assert_eq!(project["title"], "New successful title"); assert_eq!(project["description"], "New successful description"); @@ -541,7 +534,6 @@ async fn permissions_patch_project() { }, })) }; - println!("Testing {}", key); PermissionsTest::new(&test_env) .simple_project_permissions_test(edit_details, req_gen) .await.into_iter(); @@ -551,7 +543,7 @@ async fn permissions_patch_project() { .collect::>() .await; - println!("HERE!"); + // Test with status and requested_status // This requires a project with a version, so we use alpha_project_id let req_gen = |ctx: &PermissionsTestContext| { @@ -568,7 +560,6 @@ async fn permissions_patch_project() { .simple_project_permissions_test(edit_details, req_gen) .await .unwrap(); - println!("HERE!2"); // Bulk patch projects let req_gen = |ctx: &PermissionsTestContext| { From 34ac302a241d343697d62901c3f4a8f675a62836 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Mon, 23 Oct 2023 01:11:56 -0700 Subject: [PATCH 13/31] refactors --- migrations/20231005230721_dynamic-fields.sql | 7 +- src/database/models/categories.rs | 3 - src/database/models/ids.rs | 7 - src/database/models/legacy_loader_fields.rs | 216 +++++ src/database/models/loader_fields.rs | 957 +++++++++---------- src/database/models/mod.rs | 3 +- src/database/models/project_item.rs | 20 +- src/database/models/version_item.rs | 4 +- src/lib.rs | 2 +- src/models/projects.rs | 36 +- src/routes/maven.rs | 17 +- src/routes/updates.rs | 17 +- src/routes/v2/admin.rs | 6 +- src/routes/v2/collections.rs | 2 +- src/routes/v2/project_creation.rs | 42 +- src/routes/v2/projects.rs | 185 ++-- src/routes/v2/tags.rs | 50 +- src/routes/v2/version_creation.rs | 44 +- src/routes/v2/version_file.rs | 59 +- src/routes/v2/versions.rs | 79 +- src/routes/v2_reroute.rs | 103 +- src/routes/v3/mod.rs | 5 +- src/routes/v3/project_creation.rs | 83 +- src/routes/v3/projects.rs | 23 +- src/routes/v3/tags.rs | 32 +- src/routes/v3/version_creation.rs | 53 +- src/routes/v3/version_file.rs | 38 +- src/routes/v3/versions.rs | 109 ++- src/scheduler.rs | 22 +- src/search/indexing/local_import.rs | 16 +- src/search/indexing/mod.rs | 13 +- src/search/mod.rs | 23 +- src/util/actix.rs | 6 +- src/util/webhook.rs | 27 +- src/validate/mod.rs | 36 +- tests/common/api_v2/mod.rs | 5 +- tests/common/api_v2/project.rs | 19 +- tests/common/database.rs | 1 - tests/common/dummy_data.rs | 63 +- tests/common/mod.rs | 2 +- tests/common/permissions.rs | 6 +- tests/common/request_data.rs | 13 +- tests/project.rs | 32 +- tests/scopes.rs | 6 +- tests/search.rs | 259 +++-- 45 files changed, 1554 insertions(+), 1197 deletions(-) create mode 100644 src/database/models/legacy_loader_fields.rs diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 6b0f5d9f..726d3491 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -1,10 +1,11 @@ CREATE TABLE games ( - id int PRIMARY KEY, - name varchar(64) + id int PRIMARY KEY, -- Only used in db + name varchar(64), + CONSTRAINT unique_game_name UNIQUE (name) ); - INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); + ALTER TABLE mods ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs index 8150d61d..ae9e11d8 100644 --- a/src/database/models/categories.rs +++ b/src/database/models/categories.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; const TAGS_NAMESPACE: &str = "tags"; - pub struct ProjectType { pub id: ProjectTypeId, pub name: String, @@ -115,7 +114,6 @@ impl Category { } } - impl DonationPlatform { pub async fn get_id<'a, E>( id: &str, @@ -271,4 +269,3 @@ impl ProjectType { Ok(result) } } - diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 7ac0efe7..83cc30b1 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -187,9 +187,6 @@ pub struct DonationPlatformId(pub i32); #[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct VersionId(pub i64); -#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)] -#[sqlx(transparent)] -pub struct GameVersionId(pub i32); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] #[sqlx(transparent)] pub struct LoaderId(pub i32); @@ -250,10 +247,6 @@ pub struct LoaderFieldEnumId(pub i32); #[sqlx(transparent)] pub struct LoaderFieldEnumValueId(pub i32); -#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] -#[sqlx(transparent)] -pub struct GameId(pub i32); - use crate::models::ids; impl From for ProjectId { diff --git a/src/database/models/legacy_loader_fields.rs b/src/database/models/legacy_loader_fields.rs new file mode 100644 index 00000000..e5a4a7ec --- /dev/null +++ b/src/database/models/legacy_loader_fields.rs @@ -0,0 +1,216 @@ +// In V3, we switched to dynamic loader fields for a better support for more loaders, games, and potential metadata. +// This file contains the legacy loader fields, which are still used by V2 projects. +// They are still useful to have in several places where minecraft-java functionality is hardcoded- for example, +// for fetching data from forge, maven, etc. +// These fields only apply to minecraft-java, and are hardcoded to the minecraft-java game. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::database::redis::RedisPool; + +use super::{ + loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue}, + DatabaseError, LoaderFieldEnumValueId, +}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct MinecraftGameVersion { + pub id: LoaderFieldEnumValueId, + pub version: String, + #[serde(rename = "type")] + pub type_: String, + pub created: DateTime, + pub major: bool, +} + +impl MinecraftGameVersion { + // The name under which this legacy field is stored as a LoaderField + pub const FIELD_NAME: &'static str = "game_versions"; + + pub fn builder() -> MinecraftGameVersionBuilder<'static> { + MinecraftGameVersionBuilder::default() + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let game_name = Game::MinecraftJava.name(); + let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, game_name, exec, redis) + .await? + .ok_or_else(|| { + DatabaseError::SchemaError(format!( + "Could not find game version enum for '{game_name}'" + )) + })?; + let game_version_enum_values = + LoaderFieldEnumValue::list(game_version_enum.id, exec, redis).await?; + Ok(game_version_enum_values + .into_iter() + .map(MinecraftGameVersion::from_enum_value) + .collect()) + } + + // TODO: remove this + pub async fn list_transaction( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let game_name = Game::MinecraftJava.name(); + let game_version_enum = + LoaderFieldEnum::get(Self::FIELD_NAME, game_name, &mut *transaction, redis) + .await? + .ok_or_else(|| { + DatabaseError::SchemaError(format!( + "Could not find game version enum for '{game_name}'" + )) + })?; + let game_version_enum_values = + LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, redis).await?; + Ok(game_version_enum_values + .into_iter() + .map(MinecraftGameVersion::from_enum_value) + .collect()) + } + + // Tries to create a MinecraftGameVersion from a VersionField + // Clones on success + pub fn try_from_version_field( + version_field: &VersionField, + ) -> Result, DatabaseError> { + if version_field.field_name != Self::FIELD_NAME { + return Err(DatabaseError::SchemaError(format!( + "Field name {} is not {}", + version_field.field_name, + Self::FIELD_NAME + ))); + } + let game_versions = match version_field.clone() { + VersionField { + value: VersionFieldValue::ArrayEnum(_, values), + .. + } => values.into_iter().map(Self::from_enum_value).collect(), + VersionField { + value: VersionFieldValue::Enum(_, value), + .. + } => { + vec![Self::from_enum_value(value)] + } + _ => { + return Err(DatabaseError::SchemaError(format!( + "Game version requires field value to be an enum: {:?}", + version_field + ))); + } + }; + Ok(game_versions) + } + + pub fn from_enum_value(loader_field_enum_value: LoaderFieldEnumValue) -> MinecraftGameVersion { + MinecraftGameVersion { + id: loader_field_enum_value.id, + version: loader_field_enum_value.value, + created: loader_field_enum_value.created, + type_: loader_field_enum_value + .metadata + .get("type") + .and_then(|x| x.as_str()) + .map(|x| x.to_string()) + .unwrap_or_default(), + major: loader_field_enum_value + .metadata + .get("major") + .and_then(|x| x.as_bool()) + .unwrap_or_default(), + } + } +} + +#[derive(Default)] +pub struct MinecraftGameVersionBuilder<'a> { + pub version: Option<&'a str>, + pub version_type: Option<&'a str>, + pub date: Option<&'a DateTime>, +} + +impl<'a> MinecraftGameVersionBuilder<'a> { + pub fn new() -> Self { + Self::default() + } + /// The game version. Spaces must be replaced with '_' for it to be valid + pub fn version( + self, + version: &'a str, + ) -> Result, DatabaseError> { + Ok(Self { + version: Some(version), + ..self + }) + } + + pub fn version_type( + self, + version_type: &'a str, + ) -> Result, DatabaseError> { + Ok(Self { + version_type: Some(version_type), + ..self + }) + } + + pub fn created(self, created: &'a DateTime) -> MinecraftGameVersionBuilder<'a> { + Self { + date: Some(created), + ..self + } + } + + pub async fn insert<'b, E>( + self, + exec: E, + redis: &RedisPool, + ) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy, + { + let game_name = Game::MinecraftJava.name(); + let game_versions_enum = LoaderFieldEnum::get("game_versions", game_name, exec, redis) + .await? + .ok_or(DatabaseError::SchemaError( + "Missing loaders field: 'game_versions'".to_string(), + ))?; + + // Get enum id for game versions + let metadata = json!({ + "type": self.version_type, + "major": false + }); + + // This looks like a mess, but it *should* work + // This allows game versions to be partially updated without + // replacing the unspecified fields with defaults. + let result = sqlx::query!( + " + INSERT INTO loader_field_enum_values (enum_id, value, created, metadata) + VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4) + ON CONFLICT (enum_id, value) DO UPDATE + SET metadata = COALESCE($4, loader_field_enum_values.metadata), + created = COALESCE($3, loader_field_enum_values.created) + RETURNING id + ", + game_versions_enum.id.0, + self.version, + self.date.map(chrono::DateTime::naive_utc), + metadata + ) + .fetch_one(exec) + .await?; + + Ok(LoaderFieldEnumValueId(result.id)) + } +} diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index de617156..ddd79a7b 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -1,19 +1,16 @@ use std::collections::HashMap; -use crate::database::redis::RedisPool; use super::ids::*; use super::DatabaseError; +use crate::database::redis::RedisPool; use chrono::DateTime; use chrono::Utc; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use serde_json::json; const LOADERS_LIST_NAMESPACE: &str = "loaders"; - const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; - const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums"; const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = "loader_field_enum_values"; @@ -41,43 +38,6 @@ impl Game { _ => None, } } - - // TODO is this needed? - pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT id FROM games - WHERE name = $1 - ", - name - ) - .fetch_optional(exec) - .await?; - - Ok(result.map(|r| GameId(r.id))) - } - - // TODO is this needed? - pub async fn from_id<'a, E>(id: GameId, exec: E) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT name FROM games - WHERE id = $1 - ", - id.0 - ) - .fetch_optional(exec) - .await?; - - Ok(result.and_then(|r| r.name).and_then(|n| Game::from_name(&n))) - } - } #[derive(Serialize, Deserialize)] @@ -106,12 +66,19 @@ impl Loader { Ok(result.map(|r| LoaderId(r.id))) } - pub async fn list<'a, E>(game_name : &str , exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn list<'a, E>( + game: Game, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { + let game_name = game.name(); - let cached_loaders : Option> = redis.get_deserialized_from_json(LOADERS_LIST_NAMESPACE, game_name).await?; + let cached_loaders: Option> = redis + .get_deserialized_from_json(LOADERS_LIST_NAMESPACE, game_name) + .await?; if let Some(cached_loaders) = cached_loaders { return Ok(cached_loaders); } @@ -146,44 +113,10 @@ impl Loader { .try_collect::>() .await?; - redis.set_serialized_to_json(LOADERS_LIST_NAMESPACE, game_name, &result, None).await?; - - Ok(result) - } + redis + .set_serialized_to_json(LOADERS_LIST_NAMESPACE, game_name, &result, None) + .await?; - pub async fn list_id<'a, E>(game_id : GameId , exec: E) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let result = sqlx::query!( - " - SELECT l.id id, l.loader loader, l.icon icon, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types - FROM loaders l - INNER JOIN games g ON l.game_id = g.id - LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id - LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - WHERE g.id = $1 - GROUP BY l.id; - ", - game_id.0, - ) - .fetch_many(exec) - .try_filter_map(|e| async { - Ok(e.right().map(|x| Loader { - id: LoaderId(x.id), - loader: x.loader, - icon: x.icon, - supported_project_types: x - .project_types - .unwrap_or_default() - .iter() - .map(|x| x.to_string()) - .collect(), - })) - }) - .try_collect::>() - .await?; Ok(result) } } @@ -192,7 +125,7 @@ impl Loader { pub struct LoaderField { pub id: LoaderFieldId, pub loader_id: LoaderId, - pub loader_name : String, + pub loader_name: String, pub field: String, pub field_type: LoaderFieldType, pub optional: bool, @@ -212,7 +145,7 @@ pub enum LoaderFieldType { ArrayBoolean, } impl LoaderFieldType { - pub fn build(field_type_name : &str, loader_field_enum : Option) -> Option { + pub fn build(field_type_name: &str, loader_field_enum: Option) -> Option { Some(match (field_type_name, loader_field_enum) { ("integer", _) => LoaderFieldType::Integer, ("text", _) => LoaderFieldType::Text, @@ -222,7 +155,7 @@ impl LoaderFieldType { ("array_boolean", _) => LoaderFieldType::ArrayBoolean, ("enum", Some(id)) => LoaderFieldType::Enum(LoaderFieldEnumId(id)), ("array_enum", Some(id)) => LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)), - _ => return None + _ => return None, }) } @@ -243,7 +176,7 @@ impl LoaderFieldType { #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderFieldEnum { pub id: LoaderFieldEnumId, - pub game_id: GameId, + pub game: Game, pub enum_name: String, pub ordering: Option, pub hidable: bool, @@ -312,106 +245,33 @@ pub struct SideType { pub name: String, } -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct GameVersion { - pub id: LoaderFieldEnumValueId, - pub version: String, - #[serde(rename = "type")] - pub type_: String, - pub created: DateTime, - pub major: bool, -} - -// game version from loaderfieldenumvalue -// TODO: remove, after moving gameversion to legacy minecraft -impl GameVersion { - // The name under which this legacy field is stored as a LoaderField - pub const LEGACY_FIELD_NAME : &'static str = "game_versions"; - - pub async fn list<'a, E>( - exec: E, - redis: &RedisPool - ) - -> Result, DatabaseError> - where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy - { - let game_name = Game::MinecraftJava.name(); - let game_version_enum = LoaderFieldEnum::get(Self::LEGACY_FIELD_NAME, game_name, exec, &redis).await?.ok_or_else(|| DatabaseError::SchemaError(format!("Could not find game version enum for '{game_name}'")))?; - let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, exec, &redis).await?; - Ok(game_version_enum_values.into_iter().map(|x| GameVersion::from_enum_value(x)).collect()) - } - - // TODO: remove this - pub async fn list_transaction( - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - redis: &RedisPool - ) - -> Result, DatabaseError> - { - let game_name = Game::MinecraftJava.name(); - let game_version_enum = LoaderFieldEnum::get(Self::LEGACY_FIELD_NAME, game_name, &mut *transaction, &redis).await?.ok_or_else(|| DatabaseError::SchemaError(format!("Could not find game version enum for '{game_name}'")))?; - let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, &redis).await?; - Ok(game_version_enum_values.into_iter().map(|x| GameVersion::from_enum_value(x)).collect()) - } - - // Tries to create a GameVersion from a VersionField - // Clones on success - pub fn try_from_version_field(version_field: &VersionField) -> Result, DatabaseError> { - if version_field.field_name !=Self::LEGACY_FIELD_NAME { - // TODO: should this be an error? - return Err(DatabaseError::SchemaError(format!("Field name {} is not {}", version_field.field_name, Self::LEGACY_FIELD_NAME))); - } - let game_versions = match version_field.clone() { - VersionField { value: VersionFieldValue::ArrayEnum(_, values ), ..} => { - values.into_iter().map(|x| Self::from_enum_value(x)).collect() - }, - VersionField { value: VersionFieldValue::Enum(_, value ), ..} => { - vec![Self::from_enum_value(value)] - } - // TODO: should this be an error? - _ => vec![] - }; - Ok(game_versions) - } - - pub fn from_enum_value(loader_field_enum_value : LoaderFieldEnumValue) -> GameVersion { - GameVersion { - id: loader_field_enum_value.id, - version: loader_field_enum_value.value, - created: loader_field_enum_value.created, - type_: loader_field_enum_value.metadata.get("type").and_then(|x| x.as_str()).map(|x| x.to_string()).unwrap_or_default(), - major: loader_field_enum_value.metadata.get("major").and_then(|x| x.as_bool()).unwrap_or_default(), - } - } -} - impl LoaderField { - pub async fn get_field<'a, E>( - field : &str, + field: &str, loader_id: LoaderId, exec: E, - redis: &RedisPool + redis: &RedisPool, ) -> Result, DatabaseError> - where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - let fields = Self::get_fields( loader_id, exec, &redis).await?; + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let fields = Self::get_fields(loader_id, exec, redis).await?; Ok(fields.into_iter().find(|f| f.field == field)) } // Gets all fields for a given loader // Returns all as this there are probably relatively few fields per loader pub async fn get_fields<'a, E>( - loader_id : LoaderId, + loader_id: LoaderId, exec: E, - redis: &RedisPool + redis: &RedisPool, ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, - { - - let cached_fields = redis.get_deserialized_from_json(LOADER_FIELDS_NAMESPACE, &loader_id.0).await?; + { + let cached_fields = redis + .get_deserialized_from_json(LOADER_FIELDS_NAMESPACE, &loader_id.0) + .await?; if let Some(cached_fields) = cached_fields { return Ok(cached_fields); } @@ -426,10 +286,9 @@ impl LoaderField { loader_id.0, ) .fetch_many(exec) - .try_filter_map(|e| async { + .try_filter_map(|e| async { Ok(e.right().and_then( - |r| - Some(LoaderField { + |r| Some(LoaderField { id: LoaderFieldId(r.id), loader_id: LoaderId(r.loader_id), field_type: LoaderFieldType::build(&r.field_type, r.enum_type)?, @@ -443,26 +302,38 @@ impl LoaderField { .try_collect::>() .await?; - redis.set_serialized_to_json(LOADER_FIELDS_NAMESPACE, &loader_id.0, &result, None).await?; + redis + .set_serialized_to_json(LOADER_FIELDS_NAMESPACE, &loader_id.0, &result, None) + .await?; Ok(result) } } -// TODO: this could maybe return variants? +// TODO: Should this return variants? impl LoaderFieldEnum { - pub async fn get<'a, E>(enum_name : &str, game_name : &str, exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn get<'a, E>( + enum_name: &str, + game_name: &str, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let cached_enum = redis.get_deserialized_from_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, format!("{}_{}", game_name, enum_name)).await?; + let cached_enum = redis + .get_deserialized_from_json( + LOADER_FIELD_ENUMS_ID_NAMESPACE, + format!("{}_{}", game_name, enum_name), + ) + .await?; if let Some(cached_enum) = cached_enum { return Ok(cached_enum); } let result = sqlx::query!( " - SELECT lfe.id, lfe.game_id, lfe.enum_name, lfe.ordering, lfe.hidable + SELECT lfe.id, g.name, lfe.enum_name, lfe.ordering, lfe.hidable FROM loader_field_enums lfe INNER JOIN games g ON lfe.game_id = g.id WHERE g.name = $1 AND lfe.enum_name = $2 @@ -470,80 +341,158 @@ impl LoaderFieldEnum { game_name, enum_name ) - .fetch_optional(exec).await?.map(|l| LoaderFieldEnum { - id: LoaderFieldEnumId(l.id), - game_id: GameId(l.game_id), - enum_name: l.enum_name, - ordering: l.ordering, - hidable: l.hidable, - }); - - redis.set_serialized_to_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, format!("{}_{}", game_name, enum_name), &result, None).await?; + .fetch_optional(exec) + .await? + .and_then(|l| { + Some(LoaderFieldEnum { + id: LoaderFieldEnumId(l.id), + game: l.name.and_then(|n| Game::from_name(&n))?, + enum_name: l.enum_name, + ordering: l.ordering, + hidable: l.hidable, + }) + }); + + redis + .set_serialized_to_json( + LOADER_FIELD_ENUMS_ID_NAMESPACE, + format!("{}_{}", game_name, enum_name), + &result, + None, + ) + .await?; - Ok(result) + Ok(result) } } impl LoaderFieldEnumValue { + pub async fn list<'a, E>( + loader_field_enum_id: LoaderFieldEnumId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list_many(&[loader_field_enum_id], exec, redis) + .await? + .into_iter() + .next() + .map(|x| x.1) + .unwrap_or_default()) + } - pub async fn list_optional<'a, E>(list_optional : &LoaderFieldType, exec: E, redis: &RedisPool) -> Result, DatabaseError> - where E: sqlx::Executor<'a, Database = sqlx::Postgres> - { - match list_optional { - LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { - LoaderFieldEnumValue::list(*id, exec, redis).await + pub async fn list_many_loader_fields<'a, E>( + loader_fields: &[LoaderField], + exec: E, + redis: &RedisPool, + ) -> Result>, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let get_enum_id = |x: &LoaderField| match x.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => Some(id), + _ => None, + }; + + let enum_ids = loader_fields + .iter() + .filter_map(|x| get_enum_id(x)) + .collect::>(); + let values = Self::list_many(&enum_ids, exec, redis) + .await? + .into_iter() + .collect::>(); + + let mut res = HashMap::new(); + for lf in loader_fields { + if let Some(id) = get_enum_id(lf) { + res.insert(lf.id, values.get(&id).unwrap_or(&Vec::new()).to_vec()); } - _ => Ok(vec![]) } + Ok(res) } - pub async fn list<'a, E>(loader_field_enum_id : LoaderFieldEnumId, exec: E, redis: &RedisPool) -> Result, DatabaseError> + pub async fn list_many<'a, E>( + loader_field_enum_ids: &[LoaderFieldEnumId], + exec: E, + redis: &RedisPool, + ) -> Result)>, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - - let cached_enum_values = redis.get_deserialized_from_json(LOADER_FIELD_ENUM_VALUES_NAMESPACE, &loader_field_enum_id.0).await?; - if let Some(cached_enum_values) = cached_enum_values { - return Ok(cached_enum_values); + let mut found_enums = Vec::new(); + let mut remaining_enums: Vec = loader_field_enum_ids.to_vec(); + + if !remaining_enums.is_empty() { + let enums = redis + .multi_get::( + LOADER_FIELD_ENUM_VALUES_NAMESPACE, + loader_field_enum_ids.iter().map(|x| x.0), + ) + .await?; + + for lfe in enums { + if let Some(lfe) = lfe.and_then(|x| { + serde_json::from_str::<(LoaderFieldEnumId, Vec)>(&x).ok() + }) { + remaining_enums.retain(|x| lfe.0 .0 != x.0); + found_enums.push(lfe.1); + continue; + } + } } + let remaining_enums = remaining_enums.iter().map(|x| x.0).collect::>(); let result = sqlx::query!( " SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values - WHERE enum_id = $1 - ", - loader_field_enum_id.0 + WHERE enum_id = ANY($1) + ", + &remaining_enums ) .fetch_many(exec) - .try_filter_map(|e| async { Ok(e.right().map(|c| - LoaderFieldEnumValue { + .try_filter_map(|e| async { + Ok(e.right().map(|c| LoaderFieldEnumValue { id: LoaderFieldEnumValueId(c.id), enum_id: LoaderFieldEnumId(c.enum_id), value: c.value, ordering: c.ordering, created: c.created, - metadata: c.metadata.unwrap_or_default() - } - )) }) + metadata: c.metadata.unwrap_or_default(), + })) + }) .try_collect::>() .await?; - redis.set_serialized_to_json(LOADER_FIELD_ENUM_VALUES_NAMESPACE, &loader_field_enum_id.0, &result, None).await?; + // Convert from an Vec to a Vec<(LoaderFieldEnumId, Vec)> + let cachable_enum_sets: Vec<(LoaderFieldEnumId, Vec)> = result + .clone() + .into_iter() + .group_by(|x| x.enum_id) + .into_iter() + .map(|(k, v)| (k, v.collect::>().to_vec())) + .collect(); + for (k, v) in cachable_enum_sets.iter() { + redis + .set_serialized_to_json(LOADER_FIELD_ENUM_VALUES_NAMESPACE, k.0, v, None) + .await?; + } - Ok(result) + Ok(cachable_enum_sets) } // Matches filter against metadata of enum values pub async fn list_filter<'a, E>( - loader_field_enum_id : LoaderFieldEnumId, - filter : HashMap, + loader_field_enum_id: LoaderFieldEnumId, + filter: HashMap, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let result = Self::list(loader_field_enum_id, exec, redis) .await? .into_iter() @@ -562,7 +511,6 @@ impl LoaderFieldEnumValue { Ok(result) } - } impl VersionField { @@ -570,8 +518,8 @@ impl VersionField { items: Vec, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { - let mut query_version_fields = vec![]; - for item in items { + let mut query_version_fields = vec![]; + for item in items { let base = QueryVersionField { version_id: item.version_id, field_id: item.field_id, @@ -581,9 +529,15 @@ impl VersionField { }; match item.value { - VersionFieldValue::Integer(i) => query_version_fields.push(base.clone().with_int_value(i)), - VersionFieldValue::Text(s) => query_version_fields.push(base.clone().with_string_value(s)), - VersionFieldValue::Boolean(b) => query_version_fields.push(base.clone().with_int_value(if b { 1 } else { 0 })), + VersionFieldValue::Integer(i) => { + query_version_fields.push(base.clone().with_int_value(i)) + } + VersionFieldValue::Text(s) => { + query_version_fields.push(base.clone().with_string_value(s)) + } + VersionFieldValue::Boolean(b) => { + query_version_fields.push(base.clone().with_int_value(if b { 1 } else { 0 })) + } VersionFieldValue::ArrayInteger(v) => { for i in v { query_version_fields.push(base.clone().with_int_value(i)); @@ -596,24 +550,44 @@ impl VersionField { } VersionFieldValue::ArrayBoolean(v) => { for b in v { - query_version_fields.push(base.clone().with_int_value(if b { 1 } else { 0 })); + query_version_fields.push(base.clone().with_int_value(if b { + 1 + } else { + 0 + })); } } - VersionFieldValue::Enum(_, v) => query_version_fields.push(base.clone().with_enum_value(v)), + VersionFieldValue::Enum(_, v) => { + query_version_fields.push(base.clone().with_enum_value(v)) + } VersionFieldValue::ArrayEnum(_, v) => { for ev in v { query_version_fields.push(base.clone().with_enum_value(ev)); } } }; - } + } - let (field_ids, version_ids, int_values, enum_values, string_values): (Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>) = query_version_fields - .iter() - .map(|l| (l.field_id.0, l.version_id.0, l.int_value, l.enum_value.as_ref().map(|e|e.id.0), l.string_value.clone())) - .multiunzip(); + let (field_ids, version_ids, int_values, enum_values, string_values): ( + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + ) = query_version_fields + .iter() + .map(|l| { + ( + l.field_id.0, + l.version_id.0, + l.int_value, + l.enum_value.as_ref().map(|e| e.id.0), + l.string_value.clone(), + ) + }) + .multiunzip(); - sqlx::query!( + sqlx::query!( " INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value) SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[]) @@ -626,78 +600,100 @@ impl VersionField { ) .execute(&mut *transaction) .await?; - - Ok(()) + + Ok(()) } - - pub fn check_parse(version_id : VersionId, loader_field : LoaderField, value : serde_json::Value, enum_variants: Vec) -> Result - { + + pub fn check_parse( + version_id: VersionId, + loader_field: LoaderField, + value: serde_json::Value, + enum_variants: Vec, + ) -> Result { let value = VersionFieldValue::parse(&loader_field, value, enum_variants)?; Ok(VersionField { version_id, field_id: loader_field.id, loader_name: loader_field.loader_name, field_name: loader_field.field, - value + value, }) } - pub fn from_query_json(version_id : i64, loader_fields : Option, version_fields : Option, loader_field_enum_values : Option) -> Vec { - #[derive(Deserialize, Debug)] - struct JsonLoaderField { - lf_id: i32, - l_id: i32, - field: String, - loader_name: String, - field_type: String, - enum_type: Option, - min_val: Option, - max_val: Option, - optional: bool, - } + pub fn from_query_json( + version_id: i64, + loader_fields: Option, + version_fields: Option, + loader_field_enum_values: Option, + ) -> Vec { + #[derive(Deserialize, Debug)] + struct JsonLoaderField { + lf_id: i32, + l_id: i32, + field: String, + loader_name: String, + field_type: String, + enum_type: Option, + min_val: Option, + max_val: Option, + optional: bool, + } - #[derive(Deserialize, Debug)] - struct JsonVersionField { - field_id: i32, - int_value: Option, - enum_value: Option, - string_value: Option, - } + #[derive(Deserialize, Debug)] + struct JsonVersionField { + field_id: i32, + int_value: Option, + enum_value: Option, + string_value: Option, + } - #[derive(Deserialize, Debug)] - struct JsonLoaderFieldEnumValue { - id: i32, - enum_id: i32, - value: String, - ordering: Option, - created: DateTime, - metadata: Option, - } + #[derive(Deserialize, Debug)] + struct JsonLoaderFieldEnumValue { + id: i32, + enum_id: i32, + value: String, + ordering: Option, + created: DateTime, + metadata: Option, + } - let query_loader_fields : Vec = loader_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - let query_version_field_combined : Vec = version_fields.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - let query_loader_field_enum_values: Vec = loader_field_enum_values.and_then(|x| serde_json::from_value(x).ok()).unwrap_or_default(); - let version_id = VersionId(version_id); - query_loader_fields.into_iter().filter_map( |q| { + let query_loader_fields: Vec = loader_fields + .and_then(|x| serde_json::from_value(x).ok()) + .unwrap_or_default(); + let query_version_field_combined: Vec = version_fields + .and_then(|x| serde_json::from_value(x).ok()) + .unwrap_or_default(); + let query_loader_field_enum_values: Vec = + loader_field_enum_values + .and_then(|x| serde_json::from_value(x).ok()) + .unwrap_or_default(); + let version_id = VersionId(version_id); + query_loader_fields + .into_iter() + .filter_map(|q| { let loader_field_type = match LoaderFieldType::build(&q.field_type, q.enum_type) { Some(lft) => lft, - None => return None + None => return None, }; - let loader_field = LoaderField { + let loader_field = LoaderField { id: LoaderFieldId(q.lf_id), - loader_id: LoaderId(q.l_id), - field: q.field.clone(), - loader_name: q.loader_name.clone(), - field_type: loader_field_type, - optional: q.optional, - min_val: q.min_val, - max_val: q.max_val - }; - let values = query_version_field_combined.iter().filter_map(|qvf| { + loader_id: LoaderId(q.l_id), + field: q.field.clone(), + loader_name: q.loader_name.clone(), + field_type: loader_field_type, + optional: q.optional, + min_val: q.min_val, + max_val: q.max_val, + }; + let values = query_version_field_combined + .iter() + .filter_map(|qvf| { if qvf.field_id == q.lf_id { - let lfev = query_loader_field_enum_values.iter().find(|x| Some(x.id) == qvf.enum_value); - - Some(QueryVersionField { + let lfev = query_loader_field_enum_values + .iter() + .find(|x| Some(x.id) == qvf.enum_value); + + Some(QueryVersionField { version_id, field_id: LoaderFieldId(qvf.field_id), int_value: qvf.int_value, @@ -707,168 +703,234 @@ impl VersionField { value: lfev.value.clone(), ordering: lfev.ordering, created: lfev.created, - metadata: lfev.metadata.clone().unwrap_or_default() + metadata: lfev.metadata.clone().unwrap_or_default(), }), - string_value: qvf.string_value.clone() - }) + string_value: qvf.string_value.clone(), + }) } else { None } - }).collect::>(); - + }) + .collect::>(); - let v = VersionField::build( - loader_field, - version_id, - values - ).ok(); - v - }).collect() + VersionField::build(loader_field, version_id, values).ok() + }) + .collect() } - pub fn build(loader_field : LoaderField, version_id : VersionId, query_version_fields : Vec) -> Result { + pub fn build( + loader_field: LoaderField, + version_id: VersionId, + query_version_fields: Vec, + ) -> Result { let value = VersionFieldValue::build(&loader_field.field_type, query_version_fields)?; Ok(VersionField { version_id, field_id: loader_field.id, loader_name: loader_field.loader_name, field_name: loader_field.field, - value + value, }) } } impl VersionFieldValue { // TODO: this could be combined with build - pub fn parse(loader_field: &LoaderField, value : serde_json::Value, enum_array: Vec) -> Result - - { + // Build from user-submitted JSON data + // value is the attempted value of the field, which will be tried to parse to the correct type + // enum_array is the list of valid enum variants for the field, if it is an enum (see LoaderFieldEnumValue::list_many_loader_fields) + pub fn parse( + loader_field: &LoaderField, + value: serde_json::Value, + enum_array: Vec, + ) -> Result { let field_name = &loader_field.field; let field_type = &loader_field.field_type; let error_value = value.clone(); - let incorrect_type_error = |field_type : &str| - format!("Provided value '{v}' for {field_name} could not be parsed to {field_type} ", v = serde_json::to_string(&error_value).unwrap_or_default()); + let incorrect_type_error = |field_type: &str| { + format!( + "Provided value '{v}' for {field_name} could not be parsed to {field_type} ", + v = serde_json::to_string(&error_value).unwrap_or_default() + ) + }; Ok(match field_type { LoaderFieldType::Integer => VersionFieldValue::Integer( - serde_json::from_value(value).map_err(|_| incorrect_type_error("integer"))? + serde_json::from_value(value).map_err(|_| incorrect_type_error("integer"))?, ), LoaderFieldType::Text => VersionFieldValue::Text( - value.as_str().ok_or_else(|| incorrect_type_error("string"))?.to_string() + value + .as_str() + .ok_or_else(|| incorrect_type_error("string"))? + .to_string(), ), LoaderFieldType::Boolean => VersionFieldValue::Boolean( - value.as_bool().ok_or_else(|| incorrect_type_error("boolean"))? + value + .as_bool() + .ok_or_else(|| incorrect_type_error("boolean"))?, ), - LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger( -{ - let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of integers"))?; - array_values.into_iter().map(|v| v).collect() -} ), - LoaderFieldType::ArrayText => VersionFieldValue::ArrayText( - { - let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of strings"))?; - array_values.into_iter().map(|v| v.to_string()).collect() + LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of integers"))?; + array_values.into_iter().collect() + }), + LoaderFieldType::ArrayText => VersionFieldValue::ArrayText({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of strings"))?; + array_values.into_iter().collect() + }), + LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of booleans"))?; + array_values.into_iter().map(|v| v != 0).collect() + }), + LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, { + let enum_value = value.as_str().ok_or_else(|| incorrect_type_error("enum"))?; + if let Some(ev) = enum_array.into_iter().find(|v| v.value == enum_value) { + ev + } else { + return Err(format!( + "Provided value '{enum_value}' is not a valid variant for {field_name}" + )); } - ), - LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean( - { - let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of booleans"))?; - array_values.into_iter().map(|v| v != 0).collect() - } - ), - LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, - { - let enum_value = value.as_str().ok_or_else(|| incorrect_type_error("enum"))?; - if let Some(ev) = enum_array.into_iter().find(|v| v.value == enum_value) { - ev + }), + LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum(*id, { + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of enums"))?; + let mut enum_values = vec![]; + for av in array_values { + if let Some(ev) = enum_array.iter().find(|v| v.value == av) { + enum_values.push(ev.clone()); } else { - return Err(format!("Provided value '{enum_value}' is not a valid variant for {field_name}")); + return Err(format!( + "Provided value '{av}' is not a valid variant for {field_name}" + )); } } - ), - LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum(*id, - { - let array_values : Vec = serde_json::from_value(value).map_err(|_| incorrect_type_error("array of enums"))?; - let mut enum_values = vec![]; - for av in array_values { - if let Some(ev) = enum_array.iter().find(|v| v.value == av) { - enum_values.push(ev.clone()); - } else { - return Err(format!("Provided value '{av}' is not a valid variant for {field_name}")); - } - } - enum_values - } - ), + enum_values + }), }) } - pub fn build(field_type : &LoaderFieldType, qvfs : Vec) -> Result { + // Build from internal query data + // This encapsulates reundant behavior in db querie -> object conversions + pub fn build( + field_type: &LoaderFieldType, + qvfs: Vec, + ) -> Result { let field_name = field_type.to_str(); - // TODO: should not use numbers , should use id with tostring let get_first = |qvfs: Vec| -> Result { if qvfs.len() > 1 { - return Err(DatabaseError::SchemaError( - format!("Multiple fields for field {}", field_name) - )); + return Err(DatabaseError::SchemaError(format!( + "Multiple fields for field {}", + field_name + ))); } - Ok(qvfs.into_iter().next().ok_or_else(|| DatabaseError::SchemaError( - format!("No version fields for field {}", field_name) - ))?) + qvfs.into_iter().next().ok_or_else(|| { + DatabaseError::SchemaError(format!("No version fields for field {}", field_name)) + }) }; - // TODO: should not use numbers , should use id with tostring - let did_not_exist_error = |field_name : &str, desired_field : &str| DatabaseError::SchemaError( - format!("Field name {} for field {} in does not exist", desired_field , field_name)); + let did_not_exist_error = |field_name: &str, desired_field: &str| { + DatabaseError::SchemaError(format!( + "Field name {} for field {} in does not exist", + desired_field, field_name + )) + }; Ok(match field_type { LoaderFieldType::Integer => VersionFieldValue::Integer( - get_first(qvfs)?.int_value.ok_or(did_not_exist_error(field_name, "int_value"))? + get_first(qvfs)? + .int_value + .ok_or(did_not_exist_error(field_name, "int_value"))?, ), LoaderFieldType::Text => VersionFieldValue::Text( - get_first(qvfs)?.string_value.ok_or(did_not_exist_error( field_name, "string_value"))? + get_first(qvfs)? + .string_value + .ok_or(did_not_exist_error(field_name, "string_value"))?, ), LoaderFieldType::Boolean => VersionFieldValue::Boolean( - get_first(qvfs)?.int_value.ok_or(did_not_exist_error(field_name, "int_value"))? != 0 + get_first(qvfs)? + .int_value + .ok_or(did_not_exist_error(field_name, "int_value"))? + != 0, ), LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger( - qvfs.into_iter().map(|qvf| - Ok::(qvf.int_value.ok_or(did_not_exist_error(field_name, "int_value"))?)).collect::>()? + qvfs.into_iter() + .map(|qvf| { + qvf.int_value + .ok_or(did_not_exist_error(field_name, "int_value")) + }) + .collect::>()?, ), LoaderFieldType::ArrayText => VersionFieldValue::ArrayText( - qvfs.into_iter().map(|qvf| - Ok::(qvf.string_value.ok_or(did_not_exist_error( field_name, "string_value"))?)).collect::>()? + qvfs.into_iter() + .map(|qvf| { + qvf.string_value + .ok_or(did_not_exist_error(field_name, "string_value")) + }) + .collect::>()?, ), LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean( - qvfs.into_iter().map(|qvf| - Ok::(qvf.int_value.ok_or(did_not_exist_error( field_name, "int_value"))? != 0)).collect::>()? + qvfs.into_iter() + .map(|qvf| { + Ok::( + qvf.int_value + .ok_or(did_not_exist_error(field_name, "int_value"))? + != 0, + ) + }) + .collect::>()?, ), - LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, - get_first(qvfs)?.enum_value.ok_or(did_not_exist_error( field_name, "enum_value"))? + LoaderFieldType::Enum(id) => VersionFieldValue::Enum( + *id, + get_first(qvfs)? + .enum_value + .ok_or(did_not_exist_error(field_name, "enum_value"))?, ), - LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum(*id, - qvfs.into_iter().map(|qvf| - Ok::(qvf.enum_value.ok_or(did_not_exist_error( field_name, "enum_value"))?)).collect::>()? + LoaderFieldType::ArrayEnum(id) => VersionFieldValue::ArrayEnum( + *id, + qvfs.into_iter() + .map(|qvf| { + qvf.enum_value + .ok_or(did_not_exist_error(field_name, "enum_value")) + }) + .collect::>()?, ), }) } + // Serialize to internal value, such as for converting to user-facing JSON pub fn serialize_internal(&self) -> serde_json::Value { - // Serialize to internal value match self { VersionFieldValue::Integer(i) => serde_json::Value::Number((*i).into()), VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), - VersionFieldValue::ArrayInteger(v) => serde_json::Value::Array(v.iter().map(|i| serde_json::Value::Number((*i).into())).collect()), - VersionFieldValue::ArrayText(v) => serde_json::Value::Array(v.iter().map(|s| serde_json::Value::String(s.clone())).collect()), - VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array(v.iter().map(|b| serde_json::Value::Bool(*b)).collect()), + VersionFieldValue::ArrayInteger(v) => serde_json::Value::Array( + v.iter() + .map(|i| serde_json::Value::Number((*i).into())) + .collect(), + ), + VersionFieldValue::ArrayText(v) => serde_json::Value::Array( + v.iter() + .map(|s| serde_json::Value::String(s.clone())) + .collect(), + ), + VersionFieldValue::ArrayBoolean(v) => { + serde_json::Value::Array(v.iter().map(|b| serde_json::Value::Bool(*b)).collect()) + } VersionFieldValue::Enum(_, v) => serde_json::Value::String(v.value.clone()), - VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array(v.iter().map(|v| serde_json::Value::String(v.value.clone())).collect()), + VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array( + v.iter() + .map(|v| serde_json::Value::String(v.value.clone())) + .collect(), + ), } } + // For conversion to an interanl string, such as for search facets pub fn as_search_strings(&self) -> Vec { match self { VersionFieldValue::Integer(i) => vec![i.to_string()], @@ -882,160 +944,3 @@ impl VersionFieldValue { } } } - -#[derive(Default)] -pub struct GameVersionBuilder<'a> { - pub version: Option<&'a str>, - pub version_type: Option<&'a str>, - pub date: Option<&'a DateTime>, -} - -impl<'a> GameVersionBuilder<'a> { - pub fn new() -> Self { - Self::default() - } - /// The game version. Spaces must be replaced with '_' for it to be valid - pub fn version(self, version: &'a str) -> Result, DatabaseError> { - Ok(Self { - version: Some(version), - ..self - }) - } - - pub fn version_type( - self, - version_type: &'a str, - ) -> Result, DatabaseError> { - Ok(Self { - version_type: Some(version_type), - ..self - }) - } - - pub fn created(self, created: &'a DateTime) -> GameVersionBuilder<'a> { - Self { - date: Some(created), - ..self - } - } - - pub async fn insert<'b, E>(self, exec: E, redis: &RedisPool) -> Result - where - E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy - { - // TODO: this is hardcoded for minecraft-java - let game_name = Game::MinecraftJava.name(); - let game_versions_enum = LoaderFieldEnum::get("game_versions", game_name, exec, redis).await? - .ok_or(DatabaseError::SchemaError("Missing loaders field: 'game_versions'".to_string()))?; - - - // Get enum id for game versions - let metadata = json!({ - "type": self.version_type, - "major": false - }); - - // This looks like a mess, but it *should* work - // This allows game versions to be partially updated without - // replacing the unspecified fields with defaults. - let result = sqlx::query!( - " - INSERT INTO loader_field_enum_values (enum_id, value, created, metadata) - VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4) - ON CONFLICT (enum_id, value) DO UPDATE - SET metadata = COALESCE($4, loader_field_enum_values.metadata), - created = COALESCE($3, loader_field_enum_values.created) - RETURNING id - ", - game_versions_enum.id.0, - self.version, - self.date.map(chrono::DateTime::naive_utc), - metadata - ) - .fetch_one(exec) - .await?; - - Ok(GameVersionId(result.id)) - } - -} - -impl GameVersion { - pub fn builder() -> GameVersionBuilder<'static> { - GameVersionBuilder::default() - } - - // pub async fn get_id<'a, E>( - // version: &str, - // exec: E, - // ) -> Result, DatabaseError> - // where - // E: sqlx::Executor<'a, Database = sqlx::Postgres>, - // { - // let result = sqlx::query!( - // " - // SELECT id FROM game_versions - // WHERE version = $1 - // ", - // version - // ) - // .fetch_optional(exec) - // .await?; - - // Ok(result.map(|r| GameVersionId(r.id))) - // } - - // pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> - // where - // E: sqlx::Executor<'a, Database = sqlx::Postgres>, - // { - // let result = sqlx::query!( - // " - // SELECT - // SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major FROM game_versions gv - // ORDER BY created DESC - // " - // ) - // .fetch_many(exec) - // .try_filter_map(|e| async { Ok(e.right().map(|c| GameVersion { - // id: GameVersionId(c.id), - // version: c.version_, - // type_: c.type_, - // created: c.created, - // major: c.major - // })) }) - // .try_collect::>() - // .await?; - - // Ok(result) - // } - - // pub async fn list_filter<'a, E>( - // version_type_option: Option<&str>, - // major_option: Option, - // exec: E, - // redis: &RedisPool, - // ) -> Result, DatabaseError> - // where - // E: sqlx::Executor<'a, Database = sqlx::Postgres>, - // { - // let result = Self::list(exec, redis) - // .await? - // .into_iter() - // .filter(|x| { - // let mut bool = true; - - // if let Some(version_type) = version_type_option { - // bool &= &*x.type_ == version_type; - // } - // if let Some(major) = major_option { - // bool &= x.major == major; - // } - - // bool - // }) - // .collect(); - - // Ok(result) - // } -} \ No newline at end of file diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 16dac493..1f6ec9f4 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -5,6 +5,7 @@ pub mod collection_item; pub mod flow_item; pub mod ids; pub mod image_item; +pub mod legacy_loader_fields; pub mod loader_fields; pub mod notification_item; pub mod organization_item; @@ -41,5 +42,5 @@ pub enum DatabaseError { #[error("Error while serializing with the cache: {0}")] SerdeCacheError(#[from] serde_json::Error), #[error("Schema error: {0}")] - SchemaError(String) + SchemaError(String), } diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 2a10b5e5..0a6afe5a 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -1,3 +1,4 @@ +use super::loader_fields::Game; use super::{ids::*, User}; use crate::database::models; use crate::database::models::DatabaseError; @@ -141,7 +142,7 @@ impl ModCategory { #[derive(Clone)] pub struct ProjectBuilder { pub project_id: ProjectId, - pub game_id : GameId, + pub game: Game, pub project_type_id: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, @@ -174,7 +175,7 @@ impl ProjectBuilder { ) -> Result { let project_struct = Project { id: self.project_id, - game_id : self.game_id, + game: self.game, project_type: self.project_type_id, team_id: self.team_id, organization_id: self.organization_id, @@ -249,7 +250,7 @@ impl ProjectBuilder { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Project { pub id: ProjectId, - pub game_id : GameId, + pub game: Game, pub project_type: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, @@ -572,7 +573,7 @@ impl Project { let db_projects: Vec = sqlx::query!( " - SELECT m.id id, m.game_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, + SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.body body, m.published published, m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, @@ -585,6 +586,7 @@ impl Project { JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery, JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations FROM mods m + INNER JOIN games g ON g.id = m.game_id INNER JOIN project_types pt ON pt.id = m.project_type INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_gallery mg ON mg.mod_id = m.id @@ -594,7 +596,7 @@ impl Project { LEFT JOIN categories c ON mc.joining_category_id = c.id LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3) WHERE m.id = ANY($1) OR m.slug = ANY($2) - GROUP BY pt.id, t.id, m.id; + GROUP BY pt.id, t.id, m.id, g.name; ", &project_ids_parsed, &remaining_strings.into_iter().map(|x| x.to_string().to_lowercase()).collect::>(), @@ -602,12 +604,12 @@ impl Project { ) .fetch_many(exec) .try_filter_map(|e| async { - Ok(e.right().map(|m| { + Ok(e.right().and_then(|m| { let id = m.id; - QueryProject { + Some(QueryProject { inner: Project { id: ProjectId(id), - game_id: GameId(m.game_id), + game: m.name.and_then(|g| Game::from_name(&g))?, project_type: ProjectTypeId(m.project_type), team_id: TeamId(m.team_id), organization_id: m.organization_id.map(OrganizationId), @@ -676,7 +678,7 @@ impl Project { m.donations.unwrap_or_default(), ).ok().unwrap_or_default(), thread_id: ThreadId(m.thread_id), - }})) + })})) }) .try_collect::>() .await?; diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 45c8bcc2..8b28014d 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,6 +1,6 @@ use super::ids::*; -use super::DatabaseError; use super::loader_fields::VersionField; +use super::DatabaseError; use crate::database::redis::RedisPool; use crate::models::projects::{FileType, VersionStatus}; use chrono::{DateTime, Utc}; @@ -240,7 +240,7 @@ impl VersionBuilder { VersionFileBuilder::insert_many(files, self.version_id, transaction).await?; DependencyBuilder::insert_many(dependencies, self.version_id, transaction).await?; - + let loader_versions = loaders .iter() .map(|l| LoaderVersion::new(*l, version_id)) diff --git a/src/lib.rs b/src/lib.rs index 6dbd8d01..d0f595a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -179,7 +179,7 @@ pub fn app_setup( let reader = maxmind.clone(); { - let reader_ref = reader.clone(); + let reader_ref = reader; scheduler.run(std::time::Duration::from_secs(60 * 60 * 24), move || { let reader_ref = reader_ref.clone(); diff --git a/src/models/projects.rs b/src/models/projects.rs index c9707bc1..636e676d 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -481,18 +481,17 @@ pub struct Version { pub dependencies: Vec, /// The loaders that this version works on pub loaders: Vec, - } // A loader and its associated loader VersionFields #[derive(Serialize, Deserialize, Validate, Clone, Debug)] pub struct LoaderStruct { - pub loader : Loader, + pub loader: Loader, // All other fields are loader-specific VersionFields #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] - pub fields : HashMap, + pub fields: HashMap, } fn skip_nulls<'de, D>(deserializer: D) -> Result, D::Error> @@ -500,32 +499,36 @@ where D: serde::Deserializer<'de>, { let mut map = HashMap::deserialize(deserializer)?; - map.retain(|_, v : &mut serde_json::Value | !v.is_null()); + map.retain(|_, v: &mut serde_json::Value| !v.is_null()); Ok(map) } - impl From for Version { fn from(data: QueryVersion) -> Version { let v = data.inner; - let loader_names : Vec = data.loaders.into_iter().map(Loader).collect(); - let mut loaders : HashMap = HashMap::new(); + let loader_names: Vec = data.loaders.into_iter().map(Loader).collect(); + let mut loaders: HashMap = HashMap::new(); for loader in loader_names { - loaders.insert(loader.0.clone(), LoaderStruct { - loader, - fields: HashMap::new(), - }); + loaders.insert( + loader.0.clone(), + LoaderStruct { + loader, + fields: HashMap::new(), + }, + ); } for version_field in data.version_fields { if let Some(loader_struct) = loaders.get_mut(&version_field.loader_name) { // Only add the internal component of the field for display // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) - loader_struct.fields.insert(version_field.field_name, version_field.value.serialize_internal()); + loader_struct.fields.insert( + version_field.field_name, + version_field.value.serialize_internal(), + ); } } - let loaders = loaders.into_iter().map(|(_, v)| v).collect(); - + let loaders = loaders.into_values().collect(); Version { id: v.id.into(), @@ -792,11 +795,6 @@ impl FileType { } } -/// A specific version of Minecraft -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)] -#[serde(transparent)] -pub struct GameVersion(pub String); - /// A project loader #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(transparent)] diff --git a/src/routes/maven.rs b/src/routes/maven.rs index d92a1cca..9fd344e1 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -1,4 +1,5 @@ -use crate::database::models::loader_fields::{Loader, Game, GameVersion}; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::loader_fields::{Game, Loader}; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::{QueryFile, QueryVersion}; use crate::database::redis::RedisPool; @@ -179,7 +180,8 @@ async fn find_version( return Ok(exact_matches.get(0).map(|x| (*x).clone())); }; - let db_loaders: HashSet = Loader::list(Game::MinecraftJava.name(), pool, redis) + // Hardcoded to minecraft-java + let db_loaders: HashSet = Loader::list(Game::MinecraftJava, pool, redis) .await? .into_iter() .map(|x| x.loader) @@ -200,11 +202,16 @@ async fn find_version( } // For maven in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive - // TODO: should this also be changed to loader_fields? if !game_versions.is_empty() { - let version_game_versions = x.version_fields.clone().into_iter().find_map(|v| GameVersion::try_from_version_field(&v).ok()); + let version_game_versions = x + .version_fields + .clone() + .into_iter() + .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()); if let Some(version_game_versions) = version_game_versions { - bool &= version_game_versions.iter().any(|y| game_versions.contains(&y.version)); + bool &= version_game_versions + .iter() + .any(|y| game_versions.contains(&y.version)); } } diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 517a73ca..7a4c2881 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -6,7 +6,7 @@ use sqlx::PgPool; use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized}; use crate::database; -use crate::database::models::loader_fields::GameVersion; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; use crate::models::projects::VersionType; @@ -97,15 +97,18 @@ pub async fn forge_updates( for version in versions { // For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive - // TODO: should this also be changed to loader_fields? - // Will have duplicates between game_versions (for non-forge loaders), but that's okay as + // Will have duplicates between game_versions (for non-forge loaders), but that's okay as // before v3 this was stored to the project and not the version - let game_version_values : Vec = version + let game_version_values: Vec = version .loaders .iter() - .filter_map(|x| x.fields.get(GameVersion::LEGACY_FIELD_NAME).cloned()).collect(); - let game_versions : Vec = - game_version_values.into_iter().filter_map(|v| serde_json::from_value::>(v).ok()).flatten().collect(); + .filter_map(|x| x.fields.get(MinecraftGameVersion::FIELD_NAME).cloned()) + .collect(); + let game_versions: Vec = game_version_values + .into_iter() + .filter_map(|v| serde_json::from_value::>(v).ok()) + .flatten() + .collect(); if version.version_type == VersionType::Release { for game_version in &game_versions { diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index 0fe37a0d..bdd81af9 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -30,7 +30,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("admin") .service(count_download) .service(trolley_webhook) - .service(force_reindex) + .service(force_reindex), ); } @@ -328,6 +328,6 @@ pub async fn force_reindex( config: web::Data, ) -> Result { use crate::search::indexing::index_projects; - index_projects(pool.as_ref().clone(), &config).await?; + index_projects(pool.as_ref().clone(), &config).await?; Ok(HttpResponse::NoContent().finish()) -} \ No newline at end of file +} diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs index e825b9a3..a4331473 100644 --- a/src/routes/v2/collections.rs +++ b/src/routes/v2/collections.rs @@ -8,8 +8,8 @@ use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; +use crate::routes::ApiError; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 5b25de7b..16a8bd8d 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -2,14 +2,14 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::queue::session::AuthQueue; -use crate::routes::{v3, v2_reroute}; use crate::routes::v3::project_creation::CreateError; +use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; +use serde_json::json; use sqlx::postgres::PgPool; use std::sync::Arc; -use serde_json::json; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_create); @@ -24,7 +24,6 @@ pub async fn project_create( file_host: Data>, session_queue: Data, ) -> Result { - // Convert V2 multipart payload to V3 multipart payload let mut saved_slug = None; let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { @@ -41,8 +40,14 @@ pub async fn project_create( // loaders: [{"loader": "fabric", "game_versions": ["1.16.5", "1.17"]}] // Side types will be applied to each version - let client_side = json["client_side"].as_str().unwrap_or("required").to_string(); - let server_side = json["server_side"].as_str().unwrap_or("required").to_string(); + let client_side = json["client_side"] + .as_str() + .unwrap_or("required") + .to_string(); + let server_side = json["server_side"] + .as_str() + .unwrap_or("required") + .to_string(); json["client_side"] = json!(null); json["server_side"] = json!(null); @@ -66,21 +71,26 @@ pub async fn project_create( version["loaders"] = json!(loaders); } } - - }).await?; + }) + .await?; // Call V3 project creation - let response= v3::project_creation::project_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + let response = v3::project_creation::project_create( + req, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + ) + .await?; - // Convert response to V2 forma + // Convert response to V2 format match v2_reroute::extract_ok_json(response).await { Ok(mut json) => { - v2_reroute::set_side_types_from_versions(&mut json, &**client, &redis).await?; - Ok(HttpResponse::Ok().json(json)) - }, - Err(response) => Ok(response) + v2_reroute::set_side_types_from_versions(&mut json, &**client, &redis).await?; + Ok(HttpResponse::Ok().json(json)) + } + Err(response) => Ok(response), } - - // TODO: Convert response to V2 format } - diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 59149cfa..fb685300 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,22 +1,23 @@ use crate::auth::{get_user_from_headers, is_authorized}; -use crate::{database, search}; -use crate::database::models::{image_item, version_item, project_item}; use crate::database::models::project_item::{GalleryItem, ModCategory}; +use crate::database::models::{image_item, project_item, version_item}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, Loader, + DonationLink, Loader, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, + SideType, }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v2_reroute, v3}; use crate::routes::v3::projects::{delete_from_index, ProjectIds}; +use crate::routes::{v2_reroute, v3, ApiError}; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; +use crate::{database, search}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use futures::TryStreamExt; @@ -64,13 +65,12 @@ pub async fn project_search( web::Query(info): web::Query, config: web::Data, ) -> Result { - // TODO: redirect to v3 // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields // Loader fields are: - // (loader)_(field):(value) + // (loader)_(field):(value) // The first _ by convention is used to separate the loader from the field // For each v2 loader, we create a loader field facet for each of these fields that are now loader fields - let facets : Option>> = if let Some(facets) = info.facets { + let facets: Option>> = if let Some(facets) = info.facets { let facets = serde_json::from_str::>>(&facets)?; // "versions:x" => "fabric_game_versions:x", "forge_game_versions:x" ... @@ -82,49 +82,56 @@ pub async fn project_search( let meilisearch_index = client.get_index(search::get_sort_index(index)?.0).await?; let filterable_fields = meilisearch_index.get_filterable_attributes().await?; // Only keep v2 loaders that are filterable - v2_loaders = v2_loaders.into_iter().filter(|x| filterable_fields.iter().any(|f| f.starts_with(&format!("{}_game_versions", x)))).collect(); - + v2_loaders.retain(|x| { + filterable_fields + .iter() + .any(|f| f.starts_with(&format!("{}_game_versions", x))) + }); } - Some(facets.into_iter().map(|facet| { - facet + Some( + facets .into_iter() .map(|facet| { - let version = match facet.split(":").nth(1) { - Some(version) => version, - None => return vec![facet.to_string()], - }; - - if facet.starts_with("versions:") { - v2_loaders - .iter() - .map(|loader| format!("{}_game_versions:{}", loader, version)) - .collect::>() - } else if facet.starts_with("client_side:") { - v2_loaders - .iter() - .map(|loader| format!("{}_client_side:{}", loader, version)) - .collect::>() - } else if facet.starts_with("server_side:") { - v2_loaders - .iter() - .map(|loader| format!("{}_server_side:{}", loader, version)) - .collect::>() - } else { - vec![facet.to_string()] - } + facet + .into_iter() + .flat_map(|facet| { + let version = match facet.split(':').nth(1) { + Some(version) => version, + None => return vec![facet.to_string()], + }; + + if facet.starts_with("versions:") { + v2_loaders + .iter() + .map(|loader| format!("{}_game_versions:{}", loader, version)) + .collect::>() + } else if facet.starts_with("client_side:") { + v2_loaders + .iter() + .map(|loader| format!("{}_client_side:{}", loader, version)) + .collect::>() + } else if facet.starts_with("server_side:") { + v2_loaders + .iter() + .map(|loader| format!("{}_server_side:{}", loader, version)) + .collect::>() + } else { + vec![facet.to_string()] + } + }) + .collect::>() }) - .flatten() - .collect::>() - }).collect()) + .collect(), + ) } else { None }; - + let info = SearchRequest { - facets : facets.and_then(|x| serde_json::to_string(&x).ok()), + facets: facets.and_then(|x| serde_json::to_string(&x).ok()), ..info }; - + let results = search_for_project(&info, &config).await?; Ok(HttpResponse::Ok().json(results)) } @@ -180,7 +187,14 @@ pub async fn projects_get( // Convert V2 data to V3 data // Call V3 project creation - let response= v3::projects::projects_get(req, web::Query(ids), pool.clone(), redis.clone(), session_queue).await?; + let response = v3::projects::projects_get( + req, + web::Query(ids), + pool.clone(), + redis.clone(), + session_queue, + ) + .await?; // Convert response to V2 forma match v2_reroute::extract_ok_json(response).await { @@ -191,9 +205,9 @@ pub async fn projects_get( v2_reroute::set_side_types_from_versions(project, &**pool, &redis).await?; } } - Ok(HttpResponse::Ok().json(json)) - }, - Err(response) => Ok(response) + Ok(HttpResponse::Ok().json(json)) + } + Err(response) => Ok(response), } } @@ -208,16 +222,17 @@ pub async fn project_get( // Convert V2 data to V3 data // Call V3 project creation - let response= v3::projects::project_get(req, info, pool.clone(), redis.clone(), session_queue).await?; + let response = + v3::projects::project_get(req, info, pool.clone(), redis.clone(), session_queue).await?; // Convert response to V2 forma match v2_reroute::extract_ok_json(response).await { Ok(mut json) => { v2_reroute::set_side_types_from_versions(&mut json, &**pool, &redis).await?; - - Ok(HttpResponse::Ok().json(json)) - }, - Err(response) => Ok(response) + + Ok(HttpResponse::Ok().json(json)) + } + Err(response) => Ok(response), } } @@ -433,7 +448,6 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - // TODO: Should call v3 route let v2_new_project = new_project.into_inner(); let client_side = v2_new_project.client_side.clone(); let server_side = v2_new_project.server_side.clone(); @@ -451,8 +465,6 @@ pub async fn project_edit( discord_url: v2_new_project.discord_url, donation_urls: v2_new_project.donation_urls, license_id: v2_new_project.license_id, - // client_side: new_project.client_side, - // server_side: new_project.server_side, slug: v2_new_project.slug, status: v2_new_project.status, requested_status: v2_new_project.requested_status, @@ -460,45 +472,57 @@ pub async fn project_edit( moderation_message_body: v2_new_project.moderation_message_body, monetization_status: v2_new_project.monetization_status, }; - // TODO: client_side and server_side - // 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(req.clone(), info, pool.clone(), config, web::Json(new_project), redis.clone(), session_queue.clone()).await?; - + let mut response = v3::projects::project_edit( + req.clone(), + info, + pool.clone(), + config, + web::Json(new_project), + redis.clone(), + session_queue.clone(), + ) + .await?; + // If client and server side were set, we will call // the version setting route for each version to set the side types for each of them. - if response.status().is_success() { - if client_side.is_some() || server_side.is_some() { - let project_item = project_item::Project::get(&new_slug.unwrap_or(project_id), &**pool, &redis).await?; + if response.status().is_success() && (client_side.is_some() || server_side.is_some()) { + let project_item = + project_item::Project::get(&new_slug.unwrap_or(project_id), &**pool, &redis).await?; let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); let versions = version_item::Version::get_many(&version_ids, &**pool, &redis).await?; - for version in versions { - let loaders : Result, _> = version.loaders.into_iter().map(|l| serde_json::from_value(json!({ - - "loader": Loader(l), - "client_side": client_side, - "server_side": server_side, - }))).collect(); - - response = v3::versions::version_edit_helper(req.clone(), (version.inner.id.into(),), pool.clone(), redis.clone(), v3::versions::EditVersion { - loaders: Some(loaders?), - ..Default::default() - }, session_queue.clone()).await?; - } + for version in versions { + let loaders: Result, _> = version + .loaders + .into_iter() + .map(|l| { + serde_json::from_value(json!({ + "loader": Loader(l), + "client_side": client_side, + "server_side": server_side, + })) + }) + .collect(); + + response = v3::versions::version_edit_helper( + req.clone(), + (version.inner.id.into(),), + pool.clone(), + redis.clone(), + v3::versions::EditVersion { + loaders: Some(loaders?), + ..Default::default() + }, + session_queue.clone(), + ) + .await?; } - - - } - + } Ok(response) - - // TODO: Convert response to V2 format - } - #[derive(derive_new::new)] pub struct CategoryChanges<'a> { pub categories: &'a Option>, @@ -1884,4 +1908,3 @@ pub async fn project_unfollow( )) } } - diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 7694c49e..6e7c38c6 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::categories::{DonationPlatform, ProjectType, ReportType, Category}; +use crate::database::models::categories::{Category, DonationPlatform, ProjectType, ReportType}; use crate::database::models::loader_fields::Game; use crate::database::redis::RedisPool; use crate::routes::v3; -use crate::routes::v3::tags::{LoaderList, LoaderFieldsEnumQuery}; +use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderList}; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; @@ -64,7 +64,14 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::tags::loader_list(web::Query(LoaderList {game: Game::MinecraftJava.name().to_string()}), pool, redis).await?; + let response = v3::tags::loader_list( + web::Query(LoaderList { + game: Game::MinecraftJava.name().to_string(), + }), + pool, + redis, + ) + .await?; Ok(response) } @@ -89,7 +96,6 @@ pub async fn game_version_list( query: web::Query, redis: web::Data, ) -> Result { - // TODO: should cvall v3 let mut filters = HashMap::new(); if let Some(type_) = &query.type_ { filters.insert("type".to_string(), serde_json::json!(type_)); @@ -97,12 +103,18 @@ pub async fn game_version_list( if let Some(major) = query.major { filters.insert("major".to_string(), serde_json::json!(major)); } - let response = v3::tags::loader_fields_list(pool, web::Query(LoaderFieldsEnumQuery { - game: Game::MinecraftJava.name().to_string(), - field: "game_version".to_string(), - filters: Some(filters), - }), redis).await?; - + let response = v3::tags::loader_fields_list( + pool, + web::Query(LoaderFieldsEnumQuery { + game: Game::MinecraftJava.name().to_string(), + field: "game_version".to_string(), + filters: Some(filters), + }), + redis, + ) + .await?; + + // TODO: parse v3 to v2 Ok(response) } @@ -201,12 +213,16 @@ pub async fn side_type_list( pool: web::Data, redis: web::Data, ) -> Result { - // TODO: should cvall v3 - let response = v3::tags::loader_fields_list(pool, web::Query(LoaderFieldsEnumQuery { - game: Game::MinecraftJava.name().to_string(), - field: "client_type".to_string(), // same as server_type - filters: None, - }), redis).await?; - + let response = v3::tags::loader_fields_list( + pool, + web::Query(LoaderFieldsEnumQuery { + game: Game::MinecraftJava.name().to_string(), + field: "client_type".to_string(), // same as server_type + filters: None, + }), + redis, + ) + .await?; + // TODO: parse v3 to v2 Ok(response) } diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index ed1a2754..b0563baa 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -2,19 +2,18 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::ImageId; use crate::models::projects::{ - Dependency, FileType, GameVersion, Loader, ProjectId, - VersionId, VersionStatus, VersionType, + Dependency, FileType, Loader, ProjectId, VersionId, VersionStatus, VersionType, }; use crate::queue::session::AuthQueue; -use crate::routes::{v2_reroute, v3}; use crate::routes::v3::project_creation::CreateError; +use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; +use serde_json::json; use sqlx::postgres::PgPool; use std::collections::HashMap; -use serde_json::json; use std::sync::Arc; use validator::Validate; @@ -48,7 +47,7 @@ pub struct InitialVersionData { )] pub dependencies: Vec, #[validate(length(min = 1))] - pub game_versions: Vec, + pub game_versions: Vec, #[serde(alias = "version_type")] pub release_channel: VersionType, #[validate(length(min = 1))] @@ -81,7 +80,6 @@ pub async fn version_create( file_host: Data>, session_queue: Data, ) -> Result { - // TODO: should call this from the v3 let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { // Convert input data to V3 format @@ -99,12 +97,19 @@ pub async fn version_create( })); } json["loaders"] = json!(loaders); - - - }).await?; + }) + .await?; // Call V3 project creation - let response= v3::version_creation::version_create(req, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + let response = v3::version_creation::version_create( + req, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + ) + .await?; // Convert response to V2 forma match v2_reroute::extract_ok_json(response).await { @@ -128,10 +133,10 @@ pub async fn version_create( } json["game_versions"] = json!(game_versions); json["loaders"] = json!(loaders); - + Ok(HttpResponse::Ok().json(json)) - }, - Err(response) => Ok(response) + } + Err(response) => Ok(response), } } @@ -147,6 +152,15 @@ pub async fn upload_file_to_version( session_queue: web::Data, ) -> Result { // TODO: do we need to modify this? - let response= v3::version_creation::upload_file_to_version(req, url_data, payload, client.clone(), redis.clone(), file_host, session_queue).await?; + let response = v3::version_creation::upload_file_to_version( + req, + url_data, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + ) + .await?; Ok(response) -} \ No newline at end of file +} diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index b10b50a1..5542df65 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -9,7 +9,7 @@ use crate::models::projects::VersionType; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::v3; -use crate::routes::v3::version_file::{HashQuery, default_algorithm}; +use crate::routes::v3::version_file::{default_algorithm, HashQuery}; use crate::{database, models}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; @@ -257,7 +257,7 @@ pub struct UpdateData { pub version_types: Option>, } -// TODO: this being left as empty was not caught by tests, so write tests for this +// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("{version_id}/update")] pub async fn get_update_from_hash( req: HttpRequest, @@ -289,8 +289,9 @@ pub async fn get_update_from_hash( hash_query, web::Json(update_data), session_queue, - ).await?; - + ) + .await?; + Ok(response) } @@ -410,6 +411,7 @@ pub struct ManyUpdateData { pub version_types: Option>, } +// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("update")] pub async fn update_files( req: HttpRequest, @@ -418,7 +420,6 @@ pub async fn update_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - // TODO: write tests for this, it didnt get caught by cargo test let update_data = update_data.into_inner(); let mut loader_fields = HashMap::new(); let mut game_versions = vec![]; @@ -434,13 +435,9 @@ pub async fn update_files( hashes: update_data.hashes, }; - let response = v3::version_file::update_files( - req, - pool, - redis, - web::Json(update_data), - session_queue, - ).await?; + let response = + v3::version_file::update_files(req, pool, redis, web::Json(update_data), session_queue) + .await?; Ok(response) } @@ -459,6 +456,7 @@ pub struct ManyFileUpdateData { pub hashes: Vec, } +// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("update_individual")] pub async fn update_individual_files( req: HttpRequest, @@ -467,23 +465,27 @@ pub async fn update_individual_files( update_data: web::Json, session_queue: web::Data, ) -> Result { - // TODO: write tests for this, it didnt get caught by cargo test let update_data = update_data.into_inner(); let update_data = v3::version_file::ManyFileUpdateData { algorithm: update_data.algorithm, - hashes: update_data.hashes.into_iter().map(|x| { - let mut loader_fields = HashMap::new(); - let mut game_versions = vec![]; - for gv in x.game_versions.into_iter().flatten() { - game_versions.push(serde_json::json!(gv.clone())); - } - loader_fields.insert("game_versions".to_string(), game_versions); - v3::version_file::FileUpdateData { - hash: x.hash.clone(), - loaders: x.loaders.clone(), - loader_fields: Some(loader_fields), - version_types: x.version_types.clone(), - }}).collect(), + hashes: update_data + .hashes + .into_iter() + .map(|x| { + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in x.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + v3::version_file::FileUpdateData { + hash: x.hash.clone(), + loaders: x.loaders.clone(), + loader_fields: Some(loader_fields), + version_types: x.version_types, + } + }) + .collect(), }; let response = v3::version_file::update_individual_files( @@ -492,7 +494,8 @@ pub async fn update_individual_files( redis, web::Json(update_data), session_queue, - ).await?; - + ) + .await?; + Ok(response) } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index eae65dc6..1a8c7421 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -8,11 +8,11 @@ use crate::database; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; -use crate::models::ids::VersionId; use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, LoaderStruct}; +use crate::models::projects::{Dependency, FileType, LoaderStruct, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::v3; @@ -46,6 +46,7 @@ pub struct VersionListFilters { pub offset: Option, } +// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[get("version")] pub async fn version_list( req: HttpRequest, @@ -55,19 +56,19 @@ pub async fn version_list( redis: web::Data, session_queue: web::Data, ) -> Result { - // TODO: move route to v3 - // TODO: write tests for this, it didnt get caught by cargo test let loader_fields = if let Some(game_versions) = filters.game_versions { // TODO: extract this logic which is similar to the other v2->v3 version_file functions let mut loader_fields = HashMap::new(); - serde_json::from_str::>(&game_versions).ok().and_then(|versions| { - let mut game_versions: Vec = vec![]; - for gv in versions { - game_versions.push(serde_json::json!(gv.clone())); - } - loader_fields.insert("game_versions".to_string(), game_versions); - serde_json::to_string(&loader_fields).ok() - }) + serde_json::from_str::>(&game_versions) + .ok() + .and_then(|versions| { + let mut game_versions: Vec = vec![]; + for gv in versions { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields.insert("game_versions".to_string(), game_versions); + serde_json::to_string(&loader_fields).ok() + }) } else { None }; @@ -81,7 +82,9 @@ pub async fn version_list( offset: filters.offset, }; - let response = v3::versions::version_list(req, info, web::Query(filters), pool, redis, session_queue).await?; + let response = + v3::versions::version_list(req, info, web::Query(filters), pool, redis, session_queue) + .await?; //TODO: Convert response to V2 format Ok(response) @@ -197,7 +200,6 @@ pub async fn version_get( } } - Ok(HttpResponse::NotFound().body("")) } @@ -221,7 +223,7 @@ pub struct EditVersion { custom(function = "crate::util::validate::validate_deps") )] pub dependencies: Option>, - pub game_versions: Option>, + pub game_versions: Option>, pub loaders: Option>, pub featured: Option, pub primary_file: Option<(String, String)>, @@ -246,8 +248,6 @@ pub async fn version_edit( new_version: web::Json, session_queue: web::Data, ) -> Result { - // TODO: Should call v3 route - let new_version = new_version.into_inner(); let new_version = v3::versions::EditVersion { name: new_version.name, @@ -255,29 +255,38 @@ pub async fn version_edit( changelog: new_version.changelog, version_type: new_version.version_type, dependencies: new_version.dependencies, - game_versions: new_version.game_versions, - loaders: new_version.loaders.map(|l| l.into_iter().map(|l| LoaderStruct { - loader: l, - fields: HashMap::new(), - }).collect::>()), + loaders: new_version.loaders.map(|l| { + l.into_iter() + .map(|l| LoaderStruct { + loader: l, + fields: HashMap::new(), + }) + .collect::>() + }), featured: new_version.featured, primary_file: new_version.primary_file, downloads: new_version.downloads, status: new_version.status, - file_types: new_version.file_types.map(|v| - v.into_iter().map(|evft| - v3::versions::EditVersionFileType { - algorithm: evft.algorithm, - hash: evft.hash, - file_type: evft.file_type, - }).collect::>() - ) - }; - // TODO: maybe should allow client server in loaders field? but probably not needed here - - let response = v3::versions::version_edit(req, info, pool, redis, web::Json(serde_json::to_value(new_version)?), session_queue).await?; + file_types: new_version.file_types.map(|v| { + v.into_iter() + .map(|evft| v3::versions::EditVersionFileType { + algorithm: evft.algorithm, + hash: evft.hash, + file_type: evft.file_type, + }) + .collect::>() + }), + }; - // TODO: Convert response to V2 format + let response = v3::versions::version_edit( + req, + info, + pool, + redis, + web::Json(serde_json::to_value(new_version)?), + session_queue, + ) + .await?; Ok(response) } diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 6493889d..d0ae7324 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -1,47 +1,77 @@ +use super::v3::project_creation::CreateError; +use crate::{ + database::{ + models::{version_item, DatabaseError}, + redis::RedisPool, + }, + models::ids::VersionId, + util::actix::{generate_multipart, MultipartSegment, MultipartSegmentData}, +}; use actix_multipart::Multipart; +use actix_web::http::header::{HeaderMap, TryIntoHeaderPair}; use actix_web::HttpResponse; -use actix_web::http::header::{TryIntoHeaderPair,HeaderMap}; -use futures::{StreamExt, stream}; -use serde_json::{Value, json}; -use crate::{database::{models::{version_item, DatabaseError}, redis::RedisPool}, models::ids::VersionId, util::actix::{MultipartSegment, MultipartSegmentData, generate_multipart}}; -use super::v3::project_creation::CreateError; - -pub async fn set_side_types_from_versions<'a, E>(json : &mut serde_json::Value, exec: E, redis: &RedisPool) -> Result<(), DatabaseError> -where E : sqlx::Executor<'a, Database = sqlx::Postgres> +use futures::{stream, StreamExt}; +use serde_json::{json, Value}; + +pub async fn set_side_types_from_versions<'a, E>( + json: &mut serde_json::Value, + exec: E, + redis: &RedisPool, +) -> Result<(), DatabaseError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, { json["client_side"] = json!("required"); // default to required json["server_side"] = json!("required"); let version_id = json["versions"].as_array().and_then(|a| a.iter().next()); if let Some(version_id) = version_id { let version_id = serde_json::from_value::(version_id.clone())?; - let versions_item = version_item::Version::get(version_id.into(), exec, &redis).await?; + let versions_item = version_item::Version::get(version_id.into(), exec, redis).await?; if let Some(versions_item) = versions_item { - json["client_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "client_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("required")); - json["server_side"] = versions_item.version_fields.iter().find(|f| f.field_name == "server_side").map(|f| f.value.serialize_internal()).unwrap_or(json!("server_side")); + json["client_side"] = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "client_side") + .map(|f| f.value.serialize_internal()) + .unwrap_or(json!("required")); + json["server_side"] = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "server_side") + .map(|f| f.value.serialize_internal()) + .unwrap_or(json!("server_side")); } } Ok(()) } - // TODO: this is not an ideal way to do this, but it works for now -pub async fn extract_ok_json(response : HttpResponse) -> Result { +pub async fn extract_ok_json(response: HttpResponse) -> Result { if response.status() == actix_web::http::StatusCode::OK { - let failure_http_response = || HttpResponse::InternalServerError().json(json!({ - "error": "reroute_error", - "description": "Could not parse response from V2 redirection of route." - })); + let failure_http_response = || { + HttpResponse::InternalServerError().json(json!({ + "error": "reroute_error", + "description": "Could not parse response from V2 redirection of route." + })) + }; // Takes json out of HttpResponse, mutates it, then regenerates the HttpResponse let body = response.into_body(); - let bytes = actix_web::body::to_bytes(body).await.map_err(|_| failure_http_response())?; - let json_value: Value = serde_json::from_slice(&bytes).map_err(|_| failure_http_response())?; + let bytes = actix_web::body::to_bytes(body) + .await + .map_err(|_| failure_http_response())?; + let json_value: Value = + serde_json::from_slice(&bytes).map_err(|_| failure_http_response())?; Ok(json_value) } else { Err(response) } } -pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: HeaderMap, mut closure: impl FnMut(&mut serde_json::Value)) -> Result { +pub async fn alter_actix_multipart( + mut multipart: Multipart, + mut headers: HeaderMap, + mut closure: impl FnMut(&mut serde_json::Value), +) -> Result { let mut segments: Vec = Vec::new(); if let Some(field) = multipart.next().await { @@ -64,12 +94,12 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header buffer = serde_json::to_vec(&json_value)?; } - segments.push(MultipartSegment { name: field_name.to_string(), - filename: field_filename.map(|s| s.to_string()), - content_type: field_content_type, - data: MultipartSegmentData::Binary(buffer) - }) - + segments.push(MultipartSegment { + name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(buffer), + }) } while let Some(field) = multipart.next().await { @@ -86,17 +116,22 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header buffer.extend_from_slice(&data); } - segments.push(MultipartSegment { name: field_name.to_string(), - filename: field_filename.map(|s| s.to_string()), - content_type: field_content_type, - data: MultipartSegmentData::Binary(buffer) - }) - + segments.push(MultipartSegment { + name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(buffer), + }) } let (boundary, payload) = generate_multipart(segments); - match ("Content-Type", format!("multipart/form-data; boundary={}", boundary).as_str()).try_into_pair() { + match ( + "Content-Type", + format!("multipart/form-data; boundary={}", boundary).as_str(), + ) + .try_into_pair() + { Ok((key, value)) => { headers.insert(key, value); } @@ -109,5 +144,3 @@ pub async fn alter_actix_multipart(mut multipart: Multipart, mut headers: Header Ok(new_multipart) } - - diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index e2e593c3..38963085 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -3,13 +3,12 @@ use crate::util::cors::default_cors; use actix_web::{web, HttpResponse}; use serde_json::json; -pub mod projects; pub mod project_creation; +pub mod projects; pub mod tags; -pub mod versions; pub mod version_creation; pub mod version_file; - +pub mod versions; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index ce7e0bbc..67696089 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -1,8 +1,10 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; -use crate::database::models::{self, image_item, User}; -use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField, LoaderField, Game}; +use crate::database::models::loader_fields::{ + Game, LoaderField, LoaderFieldEnumValue, VersionField, +}; use crate::database::models::thread_item::ThreadBuilder; +use crate::database::models::{self, image_item, User}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; @@ -10,8 +12,7 @@ use crate::models::ids::ImageId; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, VersionId, - VersionStatus, + DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, VersionId, VersionStatus, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::ThreadType; @@ -22,7 +23,7 @@ use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; -use actix_web::web::{Data, self}; +use actix_web::web::{self, Data}; use actix_web::{HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; @@ -378,7 +379,6 @@ async fn project_create_inner( let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); let project_create_data: ProjectCreateData; - let game_id; let game; let mut versions; let mut versions_map = std::collections::HashMap::new(); @@ -456,12 +456,11 @@ async fn project_create_inner( // Check game exists, and get loaders for it let game_name = &create_data.game_name; - game_id = models::loader_fields::Game::get_id( - &create_data.game_name, - &mut *transaction, - ).await?.ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; - game = Game::from_name(&create_data.game_name).ok_or_else(|| CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")))?; - let all_loaders = models::loader_fields::Loader::list(&game_name, &mut *transaction, redis).await?; + game = Game::from_name(&create_data.game_name).ok_or_else(|| { + CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")) + })?; + let all_loaders = + models::loader_fields::Loader::list(game, &mut *transaction, redis).await?; // Create VersionBuilders for the versions specified in `initial_versions` versions = Vec::with_capacity(create_data.initial_versions.len()); @@ -483,7 +482,7 @@ async fn project_create_inner( &all_loaders, &create_data.project_type, transaction, - redis + redis, ) .await?, ); @@ -608,7 +607,12 @@ async fn project_create_inner( created_version.version_id.into(), &created_version.version_fields, &project_create_data.project_type, - version_data.loaders.clone().into_iter().map(|l|l.loader).collect(), + version_data + .loaders + .clone() + .into_iter() + .map(|l| l.loader) + .collect(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), None, @@ -637,14 +641,13 @@ async fn project_create_inner( .iter() .zip(versions.iter()) { - if version_data.file_parts.len() != builder.files.len() { return Err(CreateError::InvalidInput(String::from( "Some files were specified in initial_versions but not uploaded", ))); } } - + // Convert the list of category names to actual categories let mut categories = Vec::with_capacity(project_create_data.categories.len()); for category in &project_create_data.categories { @@ -728,7 +731,7 @@ async fn project_create_inner( let project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), - game_id, + game, project_type_id, team_id, organization_id: project_create_data.organization_id, @@ -750,7 +753,8 @@ async fn project_create_inner( license: license_id.to_string(), slug: Some(project_create_data.slug), donation_urls, - gallery_items: gallery_urls.iter() + gallery_items: gallery_urls + .iter() .map(|x| models::project_item::GalleryItem { image_url: x.url.clone(), featured: x.featured, @@ -895,7 +899,7 @@ async fn create_initial_version( // .map(|y| y.id) // }) // .collect::, CreateError>>()?; - + let mut loader_ids = vec![]; let mut loaders = vec![]; let mut version_fields = vec![]; @@ -903,24 +907,39 @@ async fn create_initial_version( let loader_name = loader_create.loader.0.clone(); // Confirm loader from list of loaders let loader_id = all_loaders - .iter() - .find(|y| { - y.loader == loader_name && y.supported_project_types.contains(project_type) - }) - .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) - .map(|y| y.id)?; + .iter() + .find(|y| y.loader == loader_name && y.supported_project_types.contains(project_type)) + .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) + .map(|y| y.id)?; loader_ids.push(loader_id); loaders.push(loader_create.loader.clone()); - for (key, value) in loader_create.fields .iter() { - // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { - CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + let loader_fields = LoaderField::get_fields(loader_id, &mut *transaction, redis).await?; + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis) + .await?; + + for (key, value) in loader_create.fields.iter() { + let loader_field = loader_fields + .iter() + .find(|lf| &lf.field == key) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Loader field '{key}' does not exist for loader '{loader_name}'" + )) })?; - let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; - version_fields.push(vf); + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + value.clone(), + enum_variants, + ) + .map_err(CreateError::InvalidInput)?; + version_fields.push(vf); } } diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 7fe47797..f9aa8877 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -1,4 +1,6 @@ use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; +use crate::database::models as db_models; +use crate::database::models::ids as db_ids; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::project_item::ModCategory; use crate::database::models::thread_item::ThreadMessageBuilder; @@ -24,20 +26,17 @@ use meilisearch_sdk::indexes::IndexesResults; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; -use crate::database::models as db_models; -use crate::database::models::ids as db_ids; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( web::scope("project") - .route("{id}", web::get().to(project_get)) - .route("projects", web::get().to(projects_get)) - .route("{id}", web::patch().to(project_edit)) - .service( - web::scope("{project_id}") - .route("versions", web::get().to(super::versions::version_list)) - ) + .route("{id}", web::get().to(project_get)) + .route("projects", web::get().to(projects_get)) + .route("{id}", web::patch().to(project_edit)) + .service( + web::scope("{project_id}") + .route("versions", web::get().to(super::versions::version_list)), + ), ); } @@ -101,7 +100,6 @@ pub async fn project_get( Ok(HttpResponse::NotFound().body("")) } - #[derive(Serialize, Deserialize, Validate)] pub struct EditProject { #[validate( @@ -478,7 +476,6 @@ pub async fn project_edit( .execute(&mut *transaction) .await?; } - if perms.contains(ProjectPermissions::EDIT_DETAILS) { if new_project.categories.is_some() { @@ -935,7 +932,6 @@ pub async fn project_search( Ok(HttpResponse::Ok().json(results)) } - pub async fn delete_from_index( id: ProjectId, config: web::Data, @@ -950,4 +946,3 @@ pub async fn delete_from_index( Ok(()) } - diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index e4d6c917..99091384 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,20 +1,17 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::loader_fields::{Loader, LoaderFieldEnumValue, LoaderFieldEnum}; +use crate::database::models::loader_fields::{Game, Loader, LoaderFieldEnum, LoaderFieldEnumValue}; use crate::database::redis::RedisPool; use actix_web::{web, HttpResponse}; use serde_json::Value; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("tag") - .route("loader", web::get().to(loader_list))) + cfg.service(web::scope("tag").route("loader", web::get().to(loader_list))) .route("loader_fields", web::get().to(loader_fields_list)); } - #[derive(serde::Serialize, serde::Deserialize)] pub struct LoaderData { icon: String, @@ -24,7 +21,7 @@ pub struct LoaderData { #[derive(serde::Deserialize)] pub struct LoaderList { - pub game: String + pub game: String, } pub async fn loader_list( @@ -32,7 +29,10 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let mut results = Loader::list(&data.game,&**pool, &redis) + let game = Game::from_name(&data.game).ok_or_else(|| { + ApiError::InvalidInput(format!("'{}' is not a supported game.", data.game)) + })?; + let mut results = Loader::list(game, &**pool, &redis) .await? .into_iter() .map(|x| LoaderData { @@ -47,12 +47,12 @@ pub async fn loader_list( Ok(HttpResponse::Ok().json(results)) } -// TODO: write tests for this and all other v3/tags and v2/tags +// TODO: write tests for this and all other v3/tags and v2/tags functoins #[derive(serde::Deserialize, serde::Serialize)] pub struct LoaderFieldsEnumQuery { pub game: String, pub field: String, - pub filters : Option> // For metadata + pub filters: Option>, // For metadata } pub async fn loader_fields_list( @@ -60,17 +60,21 @@ pub async fn loader_fields_list( query: web::Query, redis: web::Data, ) -> Result { - let query = query.into_inner(); - let loader_field_enum = LoaderFieldEnum::get(&query.field, &query.game, &**pool, &redis).await? - .ok_or_else(|| ApiError::InvalidInput(format!("'{}' was not a valid enumerable loader field for game {}.", query.field, query.game)))?; + let loader_field_enum = LoaderFieldEnum::get(&query.field, &query.game, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "'{}' was not a valid enumerable loader field for game {}.", + query.field, query.game + )) + })?; let results: Vec<_> = if let Some(filters) = query.filters { LoaderFieldEnumValue::list_filter(loader_field_enum.id, filters, &**pool, &redis).await? } else { LoaderFieldEnumValue::list(loader_field_enum.id, &**pool, &redis).await? }; - + Ok(HttpResponse::Ok().json(results)) } - diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index c9e4e1ae..8ad57c0e 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,6 +1,8 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models::loader_fields::{LoaderFieldEnumValue, VersionField, LoaderField, Game}; +use crate::database::models::loader_fields::{ + Game, LoaderField, LoaderFieldEnumValue, VersionField, +}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, @@ -13,14 +15,14 @@ use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, - VersionId, VersionStatus, VersionType, LoaderStruct, + Dependency, DependencyType, FileType, Loader, LoaderStruct, ProjectId, Version, VersionFile, + VersionId, VersionStatus, VersionType, }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; -use crate::validate::{ValidationResult, validate_file}; +use crate::validate::{validate_file, ValidationResult}; use actix_multipart::{Field, Multipart}; use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; @@ -249,9 +251,8 @@ async fn version_create_inner( .await? .name; - let game_id = project.inner.game_id; - game = Game::from_id(game_id, &mut *transaction).await?; - let all_loaders = models::loader_fields::Loader::list_id(game_id,&mut *transaction).await?; + let all_loaders = models::loader_fields::Loader::list(project.inner.game,&mut *transaction, redis).await?; + game = Some(project.inner.game); let mut loader_ids = vec![]; let mut loaders = vec![]; @@ -271,14 +272,15 @@ async fn version_create_inner( loader_ids.push(loader_id); loaders.push(loader_create.loader.clone()); + let loader_fields = LoaderField::get_fields(loader_id, &mut *transaction, redis).await?; + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; for (key, value) in loader_create.fields .iter() { - // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { - CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) - })?; - let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| CreateError::InvalidInput(s))?; - version_fields.push(vf); + let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + })?; + let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; + version_fields.push(vf); } } @@ -577,8 +579,11 @@ async fn upload_file_to_version_inner( } }; - let project = models::Project::get_id(version.inner.project_id, &mut *transaction, &redis).await? - .ok_or_else(|| CreateError::InvalidInput("Version contained an invalid project id".to_string()))?; + let project = models::Project::get_id(version.inner.project_id, &mut *transaction, &redis) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("Version contained an invalid project id".to_string()) + })?; if !user.role.is_admin() { let team_member = models::TeamMember::get_from_user_id_project( @@ -673,8 +678,6 @@ async fn upload_file_to_version_inner( }) .collect(); - let game = Game::from_id(project.inner.game_id, &mut *transaction).await?.ok_or_else(|| CreateError::InvalidInput("Version contained an invalid game id".to_string()))?; - upload_file( &mut field, file_host, @@ -684,7 +687,7 @@ async fn upload_file_to_version_inner( &mut dependencies, &cdn_url, &content_disposition, - game, + project.inner.game, project_id, version_id.into(), &version.version_fields, @@ -737,17 +740,17 @@ pub async fn upload_file( dependencies: &mut Vec, cdn_url: &str, content_disposition: &actix_web::http::header::ContentDisposition, - game : Game, + game: Game, project_id: ProjectId, version_id: VersionId, - version_fields: &Vec, + version_fields: &[VersionField], project_type: &str, loaders: Vec, ignore_primary: bool, force_primary: bool, file_type: Option, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - redis: &RedisPool + redis: &RedisPool, ) -> Result<(), CreateError> { let (file_name, file_extension) = get_name_ext(content_disposition)?; @@ -787,7 +790,7 @@ pub async fn upload_file( "Duplicate files are not allowed to be uploaded to Modrinth!".to_string(), )); } - + let validation_result = validate_file( game, data.clone().into(), @@ -795,9 +798,9 @@ pub async fn upload_file( project_type.to_string(), loaders.clone(), file_type, - version_fields.clone(), + version_fields.to_vec(), &mut *transaction, - &redis, + redis, ) .await?; diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 3acc29ab..ebdd2182 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -1,8 +1,5 @@ use super::ApiError; -use crate::auth::{ - get_user_from_headers, - is_authorized_version, -}; +use crate::auth::{get_user_from_headers, is_authorized_version}; use crate::database::redis::RedisPool; use crate::models::ids::VersionId; use crate::models::pats::Scopes; @@ -16,17 +13,15 @@ use sqlx::PgPool; use std::collections::HashMap; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service( web::scope("version_file") - .route("{version_id}/update",web::post().to(get_update_from_hash)) + .route("{version_id}/update", web::post().to(get_update_from_hash)), ); cfg.service( web::scope("version_files") - .route("update",web::post().to(update_files)) - .route("update_individual",web::post().to(update_individual_files)) + .route("update", web::post().to(update_files)) + .route("update_individual", web::post().to(update_individual_files)), ); - } #[derive(Serialize, Deserialize)] @@ -45,16 +40,15 @@ pub struct UpdateData { pub loaders: Option>, pub version_types: Option>, /* - Loader fields to filter with: - "game_versions": ["1.16.5", "1.17"] - - Returns if it matches any of the values - */ - pub loader_fields: Option>>, + Loader fields to filter with: + "game_versions": ["1.16.5", "1.17"] + Returns if it matches any of the values + */ + pub loader_fields: Option>>, } -// TODO: write tests for this +// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) pub async fn get_update_from_hash( req: HttpRequest, info: web::Path<(String,)>, @@ -103,11 +97,12 @@ pub async fn get_update_from_hash( if let Some(loaders) = &update_data.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - + if let Some(loader_fields) = &update_data.loader_fields { for (key, value) in loader_fields { bool &= x.version_fields.iter().any(|y| { - y.field_name == *key && value.contains(&y.value.serialize_internal()) + y.field_name == *key + && value.contains(&y.value.serialize_internal()) }); } } @@ -201,7 +196,8 @@ pub async fn update_files( if let Some(loader_fields) = &update_data.loader_fields { for (key, value) in loader_fields { bool &= x.version_fields.iter().any(|y| { - y.field_name == *key && value.contains(&y.value.serialize_internal()) + y.field_name == *key + && value.contains(&y.value.serialize_internal()) }); } } @@ -311,11 +307,11 @@ pub async fn update_individual_files( if let Some(loader_fields) = &query_file.loader_fields { for (key, value) in loader_fields { bool &= x.version_fields.iter().any(|y| { - y.field_name == *key && value.contains(&y.value.serialize_internal()) + y.field_name == *key + && value.contains(&y.value.serialize_internal()) }); } } - bool }) diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 997bf904..893a89db 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -1,11 +1,9 @@ use std::collections::HashMap; use super::ApiError; -use crate::auth::{ - filter_authorized_versions, get_user_from_headers, is_authorized, -}; +use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized}; use crate::database; -use crate::database::models::loader_fields::{LoaderField, VersionField, LoaderFieldEnumValue}; +use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::Organization; use crate::database::redis::RedisPool; @@ -13,7 +11,7 @@ use crate::models; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType, LoaderStruct}; +use crate::models::projects::{Dependency, FileType, LoaderStruct, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::img; @@ -24,14 +22,22 @@ use sqlx::PgPool; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { - - cfg.route("version", web::post().to(super::version_creation::version_create)); - cfg.route("{id}", web::post().to(super::version_creation::version_create)); + cfg.route( + "version", + web::post().to(super::version_creation::version_create), + ); + cfg.route( + "{id}", + web::post().to(super::version_creation::version_create), + ); cfg.service( web::scope("version") - .route("{id}", web::patch().to(version_edit)) - .route("{version_id}/file", web::post().to(super::version_creation::upload_file_to_version)) + .route("{id}", web::patch().to(version_edit)) + .route( + "{version_id}/file", + web::post().to(super::version_creation::upload_file_to_version), + ), ); } #[derive(Serialize, Deserialize, Validate, Default, Debug)] @@ -54,7 +60,6 @@ pub struct EditVersion { custom(function = "crate::util::validate::validate_deps") )] pub dependencies: Option>, - pub game_versions: Option>, pub loaders: Option>, pub featured: Option, pub primary_file: Option<(String, String)>, @@ -70,7 +75,8 @@ pub struct EditVersionFileType { pub file_type: Option, } -// TODO: Avoid this pattern +// TODO: Avoid this 'helper' pattern here and similar fnunctoins- a macro might be the best bet here to ensure it's callable from both v2 and v3 +// (web::Path can't be recreated naturally) pub async fn version_edit( req: HttpRequest, info: web::Path<(VersionId,)>, @@ -79,8 +85,16 @@ pub async fn version_edit( new_version: web::Json, session_queue: web::Data, ) -> Result { - let new_version : EditVersion = serde_json::from_value(new_version.into_inner())?; - version_edit_helper(req, info.into_inner(), pool, redis, new_version, session_queue).await + let new_version: EditVersion = serde_json::from_value(new_version.into_inner())?; + version_edit_helper( + req, + info.into_inner(), + pool, + redis, + new_version, + session_queue, + ) + .await } pub async fn version_edit_helper( req: HttpRequest, @@ -89,7 +103,6 @@ pub async fn version_edit_helper( redis: web::Data, new_version: EditVersion, session_queue: web::Data, - ) -> Result { let user = get_user_from_headers( &req, @@ -111,7 +124,6 @@ pub async fn version_edit_helper( let result = database::models::Version::get(id, &**pool, &redis).await?; if let Some(version_item) = result { - let project_item = database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) .await?; @@ -288,23 +300,40 @@ pub async fn version_edit_helper( for loader in loaders { let loader_name = loader.loader.0.clone(); - let loader_id = - database::models::loader_fields::Loader::get_id(&loader_name, &mut *transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "No database entry for loader provided.".to_string(), - ) - })?; + let loader_id = database::models::loader_fields::Loader::get_id( + &loader_name, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("No database entry for loader provided.".to_string()) + })?; loader_versions.push(LoaderVersion::new(loader_id, id)); - for (key, value) in loader.fields .iter() { - // TODO: more efficient, multiselect - let loader_field = LoaderField::get_field(&key, loader_id, &mut *transaction, &redis).await?.ok_or_else(|| { - ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) - })?; - let enum_variants = LoaderFieldEnumValue::list_optional(&loader_field.field_type, &mut *transaction, &redis).await?; - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field, value.clone(), enum_variants).map_err(|s| ApiError::InvalidInput(s))?; - version_fields.push(vf); + let loader_fields = + LoaderField::get_fields(loader_id, &mut *transaction, &redis).await?; + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut *transaction, + &redis, + ) + .await?; + + for (key, value) in loader.fields.iter() { + let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { + ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) + })?; + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + value.clone(), + enum_variants, + ) + .map_err(ApiError::InvalidInput)?; + version_fields.push(vf); } } LoaderVersion::insert_many(loader_versions, &mut transaction).await?; @@ -516,13 +545,12 @@ pub struct VersionListFilters { /* Loader fields to filter with: "game_versions": ["1.16.5", "1.17"] - + Returns if it matches any of the values */ pub loader_fields: Option, } - pub async fn version_list( req: HttpRequest, info: web::Path<(String,)>, @@ -551,10 +579,9 @@ pub async fn version_list( return Ok(HttpResponse::NotFound().body("")); } - let loader_field_filters = filters - .loader_fields - .as_ref() - .map(|x| serde_json::from_str::>>(x).unwrap_or_default()); + let loader_field_filters = filters.loader_fields.as_ref().map(|x| { + serde_json::from_str::>>(x).unwrap_or_default() + }); let loader_filters = filters .loaders .as_ref() @@ -567,7 +594,7 @@ pub async fn version_list( .filter(|x| { let mut bool = true; - // TODO: theres a lot of repeated logic here with the similar filterings in super::version_file + // TODO: theres a lot of repeated logic here with the similar filterings in super::version_file, abstract it if let Some(version_type) = filters.version_type { bool &= &*x.inner.version_type == version_type.as_str(); } @@ -578,7 +605,7 @@ pub async fn version_list( if let Some(loader_fields) = &loader_field_filters { for (key, value) in loader_fields { bool &= x.version_fields.iter().any(|y| { - y.field_name == *key && value.contains(&y.value.serialize_internal()) + y.field_name == *key && value.contains(&y.value.serialize_internal()) }); } } @@ -625,7 +652,7 @@ pub async fn version_list( // .iter() // .find(|version| { // // version.game_versions.contains(&filter.0.version) - // // && + // // && // version.loaders.contains(&filter.1.loader) // }) // .map(|version| response.push(version.clone())) diff --git a/src/scheduler.rs b/src/scheduler.rs index d4059786..68cb593b 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -38,7 +38,11 @@ impl Drop for Scheduler { use log::{info, warn}; -pub fn schedule_versions(scheduler: &mut Scheduler, pool: sqlx::Pool, redis : RedisPool) { +pub fn schedule_versions( + scheduler: &mut Scheduler, + pool: sqlx::Pool, + redis: RedisPool, +) { let version_index_interval = std::time::Duration::from_secs(parse_var("VERSION_INDEX_INTERVAL").unwrap_or(1800)); @@ -66,7 +70,10 @@ pub enum VersionIndexingError { DatabaseError(#[from] crate::database::models::DatabaseError), } -use crate::{util::env::parse_var, database::redis::RedisPool}; +use crate::{ + database::{models::legacy_loader_fields::MinecraftGameVersion, redis::RedisPool}, + util::env::parse_var, +}; use chrono::{DateTime, Utc}; use serde::Deserialize; use tokio_stream::wrappers::IntervalStream; @@ -85,7 +92,10 @@ struct VersionFormat<'a> { release_time: DateTime, } -async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) -> Result<(), VersionIndexingError> { +async fn update_versions( + pool: &sqlx::Pool, + redis: &RedisPool, +) -> Result<(), VersionIndexingError> { let input = reqwest::get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json") .await? .json::() @@ -169,7 +179,7 @@ async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) _ => "other", }; - crate::database::models::loader_fields::GameVersion::builder() + MinecraftGameVersion::builder() .version(&name)? .version_type(type_)? .created( @@ -181,7 +191,7 @@ async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) &version.release_time }, ) - .insert(pool, &redis) + .insert(pool, redis) .await?; } @@ -197,4 +207,4 @@ async fn update_versions(pool: &sqlx::Pool, redis : &RedisPool) } Ok(()) -} \ No newline at end of file +} diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 36be7025..b919828b 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -6,14 +6,16 @@ use futures::TryStreamExt; use log::info; use super::IndexingError; -use crate::database::models::ProjectId; use crate::database::models::loader_fields::VersionField; +use crate::database::models::ProjectId; use crate::search::UploadSearchProject; use sqlx::postgres::PgPool; -pub async fn index_local(pool: PgPool) -> Result<(Vec, Vec), IndexingError> { +pub async fn index_local( + pool: PgPool, +) -> Result<(Vec, Vec), IndexingError> { info!("Indexing local projects!"); - let loader_field_keys : Arc> = Arc::new(DashSet::new()); + let loader_field_keys: Arc> = Arc::new(DashSet::new()); let uploads = sqlx::query!( " @@ -146,5 +148,11 @@ pub async fn index_local(pool: PgPool) -> Result<(Vec, Vec< }}) .try_collect::>() .await?; - Ok((uploads, Arc::try_unwrap(loader_field_keys).unwrap_or_default().into_iter().collect())) + Ok(( + uploads, + Arc::try_unwrap(loader_field_keys) + .unwrap_or_default() + .into_iter() + .collect(), + )) } diff --git a/src/search/indexing/mod.rs b/src/search/indexing/mod.rs index 93002f62..e6e1f378 100644 --- a/src/search/indexing/mod.rs +++ b/src/search/indexing/mod.rs @@ -39,7 +39,7 @@ pub async fn index_projects(pool: PgPool, config: &SearchConfig) -> Result<(), I additional_fields.append(&mut loader_fields); // Write Indices - add_projects(docs_to_add, additional_fields, config).await?; + add_projects(docs_to_add, additional_fields, config).await?; Ok(()) } @@ -49,7 +49,6 @@ async fn create_index( name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result { - client .delete_index(name) .await? @@ -122,12 +121,14 @@ async fn create_and_add_to_index( name: &'static str, custom_rules: Option<&'static [&'static str]>, ) -> Result<(), IndexingError> { - let index = create_index(client, name, custom_rules).await?; - + let index = create_index(client, name, custom_rules).await?; + let mut new_filterable_attributes = index.get_filterable_attributes().await?; new_filterable_attributes.extend(additional_fields.iter().map(|s| s.to_string())); - index.set_filterable_attributes(new_filterable_attributes).await?; - + index + .set_filterable_attributes(new_filterable_attributes) + .await?; + add_to_index(client, index, projects).await?; Ok(()) } diff --git a/src/search/mod.rs b/src/search/mod.rs index 9483b139..1510fce3 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -100,17 +100,16 @@ pub struct UploadSearchProject { #[serde(flatten)] /* - Version fields are stored as: - "loader_field": ["value1", "value2", ...] - By convention, first underline separates the loader from the field name, - and any subsequent underlines may be part of the field name. - eg: - "fabric_game_versions": ["1.21", "1.22"] - "fabric_client_side": ["required"] - "fabric_server_side": ["optional"] - */ - pub loader_fields: HashMap> - + Version fields are stored as: + "loader_field": ["value1", "value2", ...] + By convention, first underline separates the loader from the field name, + and any subsequent underlines may be part of the field name. + eg: + "fabric_game_versions": ["1.21", "1.22"] + "fabric_client_side": ["required"] + "fabric_server_side": ["optional"] + */ + pub loader_fields: HashMap>, } #[derive(Serialize, Deserialize, Debug)] @@ -145,7 +144,7 @@ pub struct ResultSearchProject { pub color: Option, #[serde(flatten)] - pub loader_fields: HashMap> + pub loader_fields: HashMap>, } pub fn get_sort_index(index: &str) -> Result<(&str, [&str; 1]), SearchError> { diff --git a/src/util/actix.rs b/src/util/actix.rs index 6cc1c50e..fc77e663 100644 --- a/src/util/actix.rs +++ b/src/util/actix.rs @@ -1,9 +1,7 @@ -use bytes::{BytesMut, Bytes}; use actix_web::test::TestRequest; +use bytes::{Bytes, BytesMut}; - - -// Multipart functionality for actix +// Multipart functionality for actix // Primarily for testing or some implementations of route-redirection // (actix-test does not innately support multipart) #[derive(Debug, Clone)] diff --git a/src/util/webhook.rs b/src/util/webhook.rs index dabd80ed..fb6b44b4 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -1,4 +1,5 @@ -use crate::database::models::loader_fields::{GameVersion, VersionField}; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::loader_fields::VersionField; use crate::database::redis::RedisPool; use crate::models::projects::ProjectId; use crate::routes::ApiError; @@ -78,8 +79,8 @@ pub async fn send_discord_webhook( message: Option, ) -> Result<(), ApiError> { // TODO: this currently uses Minecraft as it is a v2 webhook, and requires 'game_versions', a minecraft-java loader field. - // This should be updated to use the generic loader fields w/ discord from the project game - let all_game_versions = GameVersion::list(pool, redis).await?; + // TODO: This should be updated to use the generic loader fields w/ discord from the project game + let all_game_versions = MinecraftGameVersion::list(pool, redis).await?; let row = sqlx::query!( @@ -221,10 +222,18 @@ pub async fn send_discord_webhook( // TODO: Modified to keep "Versions" as a field as it may be hardcoded. Ideally, this pushes all loader fields to the embed for v3 // TODO: This might need some work to manually test - let version_fields = VersionField::from_query_json(project.id, project.loader_fields, project.version_fields, project.loader_field_enum_values); - let versions = version_fields.into_iter().find_map(|vf| GameVersion::try_from_version_field(&vf).ok()).unwrap_or_default(); - - if versions.len() > 0 { + let version_fields = VersionField::from_query_json( + project.id, + project.loader_fields, + project.version_fields, + project.loader_field_enum_values, + ); + let versions = version_fields + .into_iter() + .find_map(|vf| MinecraftGameVersion::try_from_version_field(&vf).ok()) + .unwrap_or_default(); + + if !versions.is_empty() { let formatted_game_versions: String = get_gv_range(versions, all_game_versions); fields.push(DiscordEmbedField { name: "Versions", @@ -306,8 +315,8 @@ pub async fn send_discord_webhook( } fn get_gv_range( - mut game_versions: Vec, - mut all_game_versions: Vec, + mut game_versions: Vec, + mut all_game_versions: Vec, ) -> String { // both -> least to greatest game_versions.sort_by(|a, b| a.created.cmp(&b.created)); diff --git a/src/validate/mod.rs b/src/validate/mod.rs index 22eab2f3..22a7f0d3 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -1,5 +1,6 @@ +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::loader_fields::{Game, VersionField}; use crate::database::models::DatabaseError; -use crate::database::models::loader_fields::{Game, GameVersion, VersionField}; use crate::database::redis::RedisPool; use crate::models::pack::PackFormat; use crate::models::projects::{FileType, Loader}; @@ -71,7 +72,7 @@ pub enum SupportedGameVersions { PastDate(DateTime), Range(DateTime, DateTime), #[allow(dead_code)] - Custom(Vec), + Custom(Vec), } pub trait Validator: Sync { @@ -107,22 +108,26 @@ static VALIDATORS: &[&dyn Validator] = &[ ]; /// The return value is whether this file should be marked as primary or not, based on the analysis of the file +#[allow(clippy::too_many_arguments)] pub async fn validate_file( - game : Game, + game: Game, data: bytes::Bytes, file_extension: String, project_type: String, loaders: Vec, file_type: Option, - version_fields : Vec, + version_fields: Vec, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, -) -> Result - { +) -> Result { match game { - Game::MinecraftJava => { - let game_versions = version_fields.into_iter().find_map(|v| GameVersion::try_from_version_field(&v).ok()).unwrap_or_default(); - let all_game_versions = GameVersion::list_transaction(&mut *transaction, &redis).await?; + Game::MinecraftJava => { + let game_versions = version_fields + .into_iter() + .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()) + .unwrap_or_default(); + let all_game_versions = + MinecraftGameVersion::list_transaction(&mut *transaction, redis).await?; validate_minecraft_file( data, file_extension, @@ -131,7 +136,8 @@ pub async fn validate_file( game_versions, all_game_versions, file_type, - ).await + ) + .await } } } @@ -141,8 +147,8 @@ async fn validate_minecraft_file( file_extension: String, mut project_type: String, mut loaders: Vec, - game_versions: Vec, // - all_game_versions: Vec, + game_versions: Vec, + all_game_versions: Vec, file_type: Option, ) -> Result { actix_web::web::block(move || { @@ -198,8 +204,8 @@ async fn validate_minecraft_file( // Write tests for this fn game_version_supported( - game_versions: &[GameVersion], - all_game_versions: &[crate::database::models::loader_fields::GameVersion], + game_versions: &[MinecraftGameVersion], + all_game_versions: &[MinecraftGameVersion], supported_game_versions: SupportedGameVersions, ) -> bool { match supported_game_versions { @@ -220,7 +226,7 @@ fn game_version_supported( }), SupportedGameVersions::Custom(versions) => { let version_ids = versions.iter().map(|gv| gv.id).collect::>(); - let game_version_ids = game_versions.iter().map(|gv| gv.id).collect::>(); + let game_version_ids: Vec<_> = game_versions.iter().map(|gv| gv.id).collect::>(); version_ids.iter().any(|x| game_version_ids.contains(x)) } } diff --git a/tests/common/api_v2/mod.rs b/tests/common/api_v2/mod.rs index 1257dcc3..ab1fe002 100644 --- a/tests/common/api_v2/mod.rs +++ b/tests/common/api_v2/mod.rs @@ -21,7 +21,10 @@ impl ApiV2 { pub async fn reset_search_index(&self) -> ServiceResponse { let req = actix_web::test::TestRequest::post() .uri("/v2/admin/_force_reindex") - .append_header(("Modrinth-Admin", dotenvy::var("LABRINTH_ADMIN_KEY").unwrap())) + .append_header(( + "Modrinth-Admin", + dotenvy::var("LABRINTH_ADMIN_KEY").unwrap(), + )) .to_request(); self.call(req).await } diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 075dfe0d..b4526152 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -4,7 +4,11 @@ use actix_web::{ test::{self, TestRequest}, }; use bytes::Bytes; -use labrinth::{models::projects::{Project, Version}, search::SearchResults, util::actix::AppendsMultipart}; +use labrinth::{ + models::projects::{Project, Version}, + search::SearchResults, + util::actix::AppendsMultipart, +}; use serde_json::json; use crate::common::{ @@ -81,7 +85,7 @@ impl ApiV2 { test::read_body_json(resp).await } - pub async fn get_version(&self, id : &str, pat: &str) -> ServiceResponse { + pub async fn get_version(&self, id: &str, pat: &str) -> ServiceResponse { let req = TestRequest::get() .uri(&format!("/v2/version/{id}")) .append_header(("Authorization", pat)) @@ -89,7 +93,7 @@ impl ApiV2 { self.call(req).await } - pub async fn get_version_deserialized(&self, id : &str, pat: &str) -> Version { + pub async fn get_version_deserialized(&self, id: &str, pat: &str) -> Version { let resp = self.get_version(id, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await @@ -200,8 +204,12 @@ impl ApiV2 { } } - pub async fn search_deserialized(&self, query : Option<&str>, facets : Option, pat : &str) -> SearchResults { - + pub async fn search_deserialized( + &self, + query: Option<&str>, + facets: Option, + pat: &str, + ) -> SearchResults { let query_field = if let Some(query) = query { format!("&query={}", urlencoding::encode(query)) } else { @@ -224,4 +232,3 @@ impl ApiV2 { test::read_body_json(resp).await } } - diff --git a/tests/common/database.rs b/tests/common/database.rs index e30bd077..618b77cf 100644 --- a/tests/common/database.rs +++ b/tests/common/database.rs @@ -50,7 +50,6 @@ impl TemporaryDatabase { // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. pub async fn create(max_connections: Option) -> Self { let temp_database_name = generate_random_name("labrinth_tests_db_"); - println!("Creating temporary database: {}", &temp_database_name); let database_url = dotenvy::var("DATABASE_URL").expect("No database URL"); diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index 34df6693..bd18cced 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -1,5 +1,5 @@ #![allow(dead_code)] -use std::io::{Write, Cursor}; +use std::io::{Cursor, Write}; use actix_web::test::{self, TestRequest}; use labrinth::{ @@ -8,15 +8,12 @@ use labrinth::{ }; use serde_json::json; use sqlx::Executor; -use zip::{write::FileOptions, ZipWriter, CompressionMethod}; +use zip::{write::FileOptions, CompressionMethod, ZipWriter}; use crate::common::database::USER_USER_PAT; -use labrinth::util::actix::{MultipartSegmentData,MultipartSegment,AppendsMultipart}; +use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}; -use super::{ - environment::TestEnvironment, - request_data::get_public_project_creation_data, -}; +use super::{environment::TestEnvironment, request_data::get_public_project_creation_data}; pub const DUMMY_DATA_UPDATE: i64 = 1; @@ -37,17 +34,11 @@ pub enum TestFile { DummyProjectBeta, BasicMod, BasicModDifferent, - // Randomly generates a valid .jar with a random hash. + // Randomly generates a valid .jar with a random hash. // Unlike the other dummy jar files, this one is not a static file. // and BasicModRandom.bytes() will return a different file each time. - BasicModRandom { - filename: String, - bytes: Vec, - }, - BasicModpackRandom { - filename: String, - bytes: Vec, - }, + BasicModRandom { filename: String, bytes: Vec }, + BasicModpackRandom { filename: String, bytes: Vec }, } impl TestFile { @@ -58,7 +49,7 @@ impl TestFile { "schemaVersion": 1, "id": filename, "version": "1.0.1", - + "name": filename, "description": "Does nothing", "authors": [ @@ -69,10 +60,10 @@ impl TestFile { "sources": "https://www.modrinth.com", "issues": "https://www.modrinth.com" }, - + "license": "MIT", "icon": "none.png", - + "environment": "client", "entrypoints": { "main": [ @@ -83,22 +74,24 @@ impl TestFile { "minecraft": ">=1.20-" } } - ).to_string(); + ) + .to_string(); // Create a simulated zip file let mut cursor = Cursor::new(Vec::new()); { let mut zip = ZipWriter::new(&mut cursor); - zip.start_file("fabric.mod.json", FileOptions::default().compression_method(CompressionMethod::Stored)).unwrap(); + zip.start_file( + "fabric.mod.json", + FileOptions::default().compression_method(CompressionMethod::Stored), + ) + .unwrap(); zip.write_all(fabric_mod_json.as_bytes()).unwrap(); zip.finish().unwrap(); } let bytes = cursor.into_inner(); - TestFile::BasicModRandom { - filename, - bytes, - } + TestFile::BasicModRandom { filename, bytes } } pub fn build_random_mrpack() -> Self { @@ -115,24 +108,25 @@ impl TestFile { "minecraft": "1.20.1" } } - ).to_string(); + ) + .to_string(); // Create a simulated zip file let mut cursor = Cursor::new(Vec::new()); { let mut zip = ZipWriter::new(&mut cursor); - zip.start_file("modrinth.index.json", FileOptions::default().compression_method(CompressionMethod::Stored)).unwrap(); + zip.start_file( + "modrinth.index.json", + FileOptions::default().compression_method(CompressionMethod::Stored), + ) + .unwrap(); zip.write_all(modrinth_index_json.as_bytes()).unwrap(); zip.finish().unwrap(); } let bytes = cursor.into_inner(); - TestFile::BasicModpackRandom { - filename, - bytes, - } + TestFile::BasicModpackRandom { filename, bytes } } - } #[derive(Clone)] @@ -447,8 +441,9 @@ impl TestFile { TestFile::BasicModDifferent => "mod", TestFile::BasicModRandom { .. } => "mod", - TestFile::BasicModpackRandom { .. } => "modpack" - }.to_string() + TestFile::BasicModpackRandom { .. } => "modpack", + } + .to_string() } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3ec3b803..be3d7075 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -38,6 +38,6 @@ pub async fn setup(db: &TemporaryDatabase) -> LabrinthConfig { redis_pool.clone(), &mut clickhouse, file_host.clone(), - maxmind_reader.clone(), + maxmind_reader, ) } diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 43370905..08d0b552 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -185,7 +185,6 @@ impl<'a> PermissionsTest<'a> { )); } - // Patch user's permissions to success permissions modify_user_team_permissions( self.user_id, @@ -674,8 +673,7 @@ impl<'a> PermissionsTest<'a> { Ok(()) }; - tokio::try_join!(test_1, test_2, test_3, test_4, test_5, test_6, test_7,) - .map_err(|e| e.to_string())?; + tokio::try_join!(test_1, test_2, test_3, test_4, test_5, test_6, test_7,).map_err(|e| e)?; Ok(()) } @@ -838,7 +836,7 @@ impl<'a> PermissionsTest<'a> { Ok(()) }; - tokio::try_join!(test_1, test_2, test_3,).map_err(|e| e.to_string())?; + tokio::try_join!(test_1, test_2, test_3,).map_err(|e| e)?; Ok(()) } diff --git a/tests/common/request_data.rs b/tests/common/request_data.rs index 6ae751c7..d2176529 100644 --- a/tests/common/request_data.rs +++ b/tests/common/request_data.rs @@ -2,7 +2,7 @@ use serde_json::json; use super::dummy_data::{DummyImage, TestFile}; -use labrinth::util::actix::{MultipartSegmentData,MultipartSegment}; +use labrinth::util::actix::{MultipartSegment, MultipartSegmentData}; pub struct ProjectCreationRequestData { pub slug: String, @@ -21,7 +21,8 @@ pub fn get_public_project_creation_data( version_jar: Option, ) -> ProjectCreationRequestData { let json_data = get_public_project_creation_data_json(slug, version_jar.as_ref()); - let multipart_data = get_public_project_creation_data_multipart(&json_data, version_jar.as_ref()); + let multipart_data = + get_public_project_creation_data_multipart(&json_data, version_jar.as_ref()); ProjectCreationRequestData { slug: slug.to_string(), jar: version_jar, @@ -33,7 +34,7 @@ pub fn get_public_project_creation_data_json( slug: &str, version_jar: Option<&TestFile>, ) -> serde_json::Value { - let initial_versions = if let Some(ref jar) = version_jar { + let initial_versions = if let Some(jar) = version_jar { json!([{ "file_parts": [jar.filename()], "version_number": "1.2.3", @@ -79,7 +80,7 @@ pub fn get_public_project_creation_data_multipart( data: MultipartSegmentData::Text(serde_json::to_string(json_data).unwrap()), }; - if let Some(ref jar) = version_jar { + if let Some(jar) = version_jar { // Basic file let file_segment = MultipartSegment { name: jar.filename(), @@ -88,9 +89,9 @@ pub fn get_public_project_creation_data_multipart( data: MultipartSegmentData::Binary(jar.bytes()), }; - vec![json_segment.clone(), file_segment] + vec![json_segment, file_segment] } else { - vec![json_segment.clone()] + vec![json_segment] } } diff --git a/tests/project.rs b/tests/project.rs index 842df6e3..0e7d5539 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -2,13 +2,13 @@ use actix_http::StatusCode; use actix_web::test; use bytes::Bytes; use chrono::{Duration, Utc}; -use labrinth::util::actix::{MultipartSegmentData,MultipartSegment, AppendsMultipart}; use common::environment::{with_test_environment, TestEnvironment}; use common::permissions::{PermissionsTest, PermissionsTestContext}; use futures::StreamExt; use labrinth::database::models::project_item::{PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE}; use labrinth::models::ids::base62_impl::parse_base62; use labrinth::models::teams::ProjectPermissions; +use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}; use serde_json::json; use crate::common::database::*; @@ -156,9 +156,7 @@ async fn test_add_remove_project() { name: "basic-mod.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: MultipartSegmentData::Binary( - include_bytes!("../tests/files/basic-mod.jar").to_vec(), - ), + data: MultipartSegmentData::Binary(include_bytes!("../tests/files/basic-mod.jar").to_vec()), }; // Differently named file, with the same content (for hash testing) @@ -166,9 +164,7 @@ async fn test_add_remove_project() { name: "basic-mod-different.jar".to_string(), filename: Some("basic-mod-different.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: MultipartSegmentData::Binary( - include_bytes!("../tests/files/basic-mod.jar").to_vec(), - ), + data: MultipartSegmentData::Binary(include_bytes!("../tests/files/basic-mod.jar").to_vec()), }; // Differently named file, with different content @@ -290,7 +286,6 @@ pub async fn test_patch_project() { let alpha_project_slug = &test_env.dummy.as_ref().unwrap().project_alpha.project_slug; let beta_project_slug = &test_env.dummy.as_ref().unwrap().project_beta.project_slug; - // First, we do some patch requests that should fail. // Failure because the user is not authorized. let resp = api @@ -428,16 +423,25 @@ pub async fn test_patch_project() { // New slug does work let resp = api.get_project("newslug", USER_USER_PAT).await; let project: serde_json::Value = test::read_body_json(resp).await; - + assert_eq!(project["slug"], json!(Some("newslug".to_string()))); assert_eq!(project["title"], "New successful title"); assert_eq!(project["description"], "New successful description"); assert_eq!(project["body"], "New successful body"); assert_eq!(project["categories"], json!(vec![DUMMY_CATEGORIES[0]])); assert_eq!(project["license"]["id"], "MIT"); - assert_eq!(project["issues_url"], json!(Some("https://github.com".to_string()))); - assert_eq!(project["discord_url"], json!(Some("https://discord.gg".to_string()))); - assert_eq!(project["wiki_url"], json!(Some("https://wiki.com".to_string()))); + assert_eq!( + project["issues_url"], + json!(Some("https://github.com".to_string())) + ); + assert_eq!( + project["discord_url"], + json!(Some("https://discord.gg".to_string())) + ); + assert_eq!( + project["wiki_url"], + json!(Some("https://wiki.com".to_string())) + ); assert_eq!(project["client_side"], json!("optional")); assert_eq!(project["server_side"], json!("required")); assert_eq!(project["donation_urls"][0]["url"], "https://patreon.com"); @@ -536,14 +540,14 @@ async fn permissions_patch_project() { }; PermissionsTest::new(&test_env) .simple_project_permissions_test(edit_details, req_gen) - .await.into_iter(); + .await + .into_iter(); } }) .buffer_unordered(4) .collect::>() .await; - // Test with status and requested_status // This requires a project with a version, so we use alpha_project_id let req_gen = |ctx: &PermissionsTestContext| { diff --git a/tests/scopes.rs b/tests/scopes.rs index 09767901..c0508e6b 100644 --- a/tests/scopes.rs +++ b/tests/scopes.rs @@ -1,8 +1,8 @@ use actix_web::test::{self, TestRequest}; use bytes::Bytes; use chrono::{Duration, Utc}; -use labrinth::util::actix::{MultipartSegmentData,MultipartSegment, AppendsMultipart}; use labrinth::models::pats::Scopes; +use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}; use serde_json::json; use crate::common::{database::*, environment::TestEnvironment, scopes::ScopeTest}; @@ -235,9 +235,7 @@ pub async fn project_version_create_scopes() { name: "basic-mod.jar".to_string(), filename: Some("basic-mod.jar".to_string()), content_type: Some("application/java-archive".to_string()), - data: MultipartSegmentData::Binary( - include_bytes!("../tests/files/basic-mod.jar").to_vec(), - ), + data: MultipartSegmentData::Binary(include_bytes!("../tests/files/basic-mod.jar").to_vec()), }; let req_gen = || { diff --git a/tests/search.rs b/tests/search.rs index 493ad774..ac5d18b3 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -1,14 +1,14 @@ -use std::collections::HashMap; -use std::sync::Arc; +use crate::common::database::*; +use crate::common::dummy_data::DUMMY_CATEGORIES; +use crate::common::environment::TestEnvironment; +use crate::common::request_data::ProjectCreationRequestData; use common::dummy_data::TestFile; use common::request_data; +use futures::stream::StreamExt; use labrinth::models::ids::base62_impl::parse_base62; use serde_json::json; -use futures::stream::StreamExt; -use crate::common::database::*; -use crate::common::dummy_data::DUMMY_CATEGORIES; -use crate::common::request_data::ProjectCreationRequestData; -use crate::common::environment::TestEnvironment; +use std::collections::HashMap; +use std::sync::Arc; // importing common module. mod common; @@ -23,153 +23,190 @@ async fn search_projects() { // Add dummy projects of various categories for searchability let mut project_creation_futures = vec![]; - let create_async_future = |id: u64, pat: &'static str, is_modpack : bool, modify_json : Box| { - let slug = format!("{test_name}-searchable-project-{id}"); - - let jar = if is_modpack { - TestFile::build_random_mrpack() - } else { - TestFile::build_random_jar() + let create_async_future = + |id: u64, + pat: &'static str, + is_modpack: bool, + modify_json: Box| { + let slug = format!("{test_name}-searchable-project-{id}"); + + let jar = if is_modpack { + TestFile::build_random_mrpack() + } else { + TestFile::build_random_jar() + }; + let mut basic_project_json = + request_data::get_public_project_creation_data_json(&slug, Some(&jar)); + modify_json(&mut basic_project_json); + + let basic_project_multipart = request_data::get_public_project_creation_data_multipart( + &basic_project_json, + Some(&jar), + ); + // Add a project- simple, should work. + let req = api.add_public_project( + ProjectCreationRequestData { + slug, + jar: Some(jar), + segment_data: basic_project_multipart, + }, + pat, + ); + async move { + let (project, _) = req.await; + + // Approve, so that the project is searchable + let resp = api + .edit_project( + &project.id.to_string(), + json!({ + "status": "approved" + }), + MOD_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + (project.id.0, id) + } }; - let mut basic_project_json = request_data::get_public_project_creation_data_json(&slug, Some(&jar)); - modify_json(&mut basic_project_json); - - let basic_project_multipart = - request_data::get_public_project_creation_data_multipart(&basic_project_json, Some(&jar)); - // Add a project- simple, should work. - let req = api.add_public_project(ProjectCreationRequestData { - slug: slug.clone(), - jar: Some(jar), - segment_data: basic_project_multipart.clone(), - }, pat); - async move { - let (project, _) = req.await; - - // Approve, so that the project is searchable - let resp = api.edit_project(&project.id.to_string(), json!({ - "status": "approved" - }), MOD_USER_PAT).await; - assert_eq!(resp.status(), 204); - (project.id.0, id) - }}; // Test project 0 let id = 0; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[4..6]); json["server_side"] = json!("required"); json["license_id"] = json!("LGPL-3.0-or-later"); }; - project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Box::new(modify_json), + )); // Test project 1 let id = 1; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..2]); json["client_side"] = json!("optional"); }; - project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Box::new(modify_json), + )); // Test project 2 let id = 2; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..2]); json["server_side"] = json!("required"); json["title"] = json!("Mysterious Project"); }; - project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Box::new(modify_json), + )); // Test project 3 let id = 3; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..3]); json["server_side"] = json!("required"); json["initial_versions"][0]["game_versions"] = json!(["1.20.4"]); json["title"] = json!("Mysterious Project"); json["license_id"] = json!("LicenseRef-All-Rights-Reserved"); // closed source }; - project_creation_futures.push(create_async_future(id, FRIEND_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Box::new(modify_json), + )); // Test project 4 let id = 4; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[0..3]); json["client_side"] = json!("optional"); json["initial_versions"][0]["game_versions"] = json!(["1.20.5"]); }; - project_creation_futures.push(create_async_future(id, USER_USER_PAT, true, Box::new(modify_json))); - + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + true, + Box::new(modify_json), + )); + // Test project 5 let id = 5; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[5..6]); json["client_side"] = json!("optional"); json["initial_versions"][0]["game_versions"] = json!(["1.20.5"]); json["license_id"] = json!("LGPL-3.0-or-later"); }; - project_creation_futures.push(create_async_future(id, USER_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Box::new(modify_json), + )); // Test project 6 let id = 6; - let modify_json = | json : &mut serde_json::Value| { + let modify_json = |json: &mut serde_json::Value| { json["categories"] = json!(DUMMY_CATEGORIES[5..6]); json["client_side"] = json!("optional"); json["server_side"] = json!("required"); json["license_id"] = json!("LGPL-3.0-or-later"); }; - project_creation_futures.push(create_async_future(id, FRIEND_USER_PAT, false, Box::new(modify_json))); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Box::new(modify_json), + )); // Await all project creation // Returns a mapping of: // project id -> test id - let id_conversion : Arc> = Arc::new(futures::future::join_all(project_creation_futures).await.into_iter().collect()); + let id_conversion: Arc> = Arc::new( + futures::future::join_all(project_creation_futures) + .await + .into_iter() + .collect(), + ); // Pairs of: // 1. vec of search facets // 2. expected project ids to be returned by this search let pairs = vec![ - (json!([ - ["categories:fabric"] - ]), vec![0,1,2,3,4,5,6 - ]), - (json!([ - ["categories:forge"] - ]), vec![]), - (json!([ - ["categories:fabric", "categories:forge"] - ]), vec![0,1,2,3,4,5,6]), - (json!([ - ["categories:fabric"], - ["categories:forge"] - ]), vec![]), - (json!([ - ["categories:fabric"], - [&format!("categories:{}", DUMMY_CATEGORIES[0])], - ]), vec![1,2,3,4]), - (json!([ - ["project_type:modpack"] - ]), vec![4]), - (json!([ - ["client_side:required"] - ]), vec![0,2,3]), - (json!([ - ["server_side:required"] - ]), vec![0,2,3,6]), - (json!([ - ["open_source:true"] - ]), vec![0,1,2,4,5,6]), - (json!([ - ["license:MIT"] - ]), vec![1,2,4]), - (json!([ - [r#"title:'Mysterious Project'"#] - ]), vec![2,3]), - (json!([ - ["author:user"] - ]), vec![0,1,2,4,5]), - (json!([ - ["versions:1.20.5"] - ]), vec![4,5]), + (json!([["categories:fabric"]]), vec![0, 1, 2, 3, 4, 5, 6]), + (json!([["categories:forge"]]), vec![]), + ( + json!([["categories:fabric", "categories:forge"]]), + vec![0, 1, 2, 3, 4, 5, 6], + ), + (json!([["categories:fabric"], ["categories:forge"]]), vec![]), + ( + json!([ + ["categories:fabric"], + [&format!("categories:{}", DUMMY_CATEGORIES[0])], + ]), + vec![1, 2, 3, 4], + ), + (json!([["project_type:modpack"]]), vec![4]), + (json!([["client_side:required"]]), vec![0, 2, 3]), + (json!([["server_side:required"]]), vec![0, 2, 3, 6]), + (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6]), + (json!([["license:MIT"]]), vec![1, 2, 4]), + (json!([[r#"title:'Mysterious Project'"#]]), vec![2, 3]), + (json!([["author:user"]]), vec![0, 1, 2, 4, 5]), + (json!([["versions:1.20.5"]]), vec![4, 5]), ]; // TODO: versions, game versions // Untested: @@ -181,21 +218,31 @@ async fn search_projects() { // Forcibly reset the search index let resp = api.reset_search_index().await; assert_eq!(resp.status(), 204); - + // Test searches let stream = futures::stream::iter(pairs); - stream.for_each_concurrent(1, |(facets, mut expected_project_ids)| { - let id_conversion = id_conversion.clone(); - let test_name = test_name.clone(); - async move { - let projects = api.search_deserialized(Some(&test_name), Some(facets), USER_USER_PAT).await; - let mut found_project_ids : Vec = projects.hits.into_iter().map(|p| id_conversion[&parse_base62(&p.project_id).unwrap()]).collect(); - expected_project_ids.sort(); - found_project_ids.sort(); - assert_eq!(found_project_ids, expected_project_ids); - } - }).await; + stream + .for_each_concurrent(1, |(facets, mut expected_project_ids)| { + let id_conversion = id_conversion.clone(); + let test_name = test_name.clone(); + async move { + let projects = api + .search_deserialized(Some(&test_name), Some(facets), USER_USER_PAT) + .await; + let mut found_project_ids: Vec = projects + .hits + .into_iter() + .map(|p| id_conversion[&parse_base62(&p.project_id).unwrap()]) + .collect(); + expected_project_ids.sort(); + found_project_ids.sort(); + assert_eq!(found_project_ids, expected_project_ids); + } + }) + .await; // Cleanup test db test_env.cleanup().await; -} \ No newline at end of file +} + +// TODO: write a more specific test to ensure that the search bug is fixed completely From eed82242547612c6906364a36195bef2f99f5a42 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 23 Oct 2023 10:03:03 -0700 Subject: [PATCH 14/31] new search test --- sqlx-data.json | 2380 ++++++++++++++++---------------- tests/common/api_v2/project.rs | 38 +- tests/common/asserts.rs | 2 +- tests/common/dummy_data.rs | 2 +- tests/common/environment.rs | 2 +- tests/common/request_data.rs | 59 +- tests/files/dummy_data.sql | 12 +- tests/search.rs | 69 +- 8 files changed, 1346 insertions(+), 1218 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index f3a6bc45..eacac508 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -304,6 +304,178 @@ }, "query": "\n UPDATE versions\n SET downloads = $1\n WHERE (id = $2)\n " }, + "07350a0f6e0f9dd3c7c2b0fe563e3e235561365e177c29acdb58368a985b7bcc": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "version_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "project_type", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "title", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "downloads", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "follows", + "ordinal": 6, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "published", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "approved", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "updated", + "ordinal": 10, + "type_info": "Timestamptz" + }, + { + "name": "team_id", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "license", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "status_name", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 15, + "type_info": "Int4" + }, + { + "name": "project_type_name", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 17, + "type_info": "Varchar" + }, + { + "name": "categories", + "ordinal": 18, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 19, + "type_info": "VarcharArray" + }, + { + "name": "loaders", + "ordinal": 20, + "type_info": "VarcharArray" + }, + { + "name": "gallery", + "ordinal": 21, + "type_info": "VarcharArray" + }, + { + "name": "featured_gallery", + "ordinal": 22, + "type_info": "VarcharArray" + }, + { + "name": "version_fields", + "ordinal": 23, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 24, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 25, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Text" + ] + } + }, + "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n\n \n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n " + }, "07b692d2f89cdcc66da4e1a834f6fefe6a24c13c287490662585749b2b8baae3": { "describe": { "columns": [], @@ -393,6 +565,29 @@ }, "query": "\n SELECT c.id id, c.category category, c.icon icon, c.header category_header, pt.name project_type\n FROM categories c\n INNER JOIN project_types pt ON c.project_type = pt.id\n ORDER BY c.ordering, c.category\n " }, + "09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Timestamp", + "Jsonb" + ] + } + }, + "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = COALESCE($4, loader_field_enum_values.metadata),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n " + }, "09f4fba5c0c26457a7415a2196d4f5a9b2c72662b92cae8c96dda9557a024df7": { "describe": { "columns": [], @@ -794,19 +989,6 @@ }, "query": "\n DELETE FROM sessions WHERE id = $1\n " }, - "177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - } - }, - "query": "\n UPDATE mods\n SET game_versions = (\n SELECT COALESCE(ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null), array[]::varchar[])\n FROM versions v\n INNER JOIN game_versions_versions gvv ON v.id = gvv.joining_version_id\n INNER JOIN game_versions gv on gvv.game_version_id = gv.id\n WHERE v.mod_id = mods.id AND v.status != ALL($2)\n )\n WHERE id = $1\n " - }, "1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a": { "describe": { "columns": [ @@ -1024,26 +1206,6 @@ }, "query": "\n SELECT name FROM project_types pt\n INNER JOIN mods ON mods.project_type = pt.id\n WHERE mods.id = $1\n " }, - "1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT id FROM side_types\n WHERE name = $1\n " - }, "1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1": { "describe": { "columns": [ @@ -1701,48 +1863,6 @@ }, "query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n " }, - "3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - }, - { - "name": "version_", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "type_", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "created", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "major", - "ordinal": 4, - "type_info": "Bool" - } - ], - "nullable": [ - false, - false, - false, - false, - false - ], - "parameters": { - "Left": [] - } - }, - "query": "\n SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major FROM game_versions gv\n ORDER BY created DESC\n " - }, "3d700aaeb0d5129ac8c297ee0542757435a50a35ec94582d9d6ce67aa5302291": { "describe": { "columns": [], @@ -1795,10 +1915,55 @@ }, "query": "\n UPDATE users\n SET password = $1\n WHERE (id = $2)\n " }, - "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { + "436ecad917083b60c213877d14ee9f7ba3560e877c70dd9beb79d52bf24b429c": { "describe": { - "columns": [], - "nullable": [], + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "enum_name", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "ordering", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "hidable", + "ordinal": 4, + "type_info": "Bool" + } + ], + "nullable": [ + false, + true, + false, + true, + false + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "\n SELECT lfe.id, g.name, lfe.enum_name, lfe.ordering, lfe.hidable \n FROM loader_field_enums lfe\n INNER JOIN games g ON lfe.game_id = g.id\n WHERE g.name = $1 AND lfe.enum_name = $2\n " + }, + "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { + "describe": { + "columns": [], + "nullable": [], "parameters": { "Left": [ "Int8" @@ -1807,6 +1972,56 @@ }, "query": "\n DELETE FROM mods_donations\n WHERE joining_mod_id = $1\n " }, + "458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "enum_id", + "ordinal": 1, + "type_info": "Int4" + }, + { + "name": "value", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "ordering", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "metadata", + "ordinal": 4, + "type_info": "Jsonb" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + true, + false + ], + "parameters": { + "Left": [ + "Int4Array" + ] + } + }, + "query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n " + }, "45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426": { "describe": { "columns": [], @@ -1907,19 +2122,6 @@ }, "query": "UPDATE mods\n SET downloads = downloads + 1\n WHERE (id = $1)" }, - "4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int4", - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET server_side = $1\n WHERE (id = $2)\n " - }, "4c20de487460718c8c523fce28716900f5195d12397eba09a3c437d194ff2b2e": { "describe": { "columns": [ @@ -2010,18 +2212,6 @@ }, "query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n " }, - "507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM game_versions_versions WHERE joining_version_id = $1\n " - }, "50e65ff5df36ec59c5cf4470db908d7b04cf1ffb1640398ac518510178fd9a34": { "describe": { "columns": [], @@ -2049,42 +2239,6 @@ }, "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " }, - "5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - }, - { - "name": "loader", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "icon", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "project_types", - "ordinal": 3, - "type_info": "VarcharArray" - } - ], - "nullable": [ - false, - false, - false, - null - ], - "parameters": { - "Left": [] - } - }, - "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n GROUP BY l.id;\n " - }, "52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9": { "describe": { "columns": [], @@ -2155,7 +2309,84 @@ }, "query": "\n SELECT c.id id, c.user_id user_id FROM collections c\n WHERE c.user_id = $2 AND c.id = ANY($1)\n " }, - "56d0e2c6e37f97d5300ca783f475a770a3d0ab84ef4297b40f56a11ebb6053cc": { + "5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT id FROM threads\n WHERE report_id = $1\n " + }, + "5a13a79ebb1ab975f88b58e6deaba9685fe16e242c0fa4a5eea54f12f9448e6b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM reports\n WHERE version_id = $1\n " + }, + "5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT id FROM project_types\n WHERE name = $1\n " + }, + "5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE files\n SET is_primary = TRUE\n WHERE (id = $1)\n " + }, + "5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE versions\n SET status = $1\n WHERE (id = $2)\n " + }, + "5c7bc2b59e5bcbe50e556cf28fb7a20de645752beef330b6779ec256f33e666a": { "describe": { "columns": [ { @@ -2164,129 +2395,49 @@ "type_info": "Int8" }, { - "name": "version_id", + "name": "url", "ordinal": 1, - "type_info": "Int8" + "type_info": "Varchar" }, { - "name": "project_type", + "name": "size", "ordinal": 2, "type_info": "Int4" }, { - "name": "title", + "name": "created", "ordinal": 3, - "type_info": "Varchar" + "type_info": "Timestamptz" }, { - "name": "description", + "name": "owner_id", "ordinal": 4, - "type_info": "Varchar" + "type_info": "Int8" }, { - "name": "downloads", + "name": "context", "ordinal": 5, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "follows", + "name": "mod_id", "ordinal": 6, - "type_info": "Int4" + "type_info": "Int8" }, { - "name": "icon_url", + "name": "version_id", "ordinal": 7, - "type_info": "Varchar" + "type_info": "Int8" }, { - "name": "published", + "name": "thread_message_id", "ordinal": 8, - "type_info": "Timestamptz" + "type_info": "Int8" }, { - "name": "approved", + "name": "report_id", "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "updated", - "ordinal": 10, - "type_info": "Timestamptz" - }, - { - "name": "team_id", - "ordinal": 11, "type_info": "Int8" - }, - { - "name": "license", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "status_name", - "ordinal": 14, - "type_info": "Varchar" - }, - { - "name": "color", - "ordinal": 15, - "type_info": "Int4" - }, - { - "name": "client_side_type", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "server_side_type", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "project_type_name", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 19, - "type_info": "Varchar" - }, - { - "name": "categories", - "ordinal": 20, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 21, - "type_info": "VarcharArray" - }, - { - "name": "loaders", - "ordinal": 22, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 23, - "type_info": "VarcharArray" - }, - { - "name": "gallery", - "ordinal": 24, - "type_info": "VarcharArray" - }, - { - "name": "featured_gallery", - "ordinal": 25, - "type_info": "VarcharArray" } ], "nullable": [ @@ -2296,38 +2447,33 @@ false, false, false, - false, true, - false, - true, - false, - false, - false, true, - false, true, - false, - false, - false, - false, - null, - null, - null, - null, - null, - null + true ], "parameters": { "Left": [ - "TextArray", - "TextArray", - "Text" + "Int8Array" ] } }, - "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, cs.id, ss.id, pt.id, u.id;\n " + "query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE id = ANY($1)\n GROUP BY id;\n " }, - "5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976": { + "5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET role = $1\n WHERE (id = $2)\n " + }, + "5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9": { "describe": { "columns": [ { @@ -2345,121 +2491,26 @@ ] } }, - "query": "\n SELECT id FROM threads\n WHERE report_id = $1\n " + "query": "SELECT id FROM users WHERE gitlab_id = $1" }, - "59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b": { + "5d65f89c020ae032f26d742c37afe47876911eb3a16a6852299b98f2a8251fb4": { "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "title", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "color", - "ordinal": 3, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "client_side_type", - "ordinal": 6, - "type_info": "Varchar" - }, - { - "name": "server_side_type", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "project_type", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 9, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "categories", - "ordinal": 11, - "type_info": "VarcharArray" - }, - { - "name": "loaders", - "ordinal": 12, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 13, - "type_info": "Jsonb" - }, - { - "name": "gallery", - "ordinal": 14, - "type_info": "VarcharArray" - }, - { - "name": "featured_gallery", - "ordinal": 15, - "type_info": "VarcharArray" - } - ], - "nullable": [ - false, - false, - false, - true, - true, - true, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null - ], + "columns": [], + "nullable": [], "parameters": { "Left": [ - "Int8", - "TextArray", - "Text" + "Int8Array", + "VarcharArray", + "BoolArray", + "VarcharArray", + "VarcharArray", + "Int8Array" ] } }, - "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug, cs.name client_side_type, ss.name server_side_type,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', gv.id, 'version', gv.version, 'type', gv.type, 'created', gv.created, 'major', gv.major)) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE m.id = $1\n GROUP BY m.id, cs.id, ss.id, pt.id, u.id;\n " + "query": "\n INSERT INTO mods_gallery (\n mod_id, image_url, featured, title, description, ordering\n )\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::bool[], $4::varchar[], $5::varchar[], $6::bigint[])\n " }, - "5a13a79ebb1ab975f88b58e6deaba9685fe16e242c0fa4a5eea54f12f9448e6b": { + "5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179": { "describe": { "columns": [], "nullable": [], @@ -2469,41 +2520,29 @@ ] } }, - "query": "\n DELETE FROM reports\n WHERE version_id = $1\n " + "query": "\n UPDATE mods\n SET updated = NOW()\n WHERE id = $1\n " }, - "5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a": { + "5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a": { "describe": { "columns": [ { "name": "id", "ordinal": 0, - "type_info": "Int4" + "type_info": "Int8" } ], "nullable": [ false ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT id FROM project_types\n WHERE name = $1\n " - }, - "5c4262689205aafdd97a74bee0003f39eef0a34c97f97a939c14fb8fe349f7eb": { - "describe": { - "columns": [], - "nullable": [], "parameters": { "Left": [ "Int8" ] } }, - "query": "\n UPDATE files\n SET is_primary = TRUE\n WHERE (id = $1)\n " + "query": "SELECT id FROM users WHERE steam_id = $1" }, - "5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed": { + "5eb2795d25d6d03e22564048c198d821cd5ff22eb4e39b9dd7f198c9113d4f87": { "describe": { "columns": [], "nullable": [], @@ -2514,182 +2553,13 @@ ] } }, - "query": "\n UPDATE versions\n SET status = $1\n WHERE (id = $2)\n " + "query": "\n UPDATE users\n SET name = $1\n WHERE (id = $2)\n " }, - "5c7bc2b59e5bcbe50e556cf28fb7a20de645752beef330b6779ec256f33e666a": { + "5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa": { "describe": { "columns": [ { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "url", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "size", - "ordinal": 2, - "type_info": "Int4" - }, - { - "name": "created", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "owner_id", - "ordinal": 4, - "type_info": "Int8" - }, - { - "name": "context", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "mod_id", - "ordinal": 6, - "type_info": "Int8" - }, - { - "name": "version_id", - "ordinal": 7, - "type_info": "Int8" - }, - { - "name": "thread_message_id", - "ordinal": 8, - "type_info": "Int8" - }, - { - "name": "report_id", - "ordinal": 9, - "type_info": "Int8" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - true, - true, - true - ], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE id = ANY($1)\n GROUP BY id;\n " - }, - "5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE users\n SET role = $1\n WHERE (id = $2)\n " - }, - "5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT id FROM users WHERE gitlab_id = $1" - }, - "5d65f89c020ae032f26d742c37afe47876911eb3a16a6852299b98f2a8251fb4": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8Array", - "VarcharArray", - "BoolArray", - "VarcharArray", - "VarcharArray", - "Int8Array" - ] - } - }, - "query": "\n INSERT INTO mods_gallery (\n mod_id, image_url, featured, title, description, ordering\n )\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::bool[], $4::varchar[], $5::varchar[], $6::bigint[])\n " - }, - "5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET updated = NOW()\n WHERE id = $1\n " - }, - "5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT id FROM users WHERE steam_id = $1" - }, - "5eb2795d25d6d03e22564048c198d821cd5ff22eb4e39b9dd7f198c9113d4f87": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE users\n SET name = $1\n WHERE (id = $2)\n " - }, - "5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa": { - "describe": { - "columns": [ - { - "name": "name", + "name": "name", "ordinal": 0, "type_info": "Varchar" } @@ -3227,28 +3097,6 @@ }, "query": "\n SELECT m.id FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2\n WHERE m.id = ANY($1)\n " }, - "72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Timestamp" - ] - } - }, - "query": "\n INSERT INTO game_versions (version, type, created)\n VALUES ($1, COALESCE($2, 'other'), COALESCE($3, timezone('utc', now())))\n ON CONFLICT (version) DO UPDATE\n SET type = COALESCE($2, game_versions.type),\n created = COALESCE($3, game_versions.created)\n RETURNING id\n " - }, "72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37": { "describe": { "columns": [ @@ -3658,24 +3506,6 @@ }, "query": "\n UPDATE versions\n SET author_id = $1\n WHERE (author_id = $2)\n " }, - "85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537": { - "describe": { - "columns": [ - { - "name": "name", - "ordinal": 0, - "type_info": "Varchar" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [] - } - }, - "query": "\n SELECT name FROM side_types\n " - }, "868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d": { "describe": { "columns": [], @@ -3830,6 +3660,22 @@ }, "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id \n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n " }, + "8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4Array", + "Int8Array", + "Int4Array", + "TextArray", + "Int4Array" + ] + } + }, + "query": "\n INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[])\n " + }, "8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013": { "describe": { "columns": [], @@ -4003,6 +3849,18 @@ }, "query": "\n SELECT m.id\n FROM mods m\n WHERE m.organization_id = $1\n " }, + "959fdbdb336841244c60340d4803d80cafc64319eaea87921e50e7a949146481": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM version_fields WHERE version_id = $1\n " + }, "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": { "describe": { "columns": [], @@ -4245,6 +4103,18 @@ }, "query": "\n SELECT COUNT(f.id) FROM files f\n INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2)\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n " }, + "a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM version_fields vf\n WHERE vf.version_id = $1\n " + }, "a440cb2567825c3cc540c9b0831ee840f6e2a6394e89a851b83fc78220594cf2": { "describe": { "columns": [], @@ -4272,127 +4142,11 @@ }, "query": "\n INSERT INTO user_backup_codes (\n user_id, code\n )\n VALUES (\n $1, $2\n )\n " }, - "a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39": { + "aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f": { "describe": { "columns": [ { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "mod_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "author_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "version_name", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "version_number", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "changelog", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "date_published", - "ordinal": 6, - "type_info": "Timestamptz" - }, - { - "name": "downloads", - "ordinal": 7, - "type_info": "Int4" - }, - { - "name": "version_type", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "featured", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "status", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "requested_status", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "game_versions", - "ordinal": 12, - "type_info": "Jsonb" - }, - { - "name": "loaders", - "ordinal": 13, - "type_info": "VarcharArray" - }, - { - "name": "files", - "ordinal": 14, - "type_info": "Jsonb" - }, - { - "name": "hashes", - "ordinal": 15, - "type_info": "Jsonb" - }, - { - "name": "dependencies", - "ordinal": 16, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "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,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n " - }, - "aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f": { - "describe": { - "columns": [ - { - "name": "count", + "name": "count", "ordinal": 0, "type_info": "Int8" } @@ -4552,6 +4306,37 @@ }, "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n " }, + "b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Int4", + "Int4", + "Varchar" + ] + } + }, + "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, LOWER($17), $18,\n $19, $20\n )\n " + }, "b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af": { "describe": { "columns": [ @@ -4609,39 +4394,6 @@ }, "query": "\n UPDATE payouts_values\n SET mod_id = NULL\n WHERE (mod_id = $1)\n " }, - "b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Int4", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Int4", - "Int4", - "Varchar", - "Varchar", - "Text", - "Int4", - "Int4", - "Varchar" - ] - } - }, - "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n client_side, server_side, license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, $17, $18,\n LOWER($19), $20, $21, $22\n )\n " - }, "b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b": { "describe": { "columns": [], @@ -4800,18 +4552,6 @@ }, "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n " }, - "bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM game_versions_versions gvv\n WHERE gvv.joining_version_id = $1\n " - }, "bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef": { "describe": { "columns": [ @@ -4865,26 +4605,6 @@ }, "query": "\n SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1)\n " }, - "c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT id FROM game_versions\n WHERE version = $1\n " - }, "c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1": { "describe": { "columns": [], @@ -4922,6 +4642,74 @@ }, "query": "\n DELETE FROM teams\n WHERE id = $1\n " }, + "c3d561d5ab9d7fe3c67dfd83252528a30daa38496e1350a39645adad7364d8b6": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "loader_id", + "ordinal": 1, + "type_info": "Int4" + }, + { + "name": "field", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "field_type", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "optional", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "min_val", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "max_val", + "ordinal": 6, + "type_info": "Int4" + }, + { + "name": "enum_type", + "ordinal": 7, + "type_info": "Int4" + }, + { + "name": "loader", + "ordinal": 8, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false + ], + "parameters": { + "Left": [ + "Int4" + ] + } + }, + "query": "\n SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader\n FROM loader_fields lf\n INNER JOIN loaders l ON lf.loader_id = l.id\n WHERE loader_id = $1\n " + }, "c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c": { "describe": { "columns": [], @@ -5047,19 +4835,134 @@ }, "query": "SELECT id FROM users WHERE microsoft_id = $1" }, - "c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be": { + "c6aa007b0057a824448e11554a894c9ab7c681b93334e5d7ff841a7a4d64e6f9": { "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int4", - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET client_side = $1\n WHERE (id = $2)\n " - }, + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "mod_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "author_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "version_name", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "version_number", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "changelog", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "date_published", + "ordinal": 6, + "type_info": "Timestamptz" + }, + { + "name": "downloads", + "ordinal": 7, + "type_info": "Int4" + }, + { + "name": "version_type", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "featured", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "status", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "requested_status", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "loaders", + "ordinal": 12, + "type_info": "VarcharArray" + }, + { + "name": "files", + "ordinal": 13, + "type_info": "Jsonb" + }, + { + "name": "hashes", + "ordinal": 14, + "type_info": "Jsonb" + }, + { + "name": "dependencies", + "ordinal": 15, + "type_info": "Jsonb" + }, + { + "name": "version_fields", + "ordinal": 16, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 17, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 18, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "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,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n " + }, "c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d": { "describe": { "columns": [], @@ -5846,6 +5749,118 @@ }, "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11\n )\n " }, + "e2a5bf6fcd77820828f21dda100770a9e3f9b01cc6ca6e4e9ba3b16938239a38": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "project_type", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "avatar_url", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "categories", + "ordinal": 9, + "type_info": "VarcharArray" + }, + { + "name": "loaders", + "ordinal": 10, + "type_info": "VarcharArray" + }, + { + "name": "gallery", + "ordinal": 11, + "type_info": "VarcharArray" + }, + { + "name": "featured_gallery", + "ordinal": 12, + "type_info": "VarcharArray" + }, + { + "name": "version_fields", + "ordinal": 13, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 14, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 15, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "Text" + ] + } + }, + "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\n " + }, "e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed": { "describe": { "columns": [ @@ -6492,142 +6507,11 @@ }, "query": "\n UPDATE users\n SET bio = $1\n WHERE (id = $2)\n " }, - "f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc": { + "f76ccb09b568be547a05ae2e5037dc72ea41442b0f04a8c89e6d566f7ff2aa9c": { "describe": { "columns": [ { - "name": "user_id", - "ordinal": 0, - "type_info": "Int8" - } - ], - "nullable": [ - false - ], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "query": "\n UPDATE notifications\n SET read = TRUE\n WHERE id = ANY($1)\n RETURNING user_id\n " - }, - "f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM mods\n WHERE id = $1\n " - }, - "f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)" - }, - "f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM organizations\n WHERE id = $1\n " - }, - "f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Text" - ] - } - }, - "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n " - }, - "f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "\n UPDATE users\n SET steam_id = $2\n WHERE (id = $1)\n " - }, - "fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)" - }, - "fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int4Array", - "Int8Array" - ] - } - }, - "query": "\n INSERT INTO game_versions_versions (game_version_id, joining_version_id)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[])\n " - }, - "faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457": { - "describe": { - "columns": [ - { - "name": "id", + "name": "id", "ordinal": 0, "type_info": "Int8" }, @@ -6637,232 +6521,241 @@ "type_info": "Varchar" }, { - "name": "email", + "name": "project_type", "ordinal": 2, - "type_info": "Varchar" + "type_info": "Int4" }, { - "name": "avatar_url", + "name": "title", "ordinal": 3, "type_info": "Varchar" }, { - "name": "username", + "name": "description", "ordinal": 4, "type_info": "Varchar" }, { - "name": "bio", + "name": "downloads", "ordinal": 5, - "type_info": "Varchar" + "type_info": "Int4" }, { - "name": "created", + "name": "follows", "ordinal": 6, - "type_info": "Timestamptz" + "type_info": "Int4" }, { - "name": "role", + "name": "icon_url", "ordinal": 7, "type_info": "Varchar" }, { - "name": "badges", + "name": "body", "ordinal": 8, - "type_info": "Int8" + "type_info": "Varchar" }, { - "name": "balance", + "name": "published", "ordinal": 9, - "type_info": "Numeric" + "type_info": "Timestamptz" }, { - "name": "github_id", + "name": "updated", "ordinal": 10, - "type_info": "Int8" + "type_info": "Timestamptz" }, { - "name": "discord_id", + "name": "approved", "ordinal": 11, - "type_info": "Int8" + "type_info": "Timestamptz" }, { - "name": "gitlab_id", + "name": "queued", "ordinal": 12, - "type_info": "Int8" + "type_info": "Timestamptz" }, { - "name": "google_id", + "name": "status", "ordinal": 13, "type_info": "Varchar" }, { - "name": "steam_id", + "name": "requested_status", "ordinal": 14, - "type_info": "Int8" + "type_info": "Varchar" }, { - "name": "microsoft_id", + "name": "issues_url", "ordinal": 15, "type_info": "Varchar" }, { - "name": "email_verified", + "name": "source_url", "ordinal": 16, - "type_info": "Bool" + "type_info": "Varchar" }, { - "name": "password", + "name": "wiki_url", "ordinal": 17, - "type_info": "Text" + "type_info": "Varchar" }, { - "name": "totp_secret", + "name": "discord_url", "ordinal": 18, "type_info": "Varchar" }, { - "name": "trolley_id", + "name": "license_url", "ordinal": 19, - "type_info": "Text" + "type_info": "Varchar" }, { - "name": "trolley_account_status", + "name": "team_id", "ordinal": 20, - "type_info": "Text" - } - ], - "nullable": [ - false, - true, - true, - true, - false, - true, - false, - false, - false, - false, - true, - true, - true, - true, - true, - true, - false, - true, - true, - true, - true - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray" - ] - } - }, - "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, trolley_id, trolley_account_status\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n " - }, - "fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM notifications WHERE id=$1)" - }, - "fce67ce3d0c27c64af85fb7d36661513bc5ea2e96fcf12f3a51c97999b01b83c": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, "type_info": "Int8" }, { - "name": "user_id", - "ordinal": 1, + "name": "organization_id", + "ordinal": 21, "type_info": "Int8" }, { - "name": "title", - "ordinal": 2, + "name": "license", + "ordinal": 22, "type_info": "Varchar" }, { - "name": "text", - "ordinal": 3, + "name": "slug", + "ordinal": 23, "type_info": "Varchar" }, { - "name": "link", - "ordinal": 4, + "name": "moderation_message", + "ordinal": 24, "type_info": "Varchar" }, { - "name": "created", - "ordinal": 5, - "type_info": "Timestamptz" + "name": "moderation_message_body", + "ordinal": 25, + "type_info": "Varchar" }, { - "name": "read", - "ordinal": 6, - "type_info": "Bool" + "name": "project_type_name", + "ordinal": 26, + "type_info": "Varchar" }, { - "name": "notification_type", - "ordinal": 7, + "name": "webhook_sent", + "ordinal": 27, + "type_info": "Bool" + }, + { + "name": "color", + "ordinal": 28, + "type_info": "Int4" + }, + { + "name": "thread_id", + "ordinal": 29, + "type_info": "Int8" + }, + { + "name": "monetization_status", + "ordinal": 30, "type_info": "Varchar" }, { - "name": "body", - "ordinal": 8, + "name": "loaders", + "ordinal": 31, + "type_info": "VarcharArray" + }, + { + "name": "game_versions", + "ordinal": 32, + "type_info": "VarcharArray" + }, + { + "name": "categories", + "ordinal": 33, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 34, + "type_info": "VarcharArray" + }, + { + "name": "versions", + "ordinal": 35, "type_info": "Jsonb" }, { - "name": "actions", - "ordinal": 9, + "name": "gallery", + "ordinal": 36, + "type_info": "Jsonb" + }, + { + "name": "donations", + "ordinal": 37, "type_info": "Jsonb" } ], "nullable": [ + false, + true, + false, + false, + false, false, false, true, + false, + false, + false, true, true, false, + true, + true, + true, + true, + true, + true, false, true, + false, + true, + true, + true, + false, + false, true, + false, + false, + false, + false, + null, + null, + null, + null, null ], "parameters": { "Left": [ - "Int8Array" + "Int8Array", + "TextArray", + "TextArray" ] } }, - "query": "\n SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'title', na.title, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.id = ANY($1)\n GROUP BY n.id, n.user_id\n ORDER BY n.created DESC;\n " + "query": "\n SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN games g ON g.id = m.game_id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, t.id, m.id, g.name;\n " }, - "fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a": { + "f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc": { "describe": { "columns": [ { - "name": "follower_id", + "name": "user_id", "ordinal": 0, "type_info": "Int8" } @@ -6870,15 +6763,113 @@ "nullable": [ false ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n UPDATE notifications\n SET read = TRUE\n WHERE id = ANY($1)\n RETURNING user_id\n " + }, + "f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709": { + "describe": { + "columns": [], + "nullable": [], "parameters": { "Left": [ "Int8" ] } }, - "query": "\n SELECT follower_id FROM mod_follows\n WHERE mod_id = $1\n " + "query": "\n DELETE FROM mods\n WHERE id = $1\n " }, - "ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16": { + "f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)" + }, + "f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM organizations\n WHERE id = $1\n " + }, + "f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n " + }, + "f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET steam_id = $2\n WHERE (id = $1)\n " + }, + "fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)" + }, + "faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457": { "describe": { "columns": [ { @@ -6887,257 +6878,288 @@ "type_info": "Int8" }, { - "name": "project_type", + "name": "name", "ordinal": 1, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "title", + "name": "email", "ordinal": 2, "type_info": "Varchar" }, { - "name": "description", + "name": "avatar_url", "ordinal": 3, "type_info": "Varchar" }, { - "name": "downloads", + "name": "username", "ordinal": 4, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "follows", + "name": "bio", "ordinal": 5, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "icon_url", + "name": "created", "ordinal": 6, - "type_info": "Varchar" + "type_info": "Timestamptz" }, { - "name": "body", + "name": "role", "ordinal": 7, "type_info": "Varchar" }, { - "name": "published", + "name": "badges", "ordinal": 8, - "type_info": "Timestamptz" + "type_info": "Int8" }, { - "name": "updated", + "name": "balance", "ordinal": 9, - "type_info": "Timestamptz" + "type_info": "Numeric" }, { - "name": "approved", + "name": "github_id", "ordinal": 10, - "type_info": "Timestamptz" + "type_info": "Int8" }, { - "name": "queued", + "name": "discord_id", "ordinal": 11, - "type_info": "Timestamptz" + "type_info": "Int8" }, { - "name": "status", + "name": "gitlab_id", "ordinal": 12, - "type_info": "Varchar" + "type_info": "Int8" }, { - "name": "requested_status", + "name": "google_id", "ordinal": 13, "type_info": "Varchar" }, { - "name": "issues_url", + "name": "steam_id", "ordinal": 14, - "type_info": "Varchar" + "type_info": "Int8" }, { - "name": "source_url", + "name": "microsoft_id", "ordinal": 15, "type_info": "Varchar" }, { - "name": "wiki_url", + "name": "email_verified", "ordinal": 16, - "type_info": "Varchar" + "type_info": "Bool" }, { - "name": "discord_url", + "name": "password", "ordinal": 17, - "type_info": "Varchar" + "type_info": "Text" }, { - "name": "license_url", + "name": "totp_secret", "ordinal": 18, "type_info": "Varchar" }, { - "name": "team_id", + "name": "trolley_id", "ordinal": 19, - "type_info": "Int8" + "type_info": "Text" }, { - "name": "organization_id", + "name": "trolley_account_status", "ordinal": 20, - "type_info": "Int8" - }, - { - "name": "client_side", - "ordinal": 21, - "type_info": "Int4" - }, - { - "name": "server_side", - "ordinal": 22, - "type_info": "Int4" - }, + "type_info": "Text" + } + ], + "nullable": [ + false, + true, + true, + true, + false, + true, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + } + }, + "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, trolley_id, trolley_account_status\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n " + }, + "fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7": { + "describe": { + "columns": [ { - "name": "license", - "ordinal": 23, - "type_info": "Varchar" - }, + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM notifications WHERE id=$1)" + }, + "fce67ce3d0c27c64af85fb7d36661513bc5ea2e96fcf12f3a51c97999b01b83c": { + "describe": { + "columns": [ { - "name": "slug", - "ordinal": 24, - "type_info": "Varchar" + "name": "id", + "ordinal": 0, + "type_info": "Int8" }, { - "name": "moderation_message", - "ordinal": 25, - "type_info": "Varchar" + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" }, { - "name": "moderation_message_body", - "ordinal": 26, + "name": "title", + "ordinal": 2, "type_info": "Varchar" }, { - "name": "client_side_type", - "ordinal": 27, + "name": "text", + "ordinal": 3, "type_info": "Varchar" }, { - "name": "server_side_type", - "ordinal": 28, + "name": "link", + "ordinal": 4, "type_info": "Varchar" }, { - "name": "project_type_name", - "ordinal": 29, - "type_info": "Varchar" + "name": "created", + "ordinal": 5, + "type_info": "Timestamptz" }, { - "name": "webhook_sent", - "ordinal": 30, + "name": "read", + "ordinal": 6, "type_info": "Bool" }, { - "name": "color", - "ordinal": 31, - "type_info": "Int4" - }, - { - "name": "thread_id", - "ordinal": 32, - "type_info": "Int8" - }, - { - "name": "monetization_status", - "ordinal": 33, + "name": "notification_type", + "ordinal": 7, "type_info": "Varchar" }, { - "name": "loaders", - "ordinal": 34, - "type_info": "VarcharArray" - }, - { - "name": "game_versions", - "ordinal": 35, - "type_info": "VarcharArray" - }, - { - "name": "categories", - "ordinal": 36, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 37, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 38, - "type_info": "Jsonb" - }, - { - "name": "gallery", - "ordinal": 39, + "name": "body", + "ordinal": 8, "type_info": "Jsonb" }, { - "name": "donations", - "ordinal": 40, + "name": "actions", + "ordinal": 9, "type_info": "Jsonb" } ], "nullable": [ false, false, - false, - false, - false, - false, - true, - false, - false, - false, - true, - true, - false, - true, - true, - true, true, true, true, false, - true, - false, false, - false, - true, true, true, + null + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n SELECT n.id, n.user_id, n.title, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'title', na.title, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.id = ANY($1)\n GROUP BY n.id, n.user_id\n ORDER BY n.created DESC;\n " + }, + "fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "loader", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "icon", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "project_types", + "ordinal": 3, + "type_info": "VarcharArray" + } + ], + "nullable": [ false, false, false, - false, - true, - false, - false, - false, - false, - null, - null, - null, - null, null ], "parameters": { "Left": [ - "Int8Array", - "TextArray", - "TextArray" + "Text" + ] + } + }, + "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n INNER JOIN games g ON l.game_id = g.id\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n WHERE g.name = $1\n GROUP BY l.id;\n " + }, + "fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a": { + "describe": { + "columns": [ + { + "name": "follower_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" ] } }, - "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, cs.id, ss.id, t.id, m.id;\n " + "query": "\n SELECT follower_id FROM mod_follows\n WHERE mod_id = $1\n " } } \ No newline at end of file diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index b4526152..6b1fd302 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -14,7 +14,7 @@ use serde_json::json; use crate::common::{ asserts::assert_status, database::MOD_USER_PAT, - request_data::{ImageData, ProjectCreationRequestData}, + request_data::{ImageData, ProjectCreationRequestData, VersionCreationRequestData}, }; use super::ApiV2; @@ -32,7 +32,7 @@ impl ApiV2 { .set_multipart(creation_data.segment_data) .to_request(); let resp = self.call(req).await; - assert_status(resp, StatusCode::OK); + assert_status(&resp, StatusCode::OK); // Approve as a moderator. let req = TestRequest::patch() @@ -45,7 +45,7 @@ impl ApiV2 { )) .to_request(); let resp = self.call(req).await; - assert_status(resp, StatusCode::NO_CONTENT); + assert_status(&resp, StatusCode::NO_CONTENT); let project = self .get_project_deserialized(&creation_data.slug, pat) @@ -85,6 +85,38 @@ impl ApiV2 { test::read_body_json(resp).await } + pub async fn add_public_version( + &self, + creation_data: VersionCreationRequestData, + pat: &str, + ) -> Version { + // Add a project. + let req = TestRequest::post() + .uri("/v2/version") + .append_header(("Authorization", pat)) + .set_multipart(creation_data.segment_data) + .to_request(); + let resp = self.call(req).await; + assert_status(&resp, StatusCode::OK); + let value: serde_json::Value = test::read_body_json(resp).await; + let version_id = value["id"].as_str().unwrap(); + + // // Approve as a moderator. + // let req = TestRequest::patch() + // .uri(&format!("/v2/project/{}", creation_data.slug)) + // .append_header(("Authorization", MOD_USER_PAT)) + // .set_json(json!( + // { + // "status": "approved" + // } + // )) + // .to_request(); + // let resp = self.call(req).await; + // assert_status(resp, StatusCode::NO_CONTENT); + + self.get_version_deserialized(version_id, pat).await + } + pub async fn get_version(&self, id: &str, pat: &str) -> ServiceResponse { let req = TestRequest::get() .uri(&format!("/v2/version/{id}")) diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index c98dbd39..e15f177e 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -1,3 +1,3 @@ -pub fn assert_status(response: actix_web::dev::ServiceResponse, status: actix_http::StatusCode) { +pub fn assert_status(response: &actix_web::dev::ServiceResponse, status: actix_http::StatusCode) { assert_eq!(response.status(), status, "{:#?}", response.response()); } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index bd18cced..f9b7a877 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -15,7 +15,7 @@ use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegment use super::{environment::TestEnvironment, request_data::get_public_project_creation_data}; -pub const DUMMY_DATA_UPDATE: i64 = 1; +pub const DUMMY_DATA_UPDATE: i64 = 2; #[allow(dead_code)] pub const DUMMY_CATEGORIES: &[&str] = &[ diff --git a/tests/common/environment.rs b/tests/common/environment.rs index abeaf730..44cb5bcd 100644 --- a/tests/common/environment.rs +++ b/tests/common/environment.rs @@ -81,7 +81,7 @@ impl TestEnvironment { USER_USER_PAT, ) .await; - assert_status(resp, StatusCode::NO_CONTENT); + assert_status(&resp, StatusCode::NO_CONTENT); } } diff --git a/tests/common/request_data.rs b/tests/common/request_data.rs index d2176529..1522ded2 100644 --- a/tests/common/request_data.rs +++ b/tests/common/request_data.rs @@ -2,7 +2,10 @@ use serde_json::json; use super::dummy_data::{DummyImage, TestFile}; -use labrinth::util::actix::{MultipartSegment, MultipartSegmentData}; +use labrinth::{ + models::projects::ProjectId, + util::actix::{MultipartSegment, MultipartSegmentData}, +}; pub struct ProjectCreationRequestData { pub slug: String, @@ -10,6 +13,12 @@ pub struct ProjectCreationRequestData { pub segment_data: Vec, } +pub struct VersionCreationRequestData { + pub version: String, + pub jar: Option, + pub segment_data: Vec, +} + pub struct ImageData { pub filename: String, pub extension: String, @@ -21,8 +30,7 @@ pub fn get_public_project_creation_data( version_jar: Option, ) -> ProjectCreationRequestData { let json_data = get_public_project_creation_data_json(slug, version_jar.as_ref()); - let multipart_data = - get_public_project_creation_data_multipart(&json_data, version_jar.as_ref()); + let multipart_data = get_public_creation_data_multipart(&json_data, version_jar.as_ref()); ProjectCreationRequestData { slug: slug.to_string(), jar: version_jar, @@ -30,27 +38,48 @@ pub fn get_public_project_creation_data( } } +pub fn get_public_version_creation_data( + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, +) -> VersionCreationRequestData { + let mut json_data = get_public_version_creation_data_json(version_number, &version_jar); + json_data["project_id"] = json!(project_id); + let multipart_data = get_public_creation_data_multipart(&json_data, Some(&version_jar)); + VersionCreationRequestData { + version: version_number.to_string(), + jar: Some(version_jar), + segment_data: multipart_data, + } +} + +pub fn get_public_version_creation_data_json( + version_number: &str, + version_jar: &TestFile, +) -> serde_json::Value { + json!({ + "file_parts": [version_jar.filename()], + "version_number": version_number, + "version_title": "start", + "dependencies": [], + "game_versions": ["1.20.1"] , + "release_channel": "release", + "loaders": ["fabric"], + "featured": true + }) +} + pub fn get_public_project_creation_data_json( slug: &str, version_jar: Option<&TestFile>, ) -> serde_json::Value { let initial_versions = if let Some(jar) = version_jar { - json!([{ - "file_parts": [jar.filename()], - "version_number": "1.2.3", - "version_title": "start", - "dependencies": [], - "game_versions": ["1.20.1"] , - "release_channel": "release", - "loaders": ["fabric"], - "featured": true - }]) + json!([get_public_version_creation_data_json("1.2.3", jar)]) } else { json!([]) }; let is_draft = version_jar.is_none(); - json!( { "title": format!("Test Project {slug}"), @@ -68,7 +97,7 @@ pub fn get_public_project_creation_data_json( ) } -pub fn get_public_project_creation_data_multipart( +pub fn get_public_creation_data_multipart( json_data: &serde_json::Value, version_jar: Option<&TestFile>, ) -> Vec { diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index ecd79341..d9b48cb5 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -23,6 +23,10 @@ INSERT INTO loaders (id, loader, game_id) VALUES (1, 'fabric', 1); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,1); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,2); +INSERT INTO loaders (id, loader, game_id) VALUES (2, 'forge', 1); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (2,1); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (2,2); + -- Sample game versions, loaders, categories -- Game versions is '2' INSERT INTO loader_field_enum_values(enum_id, value, metadata) @@ -38,14 +42,18 @@ VALUES (2, '1.20.5', '{"type":"release","major":false}'); INSERT INTO loader_fields(loader_id, field, field_type, enum_type) VALUES (1, 'game_versions', 'array_enum', 2); +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (2, 'game_versions', 'array_enum', 2); -- Side-types INSERT INTO loader_fields(loader_id, field, field_type, enum_type) VALUES (1, 'client_side', 'enum', 1); - INSERT INTO loader_fields(loader_id, field, field_type, enum_type) VALUES (1, 'server_side', 'enum', 1); - +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (2, 'client_side', 'enum', 1); +INSERT INTO loader_fields(loader_id, field, field_type, enum_type) +VALUES (2, 'server_side', 'enum', 1); INSERT INTO categories (id, category, project_type) VALUES (1, 'combat', 1), diff --git a/tests/search.rs b/tests/search.rs index ac5d18b3..bad59f4f 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -1,7 +1,7 @@ use crate::common::database::*; use crate::common::dummy_data::DUMMY_CATEGORIES; use crate::common::environment::TestEnvironment; -use crate::common::request_data::ProjectCreationRequestData; +use crate::common::request_data::{get_public_version_creation_data, ProjectCreationRequestData}; use common::dummy_data::TestFile; use common::request_data; use futures::stream::StreamExt; @@ -39,16 +39,14 @@ async fn search_projects() { request_data::get_public_project_creation_data_json(&slug, Some(&jar)); modify_json(&mut basic_project_json); - let basic_project_multipart = request_data::get_public_project_creation_data_multipart( - &basic_project_json, - Some(&jar), - ); + let basic_project_multipart = + request_data::get_public_creation_data_multipart(&basic_project_json, Some(&jar)); // Add a project- simple, should work. let req = api.add_public_project( ProjectCreationRequestData { - slug, + slug: slug.clone(), jar: Some(jar), - segment_data: basic_project_multipart, + segment_data: basic_project_multipart.clone(), }, pat, ); @@ -171,6 +169,25 @@ async fn search_projects() { Box::new(modify_json), )); + // Test project 7 (testing the search bug) + // This project has an initial private forge version that is 1.20.3, and a fabric 1.20.5 version. + // This means that a search for fabric + 1.20.3 or forge + 1.20.5 should not return this project. + let id = 7; + let modify_json = |json: &mut serde_json::Value| { + json["categories"] = json!(DUMMY_CATEGORIES[5..6]); + json["client_side"] = json!("optional"); + json["server_side"] = json!("required"); + json["license_id"] = json!("LGPL-3.0-or-later"); + json["initial_versions"][0]["loaders"] = json!(["forge"]); + json["initial_versions"][0]["game_versions"] = json!(["1.20.2"]); + }; + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Box::new(modify_json), + )); + // Await all project creation // Returns a mapping of: // project id -> test id @@ -181,15 +198,25 @@ async fn search_projects() { .collect(), ); + // Create a second version for project 7 + let project_7 = api + .get_project_deserialized(&format!("{test_name}-searchable-project-7"), USER_USER_PAT) + .await; + api.add_public_version( + get_public_version_creation_data(project_7.id, "1.0.0", TestFile::build_random_jar()), + USER_USER_PAT, + ) + .await; + // Pairs of: // 1. vec of search facets // 2. expected project ids to be returned by this search let pairs = vec![ - (json!([["categories:fabric"]]), vec![0, 1, 2, 3, 4, 5, 6]), - (json!([["categories:forge"]]), vec![]), + (json!([["categories:fabric"]]), vec![0, 1, 2, 3, 4, 5, 6, 7]), + (json!([["categories:forge"]]), vec![7]), ( json!([["categories:fabric", "categories:forge"]]), - vec![0, 1, 2, 3, 4, 5, 6], + vec![0, 1, 2, 3, 4, 5, 6, 7], ), (json!([["categories:fabric"], ["categories:forge"]]), vec![]), ( @@ -201,12 +228,23 @@ async fn search_projects() { ), (json!([["project_type:modpack"]]), vec![4]), (json!([["client_side:required"]]), vec![0, 2, 3]), - (json!([["server_side:required"]]), vec![0, 2, 3, 6]), - (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6]), + (json!([["server_side:required"]]), vec![0, 2, 3, 6, 7]), + (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6, 7]), (json!([["license:MIT"]]), vec![1, 2, 4]), (json!([[r#"title:'Mysterious Project'"#]]), vec![2, 3]), - (json!([["author:user"]]), vec![0, 1, 2, 4, 5]), + (json!([["author:user"]]), vec![0, 1, 2, 4, 5, 7]), (json!([["versions:1.20.5"]]), vec![4, 5]), + // text search + // bug fix + ( + json!([ + // Only the forge one has 1.20.2, so its true that this project 'has' + // 1.20.2 and a fabric version, but not true that it has a 1.20.2 fabric version. + ["categories:fabric"], + ["versions:1.20.2"] + ]), + vec![], + ), ]; // TODO: versions, game versions // Untested: @@ -227,7 +265,7 @@ async fn search_projects() { let test_name = test_name.clone(); async move { let projects = api - .search_deserialized(Some(&test_name), Some(facets), USER_USER_PAT) + .search_deserialized(Some(&test_name), Some(facets.clone()), USER_USER_PAT) .await; let mut found_project_ids: Vec = projects .hits @@ -236,6 +274,7 @@ async fn search_projects() { .collect(); expected_project_ids.sort(); found_project_ids.sort(); + println!("Facets : {:?}", facets); assert_eq!(found_project_ids, expected_project_ids); } }) @@ -244,5 +283,3 @@ async fn search_projects() { // Cleanup test db test_env.cleanup().await; } - -// TODO: write a more specific test to ensure that the search bug is fixed completely From 89658d54f1b7be1764bd8925d3e73a57d90f1ba2 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 24 Oct 2023 21:00:52 -0700 Subject: [PATCH 15/31] version files changes fixes --- migrations/20231005230721_dynamic-fields.sql | 23 +- src/database/models/loader_fields.rs | 54 +- src/database/models/version_item.rs | 5 +- src/models/mod.rs | 2 + src/models/projects.rs | 3 +- src/models/v2/mod.rs | 2 + src/models/v2/projects.rs | 272 +++++++++++ src/routes/v2/mod.rs | 4 +- src/routes/v2/organizations.rs | 51 +- src/routes/v2/project_creation.rs | 16 +- src/routes/v2/projects.rs | 93 ++-- src/routes/v2/tags.rs | 64 ++- src/routes/v2/users.rs | 46 +- src/routes/v2/version_creation.rs | 33 +- src/routes/v2/version_file.rs | 238 ++++----- src/routes/v2/versions.rs | 185 +++---- src/routes/v2_reroute.rs | 52 +- src/routes/v3/mod.rs | 3 + src/routes/v3/organizations.rs | 60 +++ src/routes/v3/projects.rs | 46 +- src/routes/v3/tags.rs | 43 +- src/routes/v3/users.rs | 58 +++ src/routes/v3/version_file.rs | 203 +++++++- src/routes/v3/versions.rs | 158 +++++- tests/common/api_v2/mod.rs | 2 + tests/common/api_v2/organization.rs | 4 +- tests/common/api_v2/project.rs | 82 +--- tests/common/api_v2/tags.rs | 72 +++ tests/common/api_v2/version.rs | 324 ++++++++++++ tests/common/database.rs | 1 + tests/common/dummy_data.rs | 23 +- tests/files/dummy_data.sql | 4 +- tests/search.rs | 1 - tests/tags.rs | 65 +++ tests/version.rs | 489 +++++++++++++++++++ 35 files changed, 2205 insertions(+), 576 deletions(-) create mode 100644 src/models/v2/mod.rs create mode 100644 src/models/v2/projects.rs create mode 100644 src/routes/v3/organizations.rs create mode 100644 src/routes/v3/users.rs create mode 100644 tests/common/api_v2/tags.rs create mode 100644 tests/common/api_v2/version.rs create mode 100644 tests/tags.rs create mode 100644 tests/version.rs diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 726d3491..5b9bc71e 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -6,12 +6,14 @@ CREATE TABLE games ( INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); -ALTER TABLE mods ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java -ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1; -- all past ones are minecraft-java +ALTER TABLE loaders ADD CONSTRAINT unique_loader_name UNIQUE (loader); + +ALTER TABLE mods ADD COLUMN game_id integer REFERENCES games NOT NULL DEFAULT 1; -- all past ones are minecraft-java +ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games NOT NULL DEFAULT 1; -- all past ones are minecraft-java CREATE TABLE loader_field_enums ( id serial PRIMARY KEY, - game_id integer REFERENCES games ON UPDATE CASCADE NOT NULL DEFAULT 1, + game_id integer REFERENCES games NOT NULL DEFAULT 1, enum_name varchar(64) NOT NULL, ordering int NULL, hidable BOOLEAN NOT NULL DEFAULT FALSE @@ -19,7 +21,7 @@ CREATE TABLE loader_field_enums ( CREATE TABLE loader_field_enum_values ( id serial PRIMARY KEY, - enum_id integer REFERENCES loader_field_enums ON UPDATE CASCADE NOT NULL, + enum_id integer REFERENCES loader_field_enums NOT NULL, value varchar(64) NOT NULL, ordering int NULL, created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -34,13 +36,13 @@ CREATE TABLE loader_field_enum_values ( CREATE TABLE loader_fields ( id serial PRIMARY KEY, - loader_id integer REFERENCES loaders ON UPDATE CASCADE NOT NULL, + loader_id integer REFERENCES loaders NOT NULL, field varchar(64) NOT NULL, -- "integer", "text", "enum", "bool", -- "array_integer", "array_text", "array_enum", "array_bool" field_type varchar(64) NOT NULL, -- only for enum - enum_type integer REFERENCES loader_field_enums ON UPDATE CASCADE NULL, + enum_type integer REFERENCES loader_field_enums NULL, optional BOOLEAN NOT NULL DEFAULT true, -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing min_val integer NULL, @@ -52,13 +54,12 @@ CREATE TABLE loader_fields ( ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; CREATE TABLE version_fields ( - version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, - field_id integer REFERENCES loader_fields ON UPDATE CASCADE NOT NULL, + version_id bigint REFERENCES versions NOT NULL, + field_id integer REFERENCES loader_fields NOT NULL, -- for int/bool values int_value integer NULL, - enum_value integer REFERENCES loader_field_enum_values ON UPDATE CASCADE NULL, - string_value text NULL, - PRIMARY KEY (version_id, field_id) + enum_value integer REFERENCES loader_field_enum_values NULL, + string_value text NULL ); -- Convert side_types diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index ddd79a7b..7f3ceb64 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -9,6 +9,7 @@ use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; +const LOADER_ID: &str = "loader_id"; const LOADERS_LIST_NAMESPACE: &str = "loaders"; const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums"; @@ -49,10 +50,19 @@ pub struct Loader { } impl Loader { - pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + pub async fn get_id<'a, E>( + name: &str, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { + let cached_id: Option = redis.get_deserialized_from_json(LOADER_ID, name).await?; + if let Some(cached_id) = cached_id { + return Ok(Some(LoaderId(cached_id))); + } + let result = sqlx::query!( " SELECT id FROM loaders @@ -61,9 +71,16 @@ impl Loader { name ) .fetch_optional(exec) - .await?; + .await? + .map(|r| LoaderId(r.id)); - Ok(result.map(|r| LoaderId(r.id))) + if let Some(result) = result { + redis + .set_serialized_to_json(LOADER_ID, name, &result.0, None) + .await?; + } + + Ok(result) } pub async fn list<'a, E>( @@ -310,10 +327,9 @@ impl LoaderField { } } -// TODO: Should this return variants? impl LoaderFieldEnum { pub async fn get<'a, E>( - enum_name: &str, + enum_name: &str, // Note: NOT loader field name game_name: &str, exec: E, redis: &RedisPool, @@ -735,7 +751,6 @@ impl VersionField { } impl VersionFieldValue { - // TODO: this could be combined with build // Build from user-submitted JSON data // value is the attempted value of the field, which will be tried to parse to the correct type // enum_array is the list of valid enum variants for the field, if it is an enum (see LoaderFieldEnumValue::list_many_loader_fields) @@ -930,7 +945,8 @@ impl VersionFieldValue { } } - // For conversion to an interanl string, such as for search facets + // For conversion to an interanl string, such as for search facets or filtering + // No matter the type, it will be converted to a Vec, whre the non-array types will have a single element pub fn as_search_strings(&self) -> Vec { match self { VersionFieldValue::Integer(i) => vec![i.to_string()], @@ -943,4 +959,28 @@ impl VersionFieldValue { VersionFieldValue::ArrayEnum(_, v) => v.iter().map(|v| v.value.clone()).collect(), } } + + pub fn contains_json_value(&self, value: &serde_json::Value) -> bool { + match self { + VersionFieldValue::Integer(i) => value.as_i64() == Some(*i as i64), + VersionFieldValue::Text(s) => value.as_str() == Some(s), + VersionFieldValue::Boolean(b) => value.as_bool() == Some(*b), + VersionFieldValue::ArrayInteger(v) => value + .as_i64() + .map(|i| v.contains(&(i as i32))) + .unwrap_or(false), + VersionFieldValue::ArrayText(v) => value + .as_str() + .map(|s| v.contains(&s.to_string())) + .unwrap_or(false), + VersionFieldValue::ArrayBoolean(v) => { + value.as_bool().map(|b| v.contains(&b)).unwrap_or(false) + } + VersionFieldValue::Enum(_, v) => value.as_str() == Some(&v.value), + VersionFieldValue::ArrayEnum(_, v) => value + .as_str() + .map(|s| v.iter().any(|v| v.value == s)) + .unwrap_or(false), + } + } } diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 8b28014d..b20eae42 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -10,7 +10,7 @@ use std::cmp::Ordering; use std::collections::HashMap; use std::iter; -const VERSIONS_NAMESPACE: &str = "versions"; +pub const VERSIONS_NAMESPACE: &str = "versions"; const VERSION_FILES_NAMESPACE: &str = "versions_files"; #[derive(Clone)] @@ -253,7 +253,7 @@ impl VersionBuilder { } } -#[derive(derive_new::new)] +#[derive(derive_new::new, Serialize, Deserialize)] pub struct LoaderVersion { pub loader_id: LoaderId, pub version_id: VersionId, @@ -726,7 +726,6 @@ impl Version { .collect::>(), ) .await?; - for file in files { if let Some(mut file) = file.and_then(|x| serde_json::from_str::>(&x).ok()) diff --git a/src/models/mod.rs b/src/models/mod.rs index e1d4ace9..a1117a7c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,3 +13,5 @@ pub mod sessions; pub mod teams; pub mod threads; pub mod users; + +pub mod v2; diff --git a/src/models/projects.rs b/src/models/projects.rs index 636e676d..d565af49 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -796,7 +796,7 @@ impl FileType { } /// A project loader -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(transparent)] pub struct Loader(pub String); @@ -809,7 +809,6 @@ pub struct SearchRequest { pub index: Option, pub limit: Option, - // TODO: implement fully with explanation pub new_filters: Option, // TODO: Deprecated values below. WILL BE REMOVED V3! diff --git a/src/models/v2/mod.rs b/src/models/v2/mod.rs new file mode 100644 index 00000000..5df1866a --- /dev/null +++ b/src/models/v2/mod.rs @@ -0,0 +1,2 @@ +// Legacy models from V2, where its useful to keep the struct for rerouting/conversion +pub mod projects; diff --git a/src/models/v2/projects.rs b/src/models/v2/projects.rs new file mode 100644 index 00000000..6af70a44 --- /dev/null +++ b/src/models/v2/projects.rs @@ -0,0 +1,272 @@ +use super::super::ids::OrganizationId; +use super::super::teams::TeamId; +use super::super::users::UserId; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::{version_item, DatabaseError}; +use crate::database::redis::RedisPool; +use crate::models::ids::{ProjectId, VersionId}; +use crate::models::projects::{ + Dependency, DonationLink, GalleryItem, License, Loader, ModeratorMessage, MonetizationStatus, + Project, ProjectStatus, Version, VersionFile, VersionStatus, VersionType, +}; +use crate::models::threads::ThreadId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A project returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyProject { + /// Relevant V2 fields- these were removed or modfified in V3, + /// and are now part of the dynamic fields system + /// The support range for the client project* + pub client_side: LegacySideType, + /// The support range for the server project + pub server_side: LegacySideType, + /// A list of game versions this project supports + pub game_versions: Vec, + + // All other fields are the same as V3 + // If they change, or their constituent types change, we may need to + // add a new struct for them here. + pub id: ProjectId, + pub slug: Option, + pub project_type: String, + pub team: TeamId, + pub organization: Option, + pub title: String, + pub description: String, + pub body: String, + pub body_url: Option, + pub published: DateTime, + pub updated: DateTime, + pub approved: Option>, + pub queued: Option>, + pub status: ProjectStatus, + pub requested_status: Option, + pub moderator_message: Option, + pub license: License, + pub downloads: u32, + pub followers: u32, + pub categories: Vec, + pub additional_categories: Vec, + pub loaders: Vec, + pub versions: Vec, + pub icon_url: Option, + pub issues_url: Option, + pub source_url: Option, + pub wiki_url: Option, + pub discord_url: Option, + pub donation_urls: Option>, + pub gallery: Vec, + pub color: Option, + pub thread_id: ThreadId, + pub monetization_status: MonetizationStatus, +} + +impl LegacyProject { + // Convert from a standard V3 project to a V2 project + // Requires any queried versions to be passed in, to get access to certain version fields contained within. + // It's safe to use a db version_item for this as the only info is side types and game versions, which used to be public on project anyway. + pub fn from(data: Project, versions_item: Option) -> Self { + let mut client_side = LegacySideType::Unknown; + let mut server_side = LegacySideType::Unknown; + let mut game_versions = Vec::new(); + if let Some(versions_item) = versions_item { + client_side = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "client_side") + .and_then(|f| { + Some(LegacySideType::from_string( + f.value.serialize_internal().as_str()?, + )) + }) + .unwrap_or(LegacySideType::Unknown); + server_side = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "server_side") + .and_then(|f| { + Some(LegacySideType::from_string( + f.value.serialize_internal().as_str()?, + )) + }) + .unwrap_or(LegacySideType::Unknown); + game_versions = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "game_versions") + .and_then(|f| MinecraftGameVersion::try_from_version_field(f).ok()) + .map(|v| v.into_iter().map(|v| v.version).collect()) + .unwrap_or(Vec::new()); + } + Self { + id: data.id, + slug: data.slug, + project_type: data.project_type, + team: data.team, + organization: data.organization, + title: data.title, + description: data.description, + body: data.body, + body_url: data.body_url, + published: data.published, + updated: data.updated, + approved: data.approved, + queued: data.queued, + status: data.status, + requested_status: data.requested_status, + moderator_message: data.moderator_message, + license: data.license, + downloads: data.downloads, + followers: data.followers, + categories: data.categories, + additional_categories: data.additional_categories, + loaders: data.loaders, + versions: data.versions, + icon_url: data.icon_url, + issues_url: data.issues_url, + source_url: data.source_url, + wiki_url: data.wiki_url, + discord_url: data.discord_url, + donation_urls: data.donation_urls, + gallery: data.gallery, + color: data.color, + thread_id: data.thread_id, + monetization_status: data.monetization_status, + client_side, + server_side, + game_versions, + } + } + + // Because from needs a version_item, this is a helper function to get many from one db query. + pub async fn from_many<'a, E>( + data: Vec, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let version_ids: Vec<_> = data + .iter() + .filter_map(|p| p.versions.get(0).map(|i| (*i).into())) + .collect(); + let example_versions = version_item::Version::get_many(&version_ids, exec, redis).await?; + let mut legacy_projects = Vec::new(); + for project in data { + let version_item = example_versions + .iter() + .find(|v| v.inner.project_id == project.id.into()) + .cloned(); + let project = LegacyProject::from(project, version_item); + legacy_projects.push(project); + } + Ok(legacy_projects) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum LegacySideType { + Required, + Optional, + Unsupported, + Unknown, +} + +impl std::fmt::Display for LegacySideType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl LegacySideType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + LegacySideType::Required => "required", + LegacySideType::Optional => "optional", + LegacySideType::Unsupported => "unsupported", + LegacySideType::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> LegacySideType { + match string { + "required" => LegacySideType::Required, + "optional" => LegacySideType::Optional, + "unsupported" => LegacySideType::Unsupported, + _ => LegacySideType::Unknown, + } + } +} + +/// A specific version of a project +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyVersion { + /// Relevant V2 fields- these were removed or modfified in V3, + /// and are now part of the dynamic fields system + /// A list of game versions this project supports + pub game_versions: Vec, + /// A list of loaders this project supports + pub loaders: Vec, + + pub id: VersionId, + pub project_id: ProjectId, + pub author_id: UserId, + pub featured: bool, + pub name: String, + pub version_number: String, + pub changelog: String, + pub changelog_url: Option, + pub date_published: DateTime, + pub downloads: u32, + pub version_type: VersionType, + pub status: VersionStatus, + pub requested_status: Option, + pub files: Vec, + pub dependencies: Vec, +} + +impl From for LegacyVersion { + fn from(data: Version) -> Self { + let mut game_versions = Vec::new(); + let mut loaders = Vec::new(); + for loader in data.loaders { + loaders.push(Loader(loader.loader.0)); + if let Some(value) = loader + .fields + .get("game_versions") + .and_then(|v| v.as_array()) + { + for gv in value { + if let Some(game_version) = gv.as_str() { + game_versions.push(game_version.to_string()); + } + } + } + } + + Self { + id: data.id, + project_id: data.project_id, + author_id: data.author_id, + featured: data.featured, + name: data.name, + version_number: data.version_number, + changelog: data.changelog, + changelog_url: data.changelog_url, + date_published: data.date_published, + downloads: data.downloads, + version_type: data.version_type, + status: data.status, + requested_status: data.requested_status, + files: data.files, + dependencies: data.dependencies, + game_versions, + loaders, + } + } +} diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index 3f95ae6d..1dcb5f75 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -9,12 +9,12 @@ pub(crate) mod project_creation; mod projects; mod reports; mod statistics; -mod tags; +pub mod tags; mod teams; mod threads; mod users; mod version_creation; -mod version_file; +pub mod version_file; mod versions; pub use super::ApiError; diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index 77f7481b..7f348690 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::auth::{filter_authorized_projects, get_user_from_headers}; +use crate::auth::get_user_from_headers; use crate::database::models::team_item::TeamMember; use crate::database::models::{generate_organization_id, team_item, Organization}; use crate::database::redis::RedisPool; @@ -9,10 +9,12 @@ use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; use crate::models::organizations::OrganizationId; use crate::models::pats::Scopes; +use crate::models::projects::Project; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; +use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; -use crate::routes::ApiError; +use crate::routes::{v2_reroute, v3, ApiError}; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; @@ -499,40 +501,23 @@ pub async fn organization_projects_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let info = info.into_inner().0; - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]), + let response = v3::organizations::organization_projects_get( + req, + info, + pool.clone(), + redis.clone(), + session_queue, ) - .await - .map(|x| x.1) - .ok(); - - let possible_organization_id: Option = parse_base62(&info).ok(); - use futures::TryStreamExt; - - let project_ids = sqlx::query!( - " - SELECT m.id FROM organizations o - INNER JOIN mods m ON m.organization_id = o.id - WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL) - ", - possible_organization_id.map(|x| x as i64), - info - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { Ok(e.right().map(|m| crate::database::models::ProjectId(m.id))) }) - .try_collect::>() .await?; - let projects_data = - crate::database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; - - let projects = filter_authorized_projects(projects_data, ¤t_user, &pool).await?; - Ok(HttpResponse::Ok().json(projects)) + // Convert v3 projects to v2 + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } } #[derive(Deserialize)] diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 16a8bd8d..1e7fa6ad 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -1,6 +1,8 @@ +use crate::database::models::version_item; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; - +use crate::models::projects::Project; +use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::{v2_reroute, v3}; @@ -86,10 +88,14 @@ pub async fn project_create( .await?; // Convert response to V2 format - match v2_reroute::extract_ok_json(response).await { - Ok(mut json) => { - v2_reroute::set_side_types_from_versions(&mut json, &**client, &redis).await?; - Ok(HttpResponse::Ok().json(json)) + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => version_item::Version::get((*vid).into(), &**client, &redis).await?, + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) } Err(response) => Ok(response), } diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index fb685300..33613c0d 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -11,6 +11,7 @@ use crate::models::projects::{ SideType, }; use crate::models::teams::ProjectPermissions; +use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::projects::{delete_from_index, ProjectIds}; use crate::routes::{v2_reroute, v3, ApiError}; @@ -20,7 +21,6 @@ use crate::util::validate::validation_errors_to_string; use crate::{database, search}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; -use futures::TryStreamExt; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; @@ -65,6 +65,7 @@ pub async fn project_search( web::Query(info): web::Query, config: web::Data, ) -> Result { + // TODO: make this nicer // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields // Loader fields are: // (loader)_(field):(value) @@ -75,18 +76,18 @@ pub async fn project_search( // "versions:x" => "fabric_game_versions:x", "forge_game_versions:x" ... // They are put in the same array- considered to be 'or' - let mut v2_loaders = vec!["fabric", "forge"]; // TODO: populate + let mut v2_loaders: Vec = Vec::new(); { let client = meilisearch_sdk::Client::new(&*config.address, &*config.key); let index = info.index.as_deref().unwrap_or("relevance"); let meilisearch_index = client.get_index(search::get_sort_index(index)?.0).await?; let filterable_fields = meilisearch_index.get_filterable_attributes().await?; - // Only keep v2 loaders that are filterable - v2_loaders.retain(|x| { - filterable_fields - .iter() - .any(|f| f.starts_with(&format!("{}_game_versions", x))) - }); + for field in filterable_fields { + if field.ends_with("_game_versions") { + let loader = field.split('_').next().unwrap_or(""); + v2_loaders.push(loader.to_string()); + } + } } Some( facets @@ -133,6 +134,9 @@ pub async fn project_search( }; let results = search_for_project(&info, &config).await?; + + // TODO: convert to v2 format-we may need a new v2 struct for this for 'original' format + Ok(HttpResponse::Ok().json(results)) } @@ -148,32 +152,22 @@ pub async fn random_projects_get( pool: web::Data, redis: web::Data, ) -> Result { - count - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + let count = v3::projects::RandomProjects { count: count.count }; - let project_ids = sqlx::query!( - " - SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2) - ", - count.count as i32, - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { Ok(e.right().map(|m| db_ids::ProjectId(m.id))) }) - .try_collect::>() - .await?; - - let projects_data = db_models::Project::get_many_ids(&project_ids, &**pool, &redis) - .await? - .into_iter() - .map(Project::from) - .collect::>(); - - Ok(HttpResponse::Ok().json(projects_data)) + let response = + v3::projects::random_projects_get(web::Query(count), pool.clone(), redis.clone()).await?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => version_item::Version::get((*vid).into(), &**pool, &redis).await?, + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) + } + Err(response) => Ok(response), + } } #[get("projects")] @@ -184,8 +178,6 @@ pub async fn projects_get( redis: web::Data, session_queue: web::Data, ) -> Result { - // Convert V2 data to V3 data - // Call V3 project creation let response = v3::projects::projects_get( req, @@ -196,16 +188,11 @@ pub async fn projects_get( ) .await?; - // Convert response to V2 forma - match v2_reroute::extract_ok_json(response).await { - Ok(mut json) => { - if let Some(projects) = json.as_array_mut() { - for project in projects { - // We need versions - v2_reroute::set_side_types_from_versions(project, &**pool, &redis).await?; - } - } - Ok(HttpResponse::Ok().json(json)) + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), } @@ -225,12 +212,15 @@ pub async fn project_get( let response = v3::projects::project_get(req, info, pool.clone(), redis.clone(), session_queue).await?; - // Convert response to V2 forma - match v2_reroute::extract_ok_json(response).await { - Ok(mut json) => { - v2_reroute::set_side_types_from_versions(&mut json, &**pool, &redis).await?; - - Ok(HttpResponse::Ok().json(json)) + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => version_item::Version::get((*vid).into(), &**pool, &redis).await?, + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) } Err(response) => Ok(response), } @@ -452,6 +442,7 @@ pub async fn project_edit( let client_side = v2_new_project.client_side.clone(); let server_side = v2_new_project.server_side.clone(); let new_slug = v2_new_project.slug.clone(); + let new_project = v3::projects::EditProject { title: v2_new_project.title, description: v2_new_project.description, diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 6e7c38c6..1cecb977 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -2,10 +2,10 @@ use std::collections::HashMap; use super::ApiError; use crate::database::models::categories::{Category, DonationPlatform, ProjectType, ReportType}; -use crate::database::models::loader_fields::Game; +use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue}; use crate::database::redis::RedisPool; -use crate::routes::v3; use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderList}; +use crate::routes::{v2_reroute, v3}; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; use sqlx::PgPool; @@ -27,10 +27,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { #[derive(serde::Serialize, serde::Deserialize)] pub struct CategoryData { - icon: String, - name: String, - project_type: String, - header: String, + pub icon: String, + pub name: String, + pub project_type: String, + pub header: String, } #[get("category")] @@ -75,7 +75,7 @@ pub async fn loader_list( Ok(response) } -#[derive(serde::Serialize)] +#[derive(serde::Serialize, serde::Deserialize)] pub struct GameVersionQueryData { pub version: String, pub version_type: String, @@ -106,16 +106,41 @@ pub async fn game_version_list( let response = v3::tags::loader_fields_list( pool, web::Query(LoaderFieldsEnumQuery { - game: Game::MinecraftJava.name().to_string(), - field: "game_version".to_string(), + loader: "fabric".to_string(), + loader_field: "game_versions".to_string(), filters: Some(filters), }), redis, ) .await?; - // TODO: parse v3 to v2 - Ok(response) + // Convert to V2 format + Ok( + match v2_reroute::extract_ok_json::>(response).await { + Ok(fields) => { + let fields = fields + .into_iter() + .map(|f| GameVersionQueryData { + version: f.value, + version_type: f + .metadata + .get("type") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(), + date: f.created, + major: f + .metadata + .get("major") + .and_then(|m| m.as_bool()) + .unwrap_or_default(), + }) + .collect::>(); + HttpResponse::Ok().json(fields) + } + Err(response) => response, + }, + ) } #[derive(serde::Serialize)] @@ -216,13 +241,22 @@ pub async fn side_type_list( let response = v3::tags::loader_fields_list( pool, web::Query(LoaderFieldsEnumQuery { - game: Game::MinecraftJava.name().to_string(), - field: "client_type".to_string(), // same as server_type + loader: "fabric".to_string(), // same for minecraft loader + loader_field: "client_side".to_string(), // same as server_side filters: None, }), redis, ) .await?; - // TODO: parse v3 to v2 - Ok(response) + + // Convert to V2 format + Ok( + match v2_reroute::extract_ok_json::>(response).await { + Ok(fields) => { + let fields = fields.into_iter().map(|f| f.value).collect::>(); + HttpResponse::Ok().json(fields) + } + Err(response) => response, + }, + ) } diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 0ad8b512..f113ce52 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -9,9 +9,10 @@ use crate::models::projects::Project; use crate::models::users::{ Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData, }; +use crate::models::v2::projects::LegacyProject; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::{v2_reroute, v3, ApiError}; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; @@ -116,39 +117,16 @@ pub async fn projects_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - let user_id: UserId = id.into(); - - let can_view_private = user - .map(|y| y.role.is_mod() || y.id == user_id) - .unwrap_or(false); - - let project_data = User::get_projects(id, &**pool, &redis).await?; - - let response: Vec<_> = - crate::database::Project::get_many_ids(&project_data, &**pool, &redis) - .await? - .into_iter() - .filter(|x| can_view_private || x.inner.status.is_searchable()) - .map(Project::from) - .collect(); - - Ok(HttpResponse::Ok().json(response)) - } else { - Ok(HttpResponse::NotFound().body("")) + let response = + v3::users::projects_list(req, info, pool.clone(), redis.clone(), session_queue).await?; + + // Convert to V2 projects + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), } } diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index b0563baa..b5e2e16f 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -2,8 +2,9 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::ImageId; use crate::models::projects::{ - Dependency, FileType, Loader, ProjectId, VersionId, VersionStatus, VersionType, + Dependency, FileType, Loader, ProjectId, Version, VersionId, VersionStatus, VersionType, }; +use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::{v2_reroute, v3}; @@ -111,30 +112,11 @@ pub async fn version_create( ) .await?; - // Convert response to V2 forma - match v2_reroute::extract_ok_json(response).await { - Ok(mut json) => { - // Get game_versions out of loaders, and flatten loadedrs - let mut game_versions = Vec::new(); - let mut loaders = Vec::new(); - if let Some(loaders_json) = json["loaders"].as_array() { - for loader_json in loaders_json { - if let Some(loader) = loader_json["loader"].as_str() { - loaders.push(loader.to_string()); - } - if let Some(game_versions_json) = loader_json["game_versions"].as_array() { - for game_version_json in game_versions_json { - if let Some(game_version) = game_version_json.as_str() { - game_versions.push(game_version.to_string()); - } - } - } - } - } - json["game_versions"] = json!(game_versions); - json["loaders"] = json!(loaders); - - Ok(HttpResponse::Ok().json(json)) + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) } Err(response) => Ok(response), } @@ -151,7 +133,6 @@ pub async fn upload_file_to_version( file_host: Data>, session_queue: web::Data, ) -> Result { - // TODO: do we need to modify this? let response = v3::version_creation::upload_file_to_version( req, url_data, diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 5542df65..f25400b1 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -1,16 +1,15 @@ use super::ApiError; -use crate::auth::{ - filter_authorized_projects, filter_authorized_versions, get_user_from_headers, - is_authorized_version, -}; +use crate::auth::{get_user_from_headers, is_authorized_version}; +use crate::database; + use crate::database::redis::RedisPool; use crate::models::pats::Scopes; -use crate::models::projects::VersionType; +use crate::models::projects::{Project, Version, VersionType}; use crate::models::teams::ProjectPermissions; +use crate::models::v2::projects::{LegacyProject, LegacyVersion}; use crate::queue::session::AuthQueue; -use crate::routes::v3; use crate::routes::v3::version_file::{default_algorithm, HashQuery}; -use crate::{database, models}; +use crate::routes::{v2_reroute, v3}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -44,38 +43,17 @@ pub async fn get_version_from_hash( hash_query: web::Query, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - let hash = info.into_inner().0.to_lowercase(); - let file = database::models::Version::get_file_from_hash( - hash_query.algorithm.clone(), - hash, - hash_query.version_id.map(|x| x.into()), - &**pool, - &redis, - ) - .await?; - if let Some(file) = file { - let version = database::models::Version::get(file.version_id, &**pool, &redis).await?; - if let Some(version) = version { - if !is_authorized_version(&version.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - Ok(HttpResponse::Ok().json(models::projects::Version::from(version))) - } else { - Ok(HttpResponse::NotFound().body("")) + let response = + v3::version_file::get_version_from_hash(req, info, pool, redis, hash_query, session_queue) + .await; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response?).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) } - } else { - Ok(HttpResponse::NotFound().body("")) + Err(response) => Ok(response), } } @@ -250,14 +228,13 @@ pub async fn delete_file( } } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateData { pub loaders: Option>, pub game_versions: Option>, pub version_types: Option>, } -// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("{version_id}/update")] pub async fn get_update_from_hash( req: HttpRequest, @@ -274,7 +251,9 @@ pub async fn get_update_from_hash( for gv in update_data.game_versions.into_iter().flatten() { game_versions.push(serde_json::json!(gv.clone())); } - loader_fields.insert("game_versions".to_string(), game_versions); + if !game_versions.is_empty() { + loader_fields.insert("game_versions".to_string(), game_versions); + } let update_data = v3::version_file::UpdateData { loaders: update_data.loaders.clone(), version_types: update_data.version_types.clone(), @@ -292,7 +271,14 @@ pub async fn get_update_from_hash( ) .await?; - Ok(response) + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } } // Requests above with multiple versions below @@ -312,44 +298,34 @@ pub async fn get_versions_from_hashes( file_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let files = database::models::Version::get_files_from_hash( - file_data.algorithm.clone(), - &file_data.hashes, - &**pool, - &redis, - ) - .await?; - - let version_ids = files.iter().map(|x| x.version_id).collect::>(); - let versions_data = filter_authorized_versions( - database::models::Version::get_many(&version_ids, &**pool, &redis).await?, - &user_option, - &pool, + let file_data = file_data.into_inner(); + let file_data = v3::version_file::FileHashes { + algorithm: file_data.algorithm, + hashes: file_data.hashes, + }; + let response = v3::version_file::get_versions_from_hashes( + req, + pool, + redis, + web::Json(file_data), + session_queue, ) .await?; - let mut response = HashMap::new(); - - for version in versions_data { - for file in files.iter().filter(|x| x.version_id == version.id.into()) { - if let Some(hash) = file.hashes.get(&file_data.algorithm) { - response.insert(hash.clone(), version.clone()); - } + // Convert to V2 + match v2_reroute::extract_ok_json::>(response).await { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) } + Err(response) => Ok(response), } - - Ok(HttpResponse::Ok().json(response)) } #[post("project")] @@ -360,45 +336,46 @@ pub async fn get_projects_from_hashes( file_data: web::Json, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let files = database::models::Version::get_files_from_hash( - file_data.algorithm.clone(), - &file_data.hashes, - &**pool, - &redis, - ) - .await?; - - let project_ids = files.iter().map(|x| x.project_id).collect::>(); - - let projects_data = filter_authorized_projects( - database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?, - &user_option, - &pool, + let file_data = file_data.into_inner(); + let file_data = v3::version_file::FileHashes { + algorithm: file_data.algorithm, + hashes: file_data.hashes, + }; + let response = v3::version_file::get_projects_from_hashes( + req, + pool.clone(), + redis.clone(), + web::Json(file_data), + session_queue, ) .await?; - let mut response = HashMap::new(); - - for project in projects_data { - for file in files.iter().filter(|x| x.project_id == project.id.into()) { - if let Some(hash) = file.hashes.get(&file_data.algorithm) { - response.insert(hash.clone(), project.clone()); - } + // Convert to V2 + match v2_reroute::extract_ok_json::>(response).await { + Ok(projects_hashes) => { + let hash_to_project_id = projects_hashes + .iter() + .map(|(hash, project)| { + let project_id = project.id; + (hash.clone(), project_id) + }) + .collect::>(); + let legacy_projects = + LegacyProject::from_many(projects_hashes.into_values().collect(), &**pool, &redis) + .await?; + let legacy_projects_hashes = hash_to_project_id + .into_iter() + .filter_map(|(hash, project_id)| { + let legacy_project = + legacy_projects.iter().find(|x| x.id == project_id)?.clone(); + Some((hash.to_string(), legacy_project)) + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(legacy_projects_hashes)) } + Err(response) => Ok(response), } - - Ok(HttpResponse::Ok().json(response)) } #[derive(Deserialize)] @@ -411,7 +388,6 @@ pub struct ManyUpdateData { pub version_types: Option>, } -// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("update")] pub async fn update_files( req: HttpRequest, @@ -426,7 +402,9 @@ pub async fn update_files( for gv in update_data.game_versions.into_iter().flatten() { game_versions.push(serde_json::json!(gv.clone())); } - loader_fields.insert("game_versions".to_string(), game_versions); + if !game_versions.is_empty() { + loader_fields.insert("game_versions".to_string(), game_versions); + } let update_data = v3::version_file::ManyUpdateData { loaders: update_data.loaders.clone(), version_types: update_data.version_types.clone(), @@ -438,10 +416,24 @@ pub async fn update_files( let response = v3::version_file::update_files(req, pool, redis, web::Json(update_data), session_queue) .await?; - Ok(response) + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(returned_versions) => { + let v3_versions = returned_versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v3_versions)) + } + Err(response) => Ok(response), + } } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct FileUpdateData { pub hash: String, pub loaders: Option>, @@ -456,7 +448,6 @@ pub struct ManyFileUpdateData { pub hashes: Vec, } -// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[post("update_individual")] pub async fn update_individual_files( req: HttpRequest, @@ -477,7 +468,9 @@ pub async fn update_individual_files( for gv in x.game_versions.into_iter().flatten() { game_versions.push(serde_json::json!(gv.clone())); } - loader_fields.insert("game_versions".to_string(), game_versions); + if !game_versions.is_empty() { + loader_fields.insert("game_versions".to_string(), game_versions); + } v3::version_file::FileUpdateData { hash: x.hash.clone(), loaders: x.loaders.clone(), @@ -497,5 +490,18 @@ pub async fn update_individual_files( ) .await?; - Ok(response) + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(returned_versions) => { + let v3_versions = returned_versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v3_versions)) + } + Err(response) => Ok(response), + } } diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 1a8c7421..33ed34ce 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -1,21 +1,21 @@ use std::collections::HashMap; use super::ApiError; -use crate::auth::{ - filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, -}; +use crate::auth::get_user_from_headers; use crate::database; use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; -use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, LoaderStruct, VersionStatus, VersionType}; +use crate::models::projects::{ + Dependency, FileType, LoaderStruct, Version, VersionStatus, VersionType, +}; use crate::models::teams::ProjectPermissions; +use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; -use crate::routes::v3; +use crate::routes::{v2_reroute, v3}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -46,7 +46,6 @@ pub struct VersionListFilters { pub offset: Option, } -// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) #[get("version")] pub async fn version_list( req: HttpRequest, @@ -86,8 +85,17 @@ pub async fn version_list( v3::versions::version_list(req, info, web::Query(filters), pool, redis, session_queue) .await?; - //TODO: Convert response to V2 format - Ok(response) + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(LegacyVersion::from) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) + } + Err(response) => Ok(response), + } } // Given a project ID/slug and a version slug @@ -100,41 +108,16 @@ pub async fn version_project_get( session_queue: web::Data, ) -> Result { let id = info.into_inner(); - - let result = database::models::Project::get(&id.0, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(project) = result { - if !is_authorized(&project.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - let versions = - database::models::Version::get_many(&project.versions, &**pool, &redis).await?; - - let id_opt = parse_base62(&id.1).ok(); - let version = versions - .into_iter() - .find(|x| Some(x.inner.id.0 as u64) == id_opt || x.inner.version_number == id.1); - - if let Some(version) = version { - if is_authorized_version(&version.inner, &user_option, &pool).await? { - return Ok(HttpResponse::Ok().json(models::projects::Version::from(version))); - } + let response = + v3::versions::version_project_get_helper(req, id, pool, redis, session_queue).await?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) } + Err(response) => Ok(response), } - - Ok(HttpResponse::NotFound().body("")) } #[derive(Serialize, Deserialize)] @@ -150,26 +133,21 @@ pub async fn versions_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let version_ids = serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect::>(); - let versions_data = database::models::Version::get_many(&version_ids, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let versions = filter_authorized_versions(versions_data, &user_option, &pool).await?; - - Ok(HttpResponse::Ok().json(versions)) + let ids = v3::versions::VersionIds { ids: ids.ids }; + let response = + v3::versions::versions_get(req, web::Query(ids), pool, redis, session_queue).await?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(LegacyVersion::from) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) + } + Err(response) => Ok(response), + } } #[get("{version_id}")] @@ -181,26 +159,15 @@ pub async fn version_get( session_queue: web::Data, ) -> Result { let id = info.into_inner().0; - let version_data = database::models::Version::get(id.into(), &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(data) = version_data { - if is_authorized_version(&data.inner, &user_option, &pool).await? { - return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); + let response = v3::versions::version_get_helper(req, id, pool, redis, session_queue).await?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) } + Err(response) => Ok(response), } - - Ok(HttpResponse::NotFound().body("")) } #[derive(Serialize, Deserialize, Validate)] @@ -249,20 +216,62 @@ pub async fn version_edit( session_queue: web::Data, ) -> Result { let new_version = new_version.into_inner(); + + // TOOD: convert to loader, need to get loader first in case only game versions is passed + let new_loaders = if new_version.game_versions.is_some() || new_version.loaders.is_some() { + let old_version = database::models::Version::get((*info).0.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified version does not exist!".to_string()) + })?; + let old_version = models::projects::Version::from(old_version); + + // Which loaderes to use + let new_loader_strings: Vec<_> = if let Some(loaders) = new_version.loaders { + loaders.to_vec() + } else { + old_version + .loaders + .iter() + .map(|l| l.loader.clone()) + .collect() + }; + + // calling V2 endpoint does not allow different loader fields for different loaders + // (V3 functionality) so we can just take the first loader + let mut fields = old_version + .loaders + .into_iter() + .next() + .map(|l| l.fields) + .unwrap_or_default(); + if let Some(game_versions) = new_version.game_versions { + fields.insert( + "game_versions".to_string(), + serde_json::json!(game_versions), + ); + } + + Some( + new_loader_strings + .into_iter() + .map(|loader| LoaderStruct { + loader, + fields: fields.clone(), + }) + .collect::>(), + ) + } else { + None + }; + let new_version = v3::versions::EditVersion { name: new_version.name, version_number: new_version.version_number, changelog: new_version.changelog, version_type: new_version.version_type, dependencies: new_version.dependencies, - loaders: new_version.loaders.map(|l| { - l.into_iter() - .map(|l| LoaderStruct { - loader: l, - fields: HashMap::new(), - }) - .collect::>() - }), + loaders: new_loaders, featured: new_version.featured, primary_file: new_version.primary_file, downloads: new_version.downloads, diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index d0ae7324..9c2955b6 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -1,52 +1,15 @@ use super::v3::project_creation::CreateError; -use crate::{ - database::{ - models::{version_item, DatabaseError}, - redis::RedisPool, - }, - models::ids::VersionId, - util::actix::{generate_multipart, MultipartSegment, MultipartSegmentData}, -}; +use crate::util::actix::{generate_multipart, MultipartSegment, MultipartSegmentData}; use actix_multipart::Multipart; use actix_web::http::header::{HeaderMap, TryIntoHeaderPair}; use actix_web::HttpResponse; use futures::{stream, StreamExt}; use serde_json::{json, Value}; -pub async fn set_side_types_from_versions<'a, E>( - json: &mut serde_json::Value, - exec: E, - redis: &RedisPool, -) -> Result<(), DatabaseError> +pub async fn extract_ok_json(response: HttpResponse) -> Result where - E: sqlx::Executor<'a, Database = sqlx::Postgres>, + T: serde::de::DeserializeOwned, { - json["client_side"] = json!("required"); // default to required - json["server_side"] = json!("required"); - let version_id = json["versions"].as_array().and_then(|a| a.iter().next()); - if let Some(version_id) = version_id { - let version_id = serde_json::from_value::(version_id.clone())?; - let versions_item = version_item::Version::get(version_id.into(), exec, redis).await?; - if let Some(versions_item) = versions_item { - json["client_side"] = versions_item - .version_fields - .iter() - .find(|f| f.field_name == "client_side") - .map(|f| f.value.serialize_internal()) - .unwrap_or(json!("required")); - json["server_side"] = versions_item - .version_fields - .iter() - .find(|f| f.field_name == "server_side") - .map(|f| f.value.serialize_internal()) - .unwrap_or(json!("server_side")); - } - } - Ok(()) -} - -// TODO: this is not an ideal way to do this, but it works for now -pub async fn extract_ok_json(response: HttpResponse) -> Result { if response.status() == actix_web::http::StatusCode::OK { let failure_http_response = || { HttpResponse::InternalServerError().json(json!({ @@ -59,8 +22,7 @@ pub async fn extract_ok_json(response: HttpResponse) -> Result, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let possible_organization_id: Option = parse_base62(&info).ok(); + use futures::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT m.id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.title = $2 AND $2 IS NOT NULL) + ", + possible_organization_id.map(|x| x as i64), + info + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { Ok(e.right().map(|m| crate::database::models::ProjectId(m.id))) }) + .try_collect::>() + .await?; + + let projects_data = + crate::database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; + + let projects = filter_authorized_projects(projects_data, ¤t_user, &pool).await?; + Ok(HttpResponse::Ok().json(projects)) +} diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index f9aa8877..b2f7e77d 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -28,6 +28,7 @@ use sqlx::PgPool; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("projects_random", web::get().to(random_projects_get)); cfg.service( web::scope("project") .route("{id}", web::get().to(project_get)) @@ -35,11 +36,54 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("{id}", web::patch().to(project_edit)) .service( web::scope("{project_id}") - .route("versions", web::get().to(super::versions::version_list)), + .route("versions", web::get().to(super::versions::version_list)) + .route( + "version/{slug}", + web::get().to(super::versions::version_project_get), + ), ), ); } +#[derive(Deserialize, Validate)] +pub struct RandomProjects { + #[validate(range(min = 1, max = 100))] + pub count: u32, +} + +pub async fn random_projects_get( + web::Query(count): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + count + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let project_ids = sqlx::query!( + " + SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2) + ", + count.count as i32, + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { Ok(e.right().map(|m| db_ids::ProjectId(m.id))) }) + .try_collect::>() + .await?; + + let projects_data = db_models::Project::get_many_ids(&project_ids, &**pool, &redis) + .await? + .into_iter() + .map(Project::from) + .collect::>(); + + Ok(HttpResponse::Ok().json(projects_data)) +} + #[derive(Serialize, Deserialize)] pub struct ProjectIds { pub ids: String, diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 99091384..3eeb6a2e 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::loader_fields::{Game, Loader, LoaderFieldEnum, LoaderFieldEnumValue}; +use crate::database::models::loader_fields::{ + Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, +}; use crate::database::redis::RedisPool; use actix_web::{web, HttpResponse}; use serde_json::Value; @@ -14,9 +16,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { #[derive(serde::Serialize, serde::Deserialize)] pub struct LoaderData { - icon: String, - name: String, - supported_project_types: Vec, + pub icon: String, + pub name: String, + pub supported_project_types: Vec, } #[derive(serde::Deserialize)] @@ -47,33 +49,50 @@ pub async fn loader_list( Ok(HttpResponse::Ok().json(results)) } -// TODO: write tests for this and all other v3/tags and v2/tags functoins #[derive(serde::Deserialize, serde::Serialize)] pub struct LoaderFieldsEnumQuery { - pub game: String, - pub field: String, + pub loader: String, + pub loader_field: String, pub filters: Option>, // For metadata } +// Provides the variants for any enumerable loader field. pub async fn loader_fields_list( pool: web::Data, query: web::Query, redis: web::Data, ) -> Result { let query = query.into_inner(); - let loader_field_enum = LoaderFieldEnum::get(&query.field, &query.game, &**pool, &redis) + let loader_id = Loader::get_id(&query.loader, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!("'{}' was not a valid loader.", query.loader)) + })?; + + let loader_field = LoaderField::get_field(&query.loader_field, loader_id, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput(format!( - "'{}' was not a valid enumerable loader field for game {}.", - query.field, query.game + "'{}' was not a valid loader field for loader {}.", + query.loader_field, query.loader )) })?; + let loader_field_enum_id = match loader_field.field_type { + LoaderFieldType::Enum(enum_id) | LoaderFieldType::ArrayEnum(enum_id) => enum_id, + _ => { + return Err(ApiError::InvalidInput(format!( + "'{}' is not an enumerable field, but an '{}' field.", + query.loader_field, + loader_field.field_type.to_str() + ))) + } + }; + let results: Vec<_> = if let Some(filters) = query.filters { - LoaderFieldEnumValue::list_filter(loader_field_enum.id, filters, &**pool, &redis).await? + LoaderFieldEnumValue::list_filter(loader_field_enum_id, filters, &**pool, &redis).await? } else { - LoaderFieldEnumValue::list(loader_field_enum.id, &**pool, &redis).await? + LoaderFieldEnumValue::list(loader_field_enum_id, &**pool, &redis).await? }; Ok(HttpResponse::Ok().json(results)) diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs new file mode 100644 index 00000000..4faf6937 --- /dev/null +++ b/src/routes/v3/users.rs @@ -0,0 +1,58 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use sqlx::PgPool; + +use crate::{ + auth::get_user_from_headers, + database::{models::User, redis::RedisPool}, + models::{ids::UserId, pats::Scopes, projects::Project}, + queue::session::AuthQueue, +}; + +use super::ApiError; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("user").route("{user_id}/projects", web::get().to(projects_list))); +} + +pub async fn projects_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let user_id: UserId = id.into(); + + let can_view_private = user + .map(|y| y.role.is_mod() || y.id == user_id) + .unwrap_or(false); + + let project_data = User::get_projects(id, &**pool, &redis).await?; + + let response: Vec<_> = + crate::database::Project::get_many_ids(&project_data, &**pool, &redis) + .await? + .into_iter() + .filter(|x| can_view_private || x.inner.status.is_searchable()) + .map(Project::from) + .collect(); + + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index ebdd2182..4c64ee1f 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -1,5 +1,8 @@ use super::ApiError; -use crate::auth::{get_user_from_headers, is_authorized_version}; +use crate::auth::{ + filter_authorized_projects, filter_authorized_versions, get_user_from_headers, + is_authorized_version, +}; use crate::database::redis::RedisPool; use crate::models::ids::VersionId; use crate::models::pats::Scopes; @@ -15,15 +18,61 @@ use std::collections::HashMap; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("version_file") - .route("{version_id}/update", web::post().to(get_update_from_hash)), + .route("version_id", web::get().to(get_version_from_hash)) + .route("{version_id}/update", web::post().to(get_update_from_hash)) + .route("project", web::post().to(get_projects_from_hashes)), ); cfg.service( web::scope("version_files") .route("update", web::post().to(update_files)) - .route("update_individual", web::post().to(update_individual_files)), + .route("update_individual", web::post().to(update_individual_files)) + .route("", web::post().to(get_versions_from_hashes)), ); } +pub async fn get_version_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let hash = info.into_inner().0.to_lowercase(); + let file = database::models::Version::get_file_from_hash( + hash_query.algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + if let Some(file) = file { + let version = database::models::Version::get(file.version_id, &**pool, &redis).await?; + if let Some(version) = version { + if !is_authorized_version(&version.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + Ok(HttpResponse::Ok().json(models::projects::Version::from(version))) + } else { + Ok(HttpResponse::NotFound().body("")) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + #[derive(Serialize, Deserialize)] pub struct HashQuery { #[serde(default = "default_algorithm")] @@ -35,7 +84,7 @@ pub fn default_algorithm() -> String { "sha1".into() } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct UpdateData { pub loaders: Option>, pub version_types: Option>, @@ -48,7 +97,6 @@ pub struct UpdateData { pub loader_fields: Option>>, } -// TODO: Requires testing for v2 and v3 (errors were uncaught by cargo test) pub async fn get_update_from_hash( req: HttpRequest, info: web::Path<(String,)>, @@ -88,7 +136,6 @@ pub async fn get_update_from_hash( .into_iter() .filter(|x| { let mut bool = true; - if let Some(version_types) = &update_data.version_types { bool &= version_types .iter() @@ -97,13 +144,15 @@ pub async fn get_update_from_hash( if let Some(loaders) = &update_data.loaders { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - if let Some(loader_fields) = &update_data.loader_fields { - for (key, value) in loader_fields { - bool &= x.version_fields.iter().any(|y| { - y.field_name == *key - && value.contains(&y.value.serialize_internal()) - }); + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; } } bool @@ -124,6 +173,109 @@ pub async fn get_update_from_hash( Ok(HttpResponse::NotFound().body("")) } +// Requests above with multiple versions below +#[derive(Deserialize)] +pub struct FileHashes { + #[serde(default = "default_algorithm")] + pub algorithm: String, + pub hashes: Vec, +} + +pub async fn get_versions_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let files = database::models::Version::get_files_from_hash( + file_data.algorithm.clone(), + &file_data.hashes, + &**pool, + &redis, + ) + .await?; + + let version_ids = files.iter().map(|x| x.version_id).collect::>(); + let versions_data = filter_authorized_versions( + database::models::Version::get_many(&version_ids, &**pool, &redis).await?, + &user_option, + &pool, + ) + .await?; + + let mut response = HashMap::new(); + + for version in versions_data { + for file in files.iter().filter(|x| x.version_id == version.id.into()) { + if let Some(hash) = file.hashes.get(&file_data.algorithm) { + response.insert(hash.clone(), version.clone()); + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +pub async fn get_projects_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let files = database::models::Version::get_files_from_hash( + file_data.algorithm.clone(), + &file_data.hashes, + &**pool, + &redis, + ) + .await?; + + let project_ids = files.iter().map(|x| x.project_id).collect::>(); + + let projects_data = filter_authorized_projects( + database::models::Project::get_many_ids(&project_ids, &**pool, &redis).await?, + &user_option, + &pool, + ) + .await?; + + let mut response = HashMap::new(); + + for project in projects_data { + for file in files.iter().filter(|x| x.project_id == project.id.into()) { + if let Some(hash) = file.hashes.get(&file_data.algorithm) { + response.insert(hash.clone(), project.clone()); + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + #[derive(Deserialize)] pub struct ManyUpdateData { #[serde(default = "default_algorithm")] @@ -183,6 +335,7 @@ pub async fn update_files( .iter() .filter(|x| x.inner.project_id == file.project_id) .filter(|x| { + // TODO: Behaviour here is repeated in a few other filtering places, should be abstracted let mut bool = true; if let Some(version_types) = &update_data.version_types { @@ -194,11 +347,14 @@ pub async fn update_files( bool &= x.loaders.iter().any(|y| loaders.contains(y)); } if let Some(loader_fields) = &update_data.loader_fields { - for (key, value) in loader_fields { - bool &= x.version_fields.iter().any(|y| { - y.field_name == *key - && value.contains(&y.value.serialize_internal()) - }); + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; } } @@ -305,11 +461,14 @@ pub async fn update_individual_files( bool &= x.loaders.iter().any(|y| loaders.contains(y)); } if let Some(loader_fields) = &query_file.loader_fields { - for (key, value) in loader_fields { - bool &= x.version_fields.iter().any(|y| { - y.field_name == *key - && value.contains(&y.value.serialize_internal()) - }); + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; } } diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 893a89db..3573be74 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -1,13 +1,16 @@ use std::collections::HashMap; use super::ApiError; -use crate::auth::{filter_authorized_versions, get_user_from_headers, is_authorized}; +use crate::auth::{ + filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, +}; use crate::database; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; use crate::database::models::Organization; use crate::database::redis::RedisPool; use crate::models; +use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; @@ -26,6 +29,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { "version", web::post().to(super::version_creation::version_create), ); + cfg.route("versions", web::get().to(versions_get)); + cfg.route( "{id}", web::post().to(super::version_creation::version_create), @@ -33,6 +38,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("version") + .route("{id}", web::get().to(version_get)) .route("{id}", web::patch().to(version_edit)) .route( "{version_id}/file", @@ -40,6 +46,135 @@ pub fn config(cfg: &mut web::ServiceConfig) { ), ); } + +// Given a project ID/slug and a version slug +pub async fn version_project_get( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner(); + version_project_get_helper(req, info, pool, redis, session_queue).await +} +pub async fn version_project_get_helper( + req: HttpRequest, + id: (String, String), + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let result = database::models::Project::get(&id.0, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_authorized(&project.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let versions = + database::models::Version::get_many(&project.versions, &**pool, &redis).await?; + + let id_opt = parse_base62(&id.1).ok(); + let version = versions + .into_iter() + .find(|x| Some(x.inner.id.0 as u64) == id_opt || x.inner.version_number == id.1); + + if let Some(version) = version { + if is_authorized_version(&version.inner, &user_option, &pool).await? { + return Ok(HttpResponse::Ok().json(models::projects::Version::from(version))); + } + } + } + + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Serialize, Deserialize)] +pub struct VersionIds { + pub ids: String, +} + +pub async fn versions_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let version_ids = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + let versions_data = database::models::Version::get_many(&version_ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let versions = filter_authorized_versions(versions_data, &user_option, &pool).await?; + + Ok(HttpResponse::Ok().json(versions)) +} + +pub async fn version_get( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + version_get_helper(req, id, pool, redis, session_queue).await +} + +pub async fn version_get_helper( + req: HttpRequest, + id: models::ids::VersionId, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let version_data = database::models::Version::get(id.into(), &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = version_data { + if is_authorized_version(&data.inner, &user_option, &pool).await? { + return Ok(HttpResponse::Ok().json(models::projects::Version::from(data))); + } + } + + Ok(HttpResponse::NotFound().body("")) +} + #[derive(Serialize, Deserialize, Validate, Default, Debug)] pub struct EditVersion { #[validate( @@ -303,6 +438,7 @@ pub async fn version_edit_helper( let loader_id = database::models::loader_fields::Loader::get_id( &loader_name, &mut *transaction, + &redis, ) .await? .ok_or_else(|| { @@ -318,7 +454,6 @@ pub async fn version_edit_helper( &redis, ) .await?; - for (key, value) in loader.fields.iter() { let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) @@ -336,9 +471,9 @@ pub async fn version_edit_helper( version_fields.push(vf); } } + LoaderVersion::insert_many(loader_versions, &mut transaction).await?; VersionField::insert_many(version_fields, &mut transaction).await?; - database::models::Project::update_loaders( version_item.inner.project_id, &mut transaction, @@ -535,7 +670,7 @@ pub async fn version_edit_helper( } } -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] pub struct VersionListFilters { pub loaders: Option, pub featured: Option, @@ -594,22 +729,23 @@ pub async fn version_list( .filter(|x| { let mut bool = true; - // TODO: theres a lot of repeated logic here with the similar filterings in super::version_file, abstract it if let Some(version_type) = filters.version_type { bool &= &*x.inner.version_type == version_type.as_str(); } if let Some(loaders) = &loader_filters { bool &= x.loaders.iter().any(|y| loaders.contains(y)); } - if let Some(loader_fields) = &loader_field_filters { - for (key, value) in loader_fields { - bool &= x.version_fields.iter().any(|y| { - y.field_name == *key && value.contains(&y.value.serialize_internal()) - }); + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; } } - bool }) .collect::>(); diff --git a/tests/common/api_v2/mod.rs b/tests/common/api_v2/mod.rs index ab1fe002..2a85bbb3 100644 --- a/tests/common/api_v2/mod.rs +++ b/tests/common/api_v2/mod.rs @@ -6,7 +6,9 @@ use std::rc::Rc; pub mod organization; pub mod project; +pub mod tags; pub mod team; +pub mod version; #[derive(Clone)] pub struct ApiV2 { diff --git a/tests/common/api_v2/organization.rs b/tests/common/api_v2/organization.rs index 31f0ea4c..5cfb214d 100644 --- a/tests/common/api_v2/organization.rs +++ b/tests/common/api_v2/organization.rs @@ -3,7 +3,7 @@ use actix_web::{ test::{self, TestRequest}, }; use bytes::Bytes; -use labrinth::models::{organizations::Organization, projects::Project}; +use labrinth::models::{organizations::Organization, v2::projects::LegacyProject}; use serde_json::json; use crate::common::request_data::ImageData; @@ -58,7 +58,7 @@ impl ApiV2 { &self, id_or_title: &str, pat: &str, - ) -> Vec { + ) -> Vec { let resp = self.get_organization_projects(id_or_title, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 6b1fd302..17e8d158 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -5,7 +5,7 @@ use actix_web::{ }; use bytes::Bytes; use labrinth::{ - models::projects::{Project, Version}, + models::v2::projects::{LegacyProject, LegacyVersion}, search::SearchResults, util::actix::AppendsMultipart, }; @@ -14,7 +14,7 @@ use serde_json::json; use crate::common::{ asserts::assert_status, database::MOD_USER_PAT, - request_data::{ImageData, ProjectCreationRequestData, VersionCreationRequestData}, + request_data::{ImageData, ProjectCreationRequestData}, }; use super::ApiV2; @@ -24,7 +24,7 @@ impl ApiV2 { &self, creation_data: ProjectCreationRequestData, pat: &str, - ) -> (Project, Vec) { + ) -> (LegacyProject, Vec) { // Add a project. let req = TestRequest::post() .uri("/v2/project") @@ -57,7 +57,7 @@ impl ApiV2 { .append_header(("Authorization", pat)) .to_request(); let resp = self.call(req).await; - let versions: Vec = test::read_body_json(resp).await; + let versions: Vec = test::read_body_json(resp).await; (project, versions) } @@ -79,63 +79,17 @@ impl ApiV2 { .to_request(); self.call(req).await } - pub async fn get_project_deserialized(&self, id_or_slug: &str, pat: &str) -> Project { + pub async fn get_project_deserialized(&self, id_or_slug: &str, pat: &str) -> LegacyProject { let resp = self.get_project(id_or_slug, pat).await; assert_eq!(resp.status(), 200); test::read_body_json(resp).await } - pub async fn add_public_version( - &self, - creation_data: VersionCreationRequestData, - pat: &str, - ) -> Version { - // Add a project. - let req = TestRequest::post() - .uri("/v2/version") - .append_header(("Authorization", pat)) - .set_multipart(creation_data.segment_data) - .to_request(); - let resp = self.call(req).await; - assert_status(&resp, StatusCode::OK); - let value: serde_json::Value = test::read_body_json(resp).await; - let version_id = value["id"].as_str().unwrap(); - - // // Approve as a moderator. - // let req = TestRequest::patch() - // .uri(&format!("/v2/project/{}", creation_data.slug)) - // .append_header(("Authorization", MOD_USER_PAT)) - // .set_json(json!( - // { - // "status": "approved" - // } - // )) - // .to_request(); - // let resp = self.call(req).await; - // assert_status(resp, StatusCode::NO_CONTENT); - - self.get_version_deserialized(version_id, pat).await - } - - pub async fn get_version(&self, id: &str, pat: &str) -> ServiceResponse { - let req = TestRequest::get() - .uri(&format!("/v2/version/{id}")) - .append_header(("Authorization", pat)) - .to_request(); - self.call(req).await - } - - pub async fn get_version_deserialized(&self, id: &str, pat: &str) -> Version { - let resp = self.get_version(id, pat).await; - assert_eq!(resp.status(), 200); - test::read_body_json(resp).await - } - pub async fn get_user_projects_deserialized( &self, user_id_or_username: &str, pat: &str, - ) -> Vec { + ) -> Vec { let req = test::TestRequest::get() .uri(&format!("/v2/user/{}/projects", user_id_or_username)) .append_header(("Authorization", pat)) @@ -145,30 +99,6 @@ impl ApiV2 { test::read_body_json(resp).await } - pub async fn get_version_from_hash( - &self, - hash: &str, - algorithm: &str, - pat: &str, - ) -> ServiceResponse { - let req = test::TestRequest::get() - .uri(&format!("/v2/version_file/{hash}?algorithm={algorithm}")) - .append_header(("Authorization", pat)) - .to_request(); - self.call(req).await - } - - pub async fn get_version_from_hash_deserialized( - &self, - hash: &str, - algorithm: &str, - pat: &str, - ) -> Version { - let resp = self.get_version_from_hash(hash, algorithm, pat).await; - assert_eq!(resp.status(), 200); - test::read_body_json(resp).await - } - pub async fn edit_project( &self, id_or_slug: &str, diff --git a/tests/common/api_v2/tags.rs b/tests/common/api_v2/tags.rs new file mode 100644 index 00000000..53df709b --- /dev/null +++ b/tests/common/api_v2/tags.rs @@ -0,0 +1,72 @@ +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use labrinth::routes::{ + v2::tags::{CategoryData, GameVersionQueryData}, + v3::tags::LoaderData, +}; + +use crate::common::database::ADMIN_USER_PAT; + +use super::ApiV2; + +impl ApiV2 { + // Tag gets do not include PAT, as they are public. + + pub async fn get_side_types(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/side_type") + .append_header(("Authorization", ADMIN_USER_PAT)) + .to_request(); + self.call(req).await + } + + pub async fn get_side_types_deserialized(&self) -> Vec { + let resp = self.get_side_types().await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_loaders(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/loader") + .append_header(("Authorization", ADMIN_USER_PAT)) + .to_request(); + self.call(req).await + } + + pub async fn get_loaders_deserialized(&self) -> Vec { + let resp = self.get_loaders().await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_categories(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/category") + .append_header(("Authorization", ADMIN_USER_PAT)) + .to_request(); + self.call(req).await + } + + pub async fn get_categories_deserialized(&self) -> Vec { + let resp = self.get_categories().await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_game_versions(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/game_version") + .append_header(("Authorization", ADMIN_USER_PAT)) + .to_request(); + self.call(req).await + } + + pub async fn get_game_versions_deserialized(&self) -> Vec { + let resp = self.get_game_versions().await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } +} diff --git a/tests/common/api_v2/version.rs b/tests/common/api_v2/version.rs new file mode 100644 index 00000000..6c0fc731 --- /dev/null +++ b/tests/common/api_v2/version.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; + +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use labrinth::{ + models::{projects::VersionType, v2::projects::LegacyVersion}, + routes::v2::version_file::FileUpdateData, + util::actix::AppendsMultipart, +}; +use serde_json::json; + +use crate::common::{asserts::assert_status, request_data::VersionCreationRequestData}; + +use super::ApiV2; + +impl ApiV2 { + pub async fn add_public_version( + &self, + creation_data: VersionCreationRequestData, + pat: &str, + ) -> LegacyVersion { + // Add a project. + let req = TestRequest::post() + .uri("/v2/version") + .append_header(("Authorization", pat)) + .set_multipart(creation_data.segment_data) + .to_request(); + let resp = self.call(req).await; + assert_status(&resp, StatusCode::OK); + let value: serde_json::Value = test::read_body_json(resp).await; + let version_id = value["id"].as_str().unwrap(); + + // // Approve as a moderator. + // let req = TestRequest::patch() + // .uri(&format!("/v2/project/{}", creation_data.slug)) + // .append_header(("Authorization", MOD_USER_PAT)) + // .set_json(json!( + // { + // "status": "approved" + // } + // )) + // .to_request(); + // let resp = self.call(req).await; + // assert_status(resp, StatusCode::NO_CONTENT); + + self.get_version_deserialized(version_id, pat).await + } + + pub async fn get_version(&self, id: &str, pat: &str) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v2/version/{id}")) + .append_header(("Authorization", pat)) + .to_request(); + self.call(req).await + } + + pub async fn get_version_deserialized(&self, id: &str, pat: &str) -> LegacyVersion { + let resp = self.get_version(id, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn edit_version( + &self, + version_id: &str, + patch: serde_json::Value, + pat: &str, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/version/{version_id}")) + .append_header(("Authorization", pat)) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + pub async fn get_version_from_hash( + &self, + hash: &str, + algorithm: &str, + pat: &str, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/version_file/{hash}?algorithm={algorithm}")) + .append_header(("Authorization", pat)) + .to_request(); + self.call(req).await + } + + pub async fn get_version_from_hash_deserialized( + &self, + hash: &str, + algorithm: &str, + pat: &str, + ) -> LegacyVersion { + let resp = self.get_version_from_hash(hash, algorithm, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_versions_from_hashes( + &self, + hashes: &[&str], + algorithm: &str, + pat: &str, + ) -> ServiceResponse { + let req = TestRequest::post() + .uri("/v2/version_files") + .append_header(("Authorization", pat)) + .set_json(json!({ + "hashes": hashes, + "algorithm": algorithm, + })) + .to_request(); + self.call(req).await + } + + pub async fn get_versions_from_hashes_deserialized( + &self, + hashes: &[&str], + algorithm: &str, + pat: &str, + ) -> HashMap { + let resp = self.get_versions_from_hashes(hashes, algorithm, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn get_update_from_hash( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: &str, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!( + "/v2/version_file/{hash}/update?algorithm={algorithm}" + )) + .append_header(("Authorization", pat)) + .set_json(json!({ + "loaders": loaders, + "game_versions": game_versions, + "version_types": version_types, + })) + .to_request(); + self.call(req).await + } + + pub async fn get_update_from_hash_deserialized( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: &str, + ) -> LegacyVersion { + let resp = self + .get_update_from_hash(hash, algorithm, loaders, game_versions, version_types, pat) + .await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn update_files( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: &str, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v2/version_files/update") + .append_header(("Authorization", pat)) + .set_json(json!({ + "algorithm": algorithm, + "hashes": hashes, + "loaders": loaders, + "game_versions": game_versions, + "version_types": version_types, + })) + .to_request(); + self.call(req).await + } + + pub async fn update_files_deserialized( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: &str, + ) -> HashMap { + let resp = self + .update_files( + algorithm, + hashes, + loaders, + game_versions, + version_types, + pat, + ) + .await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + pub async fn update_individual_files( + &self, + algorithm: &str, + hashes: Vec, + pat: &str, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v2/version_files/update_individual") + .append_header(("Authorization", pat)) + .set_json(json!({ + "algorithm": algorithm, + "hashes": hashes + })) + .to_request(); + self.call(req).await + } + + pub async fn update_individual_files_deserialized( + &self, + algorithm: &str, + hashes: Vec, + pat: &str, + ) -> HashMap { + let resp = self.update_individual_files(algorithm, hashes, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } + + // TODO: Not all fields are tested currently in the V2 tests, only the v2-v3 relevant ones are + #[allow(clippy::too_many_arguments)] + pub async fn get_project_versions( + &self, + project_id_slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: &str, + ) -> ServiceResponse { + let mut query_string = String::new(); + if let Some(game_versions) = game_versions { + query_string.push_str(&format!( + "&game_versions={}", + urlencoding::encode(&serde_json::to_string(&game_versions).unwrap()) + )); + } + if let Some(loaders) = loaders { + query_string.push_str(&format!( + "&loaders={}", + urlencoding::encode(&serde_json::to_string(&loaders).unwrap()) + )); + } + if let Some(featured) = featured { + query_string.push_str(&format!("&featured={}", featured)); + } + if let Some(version_type) = version_type { + query_string.push_str(&format!("&version_type={}", version_type)); + } + if let Some(limit) = limit { + let limit = limit.to_string(); + query_string.push_str(&format!("&limit={}", limit)); + } + if let Some(offset) = offset { + let offset = offset.to_string(); + query_string.push_str(&format!("&offset={}", offset)); + } + + let req = test::TestRequest::get() + .uri(&format!( + "/v2/project/{project_id_slug}/version?{}", + query_string.trim_start_matches('&') + )) + .append_header(("Authorization", pat)) + .to_request(); + self.call(req).await + } + + #[allow(clippy::too_many_arguments)] + pub async fn get_project_versions_deserialized( + &self, + slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: &str, + ) -> Vec { + let resp = self + .get_project_versions( + slug, + game_versions, + loaders, + featured, + version_type, + limit, + offset, + pat, + ) + .await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } +} diff --git a/tests/common/database.rs b/tests/common/database.rs index 618b77cf..e30bd077 100644 --- a/tests/common/database.rs +++ b/tests/common/database.rs @@ -50,6 +50,7 @@ impl TemporaryDatabase { // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. pub async fn create(max_connections: Option) -> Self { let temp_database_name = generate_random_name("labrinth_tests_db_"); + println!("Creating temporary database: {}", &temp_database_name); let database_url = dotenvy::var("DATABASE_URL").expect("No database URL"); diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index f9b7a877..7da044eb 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -2,9 +2,10 @@ use std::io::{Cursor, Write}; use actix_web::test::{self, TestRequest}; -use labrinth::{ - models::projects::Project, - models::{organizations::Organization, pats::Scopes, projects::Version}, +use labrinth::models::{ + organizations::Organization, + pats::Scopes, + v2::projects::{LegacyProject, LegacyVersion}, }; use serde_json::json; use sqlx::Executor; @@ -260,7 +261,7 @@ pub async fn get_dummy_data(test_env: &TestEnvironment) -> DummyData { } } -pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) { +pub async fn add_project_alpha(test_env: &TestEnvironment) -> (LegacyProject, LegacyVersion) { let (project, versions) = test_env .v2 .add_public_project( @@ -271,7 +272,7 @@ pub async fn add_project_alpha(test_env: &TestEnvironment) -> (Project, Version) (project, versions.into_iter().next().unwrap()) } -pub async fn add_project_beta(test_env: &TestEnvironment) -> (Project, Version) { +pub async fn add_project_beta(test_env: &TestEnvironment) -> (LegacyProject, LegacyVersion) { // Adds dummy data to the database with sqlx (projects, versions, threads) // Generate test project data. let jar = TestFile::DummyProjectBeta; @@ -348,14 +349,14 @@ pub async fn add_organization_zeta(test_env: &TestEnvironment) -> Organization { get_organization_zeta(test_env).await } -pub async fn get_project_alpha(test_env: &TestEnvironment) -> (Project, Version) { +pub async fn get_project_alpha(test_env: &TestEnvironment) -> (LegacyProject, LegacyVersion) { // Get project let req = TestRequest::get() .uri("/v2/project/alpha") .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - let project: Project = test::read_body_json(resp).await; + let project: LegacyProject = test::read_body_json(resp).await; // Get project's versions let req = TestRequest::get() @@ -363,13 +364,13 @@ pub async fn get_project_alpha(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - let versions: Vec = test::read_body_json(resp).await; + let versions: Vec = test::read_body_json(resp).await; let version = versions.into_iter().next().unwrap(); (project, version) } -pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version) { +pub async fn get_project_beta(test_env: &TestEnvironment) -> (LegacyProject, LegacyVersion) { // Get project let req = TestRequest::get() .uri("/v2/project/beta") @@ -377,7 +378,7 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version) .to_request(); let resp = test_env.call(req).await; let project: serde_json::Value = test::read_body_json(resp).await; - let project: Project = serde_json::from_value(project).unwrap(); + let project: LegacyProject = serde_json::from_value(project).unwrap(); // Get project's versions let req = TestRequest::get() @@ -385,7 +386,7 @@ pub async fn get_project_beta(test_env: &TestEnvironment) -> (Project, Version) .append_header(("Authorization", USER_USER_PAT)) .to_request(); let resp = test_env.call(req).await; - let versions: Vec = test::read_body_json(resp).await; + let versions: Vec = test::read_body_json(resp).await; let version = versions.into_iter().next().unwrap(); (project, version) diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index d9b48cb5..837b860a 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -36,9 +36,9 @@ VALUES (2, '1.20.2', '{"type":"release","major":false}'); INSERT INTO loader_field_enum_values(enum_id, value, metadata) VALUES (2, '1.20.3', '{"type":"release","major":false}'); INSERT INTO loader_field_enum_values(enum_id, value, metadata) -VALUES (2, '1.20.4', '{"type":"release","major":false}'); +VALUES (2, '1.20.4', '{"type":"beta","major":false}'); INSERT INTO loader_field_enum_values(enum_id, value, metadata) -VALUES (2, '1.20.5', '{"type":"release","major":false}'); +VALUES (2, '1.20.5', '{"type":"release","major":true}'); INSERT INTO loader_fields(loader_id, field, field_type, enum_type) VALUES (1, 'game_versions', 'array_enum', 2); diff --git a/tests/search.rs b/tests/search.rs index bad59f4f..bdd5ab9a 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -274,7 +274,6 @@ async fn search_projects() { .collect(); expected_project_ids.sort(); found_project_ids.sort(); - println!("Facets : {:?}", facets); assert_eq!(found_project_ids, expected_project_ids); } }) diff --git a/tests/tags.rs b/tests/tags.rs new file mode 100644 index 00000000..467858b3 --- /dev/null +++ b/tests/tags.rs @@ -0,0 +1,65 @@ +use crate::common::environment::TestEnvironment; +use std::collections::HashSet; + +mod common; + +#[actix_rt::test] +async fn get_tags() { + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + + let game_versions = api.get_game_versions_deserialized().await; + let loaders = api.get_loaders_deserialized().await; + let side_types = api.get_side_types_deserialized().await; + let categories = api.get_categories_deserialized().await; + + // These tests match dummy data and will need to be updated if the dummy data changes; + let game_version_versions = game_versions + .into_iter() + .map(|x| x.version) + .collect::>(); + assert_eq!( + game_version_versions, + ["1.20.1", "1.20.2", "1.20.3", "1.20.4", "1.20.5"] + .iter() + .map(|s| s.to_string()) + .collect() + ); + + let loader_names = loaders.into_iter().map(|x| x.name).collect::>(); + assert_eq!( + loader_names, + ["fabric", "forge"].iter().map(|s| s.to_string()).collect() + ); + + let side_type_names = side_types.into_iter().collect::>(); + assert_eq!( + side_type_names, + ["unknown", "required", "optional", "unsupported"] + .iter() + .map(|s| s.to_string()) + .collect() + ); + + let category_names = categories + .into_iter() + .map(|x| x.name) + .collect::>(); + assert_eq!( + category_names, + [ + "combat", + "economy", + "food", + "optimization", + "decoration", + "mobs", + "magic" + ] + .iter() + .map(|s| s.to_string()) + .collect() + ); + + test_env.cleanup().await; +} diff --git a/tests/version.rs b/tests/version.rs new file mode 100644 index 00000000..d4b896f4 --- /dev/null +++ b/tests/version.rs @@ -0,0 +1,489 @@ +use actix_web::test; +use common::environment::TestEnvironment; +use futures::StreamExt; +use labrinth::database::models::version_item::VERSIONS_NAMESPACE; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::projects::{Loader, ProjectId, VersionId, VersionStatus, VersionType}; +use labrinth::routes::v2::version_file::FileUpdateData; +use serde_json::json; + +use crate::common::database::*; + +use crate::common::dummy_data::TestFile; +use crate::common::request_data::get_public_version_creation_data; + +// importing common module. +mod common; + +#[actix_rt::test] +async fn test_get_version() { + // Test setup and dummy data + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + let alpha_project_id: &String = &test_env.dummy.as_ref().unwrap().project_alpha.project_id; + let alpha_version_id = &test_env.dummy.as_ref().unwrap().project_alpha.version_id; + let beta_version_id = &test_env.dummy.as_ref().unwrap().project_beta.version_id; + + // Perform request on dummy data + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(&version.project_id.to_string(), alpha_project_id); + assert_eq!(&version.id.to_string(), alpha_version_id); + + let cached_project = test_env + .db + .redis_pool + .get::(VERSIONS_NAMESPACE, parse_base62(alpha_version_id).unwrap()) + .await + .unwrap() + .unwrap(); + let cached_project: serde_json::Value = serde_json::from_str(&cached_project).unwrap(); + assert_eq!( + cached_project["inner"]["project_id"], + json!(parse_base62(alpha_project_id).unwrap()) + ); + + // Request should fail on non-existent version + let resp = api.get_version("false", USER_USER_PAT).await; + assert_eq!(resp.status(), 404); + + // Similarly, request should fail on non-authorized user, on a yet-to-be-approved or hidden project, with a 404 (hiding the existence of the project) + // TODO: beta version should already be draft in dummy data, but theres a bug in finding it that + api.edit_version( + beta_version_id, + json!({ + "status": "draft" + }), + USER_USER_PAT, + ) + .await; + let resp = api.get_version(beta_version_id, USER_USER_PAT).await; + assert_eq!(resp.status(), 200); + let resp = api.get_version(beta_version_id, ENEMY_USER_PAT).await; + assert_eq!(resp.status(), 404); + + // Cleanup test db + test_env.cleanup().await; +} + +#[actix_rt::test] + +async fn version_updates() { + // Test setup and dummy data + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + + let alpha_project_id: &String = &test_env.dummy.as_ref().unwrap().project_alpha.project_id; + let alpha_version_id = &test_env.dummy.as_ref().unwrap().project_alpha.version_id; + let beta_version_id = &test_env.dummy.as_ref().unwrap().project_beta.version_id; + let alpha_version_hash = &test_env.dummy.as_ref().unwrap().project_alpha.file_hash; + let beta_version_hash = &test_env.dummy.as_ref().unwrap().project_beta.file_hash; + + // Quick test, using get version from hash + let version = api + .get_version_from_hash_deserialized(alpha_version_hash, "sha1", USER_USER_PAT) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + // Get versions from hash + let versions = api + .get_versions_from_hashes_deserialized( + &[alpha_version_hash.as_str(), beta_version_hash.as_str()], + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 2); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + assert_eq!(&versions[beta_version_hash].id.to_string(), beta_version_id); + + // When there is only the one version, there should be no updates + let version = api + .get_update_from_hash_deserialized( + alpha_version_hash, + "sha1", + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + let versions = api + .update_files_deserialized( + "sha1", + vec![alpha_version_hash.to_string()], + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + + // Add 3 new versions, 1 before, and 2 after, with differing game_version/version_types/loaders + let mut update_ids = vec![]; + for (version_number, patch_value) in [ + ( + "0.9.9", + json!({ + "game_versions": ["1.20.1"], + }), + ), + ( + "1.5.0", + json!({ + "game_versions": ["1.20.3"], + "loaders": ["fabric"], + }), + ), + ( + "1.5.1", + json!({ + "game_versions": ["1.20.4"], + "loaders": ["forge"], + "version_type": "beta" + }), + ), + ] + .iter() + { + let version = api + .add_public_version( + get_public_version_creation_data( + ProjectId(parse_base62(alpha_project_id).unwrap()), + version_number, + TestFile::build_random_jar(), + ), + USER_USER_PAT, + ) + .await; + update_ids.push(version.id); + + // Patch using json + api.edit_version(&version.id.to_string(), patch_value.clone(), USER_USER_PAT) + .await; + } + + let check_expected = |game_versions: Option>, + loaders: Option>, + version_types: Option>, + result_id: Option| async move { + let (success, result_id) = match result_id { + Some(id) => (true, id), + None => (false, VersionId(0)), + }; + // get_update_from_hash + let resp = api + .get_update_from_hash( + alpha_version_hash, + "sha1", + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(resp.status(), 200); + let body: serde_json::Value = test::read_body_json(resp).await; + let id = body["id"].as_str().unwrap(); + assert_eq!(id, &result_id.to_string()); + } else { + assert_eq!(resp.status(), 404); + } + + // update_files + let versions = api + .update_files_deserialized( + "sha1", + vec![alpha_version_hash.to_string()], + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + + println!("version types: {:?}", version_types); + // update_individual_files + let hashes = vec![FileUpdateData { + hash: alpha_version_hash.to_string(), + loaders, + game_versions, + version_types: version_types.map(|v| { + v.into_iter() + .map(|v| serde_json::from_str(&format!("\"{v}\"")).unwrap()) + .collect() + }), + }]; + let versions = api + .update_individual_files_deserialized("sha1", hashes, USER_USER_PAT) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + }; + + let tests = vec![ + check_expected( + Some(vec!["1.20.1".to_string()]), + None, + None, + Some(update_ids[0]), + ), + check_expected( + Some(vec!["1.20.3".to_string()]), + None, + None, + Some(update_ids[1]), + ), + check_expected( + Some(vec!["1.20.4".to_string()]), + None, + None, + Some(update_ids[2]), + ), + // Loader restrictions + check_expected( + None, + Some(vec!["fabric".to_string()]), + None, + Some(update_ids[1]), + ), + check_expected( + None, + Some(vec!["forge".to_string()]), + None, + Some(update_ids[2]), + ), + // Version type restrictions + check_expected( + None, + None, + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + check_expected( + None, + None, + Some(vec!["beta".to_string()]), + Some(update_ids[2]), + ), + // Specific combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + // Impossible combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["beta".to_string()]), + None, + ), + // No restrictions, should do the last one + check_expected(None, None, None, Some(update_ids[2])), + ]; + + // Wait on all tests, 4 at a time + futures::stream::iter(tests) + .buffer_unordered(4) + .collect::>() + .await; + + // We do a couple small tests for get_project_versions_deserialized as well + // TODO: expand this more. + let versions = api + .get_project_versions_deserialized( + alpha_project_id, + None, + None, + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 4); + let versions = api + .get_project_versions_deserialized( + alpha_project_id, + None, + Some(vec!["forge".to_string()]), + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + + // Cleanup test db + test_env.cleanup().await; +} + +#[actix_rt::test] +pub async fn test_patch_version() { + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + + let alpha_version_id = &test_env.dummy.as_ref().unwrap().project_alpha.version_id; + + // // First, we do some patch requests that should fail. + // // Failure because the user is not authorized. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "test 1", + }), + ENEMY_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 401); + + // Failure because these are illegal requested statuses for a normal user. + for req in ["unknown", "scheduled"] { + let resp = api + .edit_version( + alpha_version_id, + json!({ + "status": req, + // requested status it not set here, but in /schedule + }), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 400); + } + + // Sucessful request to patch many fields. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "new version name", + "version_number": "1.3.0", + "changelog": "new changelog", + "version_type": "beta", + // // "dependencies": [], TODO: test this + "game_versions": ["1.20.5"], + "loaders": ["forge"], + "featured": false, + // "primary_file": [], TODO: test this + // // "downloads": 0, TODO: moderator exclusive + "status": "draft", + // // "filetypes": ["jar"], TODO: test this + }), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.name, "new version name"); + assert_eq!(version.version_number, "1.3.0"); + assert_eq!(version.changelog, "new changelog"); + assert_eq!( + version.version_type, + serde_json::from_str::("\"beta\"").unwrap() + ); + assert_eq!(version.game_versions, vec!["1.20.5"]); + assert_eq!(version.loaders, vec![Loader("forge".to_string())]); + assert!(!version.featured); + assert_eq!(version.status, VersionStatus::from_string("draft")); + + // These ones are checking the v2-v3 rerouting, we eneusre that only 'game_versions' + // works as expected, as well as only 'loaders' + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": ["1.20.1", "1.20.2", "1.20.4"], + }), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.game_versions, vec!["1.20.1", "1.20.2", "1.20.4"]); + assert_eq!(version.loaders, vec![Loader("forge".to_string())]); // From last patch + + let resp = api + .edit_version( + alpha_version_id, + json!({ + "loaders": ["fabric"], + }), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.game_versions, vec!["1.20.1", "1.20.2", "1.20.4"]); // From last patch + assert_eq!(version.loaders, vec![Loader("fabric".to_string())]); + + // Cleanup test db + test_env.cleanup().await; +} + +#[actix_rt::test] +pub async fn test_project_versions() { + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + let alpha_project_id: &String = &test_env.dummy.as_ref().unwrap().project_alpha.project_id; + let alpha_version_id = &test_env.dummy.as_ref().unwrap().project_alpha.version_id; + let _beta_version_id = &test_env.dummy.as_ref().unwrap().project_beta.version_id; + let _alpha_version_hash = &test_env.dummy.as_ref().unwrap().project_alpha.file_hash; + let _beta_version_hash = &test_env.dummy.as_ref().unwrap().project_beta.file_hash; + + let versions = api + .get_project_versions_deserialized( + alpha_project_id, + None, + None, + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + assert_eq!(&versions[0].id.to_string(), alpha_version_id); + + test_env.cleanup().await; +} From b9b16f5a9b6fc8a1565cec719e9d747996fa7faf Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 27 Oct 2023 15:06:27 -0700 Subject: [PATCH 16/31] redesign to revs --- migrations/20231005230721_dynamic-fields.sql | 20 +- src/database/models/loader_fields.rs | 38 ++-- src/database/models/version_item.rs | 9 +- src/models/projects.rs | 45 +--- src/models/v2/projects.rs | 23 +- src/routes/updates.rs | 13 +- src/routes/v2/admin.rs | 2 +- src/routes/v2/project_creation.rs | 215 +++++++++++++++---- src/routes/v2/projects.rs | 65 ++---- src/routes/v2/tags.rs | 2 - src/routes/v2/version_creation.rs | 36 ++-- src/routes/v2/versions.rs | 59 +---- src/routes/v2_reroute.rs | 16 +- src/routes/v3/project_creation.rs | 96 +++------ src/routes/v3/tags.rs | 13 +- src/routes/v3/version_creation.rs | 63 +++--- src/routes/v3/versions.rs | 159 +++++++------- src/search/indexing/local_import.rs | 11 +- src/util/webhook.rs | 7 +- tests/files/dummy_data.sql | 21 +- 20 files changed, 439 insertions(+), 474 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 5b9bc71e..8e45a38a 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -36,8 +36,7 @@ CREATE TABLE loader_field_enum_values ( CREATE TABLE loader_fields ( id serial PRIMARY KEY, - loader_id integer REFERENCES loaders NOT NULL, - field varchar(64) NOT NULL, + field varchar(64) UNIQUE NOT NULL, -- "integer", "text", "enum", "bool", -- "array_integer", "array_text", "array_enum", "array_bool" field_type varchar(64) NOT NULL, @@ -46,9 +45,13 @@ CREATE TABLE loader_fields ( optional BOOLEAN NOT NULL DEFAULT true, -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing min_val integer NULL, - max_val integer NULL, + max_val integer NULL +); - CONSTRAINT unique_field_name_per_loader UNIQUE (loader_id, field) +CREATE TABLE loader_fields_loaders ( + loader_id integer REFERENCES loaders NOT NULL, + loader_field_id integer REFERENCES loader_fields NOT NULL, + CONSTRAINT unique_loader_field UNIQUE (loader_id, loader_field_id) ); ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; @@ -66,8 +69,11 @@ CREATE TABLE version_fields ( INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (1, 'side_types', true); INSERT INTO loader_field_enum_values (original_id, enum_id, value) SELECT id, 1, name FROM side_types st; -INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val, max_val) SELECT l.id, 'client_side', 'enum', 1, false, 1, 1 FROM loaders l; -INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val, max_val) SELECT l.id, 'server_side', 'enum', 1, false, 1, 1 FROM loaders l; +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val, max_val) SELECT 'client_side', 'enum', 1, false, 1, 1; +INSERT INTO loader_fields ( field, field_type, enum_type, optional, min_val, max_val) SELECT 'server_side', 'enum', 1, false, 1, 1; + +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'client_side' AND l.loader = ANY( ARRAY['forge', 'fabric', 'quilt', 'modloader','rift','liteloader', 'neoforge']); +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'server_side' AND l.loader = ANY( ARRAY['forge', 'fabric', 'quilt', 'modloader','rift','liteloader', 'neoforge']); INSERT INTO version_fields (version_id, field_id, enum_value) SELECT v.id, 1, m.client_side @@ -92,7 +98,7 @@ INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (2, 'game_version INSERT INTO loader_field_enum_values (original_id, enum_id, value, created, metadata) SELECT id, 2, version, created, json_build_object('type', type, 'major', major) FROM game_versions; -INSERT INTO loader_fields (loader_id, field, field_type, enum_type, optional, min_val) SELECT l.id, 'game_versions', 'array_enum', 2, false, 1 FROM loaders l; +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('game_versions', 'array_enum', 2, false, 1); INSERT INTO version_fields(version_id, field_id, enum_value) SELECT gvv.joining_version_id, 2, lfev.id diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 7f3ceb64..68fe7b98 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -141,8 +141,6 @@ impl Loader { #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderField { pub id: LoaderFieldId, - pub loader_id: LoaderId, - pub loader_name: String, pub field: String, pub field_type: LoaderFieldType, pub optional: bool, @@ -214,7 +212,6 @@ pub struct LoaderFieldEnumValue { pub struct VersionField { pub version_id: VersionId, pub field_id: LoaderFieldId, - pub loader_name: String, pub field_name: String, pub value: VersionFieldValue, } @@ -265,21 +262,21 @@ pub struct SideType { impl LoaderField { pub async fn get_field<'a, E>( field: &str, - loader_id: LoaderId, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let fields = Self::get_fields(loader_id, exec, redis).await?; + let fields = Self::get_fields(exec, redis).await?; Ok(fields.into_iter().find(|f| f.field == field)) } // Gets all fields for a given loader // Returns all as this there are probably relatively few fields per loader + // TODO: in the future, this should be to get all fields in relation to something + // - e.g. get all fields for a given game? pub async fn get_fields<'a, E>( - loader_id: LoaderId, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> @@ -287,7 +284,7 @@ impl LoaderField { E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let cached_fields = redis - .get_deserialized_from_json(LOADER_FIELDS_NAMESPACE, &loader_id.0) + .get_deserialized_from_json(LOADER_FIELDS_NAMESPACE, 0) // 0 => whatever we search for fields by .await?; if let Some(cached_fields) = cached_fields { return Ok(cached_fields); @@ -295,22 +292,17 @@ impl LoaderField { let result = sqlx::query!( " - SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader + SELECT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type FROM loader_fields lf - INNER JOIN loaders l ON lf.loader_id = l.id - WHERE loader_id = $1 ", - loader_id.0, ) .fetch_many(exec) .try_filter_map(|e| async { Ok(e.right().and_then( |r| Some(LoaderField { id: LoaderFieldId(r.id), - loader_id: LoaderId(r.loader_id), field_type: LoaderFieldType::build(&r.field_type, r.enum_type)?, field: r.field, - loader_name: r.loader, optional: r.optional, min_val: r.min_val, max_val: r.max_val @@ -320,7 +312,7 @@ impl LoaderField { .await?; redis - .set_serialized_to_json(LOADER_FIELDS_NAMESPACE, &loader_id.0, &result, None) + .set_serialized_to_json(LOADER_FIELDS_NAMESPACE, &0, &result, None) .await?; Ok(result) @@ -630,7 +622,6 @@ impl VersionField { Ok(VersionField { version_id, field_id: loader_field.id, - loader_name: loader_field.loader_name, field_name: loader_field.field, value, }) @@ -645,9 +636,7 @@ impl VersionField { #[derive(Deserialize, Debug)] struct JsonLoaderField { lf_id: i32, - l_id: i32, field: String, - loader_name: String, field_type: String, enum_type: Option, min_val: Option, @@ -674,15 +663,15 @@ impl VersionField { } let query_loader_fields: Vec = loader_fields - .and_then(|x| serde_json::from_value(x).ok()) - .unwrap_or_default(); + .and_then(|x| serde_json::from_value(x).unwrap()) + .unwrap_or_default(); let query_version_field_combined: Vec = version_fields - .and_then(|x| serde_json::from_value(x).ok()) - .unwrap_or_default(); + .and_then(|x| serde_json::from_value(x).unwrap()) + .unwrap_or_default(); let query_loader_field_enum_values: Vec = loader_field_enum_values - .and_then(|x| serde_json::from_value(x).ok()) - .unwrap_or_default(); + .and_then(|x| serde_json::from_value(x).unwrap()) + .unwrap_or_default(); let version_id = VersionId(version_id); query_loader_fields .into_iter() @@ -693,9 +682,7 @@ impl VersionField { }; let loader_field = LoaderField { id: LoaderFieldId(q.lf_id), - loader_id: LoaderId(q.l_id), field: q.field.clone(), - loader_name: q.loader_name.clone(), field_type: loader_field_type, optional: q.optional, min_val: q.min_val, @@ -743,7 +730,6 @@ impl VersionField { Ok(VersionField { version_id, field_id: loader_field.id, - loader_name: loader_field.loader_name, field_name: loader_field.field, value, }) diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index b20eae42..59b2d336 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -528,11 +528,10 @@ impl Version { 'enum_value', vf.enum_value, 'string_value', vf.string_value ) - ) version_fields, + ) filter (where vf.field_id is not null) version_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'lf_id', lf.id, - 'l_id', lf.loader_id, 'loader_name', l.loader, 'field', lf.field, 'field_type', lf.field_type, @@ -541,7 +540,7 @@ impl Version { 'max_val', lf.max_val, 'optional', lf.optional ) - ) loader_fields, + ) filter (where lf.id is not null) loader_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'id', lfev.id, @@ -551,8 +550,8 @@ impl Version { 'created', lfev.created, 'metadata', lfev.metadata ) - ) loader_field_enum_values - + ) filter (where lfev.id is not null) loader_field_enum_values + FROM versions v LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id LEFT OUTER JOIN loaders l on lv.loader_id = l.id diff --git a/src/models/projects.rs b/src/models/projects.rs index d565af49..6446aa1f 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -479,22 +479,18 @@ pub struct Version { pub files: Vec, /// A list of projects that this version depends on. pub dependencies: Vec, - /// The loaders that this version works on - pub loaders: Vec, -} -// A loader and its associated loader VersionFields -#[derive(Serialize, Deserialize, Validate, Clone, Debug)] -pub struct LoaderStruct { - pub loader: Loader, + /// The loaders that this version works on + pub loaders: Vec, // All other fields are loader-specific VersionFields + // These are flattened during serialization #[serde(deserialize_with = "skip_nulls")] #[serde(flatten)] pub fields: HashMap, } -fn skip_nulls<'de, D>(deserializer: D) -> Result, D::Error> +pub fn skip_nulls<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -505,31 +501,7 @@ where impl From for Version { fn from(data: QueryVersion) -> Version { - let v = data.inner; - - let loader_names: Vec = data.loaders.into_iter().map(Loader).collect(); - let mut loaders: HashMap = HashMap::new(); - for loader in loader_names { - loaders.insert( - loader.0.clone(), - LoaderStruct { - loader, - fields: HashMap::new(), - }, - ); - } - for version_field in data.version_fields { - if let Some(loader_struct) = loaders.get_mut(&version_field.loader_name) { - // Only add the internal component of the field for display - // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) - loader_struct.fields.insert( - version_field.field_name, - version_field.value.serialize_internal(), - ); - } - } - let loaders = loaders.into_values().collect(); - + let v = data.inner; Version { id: v.id.into(), project_id: v.project_id.into(), @@ -573,7 +545,10 @@ impl From for Version { dependency_type: DependencyType::from_string(d.dependency_type.as_str()), }) .collect(), - loaders, + loaders: data.loaders.into_iter().map(Loader).collect(), + // Only add the internal component of the field for display + // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) + fields: data.version_fields.into_iter().map(|vf| (vf.field_name, vf.value.serialize_internal())).collect() } } } @@ -802,7 +777,7 @@ pub struct Loader(pub String); // These fields must always succeed parsing; deserialize errors aren't // processed correctly (don't return JSON errors) -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct SearchRequest { pub query: Option, pub offset: Option, diff --git a/src/models/v2/projects.rs b/src/models/v2/projects.rs index 6af70a44..08cb2207 100644 --- a/src/models/v2/projects.rs +++ b/src/models/v2/projects.rs @@ -233,22 +233,19 @@ pub struct LegacyVersion { impl From for LegacyVersion { fn from(data: Version) -> Self { let mut game_versions = Vec::new(); - let mut loaders = Vec::new(); - for loader in data.loaders { - loaders.push(Loader(loader.loader.0)); - if let Some(value) = loader - .fields - .get("game_versions") - .and_then(|v| v.as_array()) - { - for gv in value { - if let Some(game_version) = gv.as_str() { - game_versions.push(game_version.to_string()); - } + if let Some(value) = data + .fields + .get("game_versions") + .and_then(|v| v.as_array()) + { + for gv in value { + if let Some(game_version) = gv.as_str() { + game_versions.push(game_version.to_string()); } } } + Self { id: data.id, project_id: data.project_id, @@ -266,7 +263,7 @@ impl From for LegacyVersion { files: data.files, dependencies: data.dependencies, game_versions, - loaders, + loaders: data.loaders, } } } diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 7a4c2881..7bf989b8 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -99,16 +99,11 @@ pub async fn forge_updates( // For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive // Will have duplicates between game_versions (for non-forge loaders), but that's okay as // before v3 this was stored to the project and not the version - let game_version_values: Vec = version - .loaders + let game_versions: Vec = version + .fields .iter() - .filter_map(|x| x.fields.get(MinecraftGameVersion::FIELD_NAME).cloned()) - .collect(); - let game_versions: Vec = game_version_values - .into_iter() - .filter_map(|v| serde_json::from_value::>(v).ok()) - .flatten() - .collect(); + .find(|(key , _)| key.as_str() == MinecraftGameVersion::FIELD_NAME) + .and_then(| (_, value) | serde_json::from_value::>(value.clone()).ok()).unwrap_or_default(); if version.version_type == VersionType::Release { for game_version in &game_versions { diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index bdd81af9..a0cb96c9 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -322,7 +322,7 @@ pub async fn trolley_webhook( Ok(HttpResponse::NoContent().finish()) } -#[post("/_force_reindex")] +#[post("/_force_reindex", guard = "admin_key_guard")] pub async fn force_reindex( pool: web::Data, config: web::Data, diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 1e7fa6ad..c67172d9 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -1,22 +1,138 @@ use crate::database::models::version_item; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::projects::Project; +use crate::models; +use crate::models::ids::ImageId; +use crate::routes::v3::project_creation::default_project_type; +use crate::models::projects::{Project, DonationLink, ProjectStatus, SideType}; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; -use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::project_creation::{CreateError, NewGalleryItem}; use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; +use serde::{Serialize, Deserialize}; use serde_json::json; use sqlx::postgres::PgPool; + +use validator::Validate; +use std::collections::HashMap; use std::sync::Arc; +use super::version_creation::InitialVersionData; + pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(project_create); } +pub fn default_requested_status() -> ProjectStatus { + ProjectStatus::Approved +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +struct ProjectCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "mod_name")] + /// The title or name of the project. + pub title: String, + #[validate(length(min = 1, max = 64))] + #[serde(default = "default_project_type")] + /// The project type of this mod + pub project_type: String, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + #[serde(alias = "mod_slug")] + /// The slug of a project, used for vanity URLs + pub slug: String, + #[validate(length(min = 3, max = 255))] + #[serde(alias = "mod_description")] + /// A short description of the project. + pub description: String, + #[validate(length(max = 65536))] + #[serde(alias = "mod_body")] + /// A long description of the project, in markdown. + pub body: String, + + /// The support range for the client project + pub client_side: SideType, + /// The support range for the server project + pub server_side: SideType, + + #[validate(length(max = 32))] + #[validate] + /// A list of initial versions to upload with the created project + pub initial_versions: Vec, + #[validate(length(max = 3))] + /// A list of the categories that the project is in. + pub categories: Vec, + #[validate(length(max = 256))] + #[serde(default = "Vec::new")] + /// A list of the categories that the project is in. + pub additional_categories: Vec, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to where to submit bugs or issues with the project. + pub issues_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the source code for the project. + pub source_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's wiki page or other relevant information. + pub wiki_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's license page + pub license_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's discord. + pub discord_url: Option, + /// An optional list of all donation links the project has\ + #[validate] + pub donation_urls: Option>, + + /// An optional boolean. If true, the project will be created as a draft. + pub is_draft: Option, + + /// The license id that the project follows + pub license_id: String, + + #[validate(length(max = 64))] + #[validate] + /// The multipart names of the gallery items to upload + pub gallery_items: Option>, + #[serde(default = "default_requested_status")] + /// The status of the mod to be set once it is approved + pub requested_status: ProjectStatus, + + // Associations to uploaded images in body/description + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + + /// The id of the organization to create the project in + pub organization_id: Option, +} + #[post("project")] pub async fn project_create( req: HttpRequest, @@ -27,52 +143,61 @@ pub async fn project_create( session_queue: Data, ) -> Result { // Convert V2 multipart payload to V3 multipart payload - let mut saved_slug = None; - let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { - // Save slug for out of closure - saved_slug = Some(json["slug"].as_str().unwrap_or("").to_string()); - + let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |legacy_create : ProjectCreateData | { + // Set game name (all v2 projects are minecraft-java) - json["game_name"] = json!("minecraft-java"); - - // Loader fields are now a struct, containing all versionfields - // loaders: ["fabric"] - // game_versions: ["1.16.5", "1.17"] - // -> becomes -> - // loaders: [{"loader": "fabric", "game_versions": ["1.16.5", "1.17"]}] + let game_name = "minecraft-java".to_string(); // Side types will be applied to each version - let client_side = json["client_side"] - .as_str() - .unwrap_or("required") - .to_string(); - let server_side = json["server_side"] - .as_str() - .unwrap_or("required") - .to_string(); - json["client_side"] = json!(null); - json["server_side"] = json!(null); - - if let Some(versions) = json["initial_versions"].as_array_mut() { - for version in versions { - // Construct loader object with version fields - // V2 fields becoming loader fields are: - // - client_side - // - server_side - // - game_versions - let mut loaders = vec![]; - for loader in version["loaders"].as_array().unwrap_or(&Vec::new()) { - let loader = loader.as_str().unwrap_or(""); - loaders.push(json!({ - "loader": loader, - "game_versions": version["game_versions"].as_array(), - "client_side": client_side, - "server_side": server_side, - })); - } - version["loaders"] = json!(loaders); - } - } + let client_side = legacy_create.client_side; + let server_side = legacy_create.server_side; + + let initial_versions = legacy_create.initial_versions.into_iter().map(|v| { + let mut fields = HashMap::new(); + fields.insert("client_side".to_string(), json!(client_side)); + fields.insert("server_side".to_string(), json!(server_side)); + fields.insert("game_versions".to_string(), json!(v.game_versions)); + + v3::version_creation::InitialVersionData { + project_id: v.project_id, + file_parts: v.file_parts, + version_number: v.version_number, + version_title: v.version_title, + version_body: v.version_body, + dependencies: v.dependencies, + release_channel: v.release_channel, + loaders: v.loaders, + featured: v.featured, + primary_file: v.primary_file, + status: v.status, + file_types: v.file_types, + uploaded_images: v.uploaded_images, + fields, + } + }); + Ok(v3::project_creation::ProjectCreateData { + title: legacy_create.title, + project_type: legacy_create.project_type, + slug: legacy_create.slug, + description: legacy_create.description, + body: legacy_create.body, + game_name, + initial_versions: initial_versions.collect(), + categories: legacy_create.categories, + additional_categories: legacy_create.additional_categories, + issues_url: legacy_create.issues_url, + source_url: legacy_create.source_url, + wiki_url: legacy_create.wiki_url, + license_url: legacy_create.license_url, + discord_url: legacy_create.discord_url, + donation_urls: legacy_create.donation_urls, + is_draft: legacy_create.is_draft, + license_id: legacy_create.license_id, + gallery_items: legacy_create.gallery_items, + requested_status: legacy_create.requested_status, + uploaded_images: legacy_create.uploaded_images, + organization_id: legacy_create.organization_id, + }) }) .await?; diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 33613c0d..ec9f6b94 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -7,7 +7,7 @@ use crate::models; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, Loader, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, }; use crate::models::teams::ProjectPermissions; @@ -18,12 +18,13 @@ use crate::routes::{v2_reroute, v3, ApiError}; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use crate::{database, search}; +use crate::database; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; +use std::collections::HashMap; use std::sync::Arc; use validator::Validate; @@ -67,63 +68,33 @@ pub async fn project_search( ) -> Result { // TODO: make this nicer // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields - // Loader fields are: - // (loader)_(field):(value) - // The first _ by convention is used to separate the loader from the field - // For each v2 loader, we create a loader field facet for each of these fields that are now loader fields + // While the backend for this has changed, it doesnt affect much + // in the API calls except that 'versions:x' is now 'game_versions:x' let facets: Option>> = if let Some(facets) = info.facets { let facets = serde_json::from_str::>>(&facets)?; - - // "versions:x" => "fabric_game_versions:x", "forge_game_versions:x" ... - // They are put in the same array- considered to be 'or' - let mut v2_loaders: Vec = Vec::new(); - { - let client = meilisearch_sdk::Client::new(&*config.address, &*config.key); - let index = info.index.as_deref().unwrap_or("relevance"); - let meilisearch_index = client.get_index(search::get_sort_index(index)?.0).await?; - let filterable_fields = meilisearch_index.get_filterable_attributes().await?; - for field in filterable_fields { - if field.ends_with("_game_versions") { - let loader = field.split('_').next().unwrap_or(""); - v2_loaders.push(loader.to_string()); - } - } - } Some( facets .into_iter() .map(|facet| { facet .into_iter() - .flat_map(|facet| { + .map(|facet| { let version = match facet.split(':').nth(1) { Some(version) => version, - None => return vec![facet.to_string()], + None => return facet.to_string(), }; if facet.starts_with("versions:") { - v2_loaders - .iter() - .map(|loader| format!("{}_game_versions:{}", loader, version)) - .collect::>() - } else if facet.starts_with("client_side:") { - v2_loaders - .iter() - .map(|loader| format!("{}_client_side:{}", loader, version)) - .collect::>() - } else if facet.starts_with("server_side:") { - v2_loaders - .iter() - .map(|loader| format!("{}_server_side:{}", loader, version)) - .collect::>() + format!("game_versions:{}", version) } else { - vec![facet.to_string()] + facet.to_string() } }) .collect::>() }) .collect(), ) + } else { None }; @@ -485,25 +456,17 @@ pub async fn project_edit( let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); let versions = version_item::Version::get_many(&version_ids, &**pool, &redis).await?; for version in versions { - let loaders: Result, _> = version - .loaders - .into_iter() - .map(|l| { - serde_json::from_value(json!({ - "loader": Loader(l), - "client_side": client_side, - "server_side": server_side, - })) - }) - .collect(); + let mut fields = HashMap::new(); + fields.insert("client_side".to_string(), json!(client_side)); + fields.insert("server_side".to_string(), json!(server_side)); response = v3::versions::version_edit_helper( req.clone(), (version.inner.id.into(),), pool.clone(), redis.clone(), v3::versions::EditVersion { - loaders: Some(loaders?), + fields, ..Default::default() }, session_queue.clone(), diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 1cecb977..6824616a 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -106,7 +106,6 @@ pub async fn game_version_list( let response = v3::tags::loader_fields_list( pool, web::Query(LoaderFieldsEnumQuery { - loader: "fabric".to_string(), loader_field: "game_versions".to_string(), filters: Some(filters), }), @@ -241,7 +240,6 @@ pub async fn side_type_list( let response = v3::tags::loader_fields_list( pool, web::Query(LoaderFieldsEnumQuery { - loader: "fabric".to_string(), // same for minecraft loader loader_field: "client_side".to_string(), // same as server_side filters: None, }), diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index b5e2e16f..49facb8c 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -18,7 +18,7 @@ use std::collections::HashMap; use std::sync::Arc; use validator::Validate; -fn default_requested_status() -> VersionStatus { +pub fn default_requested_status() -> VersionStatus { VersionStatus::Listed } @@ -81,23 +81,27 @@ pub async fn version_create( file_host: Data>, session_queue: Data, ) -> Result { - let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |json| { + let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |legacy_create : InitialVersionData| { // Convert input data to V3 format + let mut fields = HashMap::new(); + fields.insert("game_versions".to_string(), json!(legacy_create.game_versions)); - // Loader fields are now a struct, containing all versionfields - // loaders: ["fabric"] - // game_versions: ["1.16.5", "1.17"] - // -> becomes -> - // loaders: [{"loader": "fabric", "game_versions": ["1.16.5", "1.17"]}] - let mut loaders = vec![]; - for loader in json["loaders"].as_array().unwrap_or(&Vec::new()) { - let loader = loader.as_str().unwrap_or(""); - loaders.push(json!({ - "loader": loader, - "game_versions": json["game_versions"].as_array(), - })); - } - json["loaders"] = json!(loaders); + Ok(v3::version_creation::InitialVersionData { + project_id: legacy_create.project_id, + file_parts: legacy_create.file_parts, + version_number: legacy_create.version_number, + version_title: legacy_create.version_title, + version_body: legacy_create.version_body, + dependencies: legacy_create.dependencies, + release_channel: legacy_create.release_channel, + loaders: legacy_create.loaders, + featured: legacy_create.featured, + primary_file: legacy_create.primary_file, + status: legacy_create.status, + file_types: legacy_create.file_types, + uploaded_images: legacy_create.uploaded_images, + fields, + }) }) .await?; diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 33ed34ce..c373aa9a 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -10,7 +10,7 @@ use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, FileType, LoaderStruct, Version, VersionStatus, VersionType, + Dependency, FileType, Version, VersionStatus, VersionType, }; use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::LegacyVersion; @@ -217,53 +217,13 @@ pub async fn version_edit( ) -> Result { let new_version = new_version.into_inner(); - // TOOD: convert to loader, need to get loader first in case only game versions is passed - let new_loaders = if new_version.game_versions.is_some() || new_version.loaders.is_some() { - let old_version = database::models::Version::get((*info).0.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified version does not exist!".to_string()) - })?; - let old_version = models::projects::Version::from(old_version); - - // Which loaderes to use - let new_loader_strings: Vec<_> = if let Some(loaders) = new_version.loaders { - loaders.to_vec() - } else { - old_version - .loaders - .iter() - .map(|l| l.loader.clone()) - .collect() - }; - - // calling V2 endpoint does not allow different loader fields for different loaders - // (V3 functionality) so we can just take the first loader - let mut fields = old_version - .loaders - .into_iter() - .next() - .map(|l| l.fields) - .unwrap_or_default(); - if let Some(game_versions) = new_version.game_versions { - fields.insert( - "game_versions".to_string(), - serde_json::json!(game_versions), - ); - } - - Some( - new_loader_strings - .into_iter() - .map(|loader| LoaderStruct { - loader, - fields: fields.clone(), - }) - .collect::>(), - ) - } else { - None - }; + let mut fields = HashMap::new(); + if new_version.game_versions.is_some() { + fields.insert( + "game_versions".to_string(), + serde_json::json!(new_version.game_versions), + ); + } let new_version = v3::versions::EditVersion { name: new_version.name, @@ -271,7 +231,7 @@ pub async fn version_edit( changelog: new_version.changelog, version_type: new_version.version_type, dependencies: new_version.dependencies, - loaders: new_loaders, + loaders: new_version.loaders, featured: new_version.featured, primary_file: new_version.primary_file, downloads: new_version.downloads, @@ -285,6 +245,7 @@ pub async fn version_edit( }) .collect::>() }), + fields, }; let response = v3::versions::version_edit( diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 9c2955b6..2c30e591 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -4,7 +4,7 @@ use actix_multipart::Multipart; use actix_web::http::header::{HeaderMap, TryIntoHeaderPair}; use actix_web::HttpResponse; use futures::{stream, StreamExt}; -use serde_json::{json, Value}; +use serde_json::json; pub async fn extract_ok_json(response: HttpResponse) -> Result where @@ -29,11 +29,15 @@ where } } -pub async fn alter_actix_multipart( +pub async fn alter_actix_multipart( mut multipart: Multipart, mut headers: HeaderMap, - mut closure: impl FnMut(&mut serde_json::Value), -) -> Result { + mut closure: impl FnMut(T) -> Result, +) -> Result +where + T: serde::de::DeserializeOwned, + U: serde::Serialize +{ let mut segments: Vec = Vec::new(); if let Some(field) = multipart.next().await { @@ -51,8 +55,8 @@ pub async fn alter_actix_multipart( } { - let mut json_value: Value = serde_json::from_slice(&buffer)?; - closure(&mut json_value); + let json_value: T = serde_json::from_slice(&buffer)?; + let json_value : U = closure(json_value)?; buffer = serde_json::to_vec(&json_value)?; } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 67696089..97c0bf32 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -8,7 +8,7 @@ use crate::database::models::{self, image_item, User}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; -use crate::models::ids::ImageId; +use crate::models::ids::{ImageId, OrganizationId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ @@ -142,7 +142,7 @@ impl actix_web::ResponseError for CreateError { } } -fn default_project_type() -> String { +pub fn default_project_type() -> String { "mod".to_string() } @@ -151,7 +151,7 @@ fn default_requested_status() -> ProjectStatus { } #[derive(Serialize, Deserialize, Validate, Clone)] -struct ProjectCreateData { +pub struct ProjectCreateData { #[validate( length(min = 3, max = 64), custom(function = "crate::util::validate::validate_name") @@ -249,7 +249,7 @@ struct ProjectCreateData { pub uploaded_images: Vec, /// The id of the organization to create the project in - pub organization_id: Option, + pub organization_id: Option, } #[derive(Serialize, Deserialize, Validate, Clone)] @@ -607,12 +607,7 @@ async fn project_create_inner( created_version.version_id.into(), &created_version.version_fields, &project_create_data.project_type, - version_data - .loaders - .clone() - .into_iter() - .map(|l| l.loader) - .collect(), + version_data.loaders.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), None, @@ -734,7 +729,7 @@ async fn project_create_inner( game, project_type_id, team_id, - organization_id: project_create_data.organization_id, + organization_id: project_create_data.organization_id.map(|x| x.into()), title: project_create_data.title, description: project_create_data.description, body: project_create_data.body, @@ -888,59 +883,32 @@ async fn create_initial_version( // Randomly generate a new id to be used for the version let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - // let game_versions = version_data - // .game_versions - // .iter() - // .map(|x| { - // all_game_versions - // .iter() - // .find(|y| y.version == x.0) - // .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) - // .map(|y| y.id) - // }) - // .collect::, CreateError>>()?; - - let mut loader_ids = vec![]; - let mut loaders = vec![]; - let mut version_fields = vec![]; - for loader_create in version_data.loaders.iter() { - let loader_name = loader_create.loader.0.clone(); - // Confirm loader from list of loaders - let loader_id = all_loaders - .iter() - .find(|y| y.loader == loader_name && y.supported_project_types.contains(project_type)) - .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) - .map(|y| y.id)?; - - loader_ids.push(loader_id); - loaders.push(loader_create.loader.clone()); - - let loader_fields = LoaderField::get_fields(loader_id, &mut *transaction, redis).await?; - let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis) - .await?; - - for (key, value) in loader_create.fields.iter() { - let loader_field = loader_fields + let loaders = version_data + .loaders + .iter() + .map(|x| { + all_loaders .iter() - .find(|lf| &lf.field == key) - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Loader field '{key}' does not exist for loader '{loader_name}'" - )) - })?; - let enum_variants = loader_field_enum_values - .remove(&loader_field.id) - .unwrap_or_default(); - let vf: VersionField = VersionField::check_parse( - version_id.into(), - loader_field.clone(), - value.clone(), - enum_variants, - ) - .map_err(CreateError::InvalidInput)?; - version_fields.push(vf); - } + .find(|y| { + y.loader == x.0 + && y.supported_project_types + .contains(&project_type.to_string()) + }) + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + .map(|y| y.id) + }) + .collect::, CreateError>>()?; + + let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; + let mut version_fields = vec![]; + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; + for (key, value) in version_data.fields .iter() { + let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist!")) + })?; + let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; + version_fields.push(vf); } let dependencies = version_data @@ -963,7 +931,7 @@ async fn create_initial_version( changelog: version_data.version_body.clone().unwrap_or_default(), files: Vec::new(), dependencies, - loaders: loader_ids, + loaders, version_fields, featured: version_data.featured, status: VersionStatus::Listed, diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 3eeb6a2e..a7c54fcd 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -51,7 +51,6 @@ pub async fn loader_list( #[derive(serde::Deserialize, serde::Serialize)] pub struct LoaderFieldsEnumQuery { - pub loader: String, pub loader_field: String, pub filters: Option>, // For metadata } @@ -63,18 +62,12 @@ pub async fn loader_fields_list( redis: web::Data, ) -> Result { let query = query.into_inner(); - let loader_id = Loader::get_id(&query.loader, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!("'{}' was not a valid loader.", query.loader)) - })?; - - let loader_field = LoaderField::get_field(&query.loader_field, loader_id, &**pool, &redis) + let loader_field = LoaderField::get_field(&query.loader_field, &**pool, &redis) .await? .ok_or_else(|| { ApiError::InvalidInput(format!( - "'{}' was not a valid loader field for loader {}.", - query.loader_field, query.loader + "'{}' was not a valid loader field.", + query.loader_field )) })?; diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 8ad57c0e..29324605 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -15,9 +15,10 @@ use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; use crate::models::projects::{ - Dependency, DependencyType, FileType, Loader, LoaderStruct, ProjectId, Version, VersionFile, + Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus, VersionType, }; +use crate::models::projects::skip_nulls; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::routes::read_from_field; @@ -66,7 +67,7 @@ pub struct InitialVersionData { #[serde(alias = "version_type")] pub release_channel: VersionType, #[validate(length(min = 1))] - pub loaders: Vec, + pub loaders: Vec, pub featured: bool, pub primary_file: Option, #[serde(default = "default_requested_status")] @@ -77,6 +78,13 @@ pub struct InitialVersionData { #[validate(length(max = 10))] #[serde(default)] pub uploaded_images: Vec, + + // Flattened loader fields + // All other fields are loader-specific VersionFields + // These are flattened during serialization + #[serde(deserialize_with = "skip_nulls")] + #[serde(flatten)] + pub fields: HashMap, } #[derive(Serialize, Deserialize, Clone)] @@ -254,35 +262,31 @@ async fn version_create_inner( let all_loaders = models::loader_fields::Loader::list(project.inner.game,&mut *transaction, redis).await?; game = Some(project.inner.game); - let mut loader_ids = vec![]; - let mut loaders = vec![]; + let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; let mut version_fields = vec![]; - for loader_create in version_create_data.loaders.iter() { - let loader_name = loader_create.loader.0.clone(); + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; + for (key, value) in version_create_data.fields .iter() { + let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist!")) + })?; + let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); + let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; + version_fields.push(vf); + } - // Confirm loader from list of loaders - let loader_id = all_loaders + let loaders = version_create_data + .loaders .iter() - .find(|y| { - y.loader == loader_name && y.supported_project_types.contains(&project_type) + .map(|x| { + all_loaders + .iter() + .find(|y| { + y.loader == x.0 && y.supported_project_types.contains(&project_type) + }) + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + .map(|y| y.id) }) - .ok_or_else(|| CreateError::InvalidLoader(loader_name.clone())) - .map(|y| y.id)?; - - loader_ids.push(loader_id); - loaders.push(loader_create.loader.clone()); - - let loader_fields = LoaderField::get_fields(loader_id, &mut *transaction, redis).await?; - let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; - for (key, value) in loader_create.fields .iter() { - let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { - CreateError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) - })?; - let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; - version_fields.push(vf); - } - } + .collect::, CreateError>>()?; let dependencies = version_create_data .dependencies @@ -304,7 +308,7 @@ async fn version_create_inner( changelog: version_create_data.version_body.clone().unwrap_or_default(), files: Vec::new(), dependencies, - loaders: loader_ids, + loaders, version_fields, version_type: version_create_data.release_channel.to_string(), featured: version_create_data.featured, @@ -352,7 +356,7 @@ async fn version_create_inner( version.version_id.into(), &version.version_fields, &project_type, - version_data.loaders.into_iter().map(|l|l.loader).collect(), + version_data.loaders, version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), version_data.file_types.get(name).copied().flatten(), @@ -452,6 +456,7 @@ async fn version_create_inner( .collect::>(), dependencies: version_data.dependencies, loaders: version_data.loaders, + fields: version_data.fields, }; let project_id = builder.project_id; diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 3573be74..71234b11 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -4,6 +4,7 @@ use super::ApiError; use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; +use crate::models::projects::{skip_nulls, Loader}; use crate::database; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; @@ -14,7 +15,7 @@ use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{Dependency, FileType, LoaderStruct, VersionStatus, VersionType}; +use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::img; @@ -31,11 +32,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { ); cfg.route("versions", web::get().to(versions_get)); - cfg.route( - "{id}", - web::post().to(super::version_creation::version_create), - ); - cfg.service( web::scope("version") .route("{id}", web::get().to(version_get)) @@ -195,12 +191,19 @@ pub struct EditVersion { custom(function = "crate::util::validate::validate_deps") )] pub dependencies: Option>, - pub loaders: Option>, + pub loaders: Option>, pub featured: Option, pub primary_file: Option<(String, String)>, pub downloads: Option, pub status: Option, pub file_types: Option>, + + // Flattened loader fields + // All other fields are loader-specific VersionFields + // These are flattened during serialization + #[serde(deserialize_with = "skip_nulls")] + #[serde(flatten)] + pub fields: HashMap, } #[derive(Serialize, Deserialize, Debug)] @@ -249,6 +252,7 @@ pub async fn version_edit_helper( .await? .1; + println!("Inner fields: {:?}", new_version.fields); new_version .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; @@ -376,104 +380,95 @@ pub async fn version_edit_helper( } } - // if let Some(game_versions) = &new_version.game_versions { - // sqlx::query!( - // " - // DELETE FROM game_versions_versions WHERE joining_version_id = $1 - // ", - // id as database::models::ids::VersionId, - // ) - // .execute(&mut *transaction) - // .await?; - - // let mut version_versions = Vec::new(); - // for game_version in game_versions { - // let game_version_id = database::models::categories::GameVersion::get_id( - // &game_version.0, - // &mut *transaction, - // ) - // .await? - // .ok_or_else(|| { - // ApiError::InvalidInput( - // "No database entry for game version provided.".to_string(), - // ) - // })?; - - // version_versions.push(VersionVersion::new(game_version_id, id)); - // } - // VersionVersion::insert_many(version_versions, &mut transaction).await?; + if new_version.fields.len() > 0 { + println!("more than one field."); + let version_fields_names = new_version + .fields + .keys() + .map(|x| x.to_string()) + .collect::>(); + println!("Resetting the following fields: {:?}", version_fields_names); - // database::models::Project::update_game_versions( - // version_item.inner.project_id, - // &mut transaction, - // ) - // .await?; - // } + let loader_fields = LoaderField::get_fields( + &mut *transaction, + &redis, + ).await?.into_iter().filter(|lf| version_fields_names.contains(&lf.field)).collect::>(); - if let Some(loaders) = &new_version.loaders { + println!("Fetched fields: {:?}", loader_fields); + + let loader_field_ids = loader_fields.iter().map(|lf| lf.id.0).collect::>(); sqlx::query!( " - DELETE FROM loaders_versions WHERE version_id = $1 + DELETE FROM version_fields + WHERE version_id = $1 + AND field_id = ANY($2) ", id as database::models::ids::VersionId, + &loader_field_ids ) .execute(&mut *transaction) .await?; + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut *transaction, + &redis, + ) + .await?; + + println!("Variants: {:?}", loader_field_enum_values); + + // TODO: This should check if loader supports project type like the version_creation route + let mut version_fields = Vec::new(); + for (vf_name, vf_value) in new_version.fields { + println!("Iterating: {:?} {:?}", vf_name, vf_value); + let loader_field = loader_fields.iter().find(|lf| lf.field == vf_name).ok_or_else(|| { + ApiError::InvalidInput(format!("Loader field '{vf_name}' does not exist.")) + })?; + println!("Found loader field: {:?}", loader_field); + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + println!("Enum variants: {:?}", enum_variants); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + vf_value.clone(), + enum_variants, + ) + .map_err(ApiError::InvalidInput)?; + println!("Parsed version field: {:?}", vf); + version_fields.push(vf); + } + println!("Parsed version fields: {:?}", version_fields); + VersionField::insert_many(version_fields, &mut transaction).await?; + } + + if let Some(loaders) = &new_version.loaders { sqlx::query!( " - DELETE FROM version_fields WHERE version_id = $1 + DELETE FROM loaders_versions WHERE version_id = $1 ", id as database::models::ids::VersionId, ) .execute(&mut *transaction) .await?; - // TODO: This should check if loader supports project type like the version_creation route let mut loader_versions = Vec::new(); - let mut version_fields = Vec::new(); for loader in loaders { - let loader_name = loader.loader.0.clone(); - - let loader_id = database::models::loader_fields::Loader::get_id( - &loader_name, - &mut *transaction, - &redis, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("No database entry for loader provided.".to_string()) - })?; + let loader_id = + database::models::loader_fields::Loader::get_id(&loader.0, &mut *transaction, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "No database entry for loader provided.".to_string(), + ) + })?; loader_versions.push(LoaderVersion::new(loader_id, id)); - let loader_fields = - LoaderField::get_fields(loader_id, &mut *transaction, &redis).await?; - let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields( - &loader_fields, - &mut *transaction, - &redis, - ) - .await?; - for (key, value) in loader.fields.iter() { - let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { - ApiError::InvalidInput(format!("Loader field '{key}' does not exist for loader '{loader_name}'")) - })?; - let enum_variants = loader_field_enum_values - .remove(&loader_field.id) - .unwrap_or_default(); - let vf: VersionField = VersionField::check_parse( - version_id.into(), - loader_field.clone(), - value.clone(), - enum_variants, - ) - .map_err(ApiError::InvalidInput)?; - version_fields.push(vf); - } } - LoaderVersion::insert_many(loader_versions, &mut transaction).await?; - VersionField::insert_many(version_fields, &mut transaction).await?; + database::models::Project::update_loaders( version_item.inner.project_id, &mut transaction, diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index b919828b..630e2cd5 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -35,11 +35,10 @@ pub async fn index_local( 'enum_value', vf.enum_value, 'string_value', vf.string_value ) - ) version_fields, + ) filter (where vf.field_id is not null) version_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'lf_id', lf.id, - 'l_id', lf.loader_id, 'loader_name', lo.loader, 'field', lf.field, 'field_type', lf.field_type, @@ -48,7 +47,7 @@ pub async fn index_local( 'max_val', lf.max_val, 'optional', lf.optional ) - ) loader_fields, + ) filter (where lf.id is not null) loader_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'id', lfev.id, @@ -58,7 +57,7 @@ pub async fn index_local( 'created', lfev.created, 'metadata', lfev.metadata ) - ) loader_field_enum_values + ) filter (where lfev.id is not null) loader_field_enum_values FROM versions v @@ -98,9 +97,7 @@ pub async fn index_local( let version_fields = VersionField::from_query_json(m.id, m.loader_fields, m.version_fields, m.loader_field_enum_values); let loader_fields : HashMap> = version_fields.into_iter().map(|vf| { - let key = format!("{}_{}", vf.loader_name, vf.field_name); - let value = vf.value.as_search_strings(); - (key, value) + (vf.field_name, vf.value.as_search_strings()) }).collect(); for v in loader_fields.keys().cloned() { diff --git a/src/util/webhook.rs b/src/util/webhook.rs index fb6b44b4..d7fb3994 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -99,11 +99,10 @@ pub async fn send_discord_webhook( 'enum_value', vf.enum_value, 'string_value', vf.string_value ) - ) version_fields, + ) filter (where vf.field_id is not null) version_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'lf_id', lf.id, - 'l_id', lf.loader_id, 'loader_name', lo.loader, 'field', lf.field, 'field_type', lf.field_type, @@ -112,7 +111,7 @@ pub async fn send_discord_webhook( 'max_val', lf.max_val, 'optional', lf.optional ) - ) loader_fields, + ) filter (where lf.id is not null) loader_fields, JSONB_AGG( DISTINCT jsonb_build_object( 'id', lfev.id, @@ -122,7 +121,7 @@ pub async fn send_discord_webhook( 'created', lfev.created, 'metadata', lfev.metadata ) - ) loader_field_enum_values + ) filter (where lfev.id is not null) loader_field_enum_values FROM mods m LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 837b860a..7389a735 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -40,20 +40,15 @@ VALUES (2, '1.20.4', '{"type":"beta","major":false}'); INSERT INTO loader_field_enum_values(enum_id, value, metadata) VALUES (2, '1.20.5', '{"type":"release","major":true}'); -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (1, 'game_versions', 'array_enum', 2); -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (2, 'game_versions', 'array_enum', 2); +INSERT INTO loader_fields_loaders(loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'game_versions' OR lf.field = 'client_side' OR lf.field = 'server_side'; --- Side-types -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (1, 'client_side', 'enum', 1); -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (1, 'server_side', 'enum', 1); -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (2, 'client_side', 'enum', 1); -INSERT INTO loader_fields(loader_id, field, field_type, enum_type) -VALUES (2, 'server_side', 'enum', 1); +-- INSERT INTO loader_fields(field, field_type, enum_type) +-- VALUES ('game_versions', 'array_enum', 2); +-- INSERT INTO loader_fields(field, field_type, enum_type) +-- VALUES ('client_side', 'enum', 1); +-- INSERT INTO loader_fields(field, field_type, enum_type) +-- VALUES ('server_side', 'enum', 1); INSERT INTO categories (id, category, project_type) VALUES (1, 'combat', 1), From 74e9efb73f1422e32e5359f888f2946b3c1c33f9 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 27 Oct 2023 16:27:18 -0700 Subject: [PATCH 17/31] removed old caches --- migrations/20231005230721_dynamic-fields.sql | 2 ++ src/database/models/project_item.rs | 35 +++----------------- src/database/models/version_item.rs | 6 +--- src/routes/v3/version_creation.rs | 1 - src/routes/v3/versions.rs | 18 +--------- tests/files/dummy_data.sql | 6 ---- tests/version.rs | 1 - 7 files changed, 9 insertions(+), 60 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 8e45a38a..e0549588 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -105,6 +105,8 @@ SELECT gvv.joining_version_id, 2, lfev.id FROM game_versions_versions gvv INNER JOIN loader_field_enum_values lfev ON gvv.game_version_id = lfev.original_id WHERE lfev.enum_id = 2; +ALTER TABLE mods DROP COLUMN loaders; +ALTER TABLE mods DROP COLUMN game_versions; DROP TABLE game_versions_versions; DROP TABLE game_versions; diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 0a6afe5a..29752d46 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -242,8 +242,6 @@ impl ProjectBuilder { .collect_vec(); ModCategory::insert_many(mod_categories, &mut *transaction).await?; - Project::update_loaders(self.project_id, &mut *transaction).await?; - Ok(self.project_id) } } @@ -563,7 +561,6 @@ impl Project { } } } - if !remaining_strings.is_empty() { let project_ids_parsed: Vec = remaining_strings .iter() @@ -579,7 +576,8 @@ impl Project { m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, 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, pt.name project_type_name, m.webhook_sent, m.color, - t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions, + t.id thread_id, m.monetization_status monetization_status, + ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, 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, JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions, @@ -595,6 +593,8 @@ impl Project { LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id LEFT JOIN categories c ON mc.joining_category_id = c.id LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3) + LEFT JOIN loaders_versions lv ON lv.version_id = v.id + LEFT JOIN loaders l on lv.loader_id = l.id WHERE m.id = ANY($1) OR m.slug = ANY($2) GROUP BY pt.id, t.id, m.id, g.name; ", @@ -644,7 +644,7 @@ impl Project { monetization_status: MonetizationStatus::from_string( &m.monetization_status, ), - loaders: m.loaders, + loaders: m.loaders.unwrap_or_default(), }, project_type: m.project_type_name, categories: m.categories.unwrap_or_default(), @@ -754,31 +754,6 @@ impl Project { Ok(dependencies) } - pub async fn update_loaders( - id: ProjectId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), sqlx::error::Error> { - sqlx::query!( - " - UPDATE mods - SET loaders = ( - SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[]) - FROM versions v - INNER JOIN loaders_versions lv ON lv.version_id = v.id - INNER JOIN loaders l on lv.loader_id = l.id - WHERE v.mod_id = mods.id AND v.status != ALL($2) - ) - WHERE id = $1 - ", - id as ProjectId, - &*crate::models::projects::VersionStatus::iterator().filter(|x| x.is_hidden()).map(|x| x.to_string()).collect::>() - ) - .execute(&mut *transaction) - .await?; - - Ok(()) - } - pub async fn clear_cache( id: ProjectId, slug: Option, diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 59b2d336..f7fed85d 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -456,11 +456,7 @@ impl Version { .execute(&mut *transaction) .await?; - crate::database::models::Project::update_loaders( - ProjectId(project_id.mod_id), - &mut *transaction, - ) - .await?; + crate::database::models::Project::clear_cache(ProjectId(project_id.mod_id), None, None, redis).await?; Ok(Some(())) } diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 29324605..cec948c4 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -497,7 +497,6 @@ async fn version_create_inner( } } - models::Project::update_loaders(project_id, &mut *transaction).await?; models::Project::clear_cache(project_id, None, Some(true), redis).await?; Ok(HttpResponse::Ok().json(response)) diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 71234b11..c30c39a7 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -252,7 +252,6 @@ pub async fn version_edit_helper( .await? .1; - println!("Inner fields: {:?}", new_version.fields); new_version .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; @@ -381,20 +380,17 @@ pub async fn version_edit_helper( } if new_version.fields.len() > 0 { - println!("more than one field."); let version_fields_names = new_version .fields .keys() .map(|x| x.to_string()) .collect::>(); - println!("Resetting the following fields: {:?}", version_fields_names); let loader_fields = LoaderField::get_fields( &mut *transaction, &redis, ).await?.into_iter().filter(|lf| version_fields_names.contains(&lf.field)).collect::>(); - println!("Fetched fields: {:?}", loader_fields); let loader_field_ids = loader_fields.iter().map(|lf| lf.id.0).collect::>(); sqlx::query!( @@ -417,20 +413,14 @@ pub async fn version_edit_helper( ) .await?; - println!("Variants: {:?}", loader_field_enum_values); - - // TODO: This should check if loader supports project type like the version_creation route let mut version_fields = Vec::new(); for (vf_name, vf_value) in new_version.fields { - println!("Iterating: {:?} {:?}", vf_name, vf_value); let loader_field = loader_fields.iter().find(|lf| lf.field == vf_name).ok_or_else(|| { ApiError::InvalidInput(format!("Loader field '{vf_name}' does not exist.")) })?; - println!("Found loader field: {:?}", loader_field); let enum_variants = loader_field_enum_values .remove(&loader_field.id) .unwrap_or_default(); - println!("Enum variants: {:?}", enum_variants); let vf: VersionField = VersionField::check_parse( version_id.into(), loader_field.clone(), @@ -438,10 +428,8 @@ pub async fn version_edit_helper( enum_variants, ) .map_err(ApiError::InvalidInput)?; - println!("Parsed version field: {:?}", vf); version_fields.push(vf); } - println!("Parsed version fields: {:?}", version_fields); VersionField::insert_many(version_fields, &mut transaction).await?; } @@ -469,11 +457,7 @@ pub async fn version_edit_helper( } LoaderVersion::insert_many(loader_versions, &mut transaction).await?; - database::models::Project::update_loaders( - version_item.inner.project_id, - &mut transaction, - ) - .await?; + crate::database::models::Project::clear_cache(version_item.inner.project_id, None, None, &redis).await?; } if let Some(featured) = &new_version.featured { diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 7389a735..c494bb87 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -43,12 +43,6 @@ VALUES (2, '1.20.5', '{"type":"release","major":true}'); INSERT INTO loader_fields_loaders(loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'game_versions' OR lf.field = 'client_side' OR lf.field = 'server_side'; --- INSERT INTO loader_fields(field, field_type, enum_type) --- VALUES ('game_versions', 'array_enum', 2); --- INSERT INTO loader_fields(field, field_type, enum_type) --- VALUES ('client_side', 'enum', 1); --- INSERT INTO loader_fields(field, field_type, enum_type) --- VALUES ('server_side', 'enum', 1); INSERT INTO categories (id, category, project_type) VALUES (1, 'combat', 1), diff --git a/tests/version.rs b/tests/version.rs index d4b896f4..823acf24 100644 --- a/tests/version.rs +++ b/tests/version.rs @@ -221,7 +221,6 @@ async fn version_updates() { assert_eq!(versions.len(), 0); } - println!("version types: {:?}", version_types); // update_individual_files let hashes = vec![FileUpdateData { hash: alpha_version_hash.to_string(), From a40f0523bae3e915cfd958929ccca697ad1e2466 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 27 Oct 2023 16:55:00 -0700 Subject: [PATCH 18/31] removed games --- migrations/20231005230721_dynamic-fields.sql | 2 -- src/database/models/legacy_loader_fields.rs | 15 ++++++--------- src/database/models/loader_fields.rs | 13 ++++--------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index e0549588..74b11373 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -13,7 +13,6 @@ ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games NOT NULL DEFAULT CREATE TABLE loader_field_enums ( id serial PRIMARY KEY, - game_id integer REFERENCES games NOT NULL DEFAULT 1, enum_name varchar(64) NOT NULL, ordering int NULL, hidable BOOLEAN NOT NULL DEFAULT FALSE @@ -115,4 +114,3 @@ ALTER TABLE loader_field_enum_values DROP COLUMN original_id; -- drop 'minecraft-java' as default ALTER TABLE loaders ALTER COLUMN game_id DROP DEFAULT; -ALTER TABLE loader_field_enums ALTER COLUMN game_id DROP DEFAULT; diff --git a/src/database/models/legacy_loader_fields.rs b/src/database/models/legacy_loader_fields.rs index e5a4a7ec..2ba86e89 100644 --- a/src/database/models/legacy_loader_fields.rs +++ b/src/database/models/legacy_loader_fields.rs @@ -11,7 +11,7 @@ use serde_json::json; use crate::database::redis::RedisPool; use super::{ - loader_fields::{Game, LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue}, + loader_fields::{LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue}, DatabaseError, LoaderFieldEnumValueId, }; @@ -40,12 +40,11 @@ impl MinecraftGameVersion { where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { - let game_name = Game::MinecraftJava.name(); - let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, game_name, exec, redis) + let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, exec, redis) .await? .ok_or_else(|| { DatabaseError::SchemaError(format!( - "Could not find game version enum for '{game_name}'" + "Could not find game version enum." )) })?; let game_version_enum_values = @@ -61,13 +60,12 @@ impl MinecraftGameVersion { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, DatabaseError> { - let game_name = Game::MinecraftJava.name(); let game_version_enum = - LoaderFieldEnum::get(Self::FIELD_NAME, game_name, &mut *transaction, redis) + LoaderFieldEnum::get(Self::FIELD_NAME, &mut *transaction, redis) .await? .ok_or_else(|| { DatabaseError::SchemaError(format!( - "Could not find game version enum for '{game_name}'" + "Could not find game version enum." )) })?; let game_version_enum_values = @@ -178,8 +176,7 @@ impl<'a> MinecraftGameVersionBuilder<'a> { where E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy, { - let game_name = Game::MinecraftJava.name(); - let game_versions_enum = LoaderFieldEnum::get("game_versions", game_name, exec, redis) + let game_versions_enum = LoaderFieldEnum::get("game_versions", exec, redis) .await? .ok_or(DatabaseError::SchemaError( "Missing loaders field: 'game_versions'".to_string(), diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 68fe7b98..825083f5 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -191,7 +191,6 @@ impl LoaderFieldType { #[derive(Clone, Serialize, Deserialize, Debug)] pub struct LoaderFieldEnum { pub id: LoaderFieldEnumId, - pub game: Game, pub enum_name: String, pub ordering: Option, pub hidable: bool, @@ -322,7 +321,6 @@ impl LoaderField { impl LoaderFieldEnum { pub async fn get<'a, E>( enum_name: &str, // Note: NOT loader field name - game_name: &str, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> @@ -332,7 +330,7 @@ impl LoaderFieldEnum { let cached_enum = redis .get_deserialized_from_json( LOADER_FIELD_ENUMS_ID_NAMESPACE, - format!("{}_{}", game_name, enum_name), + enum_name ) .await?; if let Some(cached_enum) = cached_enum { @@ -341,12 +339,10 @@ impl LoaderFieldEnum { let result = sqlx::query!( " - SELECT lfe.id, g.name, lfe.enum_name, lfe.ordering, lfe.hidable + SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable FROM loader_field_enums lfe - INNER JOIN games g ON lfe.game_id = g.id - WHERE g.name = $1 AND lfe.enum_name = $2 + WHERE lfe.enum_name = $1 ", - game_name, enum_name ) .fetch_optional(exec) @@ -354,7 +350,6 @@ impl LoaderFieldEnum { .and_then(|l| { Some(LoaderFieldEnum { id: LoaderFieldEnumId(l.id), - game: l.name.and_then(|n| Game::from_name(&n))?, enum_name: l.enum_name, ordering: l.ordering, hidable: l.hidable, @@ -364,7 +359,7 @@ impl LoaderFieldEnum { redis .set_serialized_to_json( LOADER_FIELD_ENUMS_ID_NAMESPACE, - format!("{}_{}", game_name, enum_name), + enum_name, &result, None, ) From 8950fa332514eb6d68ad744a3fb58f614cc6a53a Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 27 Oct 2023 17:30:32 -0700 Subject: [PATCH 19/31] fmt clippy --- sqlx-data.json | 1741 +++++++++---------- src/database/models/legacy_loader_fields.rs | 17 +- src/database/models/loader_fields.rs | 42 +- src/database/models/version_item.rs | 8 +- src/models/projects.rs | 8 +- src/models/v2/projects.rs | 7 +- src/routes/updates.rs | 5 +- src/routes/v2/project_creation.rs | 123 +- src/routes/v2/projects.rs | 7 +- src/routes/v2/version_creation.rs | 49 +- src/routes/v2/versions.rs | 4 +- src/routes/v2_reroute.rs | 8 +- src/routes/v3/project_creation.rs | 29 +- src/routes/v3/version_creation.rs | 52 +- src/routes/v3/versions.rs | 55 +- 15 files changed, 1081 insertions(+), 1074 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index eacac508..927dd83c 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -304,178 +304,6 @@ }, "query": "\n UPDATE versions\n SET downloads = $1\n WHERE (id = $2)\n " }, - "07350a0f6e0f9dd3c7c2b0fe563e3e235561365e177c29acdb58368a985b7bcc": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "version_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "project_type", - "ordinal": 2, - "type_info": "Int4" - }, - { - "name": "title", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "downloads", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "follows", - "ordinal": 6, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "published", - "ordinal": 8, - "type_info": "Timestamptz" - }, - { - "name": "approved", - "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "updated", - "ordinal": 10, - "type_info": "Timestamptz" - }, - { - "name": "team_id", - "ordinal": 11, - "type_info": "Int8" - }, - { - "name": "license", - "ordinal": 12, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "status_name", - "ordinal": 14, - "type_info": "Varchar" - }, - { - "name": "color", - "ordinal": 15, - "type_info": "Int4" - }, - { - "name": "project_type_name", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "categories", - "ordinal": 18, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 19, - "type_info": "VarcharArray" - }, - { - "name": "loaders", - "ordinal": 20, - "type_info": "VarcharArray" - }, - { - "name": "gallery", - "ordinal": 21, - "type_info": "VarcharArray" - }, - { - "name": "featured_gallery", - "ordinal": 22, - "type_info": "VarcharArray" - }, - { - "name": "version_fields", - "ordinal": 23, - "type_info": "Jsonb" - }, - { - "name": "loader_fields", - "ordinal": 24, - "type_info": "Jsonb" - }, - { - "name": "loader_field_enum_values", - "ordinal": 25, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - false, - true, - false, - true, - false, - false, - null, - null, - null, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray", - "Text" - ] - } - }, - "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n\n \n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n " - }, "07b692d2f89cdcc66da4e1a834f6fefe6a24c13c287490662585749b2b8baae3": { "describe": { "columns": [], @@ -614,6 +442,44 @@ }, "query": "\n UPDATE versions\n SET name = $1\n WHERE (id = $2)\n " }, + "0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "enum_name", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "ordering", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "hidable", + "ordinal": 3, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "\n SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable \n FROM loader_field_enums lfe\n WHERE lfe.enum_name = $1\n " + }, "0b79ae3825e05ae07058a0a9d02fb0bd68ce37f3c7cf0356d565c23520988816": { "describe": { "columns": [ @@ -989,15 +855,143 @@ }, "query": "\n DELETE FROM sessions WHERE id = $1\n " }, - "1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a": { + "168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4": { "describe": { "columns": [ { - "name": "exists", + "name": "id", "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "mod_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "author_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "version_name", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "version_number", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "changelog", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "date_published", + "ordinal": 6, + "type_info": "Timestamptz" + }, + { + "name": "downloads", + "ordinal": 7, + "type_info": "Int4" + }, + { + "name": "version_type", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "featured", + "ordinal": 9, "type_info": "Bool" - } - ], + }, + { + "name": "status", + "ordinal": 10, + "type_info": "Varchar" + }, + { + "name": "requested_status", + "ordinal": 11, + "type_info": "Varchar" + }, + { + "name": "loaders", + "ordinal": 12, + "type_info": "VarcharArray" + }, + { + "name": "files", + "ordinal": 13, + "type_info": "Jsonb" + }, + { + "name": "hashes", + "ordinal": 14, + "type_info": "Jsonb" + }, + { + "name": "dependencies", + "ordinal": 15, + "type_info": "Jsonb" + }, + { + "name": "version_fields", + "ordinal": 16, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 17, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 18, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "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,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n " + }, + "1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], "nullable": [ null ], @@ -1087,6 +1081,178 @@ }, "query": "\n INSERT INTO payouts_values (user_id, mod_id, amount, created)\n SELECT * FROM UNNEST ($1::bigint[], $2::bigint[], $3::numeric[], $4::timestamptz[])\n " }, + "1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "version_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "project_type", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "title", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "downloads", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "follows", + "ordinal": 6, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "published", + "ordinal": 8, + "type_info": "Timestamptz" + }, + { + "name": "approved", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "updated", + "ordinal": 10, + "type_info": "Timestamptz" + }, + { + "name": "team_id", + "ordinal": 11, + "type_info": "Int8" + }, + { + "name": "license", + "ordinal": 12, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "status_name", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 15, + "type_info": "Int4" + }, + { + "name": "project_type_name", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 17, + "type_info": "Varchar" + }, + { + "name": "categories", + "ordinal": 18, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 19, + "type_info": "VarcharArray" + }, + { + "name": "loaders", + "ordinal": 20, + "type_info": "VarcharArray" + }, + { + "name": "gallery", + "ordinal": 21, + "type_info": "VarcharArray" + }, + { + "name": "featured_gallery", + "ordinal": 22, + "type_info": "VarcharArray" + }, + { + "name": "version_fields", + "ordinal": 23, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 24, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 25, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Text" + ] + } + }, + "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n\n \n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n " + }, "1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7": { "describe": { "columns": [ @@ -1915,52 +2081,7 @@ }, "query": "\n UPDATE users\n SET password = $1\n WHERE (id = $2)\n " }, - "436ecad917083b60c213877d14ee9f7ba3560e877c70dd9beb79d52bf24b429c": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int4" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "enum_name", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "ordering", - "ordinal": 3, - "type_info": "Int4" - }, - { - "name": "hidable", - "ordinal": 4, - "type_info": "Bool" - } - ], - "nullable": [ - false, - true, - false, - true, - false - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - } - }, - "query": "\n SELECT lfe.id, g.name, lfe.enum_name, lfe.ordering, lfe.hidable \n FROM loader_field_enums lfe\n INNER JOIN games g ON lfe.game_id = g.id\n WHERE g.name = $1 AND lfe.enum_name = $2\n " - }, - "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { + "4567790f0dc98ff20b596a33161d1f6ac8af73da67fe8c54192724626c6bf670": { "describe": { "columns": [], "nullable": [], @@ -2599,6 +2720,60 @@ }, "query": "\n UPDATE mods_gallery\n SET featured = $2\n WHERE mod_id = $1\n " }, + "622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "field", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "field_type", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "optional", + "ordinal": 3, + "type_info": "Bool" + }, + { + "name": "min_val", + "ordinal": 4, + "type_info": "Int4" + }, + { + "name": "max_val", + "ordinal": 5, + "type_info": "Int4" + }, + { + "name": "enum_type", + "ordinal": 6, + "type_info": "Int4" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [] + } + }, + "query": "\n SELECT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type\n FROM loader_fields lf\n " + }, "64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9": { "describe": { "columns": [], @@ -2872,19 +3047,6 @@ }, "query": "\n SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, t.id thread_id, r.closed\n FROM reports r\n INNER JOIN report_types rt ON rt.id = r.report_type_id\n INNER JOIN threads t ON t.report_id = r.id\n WHERE r.id = ANY($1)\n ORDER BY r.created DESC\n " }, - "6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - } - }, - "query": "\n UPDATE mods\n SET loaders = (\n SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[])\n FROM versions v\n INNER JOIN loaders_versions lv ON lv.version_id = v.id\n INNER JOIN loaders l on lv.loader_id = l.id\n WHERE v.mod_id = mods.id AND v.status != ALL($2)\n )\n WHERE id = $1\n " - }, "6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46": { "describe": { "columns": [ @@ -3849,18 +4011,6 @@ }, "query": "\n SELECT m.id\n FROM mods m\n WHERE m.organization_id = $1\n " }, - "959fdbdb336841244c60340d4803d80cafc64319eaea87921e50e7a949146481": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM version_fields WHERE version_id = $1\n " - }, "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": { "describe": { "columns": [], @@ -4215,6 +4365,19 @@ }, "query": "\n DELETE FROM mods_donations\n WHERE joining_mod_id = $1\n " }, + "acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int4Array" + ] + } + }, + "query": "\n DELETE FROM version_fields \n WHERE version_id = $1\n AND field_id = ANY($2)\n " + }, "ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71": { "describe": { "columns": [ @@ -4514,201 +4677,483 @@ }, "query": "\n DELETE FROM notifications_actions\n WHERE notification_id = ANY($1)\n " }, - "bad7cae347771e801976c26f2afaf33bda371051923b8f74a2f32a0ef5c65e57": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET discord_url = $1\n WHERE (id = $2)\n " - }, - "bc91841f9672608a28bd45a862919f2bd34fac0b3479e3b4b67a9f6bea2a562a": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET issues_url = $1\n WHERE (id = $2)\n " - }, - "bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n " - }, - "bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1)" - }, - "c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n " - }, - "c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b": { - "describe": { - "columns": [ - { - "name": "exists", - "ordinal": 0, - "type_info": "Bool" - } - ], - "nullable": [ - null - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1)\n " - }, - "c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - } - }, - "query": "\n UPDATE versions\n SET version_type = $1\n WHERE (id = $2)\n " - }, - "c3391aed338110205a170ba3032e54be0f2b753b5550d87d7b5ba3e17a57a202": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM reports\n WHERE mod_id = $1\n " - }, - "c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n DELETE FROM teams\n WHERE id = $1\n " - }, - "c3d561d5ab9d7fe3c67dfd83252528a30daa38496e1350a39645adad7364d8b6": { + "baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca": { "describe": { "columns": [ { "name": "id", "ordinal": 0, - "type_info": "Int4" + "type_info": "Int8" }, { - "name": "loader_id", + "name": "name", "ordinal": 1, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "field", + "name": "project_type", "ordinal": 2, - "type_info": "Varchar" + "type_info": "Int4" }, { - "name": "field_type", + "name": "title", "ordinal": 3, "type_info": "Varchar" }, { - "name": "optional", + "name": "description", "ordinal": 4, - "type_info": "Bool" + "type_info": "Varchar" }, { - "name": "min_val", + "name": "downloads", "ordinal": 5, "type_info": "Int4" }, { - "name": "max_val", + "name": "follows", "ordinal": 6, "type_info": "Int4" }, { - "name": "enum_type", + "name": "icon_url", "ordinal": 7, - "type_info": "Int4" + "type_info": "Varchar" }, { - "name": "loader", + "name": "body", "ordinal": 8, "type_info": "Varchar" + }, + { + "name": "published", + "ordinal": 9, + "type_info": "Timestamptz" + }, + { + "name": "updated", + "ordinal": 10, + "type_info": "Timestamptz" + }, + { + "name": "approved", + "ordinal": 11, + "type_info": "Timestamptz" + }, + { + "name": "queued", + "ordinal": 12, + "type_info": "Timestamptz" + }, + { + "name": "status", + "ordinal": 13, + "type_info": "Varchar" + }, + { + "name": "requested_status", + "ordinal": 14, + "type_info": "Varchar" + }, + { + "name": "issues_url", + "ordinal": 15, + "type_info": "Varchar" + }, + { + "name": "source_url", + "ordinal": 16, + "type_info": "Varchar" + }, + { + "name": "wiki_url", + "ordinal": 17, + "type_info": "Varchar" + }, + { + "name": "discord_url", + "ordinal": 18, + "type_info": "Varchar" + }, + { + "name": "license_url", + "ordinal": 19, + "type_info": "Varchar" + }, + { + "name": "team_id", + "ordinal": 20, + "type_info": "Int8" + }, + { + "name": "organization_id", + "ordinal": 21, + "type_info": "Int8" + }, + { + "name": "license", + "ordinal": 22, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 23, + "type_info": "Varchar" + }, + { + "name": "moderation_message", + "ordinal": 24, + "type_info": "Varchar" + }, + { + "name": "moderation_message_body", + "ordinal": 25, + "type_info": "Varchar" + }, + { + "name": "project_type_name", + "ordinal": 26, + "type_info": "Varchar" + }, + { + "name": "webhook_sent", + "ordinal": 27, + "type_info": "Bool" + }, + { + "name": "color", + "ordinal": 28, + "type_info": "Int4" + }, + { + "name": "thread_id", + "ordinal": 29, + "type_info": "Int8" + }, + { + "name": "monetization_status", + "ordinal": 30, + "type_info": "Varchar" + }, + { + "name": "loaders", + "ordinal": 31, + "type_info": "VarcharArray" + }, + { + "name": "categories", + "ordinal": 32, + "type_info": "VarcharArray" + }, + { + "name": "additional_categories", + "ordinal": 33, + "type_info": "VarcharArray" + }, + { + "name": "versions", + "ordinal": 34, + "type_info": "Jsonb" + }, + { + "name": "gallery", + "ordinal": 35, + "type_info": "Jsonb" + }, + { + "name": "donations", + "ordinal": 36, + "type_info": "Jsonb" } ], "nullable": [ + false, + true, + false, + false, false, false, false, + true, + false, false, false, true, true, + false, true, - false + true, + true, + true, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray" + ] + } + }, + "query": "\n SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN games g ON g.id = m.game_id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n LEFT JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT JOIN loaders l on lv.loader_id = l.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, t.id, m.id, g.name;\n " + }, + "bad7cae347771e801976c26f2afaf33bda371051923b8f74a2f32a0ef5c65e57": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE mods\n SET discord_url = $1\n WHERE (id = $2)\n " + }, + "bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "icon_url", + "ordinal": 4, + "type_info": "Varchar" + }, + { + "name": "slug", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "project_type", + "ordinal": 6, + "type_info": "Varchar" + }, + { + "name": "username", + "ordinal": 7, + "type_info": "Varchar" + }, + { + "name": "avatar_url", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "categories", + "ordinal": 9, + "type_info": "VarcharArray" + }, + { + "name": "loaders", + "ordinal": 10, + "type_info": "VarcharArray" + }, + { + "name": "gallery", + "ordinal": 11, + "type_info": "VarcharArray" + }, + { + "name": "featured_gallery", + "ordinal": 12, + "type_info": "VarcharArray" + }, + { + "name": "version_fields", + "ordinal": 13, + "type_info": "Jsonb" + }, + { + "name": "loader_fields", + "ordinal": 14, + "type_info": "Jsonb" + }, + { + "name": "loader_field_enum_values", + "ordinal": 15, + "type_info": "Jsonb" + } + ], + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "Text" + ] + } + }, + "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\n " + }, + "bc91841f9672608a28bd45a862919f2bd34fac0b3479e3b4b67a9f6bea2a562a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE mods\n SET issues_url = $1\n WHERE (id = $2)\n " + }, + "bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n " + }, + "bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1)" + }, + "c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n " + }, + "c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1)\n " + }, + "c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE versions\n SET version_type = $1\n WHERE (id = $2)\n " + }, + "c3391aed338110205a170ba3032e54be0f2b753b5550d87d7b5ba3e17a57a202": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM reports\n WHERE mod_id = $1\n " + }, + "c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917": { + "describe": { + "columns": [], + "nullable": [], "parameters": { "Left": [ - "Int4" + "Int8" ] } }, - "query": "\n SELECT lf.id, lf.loader_id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, l.loader\n FROM loader_fields lf\n INNER JOIN loaders l ON lf.loader_id = l.id\n WHERE loader_id = $1\n " + "query": "\n DELETE FROM teams\n WHERE id = $1\n " }, "c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c": { "describe": { @@ -4835,134 +5280,6 @@ }, "query": "SELECT id FROM users WHERE microsoft_id = $1" }, - "c6aa007b0057a824448e11554a894c9ab7c681b93334e5d7ff841a7a4d64e6f9": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "mod_id", - "ordinal": 1, - "type_info": "Int8" - }, - { - "name": "author_id", - "ordinal": 2, - "type_info": "Int8" - }, - { - "name": "version_name", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "version_number", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "changelog", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "date_published", - "ordinal": 6, - "type_info": "Timestamptz" - }, - { - "name": "downloads", - "ordinal": 7, - "type_info": "Int4" - }, - { - "name": "version_type", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "featured", - "ordinal": 9, - "type_info": "Bool" - }, - { - "name": "status", - "ordinal": 10, - "type_info": "Varchar" - }, - { - "name": "requested_status", - "ordinal": 11, - "type_info": "Varchar" - }, - { - "name": "loaders", - "ordinal": 12, - "type_info": "VarcharArray" - }, - { - "name": "files", - "ordinal": 13, - "type_info": "Jsonb" - }, - { - "name": "hashes", - "ordinal": 14, - "type_info": "Jsonb" - }, - { - "name": "dependencies", - "ordinal": 15, - "type_info": "Jsonb" - }, - { - "name": "version_fields", - "ordinal": 16, - "type_info": "Jsonb" - }, - { - "name": "loader_fields", - "ordinal": 17, - "type_info": "Jsonb" - }, - { - "name": "loader_field_enum_values", - "ordinal": 18, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "Int8Array" - ] - } - }, - "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,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n " - }, "c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d": { "describe": { "columns": [], @@ -5728,138 +6045,26 @@ "query": "\n UPDATE threads_messages\n SET body = '{\"type\": \"deleted\"}', author_id = $2\n WHERE author_id = $1\n " }, "df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed": { - "describe": { - "columns": [], - "nullable": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Int4", - "Varchar", - "Bool", - "Varchar" - ] - } - }, - "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11\n )\n " - }, - "e2a5bf6fcd77820828f21dda100770a9e3f9b01cc6ca6e4e9ba3b16938239a38": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "title", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 2, - "type_info": "Varchar" - }, - { - "name": "color", - "ordinal": 3, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 5, - "type_info": "Varchar" - }, - { - "name": "project_type", - "ordinal": 6, - "type_info": "Varchar" - }, - { - "name": "username", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "avatar_url", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "categories", - "ordinal": 9, - "type_info": "VarcharArray" - }, - { - "name": "loaders", - "ordinal": 10, - "type_info": "VarcharArray" - }, - { - "name": "gallery", - "ordinal": 11, - "type_info": "VarcharArray" - }, - { - "name": "featured_gallery", - "ordinal": 12, - "type_info": "VarcharArray" - }, - { - "name": "version_fields", - "ordinal": 13, - "type_info": "Jsonb" - }, - { - "name": "loader_fields", - "ordinal": 14, - "type_info": "Jsonb" - }, - { - "name": "loader_field_enum_values", - "ordinal": 15, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - false, - false, - true, - true, - true, - false, - false, - true, - null, - null, - null, - null, - null, - null, - null - ], + "describe": { + "columns": [], + "nullable": [], "parameters": { "Left": [ "Int8", - "TextArray", - "Text" + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Bool", + "Varchar" ] } }, - "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'l_id', lf.loader_id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\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\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11\n )\n " }, "e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed": { "describe": { @@ -6507,250 +6712,6 @@ }, "query": "\n UPDATE users\n SET bio = $1\n WHERE (id = $2)\n " }, - "f76ccb09b568be547a05ae2e5037dc72ea41442b0f04a8c89e6d566f7ff2aa9c": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Varchar" - }, - { - "name": "project_type", - "ordinal": 2, - "type_info": "Int4" - }, - { - "name": "title", - "ordinal": 3, - "type_info": "Varchar" - }, - { - "name": "description", - "ordinal": 4, - "type_info": "Varchar" - }, - { - "name": "downloads", - "ordinal": 5, - "type_info": "Int4" - }, - { - "name": "follows", - "ordinal": 6, - "type_info": "Int4" - }, - { - "name": "icon_url", - "ordinal": 7, - "type_info": "Varchar" - }, - { - "name": "body", - "ordinal": 8, - "type_info": "Varchar" - }, - { - "name": "published", - "ordinal": 9, - "type_info": "Timestamptz" - }, - { - "name": "updated", - "ordinal": 10, - "type_info": "Timestamptz" - }, - { - "name": "approved", - "ordinal": 11, - "type_info": "Timestamptz" - }, - { - "name": "queued", - "ordinal": 12, - "type_info": "Timestamptz" - }, - { - "name": "status", - "ordinal": 13, - "type_info": "Varchar" - }, - { - "name": "requested_status", - "ordinal": 14, - "type_info": "Varchar" - }, - { - "name": "issues_url", - "ordinal": 15, - "type_info": "Varchar" - }, - { - "name": "source_url", - "ordinal": 16, - "type_info": "Varchar" - }, - { - "name": "wiki_url", - "ordinal": 17, - "type_info": "Varchar" - }, - { - "name": "discord_url", - "ordinal": 18, - "type_info": "Varchar" - }, - { - "name": "license_url", - "ordinal": 19, - "type_info": "Varchar" - }, - { - "name": "team_id", - "ordinal": 20, - "type_info": "Int8" - }, - { - "name": "organization_id", - "ordinal": 21, - "type_info": "Int8" - }, - { - "name": "license", - "ordinal": 22, - "type_info": "Varchar" - }, - { - "name": "slug", - "ordinal": 23, - "type_info": "Varchar" - }, - { - "name": "moderation_message", - "ordinal": 24, - "type_info": "Varchar" - }, - { - "name": "moderation_message_body", - "ordinal": 25, - "type_info": "Varchar" - }, - { - "name": "project_type_name", - "ordinal": 26, - "type_info": "Varchar" - }, - { - "name": "webhook_sent", - "ordinal": 27, - "type_info": "Bool" - }, - { - "name": "color", - "ordinal": 28, - "type_info": "Int4" - }, - { - "name": "thread_id", - "ordinal": 29, - "type_info": "Int8" - }, - { - "name": "monetization_status", - "ordinal": 30, - "type_info": "Varchar" - }, - { - "name": "loaders", - "ordinal": 31, - "type_info": "VarcharArray" - }, - { - "name": "game_versions", - "ordinal": 32, - "type_info": "VarcharArray" - }, - { - "name": "categories", - "ordinal": 33, - "type_info": "VarcharArray" - }, - { - "name": "additional_categories", - "ordinal": 34, - "type_info": "VarcharArray" - }, - { - "name": "versions", - "ordinal": 35, - "type_info": "Jsonb" - }, - { - "name": "gallery", - "ordinal": 36, - "type_info": "Jsonb" - }, - { - "name": "donations", - "ordinal": 37, - "type_info": "Jsonb" - } - ], - "nullable": [ - false, - true, - false, - false, - false, - false, - false, - true, - false, - false, - false, - true, - true, - false, - true, - true, - true, - true, - true, - true, - false, - true, - false, - true, - true, - true, - false, - false, - true, - false, - false, - false, - false, - null, - null, - null, - null, - null - ], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TextArray" - ] - } - }, - "query": "\n SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN games g ON g.id = m.game_id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, t.id, m.id, g.name;\n " - }, "f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc": { "describe": { "columns": [ diff --git a/src/database/models/legacy_loader_fields.rs b/src/database/models/legacy_loader_fields.rs index 2ba86e89..c46d6b6e 100644 --- a/src/database/models/legacy_loader_fields.rs +++ b/src/database/models/legacy_loader_fields.rs @@ -43,9 +43,7 @@ impl MinecraftGameVersion { let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, exec, redis) .await? .ok_or_else(|| { - DatabaseError::SchemaError(format!( - "Could not find game version enum." - )) + DatabaseError::SchemaError("Could not find game version enum.".to_string()) })?; let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, exec, redis).await?; @@ -60,14 +58,11 @@ impl MinecraftGameVersion { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, DatabaseError> { - let game_version_enum = - LoaderFieldEnum::get(Self::FIELD_NAME, &mut *transaction, redis) - .await? - .ok_or_else(|| { - DatabaseError::SchemaError(format!( - "Could not find game version enum." - )) - })?; + let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, &mut *transaction, redis) + .await? + .ok_or_else(|| { + DatabaseError::SchemaError("Could not find game version enum.".to_string()) + })?; let game_version_enum_values = LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, redis).await?; Ok(game_version_enum_values diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 825083f5..e175d9ec 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -274,7 +274,7 @@ impl LoaderField { // Gets all fields for a given loader // Returns all as this there are probably relatively few fields per loader // TODO: in the future, this should be to get all fields in relation to something - // - e.g. get all fields for a given game? + // - e.g. get all fields for a given game? pub async fn get_fields<'a, E>( exec: E, redis: &RedisPool, @@ -297,15 +297,16 @@ impl LoaderField { ) .fetch_many(exec) .try_filter_map(|e| async { - Ok(e.right().and_then( - |r| Some(LoaderField { + Ok(e.right().and_then(|r| { + Some(LoaderField { id: LoaderFieldId(r.id), field_type: LoaderFieldType::build(&r.field_type, r.enum_type)?, field: r.field, optional: r.optional, min_val: r.min_val, - max_val: r.max_val - }))) + max_val: r.max_val, + }) + })) }) .try_collect::>() .await?; @@ -328,10 +329,7 @@ impl LoaderFieldEnum { E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let cached_enum = redis - .get_deserialized_from_json( - LOADER_FIELD_ENUMS_ID_NAMESPACE, - enum_name - ) + .get_deserialized_from_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name) .await?; if let Some(cached_enum) = cached_enum { return Ok(cached_enum); @@ -346,23 +344,15 @@ impl LoaderFieldEnum { enum_name ) .fetch_optional(exec) - .await? - .and_then(|l| { - Some(LoaderFieldEnum { + .await?.map(|l| LoaderFieldEnum { id: LoaderFieldEnumId(l.id), enum_name: l.enum_name, ordering: l.ordering, hidable: l.hidable, - }) - }); + }); redis - .set_serialized_to_json( - LOADER_FIELD_ENUMS_ID_NAMESPACE, - enum_name, - &result, - None, - ) + .set_serialized_to_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name, &result, None) .await?; Ok(result) @@ -658,15 +648,15 @@ impl VersionField { } let query_loader_fields: Vec = loader_fields - .and_then(|x| serde_json::from_value(x).unwrap()) - .unwrap_or_default(); + .and_then(|x| serde_json::from_value(x).unwrap()) + .unwrap_or_default(); let query_version_field_combined: Vec = version_fields - .and_then(|x| serde_json::from_value(x).unwrap()) - .unwrap_or_default(); - let query_loader_field_enum_values: Vec = - loader_field_enum_values .and_then(|x| serde_json::from_value(x).unwrap()) .unwrap_or_default(); + let query_loader_field_enum_values: Vec = + loader_field_enum_values + .and_then(|x| serde_json::from_value(x).unwrap()) + .unwrap_or_default(); let version_id = VersionId(version_id); query_loader_fields .into_iter() diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index f7fed85d..3aab2280 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -456,7 +456,13 @@ impl Version { .execute(&mut *transaction) .await?; - crate::database::models::Project::clear_cache(ProjectId(project_id.mod_id), None, None, redis).await?; + crate::database::models::Project::clear_cache( + ProjectId(project_id.mod_id), + None, + None, + redis, + ) + .await?; Ok(Some(())) } diff --git a/src/models/projects.rs b/src/models/projects.rs index 6446aa1f..203b1851 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -501,7 +501,7 @@ where impl From for Version { fn from(data: QueryVersion) -> Version { - let v = data.inner; + let v = data.inner; Version { id: v.id.into(), project_id: v.project_id.into(), @@ -548,7 +548,11 @@ impl From for Version { loaders: data.loaders.into_iter().map(Loader).collect(), // Only add the internal component of the field for display // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) - fields: data.version_fields.into_iter().map(|vf| (vf.field_name, vf.value.serialize_internal())).collect() + fields: data + .version_fields + .into_iter() + .map(|vf| (vf.field_name, vf.value.serialize_internal())) + .collect(), } } } diff --git a/src/models/v2/projects.rs b/src/models/v2/projects.rs index 08cb2207..2d8df231 100644 --- a/src/models/v2/projects.rs +++ b/src/models/v2/projects.rs @@ -233,11 +233,7 @@ pub struct LegacyVersion { impl From for LegacyVersion { fn from(data: Version) -> Self { let mut game_versions = Vec::new(); - if let Some(value) = data - .fields - .get("game_versions") - .and_then(|v| v.as_array()) - { + if let Some(value) = data.fields.get("game_versions").and_then(|v| v.as_array()) { for gv in value { if let Some(game_version) = gv.as_str() { game_versions.push(game_version.to_string()); @@ -245,7 +241,6 @@ impl From for LegacyVersion { } } - Self { id: data.id, project_id: data.project_id, diff --git a/src/routes/updates.rs b/src/routes/updates.rs index 7bf989b8..f4d6d2f8 100644 --- a/src/routes/updates.rs +++ b/src/routes/updates.rs @@ -102,8 +102,9 @@ pub async fn forge_updates( let game_versions: Vec = version .fields .iter() - .find(|(key , _)| key.as_str() == MinecraftGameVersion::FIELD_NAME) - .and_then(| (_, value) | serde_json::from_value::>(value.clone()).ok()).unwrap_or_default(); + .find(|(key, _)| key.as_str() == MinecraftGameVersion::FIELD_NAME) + .and_then(|(_, value)| serde_json::from_value::>(value.clone()).ok()) + .unwrap_or_default(); if version.version_type == VersionType::Release { for game_version in &game_versions { diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index c67172d9..00d38f3a 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -3,22 +3,22 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::ImageId; -use crate::routes::v3::project_creation::default_project_type; -use crate::models::projects::{Project, DonationLink, ProjectStatus, SideType}; +use crate::models::projects::{DonationLink, Project, ProjectStatus, SideType}; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::default_project_type; use crate::routes::v3::project_creation::{CreateError, NewGalleryItem}; use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::postgres::PgPool; -use validator::Validate; use std::collections::HashMap; use std::sync::Arc; +use validator::Validate; use super::version_creation::InitialVersionData; @@ -143,62 +143,65 @@ pub async fn project_create( session_queue: Data, ) -> Result { // Convert V2 multipart payload to V3 multipart payload - let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |legacy_create : ProjectCreateData | { - - // Set game name (all v2 projects are minecraft-java) - let game_name = "minecraft-java".to_string(); - - // Side types will be applied to each version - let client_side = legacy_create.client_side; - let server_side = legacy_create.server_side; - - let initial_versions = legacy_create.initial_versions.into_iter().map(|v| { - let mut fields = HashMap::new(); - fields.insert("client_side".to_string(), json!(client_side)); - fields.insert("server_side".to_string(), json!(server_side)); - fields.insert("game_versions".to_string(), json!(v.game_versions)); - - v3::version_creation::InitialVersionData { - project_id: v.project_id, - file_parts: v.file_parts, - version_number: v.version_number, - version_title: v.version_title, - version_body: v.version_body, - dependencies: v.dependencies, - release_channel: v.release_channel, - loaders: v.loaders, - featured: v.featured, - primary_file: v.primary_file, - status: v.status, - file_types: v.file_types, - uploaded_images: v.uploaded_images, - fields, - } - }); - Ok(v3::project_creation::ProjectCreateData { - title: legacy_create.title, - project_type: legacy_create.project_type, - slug: legacy_create.slug, - description: legacy_create.description, - body: legacy_create.body, - game_name, - initial_versions: initial_versions.collect(), - categories: legacy_create.categories, - additional_categories: legacy_create.additional_categories, - issues_url: legacy_create.issues_url, - source_url: legacy_create.source_url, - wiki_url: legacy_create.wiki_url, - license_url: legacy_create.license_url, - discord_url: legacy_create.discord_url, - donation_urls: legacy_create.donation_urls, - is_draft: legacy_create.is_draft, - license_id: legacy_create.license_id, - gallery_items: legacy_create.gallery_items, - requested_status: legacy_create.requested_status, - uploaded_images: legacy_create.uploaded_images, - organization_id: legacy_create.organization_id, - }) - }) + let payload = v2_reroute::alter_actix_multipart( + payload, + req.headers().clone(), + |legacy_create: ProjectCreateData| { + // Set game name (all v2 projects are minecraft-java) + let game_name = "minecraft-java".to_string(); + + // Side types will be applied to each version + let client_side = legacy_create.client_side; + let server_side = legacy_create.server_side; + + let initial_versions = legacy_create.initial_versions.into_iter().map(|v| { + let mut fields = HashMap::new(); + fields.insert("client_side".to_string(), json!(client_side)); + fields.insert("server_side".to_string(), json!(server_side)); + fields.insert("game_versions".to_string(), json!(v.game_versions)); + + v3::version_creation::InitialVersionData { + project_id: v.project_id, + file_parts: v.file_parts, + version_number: v.version_number, + version_title: v.version_title, + version_body: v.version_body, + dependencies: v.dependencies, + release_channel: v.release_channel, + loaders: v.loaders, + featured: v.featured, + primary_file: v.primary_file, + status: v.status, + file_types: v.file_types, + uploaded_images: v.uploaded_images, + fields, + } + }); + Ok(v3::project_creation::ProjectCreateData { + title: legacy_create.title, + project_type: legacy_create.project_type, + slug: legacy_create.slug, + description: legacy_create.description, + body: legacy_create.body, + game_name, + initial_versions: initial_versions.collect(), + categories: legacy_create.categories, + additional_categories: legacy_create.additional_categories, + issues_url: legacy_create.issues_url, + source_url: legacy_create.source_url, + wiki_url: legacy_create.wiki_url, + license_url: legacy_create.license_url, + discord_url: legacy_create.discord_url, + donation_urls: legacy_create.donation_urls, + is_draft: legacy_create.is_draft, + license_id: legacy_create.license_id, + gallery_items: legacy_create.gallery_items, + requested_status: legacy_create.requested_status, + uploaded_images: legacy_create.uploaded_images, + organization_id: legacy_create.organization_id, + }) + }, + ) .await?; // Call V3 project creation diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index ec9f6b94..d1bf8ac6 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,4 +1,5 @@ use crate::auth::{get_user_from_headers, is_authorized}; +use crate::database; use crate::database::models::project_item::{GalleryItem, ModCategory}; use crate::database::models::{image_item, project_item, version_item}; use crate::database::redis::RedisPool; @@ -7,8 +8,7 @@ use crate::models; use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, - SideType, + DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, }; use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::LegacyProject; @@ -18,7 +18,6 @@ use crate::routes::{v2_reroute, v3, ApiError}; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use crate::database; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -94,7 +93,6 @@ pub async fn project_search( }) .collect(), ) - } else { None }; @@ -456,7 +454,6 @@ pub async fn project_edit( let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); let versions = version_item::Version::get_many(&version_ids, &**pool, &redis).await?; for version in versions { - let mut fields = HashMap::new(); fields.insert("client_side".to_string(), json!(client_side)); fields.insert("server_side".to_string(), json!(server_side)); diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 49facb8c..e6cea47c 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -81,28 +81,35 @@ pub async fn version_create( file_host: Data>, session_queue: Data, ) -> Result { - let payload = v2_reroute::alter_actix_multipart(payload, req.headers().clone(), |legacy_create : InitialVersionData| { - // Convert input data to V3 format - let mut fields = HashMap::new(); - fields.insert("game_versions".to_string(), json!(legacy_create.game_versions)); + let payload = v2_reroute::alter_actix_multipart( + payload, + req.headers().clone(), + |legacy_create: InitialVersionData| { + // Convert input data to V3 format + let mut fields = HashMap::new(); + fields.insert( + "game_versions".to_string(), + json!(legacy_create.game_versions), + ); - Ok(v3::version_creation::InitialVersionData { - project_id: legacy_create.project_id, - file_parts: legacy_create.file_parts, - version_number: legacy_create.version_number, - version_title: legacy_create.version_title, - version_body: legacy_create.version_body, - dependencies: legacy_create.dependencies, - release_channel: legacy_create.release_channel, - loaders: legacy_create.loaders, - featured: legacy_create.featured, - primary_file: legacy_create.primary_file, - status: legacy_create.status, - file_types: legacy_create.file_types, - uploaded_images: legacy_create.uploaded_images, - fields, - }) - }) + Ok(v3::version_creation::InitialVersionData { + project_id: legacy_create.project_id, + file_parts: legacy_create.file_parts, + version_number: legacy_create.version_number, + version_title: legacy_create.version_title, + version_body: legacy_create.version_body, + dependencies: legacy_create.dependencies, + release_channel: legacy_create.release_channel, + loaders: legacy_create.loaders, + featured: legacy_create.featured, + primary_file: legacy_create.primary_file, + status: legacy_create.status, + file_types: legacy_create.file_types, + uploaded_images: legacy_create.uploaded_images, + fields, + }) + }, + ) .await?; // Call V3 project creation diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index c373aa9a..f4d6717c 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -9,9 +9,7 @@ use crate::models; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; -use crate::models::projects::{ - Dependency, FileType, Version, VersionStatus, VersionType, -}; +use crate::models::projects::{Dependency, FileType, Version, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 2c30e591..48ef7aee 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -33,10 +33,10 @@ pub async fn alter_actix_multipart( mut multipart: Multipart, mut headers: HeaderMap, mut closure: impl FnMut(T) -> Result, -) -> Result -where +) -> Result +where T: serde::de::DeserializeOwned, - U: serde::Serialize + U: serde::Serialize, { let mut segments: Vec = Vec::new(); @@ -56,7 +56,7 @@ where { let json_value: T = serde_json::from_slice(&buffer)?; - let json_value : U = closure(json_value)?; + let json_value: U = closure(json_value)?; buffer = serde_json::to_vec(&json_value)?; } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 97c0bf32..72b2a4f4 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -818,7 +818,7 @@ async fn project_create_inner( slug: project_builder.slug.clone(), project_type: project_create_data.project_type.clone(), team: team_id.into(), - organization: project_create_data.organization_id.map(|x| x.into()), + organization: project_create_data.organization_id, title: project_builder.title.clone(), description: project_builder.description.clone(), body: project_builder.body.clone(), @@ -901,13 +901,26 @@ async fn create_initial_version( let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; let mut version_fields = vec![]; - let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; - for (key, value) in version_data.fields .iter() { - let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { - CreateError::InvalidInput(format!("Loader field '{key}' does not exist!")) - })?; - let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis) + .await?; + for (key, value) in version_data.fields.iter() { + let loader_field = loader_fields + .iter() + .find(|lf| &lf.field == key) + .ok_or_else(|| { + CreateError::InvalidInput(format!("Loader field '{key}' does not exist!")) + })?; + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + value.clone(), + enum_variants, + ) + .map_err(CreateError::InvalidInput)?; version_fields.push(vf); } diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index cec948c4..2fb88562 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -14,11 +14,11 @@ use crate::models::images::{Image, ImageContext, ImageId}; use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; +use crate::models::projects::skip_nulls; use crate::models::projects::{ - Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, - VersionId, VersionStatus, VersionType, + Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, VersionId, + VersionStatus, VersionType, }; -use crate::models::projects::skip_nulls; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::util::routes::read_from_field; @@ -202,8 +202,11 @@ async fn version_create_inner( let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); // Ensure that the project this version is being added to exists - let project = models::Project::get_id(project_id, &mut *transaction, redis).await? - .ok_or_else(|| CreateError::InvalidInput("An invalid project id was supplied".to_string()))?; + let project = models::Project::get_id(project_id, &mut *transaction, redis) + .await? + .ok_or_else(|| { + CreateError::InvalidInput("An invalid project id was supplied".to_string()) + })?; // Check that the user creating this version is a team member // of the project the version is being added to. @@ -259,18 +262,41 @@ async fn version_create_inner( .await? .name; - let all_loaders = models::loader_fields::Loader::list(project.inner.game,&mut *transaction, redis).await?; + let all_loaders = models::loader_fields::Loader::list( + project.inner.game, + &mut *transaction, + redis, + ) + .await?; game = Some(project.inner.game); let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; let mut version_fields = vec![]; - let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis).await?; - for (key, value) in version_create_data.fields .iter() { - let loader_field = loader_fields.iter().find(|lf| &lf.field == key).ok_or_else(|| { - CreateError::InvalidInput(format!("Loader field '{key}' does not exist!")) - })?; - let enum_variants = loader_field_enum_values.remove(&loader_field.id).unwrap_or_default(); - let vf: VersionField = VersionField::check_parse(version_id.into(), loader_field.clone(), value.clone(), enum_variants).map_err(CreateError::InvalidInput)?; + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut *transaction, + redis, + ) + .await?; + for (key, value) in version_create_data.fields.iter() { + let loader_field = loader_fields + .iter() + .find(|lf| &lf.field == key) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Loader field '{key}' does not exist!" + )) + })?; + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + value.clone(), + enum_variants, + ) + .map_err(CreateError::InvalidInput)?; version_fields.push(vf); } diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index c30c39a7..5d4a1e91 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -4,7 +4,6 @@ use super::ApiError; use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; -use crate::models::projects::{skip_nulls, Loader}; use crate::database; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; @@ -15,6 +14,7 @@ use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::VersionId; use crate::models::images::ImageContext; use crate::models::pats::Scopes; +use crate::models::projects::{skip_nulls, Loader}; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; @@ -197,7 +197,7 @@ pub struct EditVersion { pub downloads: Option, pub status: Option, pub file_types: Option>, - + // Flattened loader fields // All other fields are loader-specific VersionFields // These are flattened during serialization @@ -379,18 +379,18 @@ pub async fn version_edit_helper( } } - if new_version.fields.len() > 0 { + if !new_version.fields.is_empty() { let version_fields_names = new_version .fields .keys() .map(|x| x.to_string()) .collect::>(); - let loader_fields = LoaderField::get_fields( - &mut *transaction, - &redis, - ).await?.into_iter().filter(|lf| version_fields_names.contains(&lf.field)).collect::>(); - + let loader_fields = LoaderField::get_fields(&mut *transaction, &redis) + .await? + .into_iter() + .filter(|lf| version_fields_names.contains(&lf.field)) + .collect::>(); let loader_field_ids = loader_fields.iter().map(|lf| lf.id.0).collect::>(); sqlx::query!( @@ -405,8 +405,7 @@ pub async fn version_edit_helper( .execute(&mut *transaction) .await?; - let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields( + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( &loader_fields, &mut *transaction, &redis, @@ -415,9 +414,14 @@ pub async fn version_edit_helper( let mut version_fields = Vec::new(); for (vf_name, vf_value) in new_version.fields { - let loader_field = loader_fields.iter().find(|lf| lf.field == vf_name).ok_or_else(|| { - ApiError::InvalidInput(format!("Loader field '{vf_name}' does not exist.")) - })?; + let loader_field = loader_fields + .iter() + .find(|lf| lf.field == vf_name) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Loader field '{vf_name}' does not exist." + )) + })?; let enum_variants = loader_field_enum_values .remove(&loader_field.id) .unwrap_or_default(); @@ -445,19 +449,26 @@ pub async fn version_edit_helper( let mut loader_versions = Vec::new(); for loader in loaders { - let loader_id = - database::models::loader_fields::Loader::get_id(&loader.0, &mut *transaction, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "No database entry for loader provided.".to_string(), - ) - })?; + let loader_id = database::models::loader_fields::Loader::get_id( + &loader.0, + &mut *transaction, + &redis, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("No database entry for loader provided.".to_string()) + })?; loader_versions.push(LoaderVersion::new(loader_id, id)); } LoaderVersion::insert_many(loader_versions, &mut transaction).await?; - crate::database::models::Project::clear_cache(version_item.inner.project_id, None, None, &redis).await?; + crate::database::models::Project::clear_cache( + version_item.inner.project_id, + None, + None, + &redis, + ) + .await?; } if let Some(featured) = &new_version.featured { From 9086d5252cfe4f24cb676419462a04a08c3af154 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Fri, 27 Oct 2023 18:38:53 -0700 Subject: [PATCH 20/31] merge conflicts --- src/database/models/legacy_loader_fields.rs | 4 +-- src/database/models/loader_fields.rs | 2 +- src/routes/v3/project_creation.rs | 22 ++++++------- src/routes/v3/projects.rs | 2 +- src/routes/v3/version_creation.rs | 36 ++++++++++----------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/database/models/legacy_loader_fields.rs b/src/database/models/legacy_loader_fields.rs index c46d6b6e..d322768e 100644 --- a/src/database/models/legacy_loader_fields.rs +++ b/src/database/models/legacy_loader_fields.rs @@ -58,13 +58,13 @@ impl MinecraftGameVersion { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, DatabaseError> { - let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, &mut *transaction, redis) + let game_version_enum = LoaderFieldEnum::get(Self::FIELD_NAME, &mut **transaction, redis) .await? .ok_or_else(|| { DatabaseError::SchemaError("Could not find game version enum.".to_string()) })?; let game_version_enum_values = - LoaderFieldEnumValue::list(game_version_enum.id, &mut *transaction, redis).await?; + LoaderFieldEnumValue::list(game_version_enum.id, &mut **transaction, redis).await?; Ok(game_version_enum_values .into_iter() .map(MinecraftGameVersion::from_enum_value) diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index e175d9ec..1a77eccb 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -591,7 +591,7 @@ impl VersionField { &string_values[..] as &[Option], &enum_values[..] as &[Option] ) - .execute(&mut *transaction) + .execute(&mut **transaction) .await?; Ok(()) diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 72b2a4f4..005d3435 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -429,7 +429,7 @@ async fn project_create_inner( ", slug_project_id as models::ids::ProjectId ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await .map_err(|e| CreateError::DatabaseError(e.into()))?; @@ -445,7 +445,7 @@ async fn project_create_inner( ", create_data.slug ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await .map_err(|e| CreateError::DatabaseError(e.into()))?; @@ -460,7 +460,7 @@ async fn project_create_inner( CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")) })?; let all_loaders = - models::loader_fields::Loader::list(game, &mut *transaction, redis).await?; + models::loader_fields::Loader::list(game, &mut **transaction, redis).await?; // Create VersionBuilders for the versions specified in `initial_versions` versions = Vec::with_capacity(create_data.initial_versions.len()); @@ -493,7 +493,7 @@ async fn project_create_inner( let project_type_id = models::categories::ProjectType::get_id( project_create_data.project_type.as_str(), - &mut *transaction, + &mut **transaction, ) .await? .ok_or_else(|| { @@ -649,7 +649,7 @@ async fn project_create_inner( let id = models::categories::Category::get_id_project( category, project_type_id, - &mut *transaction, + &mut **transaction, ) .await? .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; @@ -662,7 +662,7 @@ async fn project_create_inner( let id = models::categories::Category::get_id_project( category, project_type_id, - &mut *transaction, + &mut **transaction, ) .await? .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; @@ -706,7 +706,7 @@ async fn project_create_inner( if let Some(urls) = &project_create_data.donation_urls { for url in urls { let platform_id = - models::categories::DonationPlatform::get_id(&url.id, &mut *transaction) + models::categories::DonationPlatform::get_id(&url.id, &mut **transaction) .await? .ok_or_else(|| { CreateError::InvalidInput(format!( @@ -771,7 +771,7 @@ async fn project_create_inner( for image_id in project_create_data.uploaded_images { if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + image_item::Image::get(image_id.into(), &mut **transaction, redis).await? { let image: Image = db_image.into(); if !matches!(image.context, ImageContext::Project { .. }) @@ -792,7 +792,7 @@ async fn project_create_inner( id as models::ids::ProjectId, image_id.0 as i64 ) - .execute(&mut *transaction) + .execute(&mut **transaction) .await?; image_item::Image::clear_cache(image.id.into(), redis).await?; @@ -899,10 +899,10 @@ async fn create_initial_version( }) .collect::, CreateError>>()?; - let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; + let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?; let mut version_fields = vec![]; let mut loader_field_enum_values = - LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut *transaction, redis) + LoaderFieldEnumValue::list_many_loader_fields(&loader_fields, &mut **transaction, redis) .await?; for (key, value) in version_data.fields.iter() { let loader_field = loader_fields diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index b2f7e77d..40b6f624 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -955,7 +955,7 @@ pub async fn edit_project_categories( let mut mod_categories = Vec::new(); for category in categories { - let category_id = db_models::categories::Category::get_id(category, &mut *transaction) + let category_id = db_models::categories::Category::get_id(category, &mut **transaction) .await? .ok_or_else(|| { ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 2fb88562..728ccfaa 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -202,7 +202,7 @@ async fn version_create_inner( let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); // Ensure that the project this version is being added to exists - let project = models::Project::get_id(project_id, &mut *transaction, redis) + let project = models::Project::get_id(project_id, &mut **transaction, redis) .await? .ok_or_else(|| { CreateError::InvalidInput("An invalid project id was supplied".to_string()) @@ -213,14 +213,14 @@ async fn version_create_inner( let team_member = models::TeamMember::get_from_user_id_project( project_id, user.id.into(), - &mut *transaction, + &mut **transaction, ) .await?; // Get organization attached, if exists, and the member project permissions let organization = models::Organization::get_associated_organization_project_id( project_id, - &mut *transaction, + &mut **transaction, ) .await?; @@ -228,7 +228,7 @@ async fn version_create_inner( models::TeamMember::get_from_user_id( organization.team_id, user.id.into(), - &mut *transaction, + &mut **transaction, ) .await? } else { @@ -258,23 +258,23 @@ async fn version_create_inner( ", project_id as models::ProjectId, ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await? .name; let all_loaders = models::loader_fields::Loader::list( project.inner.game, - &mut *transaction, + &mut **transaction, redis, ) .await?; game = Some(project.inner.game); - let loader_fields = LoaderField::get_fields(&mut *transaction, redis).await?; + let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?; let mut version_fields = vec![]; let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( &loader_fields, - &mut *transaction, + &mut **transaction, redis, ) .await?; @@ -360,7 +360,7 @@ async fn version_create_inner( ", version.project_id as models::ProjectId, ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await? .name; @@ -424,7 +424,7 @@ async fn version_create_inner( ", builder.project_id as crate::database::models::ids::ProjectId ) - .fetch_many(&mut *transaction) + .fetch_many(&mut **transaction) .try_filter_map(|e| async { Ok(e.right().map(|m| models::ids::UserId(m.follower_id))) }) .try_collect::>() .await?; @@ -490,7 +490,7 @@ async fn version_create_inner( for image_id in version_data.uploaded_images { if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + image_item::Image::get(image_id.into(), &mut **transaction, redis).await? { let image: Image = db_image.into(); if !matches!(image.context, ImageContext::Report { .. }) @@ -511,7 +511,7 @@ async fn version_create_inner( version_id.0 as i64, image_id.0 as i64 ) - .execute(&mut *transaction) + .execute(&mut **transaction) .await?; image_item::Image::clear_cache(image.id.into(), redis).await?; @@ -609,7 +609,7 @@ async fn upload_file_to_version_inner( } }; - let project = models::Project::get_id(version.inner.project_id, &mut *transaction, &redis) + let project = models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) .await? .ok_or_else(|| { CreateError::InvalidInput("Version contained an invalid project id".to_string()) @@ -619,7 +619,7 @@ async fn upload_file_to_version_inner( let team_member = models::TeamMember::get_from_user_id_project( version.inner.project_id, user.id.into(), - &mut *transaction, + &mut **transaction, ) .await?; @@ -633,7 +633,7 @@ async fn upload_file_to_version_inner( models::TeamMember::get_from_user_id( organization.team_id, user.id.into(), - &mut *transaction, + &mut **transaction, ) .await? } else { @@ -664,7 +664,7 @@ async fn upload_file_to_version_inner( ", version.inner.project_id as models::ProjectId, ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await? .name; @@ -810,7 +810,7 @@ pub async fn upload_file( "sha1", project_id.0 as i64 ) - .fetch_one(&mut *transaction) + .fetch_one(&mut **transaction) .await? .exists .unwrap_or(false); @@ -856,7 +856,7 @@ pub async fn upload_file( ", &*hashes ) - .fetch_all(&mut *transaction) + .fetch_all(&mut **transaction) .await?; for file in &format.files { From d02505951e98f63a9dfcba3d6997e595fce74808 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Fri, 27 Oct 2023 19:15:22 -0700 Subject: [PATCH 21/31] fmt, prepare --- .editorconfig | 0 ...ab77d9e8155386a5be60e01b3ad7db8541a27.json | 25 +++ ...59fdb631955011988910f033dd740b6a3b79b.json | 40 ++++ ...ab462fa4e716f92c8700c436dee9472101aa4.json | 130 +++++++++++++ ...fdfa554d84254b14053496c118dec24bf5049.json | 15 -- ...78905a8439a12c9ec94042016e607c9788c98.json | 174 ++++++++++++++++++ ...30acf5bbf18f1de328980bb7f3da7f5f6569e.json | 22 --- ...da65cc6043f88971ddc3fd23ba3be00717dfc.json | 44 ----- ...51af1cc6fabe88a469338d5a59533eabf80c5.json | 168 ----------------- ...47d3635f629cfb892fc8ac1964d8ecc269576.json | 52 ++++++ ...85b0506fc62a63ca0ee5f38890824301d6515.json | 15 -- ...aee2b90aec719b6b203f922824eced5ea8369.json | 14 -- ...07c02d916399adfa52fb11a79b8f7b56ecf8b.json | 114 ------------ ...768558305f1270c1c43ef767f54b9baf5b5af.json | 56 ++++++ ...327685f9be696254bf2370d0c995aafc6a2d8.json | 15 -- ...1b9899abd6186ab32a067a7d4b8a0846ebd18.json | 24 --- ...bed09204010813111e66a7ca84bc0e603f537.json | 20 -- ...929b7178f81056788ffb207a6c5e4bbcc7a7d.json | 18 ++ ...bfb775fb088579fe23bcb87f50f5a8578f3c0.json | 14 ++ ...a4248123f3fe212a876e192f549acd6edcb39.json | 118 ------------ ...c70c997443f7319a5c535df967d56d24bd54a.json | 15 ++ ...1e7168f7cc483d219813e4c70f5f805e84a3.json} | 6 +- ...4e740b51b303aaf533662285061e7f5c0bca.json} | 108 +++++------ ...bf9633f160dfb783cefa751ff400655e8660f.json | 114 ++++++++++++ ...40e338c3973bc7a7374799ced3df5e38d3134.json | 14 -- ...f7488c6616b7c1dfbde76a712fd57e91ba158.json | 22 --- ...3f95152fafec593da8d06c9b2b665218a02be.json | 15 -- ...c2e07b45883608afc6ac91ac6f74736a12256.json | 15 -- ...3b0d629c470f654f1e30250bd8773ed04f5b.json} | 8 +- src/database/models/loader_fields.rs | 13 +- src/routes/v2/version_file.rs | 2 +- tests/search.rs | 4 +- 32 files changed, 697 insertions(+), 717 deletions(-) create mode 100644 .editorconfig create mode 100644 .sqlx/query-09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27.json create mode 100644 .sqlx/query-0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b.json create mode 100644 .sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json delete mode 100644 .sqlx/query-177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049.json create mode 100644 .sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json delete mode 100644 .sqlx/query-1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e.json delete mode 100644 .sqlx/query-3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc.json delete mode 100644 .sqlx/query-4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5.json create mode 100644 .sqlx/query-458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576.json delete mode 100644 .sqlx/query-4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515.json delete mode 100644 .sqlx/query-507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369.json delete mode 100644 .sqlx/query-59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b.json create mode 100644 .sqlx/query-622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af.json delete mode 100644 .sqlx/query-6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8.json delete mode 100644 .sqlx/query-72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18.json delete mode 100644 .sqlx/query-85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537.json create mode 100644 .sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json create mode 100644 .sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json delete mode 100644 .sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json create mode 100644 .sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json rename .sqlx/{query-b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c.json => query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json} (61%) rename .sqlx/{query-ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16.json => query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json} (56%) create mode 100644 .sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json delete mode 100644 .sqlx/query-bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134.json delete mode 100644 .sqlx/query-c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158.json delete mode 100644 .sqlx/query-c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be.json delete mode 100644 .sqlx/query-fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256.json rename .sqlx/{query-5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13.json => query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json} (64%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e69de29b diff --git a/.sqlx/query-09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27.json b/.sqlx/query-09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27.json new file mode 100644 index 00000000..9288972f --- /dev/null +++ b/.sqlx/query-09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = COALESCE($4, loader_field_enum_values.metadata),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Timestamp", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "09ab64836127f6edb22a5deaa33ab77d9e8155386a5be60e01b3ad7db8541a27" +} diff --git a/.sqlx/query-0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b.json b/.sqlx/query-0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b.json new file mode 100644 index 00000000..56a912c3 --- /dev/null +++ b/.sqlx/query-0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable \n FROM loader_field_enums lfe\n WHERE lfe.enum_name = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "hidable", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "0b52dc08a903a9c82234f6e1a2c59fdb631955011988910f033dd740b6a3b79b" +} diff --git a/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json b/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json new file mode 100644 index 00000000..ddbe02ed --- /dev/null +++ b/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json @@ -0,0 +1,130 @@ +{ + "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,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "changelog", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "version_type", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 13, + "name": "files", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "hashes", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "dependencies", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4" +} diff --git a/.sqlx/query-177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049.json b/.sqlx/query-177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049.json deleted file mode 100644 index 3752ef7f..00000000 --- a/.sqlx/query-177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET game_versions = (\n SELECT COALESCE(ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null), array[]::varchar[])\n FROM versions v\n INNER JOIN game_versions_versions gvv ON v.id = gvv.joining_version_id\n INNER JOIN game_versions gv on gvv.game_version_id = gv.id\n WHERE v.mod_id = mods.id AND v.status != ALL($2)\n )\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "177716d2b04fd2a2b63b2e14c8ffdfa554d84254b14053496c118dec24bf5049" -} diff --git a/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json b/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json new file mode 100644 index 00000000..413d087f --- /dev/null +++ b/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json @@ -0,0 +1,174 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n\n \n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "project_type", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "follows", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "approved", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "license", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "status_name", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 16, + "name": "project_type_name", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 19, + "name": "additional_categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 20, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 21, + "name": "gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 22, + "name": "featured_gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 23, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 24, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 25, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98" +} diff --git a/.sqlx/query-1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e.json b/.sqlx/query-1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e.json deleted file mode 100644 index 7b6e7925..00000000 --- a/.sqlx/query-1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id FROM side_types\n WHERE name = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "1db6be78a74ff04c52ee105e0df30acf5bbf18f1de328980bb7f3da7f5f6569e" -} diff --git a/.sqlx/query-3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc.json b/.sqlx/query-3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc.json deleted file mode 100644 index 8e6b0322..00000000 --- a/.sqlx/query-3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT gv.id id, gv.version version_, gv.type type_, gv.created created, gv.major FROM game_versions gv\n ORDER BY created DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "version_", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "type_", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "major", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc" -} diff --git a/.sqlx/query-4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5.json b/.sqlx/query-4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5.json deleted file mode 100644 index 18ddca84..00000000 --- a/.sqlx/query-4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($1)\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE m.status = ANY($2)\n GROUP BY m.id, cs.id, ss.id, pt.id, u.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "project_type", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "title", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "follows", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "published", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "approved", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "updated", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "team_id", - "type_info": "Int8" - }, - { - "ordinal": 11, - "name": "license", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "status_name", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "color", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "client_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "server_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 17, - "name": "project_type_name", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 20, - "name": "additional_categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 21, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 22, - "name": "versions", - "type_info": "VarcharArray" - }, - { - "ordinal": 23, - "name": "gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 24, - "name": "featured_gallery", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - false, - true, - false, - true, - false, - false, - false, - false, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "4514723bdc1eb8a781215075bec51af1cc6fabe88a469338d5a59533eabf80c5" -} diff --git a/.sqlx/query-458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576.json b/.sqlx/query-458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576.json new file mode 100644 index 00000000..3b9f7c91 --- /dev/null +++ b/.sqlx/query-458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false + ] + }, + "hash": "458630d00e46183c65f95729d2647d3635f629cfb892fc8ac1964d8ecc269576" +} diff --git a/.sqlx/query-4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515.json b/.sqlx/query-4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515.json deleted file mode 100644 index 3ecd8c38..00000000 --- a/.sqlx/query-4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET server_side = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "4a54d350b4695c32a802675506e85b0506fc62a63ca0ee5f38890824301d6515" -} diff --git a/.sqlx/query-507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369.json b/.sqlx/query-507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369.json deleted file mode 100644 index 5d4ebbde..00000000 --- a/.sqlx/query-507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM game_versions_versions WHERE joining_version_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "507314fdcacaa3c7751738c9d0baee2b90aec719b6b203f922824eced5ea8369" -} diff --git a/.sqlx/query-59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b.json b/.sqlx/query-59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b.json deleted file mode 100644 index 12069b4d..00000000 --- a/.sqlx/query-59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug, cs.name client_side_type, ss.name server_side_type,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', gv.id, 'version', gv.version, 'type', gv.type, 'created', gv.created, 'major', gv.major)) filter (where gv.version is not null) versions,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN game_versions_versions gvv ON gvv.joining_version_id = v.id\n LEFT OUTER JOIN game_versions gv ON gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n WHERE m.id = $1\n GROUP BY m.id, cs.id, ss.id, pt.id, u.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "title", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "color", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "client_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "server_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "project_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 10, - "name": "avatar_url", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 12, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, - "name": "versions", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 15, - "name": "featured_gallery", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "Int8", - "TextArray", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - true, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null - ] - }, - "hash": "59e95e832615c375753bfc9a56b07c02d916399adfa52fb11a79b8f7b56ecf8b" -} diff --git a/.sqlx/query-622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af.json b/.sqlx/query-622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af.json new file mode 100644 index 00000000..b92f9f5a --- /dev/null +++ b/.sqlx/query-622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type\n FROM loader_fields lf\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "field", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "field_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "optional", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "min_val", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_val", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "enum_type", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "622496d06b9d1e5019d7dcb45ac768558305f1270c1c43ef767f54b9baf5b5af" +} diff --git a/.sqlx/query-6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8.json b/.sqlx/query-6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8.json deleted file mode 100644 index 603c03e6..00000000 --- a/.sqlx/query-6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET loaders = (\n SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[])\n FROM versions v\n INNER JOIN loaders_versions lv ON lv.version_id = v.id\n INNER JOIN loaders l on lv.loader_id = l.id\n WHERE v.mod_id = mods.id AND v.status != ALL($2)\n )\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "6b89c2b2557e304c2a3a02d7824327685f9be696254bf2370d0c995aafc6a2d8" -} diff --git a/.sqlx/query-72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18.json b/.sqlx/query-72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18.json deleted file mode 100644 index a5495efc..00000000 --- a/.sqlx/query-72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO game_versions (version, type, created)\n VALUES ($1, COALESCE($2, 'other'), COALESCE($3, timezone('utc', now())))\n ON CONFLICT (version) DO UPDATE\n SET type = COALESCE($2, game_versions.type),\n created = COALESCE($3, game_versions.created)\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Timestamp" - ] - }, - "nullable": [ - false - ] - }, - "hash": "72c75313688dfd88a659c5250c71b9899abd6186ab32a067a7d4b8a0846ebd18" -} diff --git a/.sqlx/query-85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537.json b/.sqlx/query-85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537.json deleted file mode 100644 index 84cad42e..00000000 --- a/.sqlx/query-85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT name FROM side_types\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "name", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "85c6de008681d9fc9dc51b17330bed09204010813111e66a7ca84bc0e603f537" -} diff --git a/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json b/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json new file mode 100644 index 00000000..874d0bc0 --- /dev/null +++ b/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4Array", + "Int8Array", + "Int4Array", + "TextArray", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d" +} diff --git a/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json b/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json new file mode 100644 index 00000000..897894b7 --- /dev/null +++ b/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM version_fields vf\n WHERE vf.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0" +} diff --git a/.sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json b/.sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json deleted file mode 100644 index ad89e886..00000000 --- a/.sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "version_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "version_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "featured", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "game_versions", - "type_info": "Jsonb" - }, - { - "ordinal": 13, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 14, - "name": "files", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "hashes", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "dependencies", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null - ] - }, - "hash": "a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39" -} diff --git a/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json b/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json new file mode 100644 index 00000000..a80e8a36 --- /dev/null +++ b/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM version_fields \n WHERE version_id = $1\n AND field_id = ANY($2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a" +} diff --git a/.sqlx/query-b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c.json b/.sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json similarity index 61% rename from .sqlx/query-b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c.json rename to .sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json index 74091817..09221ca6 100644 --- a/.sqlx/query-b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c.json +++ b/.sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n client_side, server_side, license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, $17, $18,\n LOWER($19), $20, $21, $22\n )\n ", + "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, LOWER($17), $18,\n $19, $20\n )\n ", "describe": { "columns": [], "parameters": { @@ -19,8 +19,6 @@ "Varchar", "Varchar", "Varchar", - "Int4", - "Int4", "Varchar", "Varchar", "Text", @@ -31,5 +29,5 @@ }, "nullable": [] }, - "hash": "b36877d60945eaae76680770a5d28d2cbb26cfbb0ec94ecc8f0741f48178ec1c" + "hash": "b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3" } diff --git a/.sqlx/query-ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16.json b/.sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json similarity index 56% rename from .sqlx/query-ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16.json rename to .sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json index d47d4f1a..e077f28a 100644 --- a/.sqlx/query-ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16.json +++ b/.sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.client_side client_side, m.server_side server_side, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n cs.name client_side_type, ss.name server_side_type, pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status, m.loaders loaders, m.game_versions game_versions,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN side_types cs ON m.client_side = cs.id\n INNER JOIN side_types ss ON m.server_side = ss.id\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, cs.id, ss.id, t.id, m.id;\n ", + "query": "\n SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN games g ON g.id = m.game_id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n LEFT JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT JOIN loaders l on lv.loader_id = l.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, t.id, m.id, g.name;\n ", "describe": { "columns": [ { @@ -10,201 +10,181 @@ }, { "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, "name": "project_type", "type_info": "Int4" }, { - "ordinal": 2, + "ordinal": 3, "name": "title", "type_info": "Varchar" }, { - "ordinal": 3, + "ordinal": 4, "name": "description", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 5, "name": "downloads", "type_info": "Int4" }, { - "ordinal": 5, + "ordinal": 6, "name": "follows", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 7, "name": "icon_url", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "body", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "published", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "updated", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "approved", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 12, "name": "queued", "type_info": "Timestamptz" }, { - "ordinal": 12, + "ordinal": 13, "name": "status", "type_info": "Varchar" }, { - "ordinal": 13, + "ordinal": 14, "name": "requested_status", "type_info": "Varchar" }, { - "ordinal": 14, + "ordinal": 15, "name": "issues_url", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 16, "name": "source_url", "type_info": "Varchar" }, { - "ordinal": 16, + "ordinal": 17, "name": "wiki_url", "type_info": "Varchar" }, { - "ordinal": 17, + "ordinal": 18, "name": "discord_url", "type_info": "Varchar" }, { - "ordinal": 18, + "ordinal": 19, "name": "license_url", "type_info": "Varchar" }, { - "ordinal": 19, + "ordinal": 20, "name": "team_id", "type_info": "Int8" }, { - "ordinal": 20, + "ordinal": 21, "name": "organization_id", "type_info": "Int8" }, - { - "ordinal": 21, - "name": "client_side", - "type_info": "Int4" - }, { "ordinal": 22, - "name": "server_side", - "type_info": "Int4" - }, - { - "ordinal": 23, "name": "license", "type_info": "Varchar" }, { - "ordinal": 24, + "ordinal": 23, "name": "slug", "type_info": "Varchar" }, { - "ordinal": 25, + "ordinal": 24, "name": "moderation_message", "type_info": "Varchar" }, { - "ordinal": 26, + "ordinal": 25, "name": "moderation_message_body", "type_info": "Varchar" }, { - "ordinal": 27, - "name": "client_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 28, - "name": "server_side_type", - "type_info": "Varchar" - }, - { - "ordinal": 29, + "ordinal": 26, "name": "project_type_name", "type_info": "Varchar" }, { - "ordinal": 30, + "ordinal": 27, "name": "webhook_sent", "type_info": "Bool" }, { - "ordinal": 31, + "ordinal": 28, "name": "color", "type_info": "Int4" }, { - "ordinal": 32, + "ordinal": 29, "name": "thread_id", "type_info": "Int8" }, { - "ordinal": 33, + "ordinal": 30, "name": "monetization_status", "type_info": "Varchar" }, { - "ordinal": 34, + "ordinal": 31, "name": "loaders", "type_info": "VarcharArray" }, { - "ordinal": 35, - "name": "game_versions", - "type_info": "VarcharArray" - }, - { - "ordinal": 36, + "ordinal": 32, "name": "categories", "type_info": "VarcharArray" }, { - "ordinal": 37, + "ordinal": 33, "name": "additional_categories", "type_info": "VarcharArray" }, { - "ordinal": 38, + "ordinal": 34, "name": "versions", "type_info": "Jsonb" }, { - "ordinal": 39, + "ordinal": 35, "name": "gallery", "type_info": "Jsonb" }, { - "ordinal": 40, + "ordinal": 36, "name": "donations", "type_info": "Jsonb" } @@ -218,6 +198,7 @@ }, "nullable": [ false, + true, false, false, false, @@ -239,20 +220,15 @@ false, true, false, - false, - false, true, true, true, false, false, - false, - false, true, false, false, - false, - false, + null, null, null, null, @@ -260,5 +236,5 @@ null ] }, - "hash": "ffcc8c65721465514ad39a0e9bd6138eda0fa32dd3399a8e850a76beb1f1bf16" + "hash": "baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca" } diff --git a/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json b/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json new file mode 100644 index 00000000..5bc6e3d1 --- /dev/null +++ b/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json @@ -0,0 +1,114 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "project_type", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "avatar_url", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 10, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 11, + "name": "gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 12, + "name": "featured_gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 13, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 14, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f" +} diff --git a/.sqlx/query-bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134.json b/.sqlx/query-bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134.json deleted file mode 100644 index 0db64c92..00000000 --- a/.sqlx/query-bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM game_versions_versions gvv\n WHERE gvv.joining_version_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134" -} diff --git a/.sqlx/query-c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158.json b/.sqlx/query-c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158.json deleted file mode 100644 index 698f31f3..00000000 --- a/.sqlx/query-c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id FROM game_versions\n WHERE version = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158" -} diff --git a/.sqlx/query-c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be.json b/.sqlx/query-c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be.json deleted file mode 100644 index 78fd9eda..00000000 --- a/.sqlx/query-c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET client_side = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "c5d44333c62223bd3e68185d1fb3f95152fafec593da8d06c9b2b665218a02be" -} diff --git a/.sqlx/query-fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256.json b/.sqlx/query-fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256.json deleted file mode 100644 index c4163630..00000000 --- a/.sqlx/query-fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO game_versions_versions (game_version_id, joining_version_id)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[])\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "fa54ed32004b883daa44eeb413fc2e07b45883608afc6ac91ac6f74736a12256" -} diff --git a/.sqlx/query-5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13.json b/.sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json similarity index 64% rename from .sqlx/query-5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13.json rename to .sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json index 27a8b53e..faaf49de 100644 --- a/.sqlx/query-5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13.json +++ b/.sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n GROUP BY l.id;\n ", + "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n INNER JOIN games g ON l.game_id = g.id\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n WHERE g.name = $1\n GROUP BY l.id;\n ", "describe": { "columns": [ { @@ -25,7 +25,9 @@ } ], "parameters": { - "Left": [] + "Left": [ + "Text" + ] }, "nullable": [ false, @@ -34,5 +36,5 @@ null ] }, - "hash": "5295fba2053675c8414c0b37a59943535b9a438a642ea1c68045e987f05ade13" + "hash": "fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b" } diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 1a77eccb..ea6069bb 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -344,12 +344,13 @@ impl LoaderFieldEnum { enum_name ) .fetch_optional(exec) - .await?.map(|l| LoaderFieldEnum { - id: LoaderFieldEnumId(l.id), - enum_name: l.enum_name, - ordering: l.ordering, - hidable: l.hidable, - }); + .await? + .map(|l| LoaderFieldEnum { + id: LoaderFieldEnumId(l.id), + enum_name: l.enum_name, + ordering: l.ordering, + hidable: l.hidable, + }); redis .set_serialized_to_json(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name, &result, None) diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index f25400b1..7c3c56bb 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -368,7 +368,7 @@ pub async fn get_projects_from_hashes( .filter_map(|(hash, project_id)| { let legacy_project = legacy_projects.iter().find(|x| x.id == project_id)?.clone(); - Some((hash.to_string(), legacy_project)) + Some((hash, legacy_project)) }) .collect::>(); diff --git a/tests/search.rs b/tests/search.rs index bdd5ab9a..71de5ee3 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -44,9 +44,9 @@ async fn search_projects() { // Add a project- simple, should work. let req = api.add_public_project( ProjectCreationRequestData { - slug: slug.clone(), + slug, jar: Some(jar), - segment_data: basic_project_multipart.clone(), + segment_data: basic_project_multipart, }, pat, ); From 0072fe870967832cff96d52eaf862ba02eda9078 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Mon, 30 Oct 2023 13:47:45 -0700 Subject: [PATCH 22/31] moved v2 routes over to v3 --- src/routes/v2/analytics_get.rs | 530 ++--------- src/routes/v2/collections.rs | 453 +--------- src/routes/v2/images.rs | 204 +---- src/routes/v2/moderation.rs | 40 +- src/routes/v2/notifications.rs | 253 +----- src/routes/v2/organizations.rs | 800 ++--------------- src/routes/v2/projects.rs | 1361 +++------------------------- src/routes/v2/reports.rs | 469 ++-------- src/routes/v2/statistics.rs | 78 +- src/routes/v2/tags.rs | 76 +- src/routes/v2/teams.rs | 804 +---------------- src/routes/v2/threads.rs | 553 +----------- src/routes/v2/users.rs | 632 ++----------- src/routes/v2/version_file.rs | 155 +--- src/routes/v2/versions.rs | 181 +--- src/routes/v3/analytics_get.rs | 599 +++++++++++++ src/routes/v3/collections.rs | 538 +++++++++++ src/routes/v3/images.rs | 234 +++++ src/routes/v3/mod.rs | 15 + src/routes/v3/moderation.rs | 65 ++ src/routes/v3/notifications.rs | 289 ++++++ src/routes/v3/organizations.rs | 870 +++++++++++++++++- src/routes/v3/projects.rs | 1519 +++++++++++++++++++++++++++++++- src/routes/v3/reports.rs | 524 +++++++++++ src/routes/v3/statistics.rs | 85 ++ src/routes/v3/tags.rs | 124 ++- src/routes/v3/teams.rs | 940 ++++++++++++++++++++ src/routes/v3/threads.rs | 622 +++++++++++++ src/routes/v3/users.rs | 774 +++++++++++++++- src/routes/v3/version_file.rs | 175 +++- src/routes/v3/versions.rs | 193 +++- tests/common/api_v2/team.rs | 3 + 32 files changed, 8118 insertions(+), 6040 deletions(-) create mode 100644 src/routes/v3/analytics_get.rs create mode 100644 src/routes/v3/collections.rs create mode 100644 src/routes/v3/images.rs create mode 100644 src/routes/v3/moderation.rs create mode 100644 src/routes/v3/notifications.rs create mode 100644 src/routes/v3/reports.rs create mode 100644 src/routes/v3/statistics.rs create mode 100644 src/routes/v3/teams.rs create mode 100644 src/routes/v3/threads.rs diff --git a/src/routes/v2/analytics_get.rs b/src/routes/v2/analytics_get.rs index d6b61584..d719010a 100644 --- a/src/routes/v2/analytics_get.rs +++ b/src/routes/v2/analytics_get.rs @@ -1,24 +1,17 @@ use super::ApiError; use crate::database::redis::RedisPool; +use crate::routes::v3; use crate::{ - auth::{filter_authorized_projects, filter_authorized_versions, get_user_from_headers}, - database::models::{project_item, user_item, version_item}, - models::{ - ids::{ - base62_impl::{parse_base62, to_base62}, - ProjectId, VersionId, - }, - pats::Scopes, - }, + models:: + ids:: + VersionId, queue::session::AuthQueue, }; use actix_web::{get, web, HttpRequest, HttpResponse}; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::postgres::types::PgInterval; use sqlx::PgPool; use std::collections::HashMap; -use std::convert::TryInto; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -75,66 +68,21 @@ pub async fn playtimes_get( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ANALYTICS]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - let version_ids = data - .version_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, version_ids) = - filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; - - // Get the views - let playtimes = crate::clickhouse::fetch_playtimes( - project_ids, - version_ids, - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await?; - - let mut hm = HashMap::new(); - for playtime in playtimes { - let id_string = to_base62(playtime.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(playtime.time, playtime.total_seconds); - } - } - - Ok(HttpResponse::Ok().json(hm)) + let data = data.into_inner(); + v3::analytics_get::playtimes_get( + req, + clickhouse, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: data.version_ids, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await } /// Get view data for a set of projects or versions @@ -155,66 +103,21 @@ pub async fn views_get( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ANALYTICS]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - let version_ids = data - .version_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, version_ids) = - filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; - - // Get the views - let views = crate::clickhouse::fetch_views( - project_ids, - version_ids, - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await?; - - let mut hm = HashMap::new(); - for views in views { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.time, views.total_views); - } - } - - Ok(HttpResponse::Ok().json(hm)) + let data = data.into_inner(); + v3::analytics_get::views_get( + req, + clickhouse, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: data.version_ids, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await } /// Get download data for a set of projects or versions @@ -235,66 +138,21 @@ pub async fn downloads_get( pool: web::Data, redis: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ANALYTICS]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - let version_ids = data - .version_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, version_ids) = - filter_allowed_ids(project_ids, version_ids, user_option, &pool, &redis).await?; - - // Get the downloads - let downloads = crate::clickhouse::fetch_downloads( - project_ids, - version_ids, - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await?; - - let mut hm = HashMap::new(); - for downloads in downloads { - let id_string = to_base62(downloads.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(downloads.time, downloads.total_downloads); - } - } - - Ok(HttpResponse::Ok().json(hm)) + let data = data.into_inner(); + v3::analytics_get::downloads_get( + req, + clickhouse, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: data.version_ids, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await } /// Get payout data for a set of projects @@ -314,68 +172,20 @@ pub async fn revenue_get( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_READ]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, _) = filter_allowed_ids(project_ids, None, user, &pool, &redis).await?; - - let duration: PgInterval = Duration::minutes(resolution_minutes as i64) - .try_into() - .unwrap(); - // Get the revenue data - let payouts_values = sqlx::query!( - " - SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start - FROM payouts_values - WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3 - GROUP by mod_id, interval_start ORDER BY interval_start - ", - &project_ids.unwrap_or_default().into_iter().map(|x| x.0 as i64).collect::>(), - start_date, - end_date, - duration, - ) - .fetch_all(&**pool) - .await?; - - let mut hm = HashMap::new(); - for value in payouts_values { - if let Some(mod_id) = value.mod_id { - if let Some(amount) = value.amount_sum { - if let Some(interval_start) = value.interval_start { - let id_string = to_base62(mod_id as u64); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(interval_start.timestamp(), amount); - } - } - } - } - } - - Ok(HttpResponse::Ok().json(hm)) + let data = data.into_inner(); + v3::analytics_get::revenue_get( + req, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: None, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await } /// Get country data for a set of projects or versions @@ -399,64 +209,21 @@ pub async fn countries_downloads_get( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ANALYTICS]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - let version_ids = data - .version_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, version_ids) = - filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; - - // Get the countries - let countries = crate::clickhouse::fetch_countries( - project_ids, - version_ids, - start_date, - end_date, - clickhouse.into_inner(), - ) - .await?; - - let mut hm = HashMap::new(); - for views in countries { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.country, views.total_downloads); - } - } - - Ok(HttpResponse::Ok().json(hm)) + let data = data.into_inner(); + v3::analytics_get::countries_downloads_get( + req, + clickhouse, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: data.version_ids, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await } /// Get country data for a set of projects or versions @@ -480,126 +247,19 @@ pub async fn countries_views_get( pool: web::Data, redis: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ANALYTICS]), - ) - .await - .map(|x| x.1)?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - let version_ids = data - .version_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; - - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let (project_ids, version_ids) = - filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; - - // Get the countries - let countries = crate::clickhouse::fetch_countries( - project_ids, - version_ids, - start_date, - end_date, - clickhouse.into_inner(), - ) - .await?; - - let mut hm = HashMap::new(); - for views in countries { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.country, views.total_views); - } - } - - Ok(HttpResponse::Ok().json(hm)) -} - -async fn filter_allowed_ids( - mut project_ids: Option>, - version_ids: Option>, - user: crate::models::users::User, - pool: &web::Data, - redis: &RedisPool, -) -> Result<(Option>, Option>), ApiError> { - if project_ids.is_some() && version_ids.is_some() { - return Err(ApiError::InvalidInput( - "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), - )); - } - - // If no project_ids or version_ids are provided, we default to all projects the user has access to - if project_ids.is_none() && version_ids.is_none() { - project_ids = Some( - user_item::User::get_projects(user.id.into(), &***pool, redis) - .await? - .into_iter() - .map(|x| ProjectId::from(x).to_string()) - .collect(), - ); - } - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - - let project_ids = if let Some(project_ids) = project_ids { - // Submitted project_ids are filtered by the user's permissions - let ids = project_ids - .iter() - .map(|id| Ok(ProjectId(parse_base62(id)?).into())) - .collect::, ApiError>>()?; - let projects = project_item::Project::get_many_ids(&ids, &***pool, redis).await?; - let ids: Vec = filter_authorized_projects(projects, &Some(user.clone()), pool) - .await? - .into_iter() - .map(|x| x.id) - .collect::>(); - Some(ids) - } else { - None - }; - let version_ids = if let Some(version_ids) = version_ids { - // Submitted version_ids are filtered by the user's permissions - let ids = version_ids - .iter() - .map(|id| Ok(VersionId(parse_base62(id)?).into())) - .collect::, ApiError>>()?; - let versions = version_item::Version::get_many(&ids, &***pool, redis).await?; - let ids: Vec = filter_authorized_versions(versions, &Some(user), pool) - .await? - .into_iter() - .map(|x| x.id) - .collect::>(); - Some(ids) - } else { - None - }; - - // Only one of project_ids or version_ids will be Some - Ok((project_ids, version_ids)) -} + let data = data.into_inner(); + v3::analytics_get::countries_views_get( + req, + clickhouse, + web::Query(v3::analytics_get::GetData { + project_ids: data.project_ids, + version_ids: data.version_ids, + start_date: data.start_date, + end_date: data.end_date, + resolution_minutes: data.resolution_minutes, + }), + session_queue, + pool, + redis, + ).await +} \ No newline at end of file diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs index eb50b981..0bdfb2bf 100644 --- a/src/routes/v2/collections.rs +++ b/src/routes/v2/collections.rs @@ -1,22 +1,11 @@ -use crate::auth::checks::{filter_authorized_collections, is_authorized_collection}; -use crate::auth::get_user_from_headers; -use crate::database::models::{collection_item, generate_collection_id, project_item}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::collections::{Collection, CollectionStatus}; -use crate::models::ids::base62_impl::parse_base62; -use crate::models::ids::{CollectionId, ProjectId}; -use crate::models::pats::Scopes; +use crate::models::collections::CollectionStatus; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; -use crate::routes::ApiError; -use crate::util::routes::read_from_payload; -use crate::util::validate::validation_errors_to_string; -use crate::{database, models}; +use crate::routes::{ApiError, v3}; use actix_web::web::Data; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::Utc; -use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; @@ -61,68 +50,17 @@ pub async fn collection_create( session_queue: Data, ) -> Result { let collection_create_data = collection_create_data.into_inner(); - - // The currently logged in user - let current_user = get_user_from_headers( - &req, - &**client, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_CREATE]), - ) - .await? - .1; - - collection_create_data - .validate() - .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - - let mut transaction = client.begin().await?; - - let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into(); - - let initial_project_ids = project_item::Project::get_many( - &collection_create_data.projects, - &mut *transaction, - &redis, - ) - .await? - .into_iter() - .map(|x| x.inner.id.into()) - .collect::>(); - - let collection_builder_actual = collection_item::CollectionBuilder { - collection_id: collection_id.into(), - user_id: current_user.id.into(), - title: collection_create_data.title, - description: collection_create_data.description, - status: CollectionStatus::Listed, - projects: initial_project_ids - .iter() - .copied() - .map(|x| x.into()) - .collect(), - }; - let collection_builder = collection_builder_actual.clone(); - - let now = Utc::now(); - collection_builder_actual.insert(&mut transaction).await?; - - let response = crate::models::collections::Collection { - id: collection_id, - user: collection_builder.user_id.into(), - title: collection_builder.title.clone(), - description: collection_builder.description.clone(), - created: now, - updated: now, - icon_url: None, - color: None, - status: collection_builder.status, - projects: initial_project_ids, - }; - transaction.commit().await?; - - Ok(HttpResponse::Ok().json(response)) + v3::collections::collection_create( + req, + web::Json(v3::collections::CollectionCreateData { + title: collection_create_data.title, + description: collection_create_data.description, + projects: collection_create_data.projects, + }), + client, + redis, + session_queue, + ).await } #[derive(Serialize, Deserialize)] @@ -137,28 +75,9 @@ pub async fn collections_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; - let ids = ids - .into_iter() - .map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64))) - .collect::, _>>()?; - - let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?; - - Ok(HttpResponse::Ok().json(collections)) + v3::collections::collections_get(req, web::Query(v3::collections::CollectionIds{ + ids: ids.ids + }), pool, redis, session_queue).await } #[get("{id}")] @@ -169,27 +88,7 @@ pub async fn collection_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - - let id = database::models::CollectionId(parse_base62(&string)? as i64); - let collection_data = database::models::Collection::get(id, &**pool, &redis).await?; - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(data) = collection_data { - if is_authorized_collection(&data, &user_option).await? { - return Ok(HttpResponse::Ok().json(Collection::from(data))); - } - } - Ok(HttpResponse::NotFound().body("")) + v3::collections::collection_get(req, info, pool, redis, session_queue).await } #[derive(Deserialize, Validate)] @@ -215,131 +114,15 @@ pub async fn collection_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_WRITE]), - ) - .await? - .1; - - new_collection - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let string = info.into_inner().0; - let id = database::models::CollectionId(parse_base62(&string)? as i64); - let result = database::models::Collection::get(id, &**pool, &redis).await?; - - if let Some(collection_item) = result { - if !can_modify_collection(&collection_item, &user) { - return Ok(HttpResponse::Unauthorized().body("")); - } - - let id = collection_item.id; - - let mut transaction = pool.begin().await?; - - if let Some(title) = &new_collection.title { - sqlx::query!( - " - UPDATE collections - SET title = $1 - WHERE (id = $2) - ", - title.trim(), - id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(description) = &new_collection.description { - sqlx::query!( - " - UPDATE collections - SET description = $1 - WHERE (id = $2) - ", - description, - id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(status) = &new_collection.status { - if !(user.role.is_mod() - || collection_item.status.is_approved() && status.can_be_requested()) - { - return Err(ApiError::CustomAuthentication( - "You don't have permission to set this status!".to_string(), - )); - } - - sqlx::query!( - " - UPDATE collections - SET status = $1 - WHERE (id = $2) - ", - status.to_string(), - id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; + let new_collection = new_collection.into_inner(); + v3::collections::collection_edit(req, info, pool, web::Json( + v3::collections::EditCollection { + title: new_collection.title, + description: new_collection.description, + status: new_collection.status, + new_projects: new_collection.new_projects, } - - if let Some(new_project_ids) = &new_collection.new_projects { - // Delete all existing projects - sqlx::query!( - " - DELETE FROM collections_mods - WHERE collection_id = $1 - ", - collection_item.id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; - - let collection_item_ids = new_project_ids - .iter() - .map(|_| collection_item.id.0) - .collect_vec(); - let mut validated_project_ids = Vec::new(); - for project_id in new_project_ids { - let project = database::models::Project::get(project_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "The specified project {project_id} does not exist!" - )) - })?; - validated_project_ids.push(project.inner.id.0); - } - // Insert- don't throw an error if it already exists - sqlx::query!( - " - INSERT INTO collections_mods (collection_id, mod_id) - SELECT * FROM UNNEST ($1::int8[], $2::int8[]) - ON CONFLICT DO NOTHING - ", - &collection_item_ids[..], - &validated_project_ids[..], - ) - .execute(&mut *transaction) - .await?; - } - - database::models::Collection::clear_cache(collection_item.id, &redis).await?; - - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + ), redis, session_queue).await } #[derive(Serialize, Deserialize)] @@ -356,82 +139,21 @@ pub async fn collection_icon_edit( pool: web::Data, redis: web::Data, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_WRITE]), - ) - .await? - .1; - - let string = info.into_inner().0; - let id = database::models::CollectionId(parse_base62(&string)? as i64); - let collection_item = database::models::Collection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified collection does not exist!".to_string()) - })?; - - if !can_modify_collection(&collection_item, &user) { - return Ok(HttpResponse::Unauthorized().body("")); - } - - if let Some(icon) = collection_item.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let bytes = - read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; - - let color = crate::util::img::get_color_from_img(&bytes)?; - - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let collection_id: CollectionId = collection_item.id.into(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/{}/{}.{}", collection_id, hash, ext.ext), - bytes.freeze(), - ) - .await?; - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE collections - SET icon_url = $1, color = $2 - WHERE (id = $3) - ", - format!("{}/{}", cdn_url, upload_data.file_name), - color.map(|x| x as i32), - collection_item.id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; - - database::models::Collection::clear_cache(collection_item.id, &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput(format!( - "Invalid format for collection icon: {}", - ext.ext - ))) - } + v3::collections::collection_icon_edit( + web::Query(v3::collections::Extension { + ext: ext.ext + }), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ).await } #[delete("{id}/icon")] @@ -443,54 +165,14 @@ pub async fn delete_collection_icon( file_host: web::Data>, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_WRITE]), - ) - .await? - .1; - - let string = info.into_inner().0; - let id = database::models::CollectionId(parse_base62(&string)? as i64); - let collection_item = database::models::Collection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified collection does not exist!".to_string()) - })?; - if !can_modify_collection(&collection_item, &user) { - return Ok(HttpResponse::Unauthorized().body("")); - } - - let cdn_url = dotenvy::var("CDN_URL")?; - if let Some(icon) = collection_item.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE collections - SET icon_url = NULL, color = NULL - WHERE (id = $1) - ", - collection_item.id as database::models::ids::CollectionId, - ) - .execute(&mut *transaction) - .await?; - - database::models::Collection::clear_cache(collection_item.id, &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::collections::delete_collection_icon( + req, + info, + pool, + redis, + file_host, + session_queue, + ).await } #[delete("{id}")] @@ -501,44 +183,5 @@ pub async fn collection_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_DELETE]), - ) - .await? - .1; - - let string = info.into_inner().0; - let id = database::models::CollectionId(parse_base62(&string)? as i64); - let collection = database::models::Collection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified collection does not exist!".to_string()) - })?; - if !can_modify_collection(&collection, &user) { - return Ok(HttpResponse::Unauthorized().body("")); - } - let mut transaction = pool.begin().await?; - - let result = - database::models::Collection::remove(collection.id, &mut transaction, &redis).await?; - database::models::Collection::clear_cache(collection.id, &redis).await?; - - transaction.commit().await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } -} - -fn can_modify_collection( - collection: &database::models::Collection, - user: &models::users::User, -) -> bool { - collection.user_id == user.id.into() || user.role.is_mod() -} + v3::collections::collection_delete(req, info, pool, redis, session_queue).await +} \ No newline at end of file diff --git a/src/routes/v2/images.rs b/src/routes/v2/images.rs index 0d1eecbb..71a64e13 100644 --- a/src/routes/v2/images.rs +++ b/src/routes/v2/images.rs @@ -1,17 +1,11 @@ use std::sync::Arc; -use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version}; -use crate::database; -use crate::database::models::{project_item, report_item, thread_item, version_item}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::{ThreadMessageId, VersionId}; -use crate::models::images::{Image, ImageContext}; use crate::models::reports::ReportId; use crate::queue::session::AuthQueue; -use crate::routes::v2::threads::is_authorized_thread; -use crate::routes::ApiError; -use crate::util::routes::read_from_payload; +use crate::routes::{ApiError, v3}; use actix_web::{post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -40,195 +34,17 @@ pub async fn images_add( req: HttpRequest, web::Query(data): web::Query, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) { - let mut context = ImageContext::from_str(&data.context, None); - - let scopes = vec![context.relevant_scope()]; - - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes)) - .await? - .1; - - // Attempt to associated a supplied id with the context - // If the context cannot be found, or the user is not authorized to upload images for the context, return an error - match &mut context { - ImageContext::Project { project_id } => { - if let Some(id) = data.project_id { - let project = project_item::Project::get(&id, &**pool, &redis).await?; - if let Some(project) = project { - if is_authorized(&project.inner, &Some(user.clone()), &pool).await? { - *project_id = Some(project.inner.id.into()); - } else { - return Err(ApiError::CustomAuthentication( - "You are not authorized to upload images for this project" - .to_string(), - )); - } - } else { - return Err(ApiError::InvalidInput( - "The project could not be found.".to_string(), - )); - } - } - } - ImageContext::Version { version_id } => { - if let Some(id) = data.version_id { - let version = version_item::Version::get(id.into(), &**pool, &redis).await?; - if let Some(version) = version { - if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await? - { - *version_id = Some(version.inner.id.into()); - } else { - return Err(ApiError::CustomAuthentication( - "You are not authorized to upload images for this version" - .to_string(), - )); - } - } else { - return Err(ApiError::InvalidInput( - "The version could not be found.".to_string(), - )); - } - } - } - ImageContext::ThreadMessage { thread_message_id } => { - if let Some(id) = data.thread_message_id { - let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread message could not found.".to_string(), - ) - })?; - let thread = thread_item::Thread::get(thread_message.thread_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread associated with the thread message could not be found" - .to_string(), - ) - })?; - if is_authorized_thread(&thread, &user, &pool).await? { - *thread_message_id = Some(thread_message.id.into()); - } else { - return Err(ApiError::CustomAuthentication( - "You are not authorized to upload images for this thread message" - .to_string(), - )); - } - } - } - ImageContext::Report { report_id } => { - if let Some(id) = data.report_id { - let report = report_item::Report::get(id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The report could not be found.".to_string()) - })?; - let thread = thread_item::Thread::get(report.thread_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread associated with the report could not be found." - .to_string(), - ) - })?; - if is_authorized_thread(&thread, &user, &pool).await? { - *report_id = Some(report.id.into()); - } else { - return Err(ApiError::CustomAuthentication( - "You are not authorized to upload images for this report".to_string(), - )); - } - } - } - ImageContext::Unknown => { - return Err(ApiError::InvalidInput( - "Context must be one of: project, version, thread_message, report".to_string(), - )); - } - } - - // Upload the image to the file host - let bytes = - read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?; - - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/cached_images/{}.{}", hash, data.ext), - bytes.freeze(), - ) - .await?; - - let mut transaction = pool.begin().await?; - - let db_image: database::models::Image = database::models::Image { - id: database::models::generate_image_id(&mut transaction).await?, - url: format!("{}/{}", cdn_url, upload_data.file_name), - size: upload_data.content_length as u64, - created: chrono::Utc::now(), - owner_id: database::models::UserId::from(user.id), - context: context.context_as_str().to_string(), - project_id: if let ImageContext::Project { - project_id: Some(id), - } = context - { - Some(database::models::ProjectId::from(id)) - } else { - None - }, - version_id: if let ImageContext::Version { - version_id: Some(id), - } = context - { - Some(database::models::VersionId::from(id)) - } else { - None - }, - thread_message_id: if let ImageContext::ThreadMessage { - thread_message_id: Some(id), - } = context - { - Some(database::models::ThreadMessageId::from(id)) - } else { - None - }, - report_id: if let ImageContext::Report { - report_id: Some(id), - } = context - { - Some(database::models::ReportId::from(id)) - } else { - None - }, - }; - - // Insert - db_image.insert(&mut transaction).await?; - - let image = Image { - id: db_image.id.into(), - url: db_image.url, - size: db_image.size, - created: db_image.created, - owner_id: db_image.owner_id.into(), - context, - }; - - transaction.commit().await?; - - Ok(HttpResponse::Ok().json(image)) - } else { - Err(ApiError::InvalidInput( - "The specified file is not an image!".to_string(), - )) - } + v3::images::images_add(req, web::Query(v3::images::ImageUpload { + ext: data.ext, + context: data.context, + project_id: data.project_id, + version_id: data.version_id, + thread_message_id: data.thread_message_id, + report_id: data.report_id, + }), file_host, payload, pool, redis, session_queue).await } diff --git a/src/routes/v2/moderation.rs b/src/routes/v2/moderation.rs index ebebf654..b1287eeb 100644 --- a/src/routes/v2/moderation.rs +++ b/src/routes/v2/moderation.rs @@ -1,9 +1,7 @@ use super::ApiError; -use crate::database; use crate::database::redis::RedisPool; -use crate::models::projects::ProjectStatus; use crate::queue::session::AuthQueue; -use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; +use crate::routes::v3; use actix_web::{get, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; @@ -30,37 +28,7 @@ pub async fn get_projects( count: web::Query, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await?; - - use futures::stream::TryStreamExt; - - let project_ids = sqlx::query!( - " - SELECT id FROM mods - WHERE status = $1 - ORDER BY queued ASC - LIMIT $2; - ", - ProjectStatus::Processing.as_str(), - count.count as i64 - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ProjectId(m.id))) }) - .try_collect::>() - .await?; - - let projects: Vec<_> = database::Project::get_many_ids(&project_ids, &**pool, &redis) - .await? - .into_iter() - .map(crate::models::projects::Project::from) - .collect(); - - Ok(HttpResponse::Ok().json(projects)) + v3::moderation::get_projects(req, pool, redis, + web::Query(v3::moderation::ResultCount { + count: count.count}), session_queue).await } diff --git a/src/routes/v2/notifications.rs b/src/routes/v2/notifications.rs index 10d7aa12..9bb10c67 100644 --- a/src/routes/v2/notifications.rs +++ b/src/routes/v2/notifications.rs @@ -1,9 +1,6 @@ -use crate::auth::get_user_from_headers; -use crate::database; +use crate::routes::v3; use crate::database::redis::RedisPool; use crate::models::ids::NotificationId; -use crate::models::notifications::Notification; -use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; @@ -36,36 +33,13 @@ pub async fn notifications_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_READ]), - ) - .await? - .1; - - use database::models::notification_item::Notification as DBNotification; - use database::models::NotificationId as DBNotificationId; - - let notification_ids: Vec = - serde_json::from_str::>(ids.ids.as_str())? - .into_iter() - .map(DBNotificationId::from) - .collect(); - - let notifications_data: Vec = - database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) - .await?; - - let notifications: Vec = notifications_data - .into_iter() - .filter(|n| n.user_id == user.id.into() || user.role.is_admin()) - .map(Notification::from) - .collect(); - - Ok(HttpResponse::Ok().json(notifications)) + v3::notifications::notifications_get( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ).await } #[get("{id}")] @@ -76,30 +50,13 @@ pub async fn notification_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_READ]), - ) - .await? - .1; - - let id = info.into_inner().0; - - let notification_data = - database::models::notification_item::Notification::get(id.into(), &**pool).await?; - - if let Some(data) = notification_data { - if user.id == data.user_id.into() || user.role.is_admin() { - Ok(HttpResponse::Ok().json(Notification::from(data))) - } else { - Ok(HttpResponse::NotFound().body("")) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::notifications::notification_get( + req, + info, + pool, + redis, + session_queue, + ).await } #[patch("{id}")] @@ -110,43 +67,13 @@ pub async fn notification_read( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_WRITE]), - ) - .await? - .1; - - let id = info.into_inner().0; - - let notification_data = - database::models::notification_item::Notification::get(id.into(), &**pool).await?; - - if let Some(data) = notification_data { - if data.user_id == user.id.into() || user.role.is_admin() { - let mut transaction = pool.begin().await?; - - database::models::notification_item::Notification::read( - id.into(), - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You are not authorized to read this notification!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::notifications::notification_read( + req, + info, + pool, + redis, + session_queue, + ).await } #[delete("{id}")] @@ -157,43 +84,13 @@ pub async fn notification_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_WRITE]), - ) - .await? - .1; - - let id = info.into_inner().0; - - let notification_data = - database::models::notification_item::Notification::get(id.into(), &**pool).await?; - - if let Some(data) = notification_data { - if data.user_id == user.id.into() || user.role.is_admin() { - let mut transaction = pool.begin().await?; - - database::models::notification_item::Notification::remove( - id.into(), - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You are not authorized to delete this notification!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::notifications::notification_delete( + req, + info, + pool, + redis, + session_queue, + ).await } #[patch("notifications")] @@ -204,45 +101,13 @@ pub async fn notifications_read( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_WRITE]), - ) - .await? - .1; - - let notification_ids = serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect::>(); - - let mut transaction = pool.begin().await?; - - let notifications_data = - database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) - .await?; - - let mut notifications: Vec = Vec::new(); - - for notification in notifications_data { - if notification.user_id == user.id.into() || user.role.is_admin() { - notifications.push(notification.id); - } - } - - database::models::notification_item::Notification::read_many( - ¬ifications, - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::notifications::notifications_read( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ).await } #[delete("notifications")] @@ -253,43 +118,11 @@ pub async fn notifications_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_WRITE]), - ) - .await? - .1; - - let notification_ids = serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect::>(); - - let mut transaction = pool.begin().await?; - - let notifications_data = - database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) - .await?; - - let mut notifications: Vec = Vec::new(); - - for notification in notifications_data { - if notification.user_id == user.id.into() || user.role.is_admin() { - notifications.push(notification.id); - } - } - - database::models::notification_item::Notification::remove_many( - ¬ifications, - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::notifications::notifications_delete( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ).await } diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index 7f348690..ef4e4b36 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -1,25 +1,12 @@ -use std::collections::HashMap; use std::sync::Arc; - -use crate::auth::get_user_from_headers; -use crate::database::models::team_item::TeamMember; -use crate::database::models::{generate_organization_id, team_item, Organization}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::ids::base62_impl::parse_base62; -use crate::models::organizations::OrganizationId; -use crate::models::pats::Scopes; use crate::models::projects::Project; -use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::routes::{v2_reroute, v3, ApiError}; -use crate::util::routes::read_from_payload; -use crate::util::validate::validation_errors_to_string; -use crate::{database, models}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; @@ -60,83 +47,17 @@ pub async fn organization_create( redis: web::Data, session_queue: web::Data, ) -> Result { - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_CREATE]), - ) - .await? - .1; - - new_organization - .validate() - .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; - - let mut transaction = pool.begin().await?; - - // Try title - let title_organization_id_option: Option = - serde_json::from_str(&format!("\"{}\"", new_organization.title)).ok(); - let mut organization_strings = vec![]; - if let Some(title_organization_id) = title_organization_id_option { - organization_strings.push(title_organization_id.to_string()); - } - organization_strings.push(new_organization.title.clone()); - let results = Organization::get_many(&organization_strings, &mut *transaction, &redis).await?; - if !results.is_empty() { - return Err(CreateError::SlugCollision); - } - - let organization_id = generate_organization_id(&mut transaction).await?; - - // Create organization managerial team - let team = team_item::TeamBuilder { - members: vec![team_item::TeamMemberBuilder { - user_id: current_user.id.into(), - role: crate::models::teams::OWNER_ROLE.to_owned(), - permissions: ProjectPermissions::all(), - organization_permissions: Some(OrganizationPermissions::all()), - accepted: true, - payouts_split: Decimal::ONE_HUNDRED, - ordering: 0, - }], - }; - let team_id = team.insert(&mut transaction).await?; - - // Create organization - let organization = Organization { - id: organization_id, - title: new_organization.title.clone(), - description: new_organization.description.clone(), - team_id, - icon_url: None, - color: None, - }; - organization.clone().insert(&mut transaction).await?; - transaction.commit().await?; - - // Only member is the owner, the logged in one - let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis) - .await? - .into_iter() - .next(); - let members_data = if let Some(member_data) = member_data { - vec![crate::models::teams::TeamMember::from_model( - member_data, - current_user.clone(), - false, - )] - } else { - return Err(CreateError::InvalidInput( - "Failed to get created team.".to_owned(), // should never happen - )); - }; - - let organization = models::organizations::Organization::from(organization, members_data); - - Ok(HttpResponse::Ok().json(organization)) + let new_organization = new_organization.into_inner(); + v3::organizations::organization_create( + req, + web::Json(v3::organizations::NewOrganization { + title: new_organization.title, + description: new_organization.description, + }), + pool.clone(), + redis.clone(), + session_queue, + ).await } #[get("{id}")] @@ -147,57 +68,13 @@ pub async fn organization_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let id = info.into_inner().0; - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - let user_id = current_user.as_ref().map(|x| x.id.into()); - - let organization_data = Organization::get(&id, &**pool, &redis).await?; - if let Some(data) = organization_data { - let members_data = TeamMember::get_from_team_full(data.team_id, &**pool, &redis).await?; - - let users = crate::database::models::User::get_many_ids( - &members_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - let logged_in = current_user - .as_ref() - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| { - logged_in - || x.accepted - || user_id - .map(|y: crate::database::models::UserId| y == x.user_id) - .unwrap_or(false) - }) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }) - .collect(); - - let organization = models::organizations::Organization::from(data, team_members); - return Ok(HttpResponse::Ok().json(organization)); - } - Ok(HttpResponse::NotFound().body("")) + v3::organizations::organization_get( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ).await } #[derive(Deserialize)] @@ -212,72 +89,13 @@ pub async fn organizations_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; - let organizations_data = Organization::get_many(&ids, &**pool, &redis).await?; - let team_ids = organizations_data - .iter() - .map(|x| x.team_id) - .collect::>(); - - let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; - let users = crate::database::models::User::get_many_ids( - &teams_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - let user_id = current_user.as_ref().map(|x| x.id.into()); - - let mut organizations = vec![]; - - let mut team_groups = HashMap::new(); - for item in teams_data { - team_groups.entry(item.team_id).or_insert(vec![]).push(item); - } - - for data in organizations_data { - let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); - let logged_in = current_user - .as_ref() - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| { - logged_in - || x.accepted - || user_id - .map(|y: crate::database::models::UserId| y == x.user_id) - .unwrap_or(false) - }) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }) - .collect(); - - let organization = models::organizations::Organization::from(data, team_members); - organizations.push(organization); - } - - Ok(HttpResponse::Ok().json(organizations)) + v3::organizations::organizations_get( + req, + web::Query(v3::organizations::OrganizationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ).await } #[derive(Serialize, Deserialize, Validate)] @@ -301,132 +119,18 @@ pub async fn organizations_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_WRITE]), - ) - .await? - .1; - - new_organization - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let string = info.into_inner().0; - let result = database::models::Organization::get(&string, &**pool, &redis).await?; - if let Some(organization_item) = result { - let id = organization_item.id; - - let team_member = database::models::TeamMember::get_from_user_id( - organization_item.team_id, - user.id.into(), - &**pool, - ) - .await?; - - let permissions = - OrganizationPermissions::get_permissions_by_role(&user.role, &team_member); - - if let Some(perms) = permissions { - let mut transaction = pool.begin().await?; - if let Some(description) = &new_organization.description { - if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the description of this organization!" - .to_string(), - )); - } - sqlx::query!( - " - UPDATE organizations - SET description = $1 - WHERE (id = $2) - ", - description, - id as database::models::ids::OrganizationId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(title) = &new_organization.title { - if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the title of this organization!" - .to_string(), - )); - } - - let title_organization_id_option: Option = parse_base62(title).ok(); - if let Some(title_organization_id) = title_organization_id_option { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1) - ", - title_organization_id as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Title collides with other organization's id!".to_string(), - )); - } - } - - // Make sure the new title is different from the old one - // We are able to unwrap here because the title is always set - if !title.eq(&organization_item.title.clone()) { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1)) - ", - title - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Title collides with other organization's id!".to_string(), - )); - } - } - - sqlx::query!( - " - UPDATE organizations - SET title = LOWER($1) - WHERE (id = $2) - ", - Some(title), - id as database::models::ids::OrganizationId, - ) - .execute(&mut *transaction) - .await?; - } - - database::models::Organization::clear_cache( - organization_item.id, - Some(organization_item.title), - &redis, - ) - .await?; - - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this organization!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + let new_organization = new_organization.into_inner(); + v3::organizations::organizations_edit( + req, + info, + web::Json(v3::organizations::OrganizationEdit { + description: new_organization.description, + title: new_organization.title, + }), + pool.clone(), + redis.clone(), + session_queue, + ).await } #[delete("{id}")] @@ -437,60 +141,13 @@ pub async fn organization_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_DELETE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let organization = database::models::Organization::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - if !user.role.is_admin() { - let team_member = database::models::TeamMember::get_from_user_id_organization( - organization.id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - let permissions = - OrganizationPermissions::get_permissions_by_role(&user.role, &Some(team_member)) - .unwrap_or_default(); - - if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to delete this organization!".to_string(), - )); - } - } - - let mut transaction = pool.begin().await?; - let result = - database::models::Organization::remove(organization.id, &mut transaction, &redis).await?; - - transaction.commit().await?; - - database::models::Organization::clear_cache(organization.id, Some(organization.title), &redis) - .await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::organizations::organization_delete( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ).await } #[get("{id}/projects")] @@ -533,98 +190,17 @@ pub async fn organization_projects_add( redis: web::Data, session_queue: web::Data, ) -> Result { - let info = info.into_inner().0; - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), - ) - .await? - .1; - - let organization = database::models::Organization::get(&info, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - let project_item = database::models::Project::get(&project_info.project_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - if project_item.inner.organization_id.is_some() { - return Err(ApiError::InvalidInput( - "The specified project is already owned by an organization!".to_string(), - )); - } - - let project_team_member = database::models::TeamMember::get_from_user_id_project( - project_item.inner.id, - current_user.id.into(), - &**pool, - ) - .await? - .ok_or_else(|| ApiError::InvalidInput("You are not a member of this project!".to_string()))?; - - let organization_team_member = database::models::TeamMember::get_from_user_id_organization( - organization.id, - current_user.id.into(), - &**pool, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("You are not a member of this organization!".to_string()) - })?; - - // Require ownership of a project to add it to an organization - if !current_user.role.is_admin() - && !project_team_member - .role - .eq(crate::models::teams::OWNER_ROLE) - { - return Err(ApiError::CustomAuthentication( - "You need to be an owner of a project to add it to an organization!".to_string(), - )); - } - - let permissions = OrganizationPermissions::get_permissions_by_role( - ¤t_user.role, - &Some(organization_team_member), - ) - .unwrap_or_default(); - if permissions.contains(OrganizationPermissions::ADD_PROJECT) { - let mut transaction = pool.begin().await?; - sqlx::query!( - " - UPDATE mods - SET organization_id = $1 - WHERE (id = $2) - ", - organization.id as database::models::OrganizationId, - project_item.inner.id as database::models::ids::ProjectId - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; - database::models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to add projects to this organization!".to_string(), - )); - } - Ok(HttpResponse::Ok().finish()) + let project_info = project_info.into_inner(); + v3::organizations::organization_projects_add( + req, + info, + web::Json(v3::organizations::OrganizationProjectAdd { + project_id: project_info.project_id, + }), + pool.clone(), + redis.clone(), + session_queue, + ).await } #[delete("{organization_id}/projects/{project_id}")] @@ -635,83 +211,13 @@ pub async fn organization_projects_remove( redis: web::Data, session_queue: web::Data, ) -> Result { - let (organization_id, project_id) = info.into_inner(); - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), - ) - .await? - .1; - - let organization = database::models::Organization::get(&organization_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - let project_item = database::models::Project::get(&project_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !project_item - .inner - .organization_id - .eq(&Some(organization.id)) - { - return Err(ApiError::InvalidInput( - "The specified project is not owned by this organization!".to_string(), - )); - } - - let organization_team_member = database::models::TeamMember::get_from_user_id_organization( - organization.id, - current_user.id.into(), - &**pool, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("You are not a member of this organization!".to_string()) - })?; - - let permissions = OrganizationPermissions::get_permissions_by_role( - ¤t_user.role, - &Some(organization_team_member), - ) - .unwrap_or_default(); - if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) { - let mut transaction = pool.begin().await?; - sqlx::query!( - " - UPDATE mods - SET organization_id = NULL - WHERE (id = $1) - ", - project_item.inner.id as database::models::ids::ProjectId - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; - database::models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to add projects to this organization!".to_string(), - )); - } - Ok(HttpResponse::Ok().finish()) + v3::organizations::organization_projects_remove( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ).await } #[derive(Serialize, Deserialize)] @@ -728,102 +234,19 @@ pub async fn organization_icon_edit( pool: web::Data, redis: web::Data, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let organization_item = database::models::Organization::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - organization_item.team_id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let permissions = - OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) - .unwrap_or_default(); - - if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this organization's icon.".to_string(), - )); - } - } - - if let Some(icon) = organization_item.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let bytes = - read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; - - let color = crate::util::img::get_color_from_img(&bytes)?; - - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let organization_id: OrganizationId = organization_item.id.into(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/{}/{}.{}", organization_id, hash, ext.ext), - bytes.freeze(), - ) - .await?; - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE organizations - SET icon_url = $1, color = $2 - WHERE (id = $3) - ", - format!("{}/{}", cdn_url, upload_data.file_name), - color.map(|x| x as i32), - organization_item.id as database::models::ids::OrganizationId, - ) - .execute(&mut *transaction) - .await?; - - database::models::Organization::clear_cache( - organization_item.id, - Some(organization_item.title), - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput(format!( - "Invalid format for project icon: {}", - ext.ext - ))) - } + v3::organizations::organization_icon_edit( + web::Query(v3::organizations::Extension { ext: ext.ext }), + req, + info, + pool.clone(), + redis.clone(), + file_host, + payload, + session_queue, + ).await } #[delete("{id}/icon")] @@ -835,73 +258,12 @@ pub async fn delete_organization_icon( file_host: web::Data>, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let organization_item = database::models::Organization::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified organization does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let team_member = database::models::TeamMember::get_from_user_id( - organization_item.team_id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let permissions = - OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) - .unwrap_or_default(); - - if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this organization's icon.".to_string(), - )); - } - } - - let cdn_url = dotenvy::var("CDN_URL")?; - if let Some(icon) = organization_item.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE organizations - SET icon_url = NULL, color = NULL - WHERE (id = $1) - ", - organization_item.id as database::models::ids::OrganizationId, - ) - .execute(&mut *transaction) - .await?; - - database::models::Organization::clear_cache( - organization_item.id, - Some(organization_item.title), - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::organizations::delete_organization_icon( + req, + info, + pool.clone(), + redis.clone(), + file_host, + session_queue, + ).await } diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 4b84ec5f..88db982f 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,23 +1,15 @@ -use crate::auth::{get_user_from_headers, is_authorized}; -use crate::database; -use crate::database::models::project_item::{GalleryItem, ModCategory}; -use crate::database::models::{image_item, project_item, version_item}; +use crate::database::models::{project_item, version_item}; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; -use crate::models::images::ImageContext; -use crate::models::pats::Scopes; use crate::models::projects::{ - DonationLink, MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, SideType, + DonationLink, MonetizationStatus, Project, ProjectStatus, SearchRequest, SideType, }; -use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; -use crate::routes::v3::projects::{delete_from_index, ProjectIds}; +use crate::routes::v3::projects::ProjectIds; use crate::routes::{v2_reroute, v3, ApiError}; use crate::search::{search_for_project, SearchConfig, SearchError}; -use crate::util::routes::read_from_payload; -use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -27,9 +19,6 @@ use std::collections::HashMap; use std::sync::Arc; use validator::Validate; -use database::models as db_models; -use db_models::ids as db_ids; - pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(project_search); cfg.service(projects_get); @@ -202,17 +191,7 @@ pub async fn project_get_check( pool: web::Data, redis: web::Data, ) -> Result { - let slug = info.into_inner().0; - - let project_data = db_models::Project::get(&slug, &**pool, &redis).await?; - - if let Some(project) = project_data { - Ok(HttpResponse::Ok().json(json! ({ - "id": models::ids::ProjectId::from(project.inner.id) - }))) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::projects::project_get_check(info, pool, redis).await } #[derive(Serialize)] @@ -229,73 +208,8 @@ pub async fn dependency_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - - let result = db_models::Project::get(&string, &**pool, &redis).await?; - - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if let Some(project) = result { - if !is_authorized(&project.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - let dependencies = - database::Project::get_dependencies(project.inner.id, &**pool, &redis).await?; - - let project_ids = dependencies - .iter() - .filter_map(|x| { - if x.0.is_none() { - if let Some(mod_dependency_id) = x.2 { - Some(mod_dependency_id) - } else { - x.1 - } - } else { - x.1 - } - }) - .collect::>(); - - let dep_version_ids = dependencies - .iter() - .filter_map(|x| x.0) - .collect::>(); - let (projects_result, versions_result) = futures::future::try_join( - database::Project::get_many_ids(&project_ids, &**pool, &redis), - database::Version::get_many(&dep_version_ids, &**pool, &redis), - ) - .await?; - - let mut projects = projects_result - .into_iter() - .map(models::projects::Project::from) - .collect::>(); - let mut versions = versions_result - .into_iter() - .map(models::projects::Version::from) - .collect::>(); - - projects.sort_by(|a, b| b.published.cmp(&a.published)); - projects.dedup_by(|a, b| a.id == b.id); - - versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); - versions.dedup_by(|a, b| a.id == b.id); - - Ok(HttpResponse::Ok().json(DependencyInfo { projects, versions })) - } else { - Ok(HttpResponse::NotFound().body("")) - } + // TODO: requires V2 conversion and tests, probably + v3::projects::dependency_list(req, info, pool, redis, session_queue).await } #[derive(Serialize, Deserialize, Validate)] @@ -474,13 +388,6 @@ pub async fn project_edit( Ok(response) } -#[derive(derive_new::new)] -pub struct CategoryChanges<'a> { - pub categories: &'a Option>, - pub add_categories: &'a Option>, - pub remove_categories: &'a Option>, -} - #[derive(Deserialize, Validate)] pub struct BulkEditProject { #[validate(length(max = 3))] @@ -553,332 +460,29 @@ pub async fn projects_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - bulk_edit_project - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let project_ids: Vec = serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect(); - - let projects_data = db_models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; - - if let Some(id) = project_ids - .iter() - .find(|x| !projects_data.iter().any(|y| x == &&y.inner.id)) - { - return Err(ApiError::InvalidInput(format!( - "Project {} not found", - ProjectId(id.0 as u64) - ))); - } - - let team_ids = projects_data - .iter() - .map(|x| x.inner.team_id) - .collect::>(); - let team_members = - db_models::TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; - - let organization_ids = projects_data - .iter() - .filter_map(|x| x.inner.organization_id) - .collect::>(); - let organizations = - db_models::Organization::get_many_ids(&organization_ids, &**pool, &redis).await?; - - let organization_team_ids = organizations - .iter() - .map(|x| x.team_id) - .collect::>(); - let organization_team_members = - db_models::TeamMember::get_from_team_full_many(&organization_team_ids, &**pool, &redis) - .await?; - - let categories = db_models::categories::Category::list(&**pool, &redis).await?; - let donation_platforms = db_models::categories::DonationPlatform::list(&**pool, &redis).await?; - - let mut transaction = pool.begin().await?; - - for project in projects_data { - if !user.role.is_mod() { - let team_member = team_members - .iter() - .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()); - - let organization = project - .inner - .organization_id - .and_then(|oid| organizations.iter().find(|x| x.id == oid)); - - let organization_team_member = if let Some(organization) = organization { - organization_team_members - .iter() - .find(|x| x.team_id == organization.team_id && x.user_id == user.id.into()) - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member.cloned(), - &organization_team_member.cloned(), - ) - .unwrap_or_default(); - - if team_member.is_some() { - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication(format!( - "You do not have the permissions to bulk edit project {}!", - project.inner.title - ))); - } - } else if project.inner.status.is_hidden() { - return Err(ApiError::InvalidInput(format!( - "Project {} not found", - ProjectId(project.inner.id.0 as u64) - ))); - } else { - return Err(ApiError::CustomAuthentication(format!( - "You are not a member of project {}!", - project.inner.title - ))); - }; - } - - bulk_edit_project_categories( - &categories, - &project.categories, - project.inner.id as db_ids::ProjectId, - CategoryChanges::new( - &bulk_edit_project.categories, - &bulk_edit_project.add_categories, - &bulk_edit_project.remove_categories, - ), - 3, - false, - &mut transaction, - ) - .await?; - - bulk_edit_project_categories( - &categories, - &project.additional_categories, - project.inner.id as db_ids::ProjectId, - CategoryChanges::new( - &bulk_edit_project.additional_categories, - &bulk_edit_project.add_additional_categories, - &bulk_edit_project.remove_additional_categories, - ), - 256, - true, - &mut transaction, - ) - .await?; - - let project_donations: Vec = project - .donation_urls - .into_iter() - .map(|d| DonationLink { - id: d.platform_short, - platform: d.platform_name, - url: d.url, - }) - .collect(); - let mut set_donation_links = - if let Some(donation_links) = bulk_edit_project.donation_urls.clone() { - donation_links - } else { - project_donations.clone() - }; - - if let Some(delete_donations) = &bulk_edit_project.remove_donation_urls { - for donation in delete_donations { - if let Some(pos) = set_donation_links - .iter() - .position(|x| donation.url == x.url && donation.id == x.id) - { - set_donation_links.remove(pos); - } - } - } - - if let Some(add_donations) = &bulk_edit_project.add_donation_urls { - set_donation_links.append(&mut add_donations.clone()); - } - - if set_donation_links != project_donations { - sqlx::query!( - " - DELETE FROM mods_donations - WHERE joining_mod_id = $1 - ", - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - for donation in set_donation_links { - let platform_id = donation_platforms - .iter() - .find(|x| x.short == donation.id) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Platform {} does not exist.", - donation.id.clone() - )) - })? - .id; - - sqlx::query!( - " - INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url) - VALUES ($1, $2, $3) - ", - project.inner.id as db_ids::ProjectId, - platform_id as db_ids::DonationPlatformId, - donation.url - ) - .execute(&mut *transaction) - .await?; - } - } - - if let Some(issues_url) = &bulk_edit_project.issues_url { - sqlx::query!( - " - UPDATE mods - SET issues_url = $1 - WHERE (id = $2) - ", - issues_url.as_deref(), - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(source_url) = &bulk_edit_project.source_url { - sqlx::query!( - " - UPDATE mods - SET source_url = $1 - WHERE (id = $2) - ", - source_url.as_deref(), - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(wiki_url) = &bulk_edit_project.wiki_url { - sqlx::query!( - " - UPDATE mods - SET wiki_url = $1 - WHERE (id = $2) - ", - wiki_url.as_deref(), - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(discord_url) = &bulk_edit_project.discord_url { - sqlx::query!( - " - UPDATE mods - SET discord_url = $1 - WHERE (id = $2) - ", - discord_url.as_deref(), - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - db_models::Project::clear_cache(project.inner.id, project.inner.slug, None, &redis).await?; - } - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) -} - -pub async fn bulk_edit_project_categories( - all_db_categories: &[db_models::categories::Category], - project_categories: &Vec, - project_id: db_ids::ProjectId, - bulk_changes: CategoryChanges<'_>, - max_num_categories: usize, - is_additional: bool, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result<(), ApiError> { - let mut set_categories = if let Some(categories) = bulk_changes.categories.clone() { - categories - } else { - project_categories.clone() - }; - - if let Some(delete_categories) = &bulk_changes.remove_categories { - for category in delete_categories { - if let Some(pos) = set_categories.iter().position(|x| x == category) { - set_categories.remove(pos); - } - } - } - - if let Some(add_categories) = &bulk_changes.add_categories { - for category in add_categories { - if set_categories.len() < max_num_categories { - set_categories.push(category.clone()); - } else { - break; - } - } - } - - if &set_categories != project_categories { - sqlx::query!( - " - DELETE FROM mods_categories - WHERE joining_mod_id = $1 AND is_additional = $2 - ", - project_id as db_ids::ProjectId, - is_additional - ) - .execute(&mut **transaction) - .await?; - - let mut mod_categories = Vec::new(); - for category in set_categories { - let category_id = all_db_categories - .iter() - .find(|x| x.category == category) - .ok_or_else(|| { - ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) - })? - .id; - mod_categories.push(ModCategory::new(project_id, category_id, is_additional)); - } - ModCategory::insert_many(mod_categories, &mut *transaction).await?; - } - - Ok(()) + let bulk_edit_project = bulk_edit_project.into_inner(); + v3::projects::projects_edit( + req, + web::Query(ids), + pool.clone(), + web::Json(v3::projects::BulkEditProject { + categories: bulk_edit_project.categories, + add_categories: bulk_edit_project.add_categories, + remove_categories: bulk_edit_project.remove_categories, + additional_categories: bulk_edit_project.additional_categories, + add_additional_categories: bulk_edit_project.add_additional_categories, + remove_additional_categories: bulk_edit_project.remove_additional_categories, + donation_urls: bulk_edit_project.donation_urls, + add_donation_urls: bulk_edit_project.add_donation_urls, + remove_donation_urls: bulk_edit_project.remove_donation_urls, + issues_url: bulk_edit_project.issues_url, + source_url: bulk_edit_project.source_url, + wiki_url: bulk_edit_project.wiki_url, + discord_url: bulk_edit_project.discord_url, + }), + redis, + session_queue, + ).await } #[derive(Deserialize)] @@ -896,84 +500,19 @@ pub async fn project_schedule( session_queue: web::Data, scheduling_data: web::Json, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), + let scheduling_data = scheduling_data.into_inner(); + v3::projects::project_schedule( + req, + info, + pool, + redis, + session_queue, + web::Json(v3::projects::SchedulingData { + time: scheduling_data.time, + requested_status: scheduling_data.requested_status, + }), ) - .await? - .1; - - if scheduling_data.time < Utc::now() { - return Err(ApiError::InvalidInput( - "You cannot schedule a project to be released in the past!".to_string(), - )); - } - - if !scheduling_data.requested_status.can_be_requested() { - return Err(ApiError::InvalidInput( - "Specified requested status cannot be requested!".to_string(), - )); - } - - let string = info.into_inner().0; - let result = db_models::Project::get(&string, &**pool, &redis).await?; - - if let Some(project_item) = result { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member.clone(), - &organization_team_member.clone(), - ) - .unwrap_or_default(); - - if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to edit this project's scheduling data!".to_string(), - )); - } - - if !project_item.inner.status.is_approved() { - return Err(ApiError::InvalidInput( - "This project has not been approved yet. Submit to the queue with the private status to schedule it in the future!".to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET status = $1, approved = $2 - WHERE (id = $3) - ", - ProjectStatus::Scheduled.as_str(), - scheduling_data.time, - project_item.inner.id as db_ids::ProjectId, - ) - .execute(&**pool) - .await?; - - db_models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + .await } #[derive(Serialize, Deserialize)] @@ -990,113 +529,19 @@ pub async fn project_icon_edit( pool: web::Data, redis: web::Data, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let project_item = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this project's icon.".to_string(), - )); - } - } - - if let Some(icon) = project_item.inner.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let bytes = - read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; - - let color = crate::util::img::get_color_from_img(&bytes)?; - - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let project_id: ProjectId = project_item.inner.id.into(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/{}/{}.{}", project_id, hash, ext.ext), - bytes.freeze(), - ) - .await?; - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE mods - SET icon_url = $1, color = $2 - WHERE (id = $3) - ", - format!("{}/{}", cdn_url, upload_data.file_name), - color.map(|x| x as i32), - project_item.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - db_models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput(format!( - "Invalid format for project icon: {}", - ext.ext - ))) - } + v3::projects::project_icon_edit( + web::Query(v3::projects::Extension { ext: ext.ext }), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ).await } #[delete("{id}/icon")] @@ -1108,80 +553,7 @@ pub async fn delete_project_icon( file_host: web::Data>, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let project_item = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this project's icon.".to_string(), - )); - } - } - - let cdn_url = dotenvy::var("CDN_URL")?; - if let Some(icon) = project_item.inner.icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE mods - SET icon_url = NULL, color = NULL - WHERE (id = $1) - ", - project_item.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::projects::delete_project_icon(req, info, pool, redis, file_host, session_queue).await } #[derive(Serialize, Deserialize, Validate)] @@ -1204,136 +576,25 @@ pub async fn add_gallery_item( pool: web::Data, redis: web::Data, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { - item.validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let project_item = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if project_item.gallery_items.len() > 64 { - return Err(ApiError::CustomAuthentication( - "You have reached the maximum of gallery images to upload.".to_string(), - )); - } - - if !user.role.is_admin() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this project's gallery.".to_string(), - )); - } - } - - let bytes = read_from_payload( - &mut payload, - 5 * (1 << 20), - "Gallery image exceeds the maximum of 5MiB.", - ) - .await?; - let hash = sha1::Sha1::from(&bytes).hexdigest(); - - let id: ProjectId = project_item.inner.id.into(); - let url = format!("data/{}/images/{}.{}", id, hash, &*ext.ext); - - let file_url = format!("{cdn_url}/{url}"); - if project_item - .gallery_items - .iter() - .any(|x| x.image_url == file_url) - { - return Err(ApiError::InvalidInput( - "You may not upload duplicate gallery images!".to_string(), - )); - } - - file_host - .upload_file(content_type, &url, bytes.freeze()) - .await?; - - let mut transaction = pool.begin().await?; - - if item.featured { - sqlx::query!( - " - UPDATE mods_gallery - SET featured = $2 - WHERE mod_id = $1 - ", - project_item.inner.id as db_ids::ProjectId, - false, - ) - .execute(&mut *transaction) - .await?; - } - - let gallery_item = vec![db_models::project_item::GalleryItem { - image_url: file_url, + v3::projects::add_gallery_item( + web::Query(v3::projects::Extension { ext: ext.ext }), + req, + web::Query(v3::projects::GalleryCreateQuery { featured: item.featured, title: item.title, description: item.description, - created: Utc::now(), - ordering: item.ordering.unwrap_or(0), - }]; - GalleryItem::insert_many(gallery_item, project_item.inner.id, &mut transaction).await?; - - db_models::Project::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - &redis, - ) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput(format!( - "Invalid format for gallery image: {}", - ext.ext - ))) - } + ordering: item.ordering, + }), + info, + pool, + redis, + file_host, + payload, + session_queue, + ).await } #[derive(Serialize, Deserialize, Validate)] @@ -1367,148 +628,20 @@ pub async fn edit_gallery_item( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - item.validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let project_item = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this project's gallery.".to_string(), - )); - } - } - let mut transaction = pool.begin().await?; - - let id = sqlx::query!( - " - SELECT id FROM mods_gallery - WHERE image_url = $1 - ", - item.url - ) - .fetch_optional(&mut *transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Gallery item at URL {} is not part of the project's gallery.", - item.url - )) - })? - .id; - - let mut transaction = pool.begin().await?; - - if let Some(featured) = item.featured { - if featured { - sqlx::query!( - " - UPDATE mods_gallery - SET featured = $2 - WHERE mod_id = $1 - ", - project_item.inner.id as db_ids::ProjectId, - false, - ) - .execute(&mut *transaction) - .await?; - } - - sqlx::query!( - " - UPDATE mods_gallery - SET featured = $2 - WHERE id = $1 - ", - id, - featured - ) - .execute(&mut *transaction) - .await?; - } - if let Some(title) = item.title { - sqlx::query!( - " - UPDATE mods_gallery - SET title = $2 - WHERE id = $1 - ", - id, - title - ) - .execute(&mut *transaction) - .await?; - } - if let Some(description) = item.description { - sqlx::query!( - " - UPDATE mods_gallery - SET description = $2 - WHERE id = $1 - ", - id, - description - ) - .execute(&mut *transaction) - .await?; - } - if let Some(ordering) = item.ordering { - sqlx::query!( - " - UPDATE mods_gallery - SET ordering = $2 - WHERE id = $1 - ", - id, - ordering - ) - .execute(&mut *transaction) - .await?; - } - - db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::projects::edit_gallery_item( + req, + web::Query(v3::projects::GalleryEditQuery { + url: item.url, + featured: item.featured, + title: item.title, + description: item.description, + ordering: item.ordering, + }), + info, + pool, + redis, + session_queue, + ).await } #[derive(Serialize, Deserialize)] @@ -1526,96 +659,15 @@ pub async fn delete_gallery_item( file_host: web::Data>, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let project_item = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !user.role.is_mod() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this project's gallery.".to_string(), - )); - } - } - let mut transaction = pool.begin().await?; - - let id = sqlx::query!( - " - SELECT id FROM mods_gallery - WHERE image_url = $1 - ", - item.url - ) - .fetch_optional(&mut *transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Gallery item at URL {} is not part of the project's gallery.", - item.url - )) - })? - .id; - - let cdn_url = dotenvy::var("CDN_URL")?; - let name = item.url.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - DELETE FROM mods_gallery - WHERE id = $1 - ", - id - ) - .execute(&mut *transaction) - .await?; - - db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::projects::delete_gallery_item( + req, + web::Query(v3::projects::GalleryDeleteQuery { url: item.url }), + info, + pool, + redis, + file_host, + session_queue, + ).await } #[delete("{id}")] @@ -1627,83 +679,14 @@ pub async fn project_delete( config: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_DELETE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let project = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - if !user.role.is_admin() { - let (team_member, organization_team_member) = - db_models::TeamMember::get_for_project_permissions( - &project.inner, - user.id.into(), - &**pool, - ) - .await?; - - // Hide the project - if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); - } - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::DELETE_PROJECT) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to delete this project!".to_string(), - )); - } - } - - let mut transaction = pool.begin().await?; - let context = ImageContext::Project { - project_id: Some(project.inner.id.into()), - }; - let uploaded_images = db_models::Image::get_many_contexted(context, &mut transaction).await?; - for image in uploaded_images { - image_item::Image::remove(image.id, &mut transaction, &redis).await?; - } - - sqlx::query!( - " - DELETE FROM collections_mods - WHERE mod_id = $1 - ", - project.inner.id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - let result = db_models::Project::remove(project.inner.id, &mut transaction, &redis).await?; - - transaction.commit().await?; - - delete_from_index(project.inner.id.into(), config).await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::projects::project_delete( + req, + info, + pool, + redis, + config, + session_queue, + ).await } #[post("{id}/follow")] @@ -1714,75 +697,13 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let result = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - let user_id: db_ids::UserId = user.id.into(); - let project_id: db_ids::ProjectId = result.inner.id; - - if !is_authorized(&result.inner, &Some(user), &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - let following = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) - ", - user_id as db_ids::UserId, - project_id as db_ids::ProjectId - ) - .fetch_one(&**pool) - .await? - .exists - .unwrap_or(false); - - if !following { - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE mods - SET follows = follows + 1 - WHERE id = $1 - ", - project_id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - INSERT INTO mod_follows (follower_id, mod_id) - VALUES ($1, $2) - ", - user_id as db_ids::UserId, - project_id as db_ids::ProjectId - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput( - "You are already following this project!".to_string(), - )) - } + v3::projects::project_follow( + req, + info, + pool, + redis, + session_queue, + ).await } #[delete("{id}/follow")] @@ -1793,69 +714,11 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_WRITE]), - ) - .await? - .1; - let string = info.into_inner().0; - - let result = db_models::Project::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified project does not exist!".to_string()) - })?; - - let user_id: db_ids::UserId = user.id.into(); - let project_id = result.inner.id; - - let following = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) - ", - user_id as db_ids::UserId, - project_id as db_ids::ProjectId - ) - .fetch_one(&**pool) - .await? - .exists - .unwrap_or(false); - - if following { - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE mods - SET follows = follows - 1 - WHERE id = $1 - ", - project_id as db_ids::ProjectId, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - DELETE FROM mod_follows - WHERE follower_id = $1 AND mod_id = $2 - ", - user_id as db_ids::UserId, - project_id as db_ids::ProjectId - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput( - "You are not following this project!".to_string(), - )) - } + v3::projects::project_unfollow( + req, + info, + pool, + redis, + session_queue, + ).await } diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index 2589778a..7c3f4a3f 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -1,20 +1,9 @@ -use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; -use crate::database; -use crate::database::models::image_item; -use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder}; use crate::database::redis::RedisPool; use crate::models::ids::ImageId; -use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId}; -use crate::models::images::{Image, ImageContext}; -use crate::models::pats::Scopes; -use crate::models::reports::{ItemType, Report}; -use crate::models::threads::{MessageBody, ThreadType}; +use crate::models::reports::ItemType; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; -use crate::util::img; +use crate::routes::{ApiError, v3}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::Utc; -use futures::StreamExt; use serde::Deserialize; use sqlx::PgPool; use validator::Validate; @@ -44,177 +33,17 @@ pub struct CreateReport { pub async fn report_create( req: HttpRequest, pool: web::Data, - mut body: web::Payload, + body: web::Payload, redis: web::Data, session_queue: web::Data, ) -> Result { - let mut transaction = pool.begin().await?; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_CREATE]), - ) - .await? - .1; - - let mut bytes = web::BytesMut::new(); - while let Some(item) = body.next().await { - bytes.extend_from_slice(&item.map_err(|_| { - ApiError::InvalidInput("Error while parsing request payload!".to_string()) - })?); - } - let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?; - - let id = crate::database::models::generate_report_id(&mut transaction).await?; - let report_type = crate::database::models::categories::ReportType::get_id( - &new_report.report_type, - &mut *transaction, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!("Invalid report type: {}", new_report.report_type)) - })?; - - let mut report = crate::database::models::report_item::Report { - id, - report_type_id: report_type, - project_id: None, - version_id: None, - user_id: None, - body: new_report.body.clone(), - reporter: current_user.id.into(), - created: Utc::now(), - closed: false, - }; - - match new_report.item_type { - ItemType::Project => { - let project_id = ProjectId(parse_base62(new_report.item_id.as_str())?); - - let result = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", - project_id.0 as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( - "Project could not be found: {}", - new_report.item_id - ))); - } - - report.project_id = Some(project_id.into()) - } - ItemType::Version => { - let version_id = VersionId(parse_base62(new_report.item_id.as_str())?); - - let result = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)", - version_id.0 as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( - "Version could not be found: {}", - new_report.item_id - ))); - } - - report.version_id = Some(version_id.into()) - } - ItemType::User => { - let user_id = UserId(parse_base62(new_report.item_id.as_str())?); - - let result = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", - user_id.0 as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( - "User could not be found: {}", - new_report.item_id - ))); - } - - report.user_id = Some(user_id.into()) - } - ItemType::Unknown => { - return Err(ApiError::InvalidInput(format!( - "Invalid report item type: {}", - new_report.item_type.as_str() - ))) - } - } - - report.insert(&mut transaction).await?; - - for image_id in new_report.uploaded_images { - if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut *transaction, &redis).await? - { - let image: Image = db_image.into(); - if !matches!(image.context, ImageContext::Report { .. }) - || image.context.inner_id().is_some() - { - return Err(ApiError::InvalidInput(format!( - "Image {} is not unused and in the 'report' context", - image_id - ))); - } - - sqlx::query!( - " - UPDATE uploaded_images - SET report_id = $1 - WHERE id = $2 - ", - id.0 as i64, - image_id.0 as i64 - ) - .execute(&mut *transaction) - .await?; - - image_item::Image::clear_cache(image.id.into(), &redis).await?; - } else { - return Err(ApiError::InvalidInput(format!( - "Image {} could not be found", - image_id - ))); - } - } - - let thread_id = ThreadBuilder { - type_: ThreadType::Report, - members: vec![], - project_id: None, - report_id: Some(report.id), - } - .insert(&mut transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::Ok().json(Report { - id: id.into(), - report_type: new_report.report_type.clone(), - item_id: new_report.item_id.clone(), - item_type: new_report.item_type.clone(), - reporter: current_user.id, - body: new_report.body.clone(), - created: Utc::now(), - closed: false, - thread_id: thread_id.into(), - })) + v3::reports::report_create( + req, + pool, + body, + redis, + session_queue, + ).await } #[derive(Deserialize)] @@ -240,65 +69,17 @@ pub async fn reports( count: web::Query, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_READ]), - ) - .await? - .1; - - use futures::stream::TryStreamExt; - - let report_ids = if user.role.is_mod() && count.all { - sqlx::query!( - " - SELECT id FROM reports - WHERE closed = FALSE - ORDER BY created ASC - LIMIT $1; - ", - count.count as i64 - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right() - .map(|m| crate::database::models::ids::ReportId(m.id))) - }) - .try_collect::>() - .await? - } else { - sqlx::query!( - " - SELECT id FROM reports - WHERE closed = FALSE AND reporter = $1 - ORDER BY created ASC - LIMIT $2; - ", - user.id.0 as i64, - count.count as i64 - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right() - .map(|m| crate::database::models::ids::ReportId(m.id))) - }) - .try_collect::>() - .await? - }; - - let query_reports = - crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?; - - let mut reports: Vec = Vec::new(); - - for x in query_reports { - reports.push(x.into()); - } - - Ok(HttpResponse::Ok().json(reports)) + + v3::reports::reports( + req, + pool, + redis, + web::Query(v3::reports::ReportsRequestOptions { + count: count.count, + all: count.all, + }), + session_queue, + ).await } #[derive(Deserialize)] @@ -314,32 +95,13 @@ pub async fn reports_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let report_ids: Vec = - serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect(); - - let reports_data = - crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?; - - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_READ]), - ) - .await? - .1; - - let all_reports = reports_data - .into_iter() - .filter(|x| user.role.is_mod() || x.reporter == user.id.into()) - .map(|x| x.into()) - .collect::>(); - - Ok(HttpResponse::Ok().json(all_reports)) + v3::reports::reports_get( + req, + web::Query(v3::reports::ReportIds { ids: ids.ids }), + pool, + redis, + session_queue, + ).await } #[get("report/{id}")] @@ -350,29 +112,13 @@ pub async fn report_get( info: web::Path<(crate::models::reports::ReportId,)>, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_READ]), - ) - .await? - .1; - let id = info.into_inner().0.into(); - - let report = crate::database::models::report_item::Report::get(id, &**pool).await?; - - if let Some(report) = report { - if !user.role.is_mod() && report.reporter != user.id.into() { - return Ok(HttpResponse::NotFound().body("")); - } - - let report: Report = report.into(); - Ok(HttpResponse::Ok().json(report)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::reports::report_get( + req, + pool, + redis, + info, + session_queue, + ).await } #[derive(Deserialize, Validate)] @@ -391,101 +137,18 @@ pub async fn report_edit( session_queue: web::Data, edit_report: web::Json, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_WRITE]), - ) - .await? - .1; - let id = info.into_inner().0.into(); - - let report = crate::database::models::report_item::Report::get(id, &**pool).await?; - - if let Some(report) = report { - if !user.role.is_mod() && report.reporter != user.id.into() { - return Ok(HttpResponse::NotFound().body("")); - } - - let mut transaction = pool.begin().await?; - - if let Some(edit_body) = &edit_report.body { - sqlx::query!( - " - UPDATE reports - SET body = $1 - WHERE (id = $2) - ", - edit_body, - id as crate::database::models::ids::ReportId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(edit_closed) = edit_report.closed { - if !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You cannot reopen a report!".to_string(), - )); - } - - ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: if !edit_closed && report.closed { - MessageBody::ThreadReopen - } else { - MessageBody::ThreadClosure - }, - thread_id: report.thread_id, - } - .insert(&mut transaction) - .await?; - - sqlx::query!( - " - UPDATE reports - SET closed = $1 - WHERE (id = $2) - ", - edit_closed, - id as crate::database::models::ids::ReportId, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - UPDATE threads - SET show_in_mod_inbox = $1 - WHERE id = $2 - ", - !(edit_closed || report.closed), - report.thread_id.0, - ) - .execute(&mut *transaction) - .await?; - } - - // delete any images no longer in the body - let checkable_strings: Vec<&str> = vec![&edit_report.body] - .into_iter() - .filter_map(|x: &Option| x.as_ref().map(|y| y.as_str())) - .collect(); - let image_context = ImageContext::Report { - report_id: Some(id.into()), - }; - img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + let edit_report = edit_report.into_inner(); + v3::reports::report_edit( + req, + pool, + redis, + info, + session_queue, + web::Json(v3::reports::EditReport { + body: edit_report.body, + closed: edit_report.closed, + }), + ).await } #[delete("report/{id}")] @@ -496,35 +159,11 @@ pub async fn report_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::REPORT_DELETE]), - ) - .await?; - - let mut transaction = pool.begin().await?; - - let id = info.into_inner().0; - let context = ImageContext::Report { - report_id: Some(id), - }; - let uploaded_images = - database::models::Image::get_many_contexted(context, &mut transaction).await?; - for image in uploaded_images { - image_item::Image::remove(image.id, &mut transaction, &redis).await?; - } - - let result = - crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction) - .await?; - transaction.commit().await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::reports::report_delete( + req, + pool, + info, + redis, + session_queue, + ).await } diff --git a/src/routes/v2/statistics.rs b/src/routes/v2/statistics.rs index a5220a8e..cb2c3280 100644 --- a/src/routes/v2/statistics.rs +++ b/src/routes/v2/statistics.rs @@ -1,6 +1,5 @@ -use crate::routes::ApiError; +use crate::routes::{ApiError, v3}; use actix_web::{get, web, HttpResponse}; -use serde_json::json; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -9,78 +8,5 @@ pub fn config(cfg: &mut web::ServiceConfig) { #[get("statistics")] pub async fn get_stats(pool: web::Data) -> Result { - let projects = sqlx::query!( - " - SELECT COUNT(id) - FROM mods - WHERE status = ANY($1) - ", - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - ) - .fetch_one(&**pool) - .await?; - - let versions = sqlx::query!( - " - SELECT COUNT(v.id) - FROM versions v - INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) - WHERE v.status = ANY($2) - ", - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - &*crate::models::projects::VersionStatus::iterator() - .filter(|x| x.is_listed()) - .map(|x| x.to_string()) - .collect::>(), - ) - .fetch_one(&**pool) - .await?; - - let authors = sqlx::query!( - " - SELECT COUNT(DISTINCT u.id) - FROM users u - INNER JOIN team_members tm on u.id = tm.user_id AND tm.accepted = TRUE - INNER JOIN mods m on tm.team_id = m.team_id AND m.status = ANY($1) - ", - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - ) - .fetch_one(&**pool) - .await?; - - let files = sqlx::query!( - " - SELECT COUNT(f.id) FROM files f - INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2) - INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) - ", - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - &*crate::models::projects::VersionStatus::iterator() - .filter(|x| x.is_listed()) - .map(|x| x.to_string()) - .collect::>(), - ) - .fetch_one(&**pool) - .await?; - - let json = json!({ - "projects": projects.count, - "versions": versions.count, - "authors": authors.count, - "files": files.count, - }); - - Ok(HttpResponse::Ok().json(json)) + v3::statistics::get_stats(pool).await } diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 6824616a..7a560844 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::categories::{Category, DonationPlatform, ProjectType, ReportType}; use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue}; use crate::database::redis::RedisPool; use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderList}; @@ -38,18 +37,10 @@ pub async fn category_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = Category::list(&**pool, &redis) - .await? - .into_iter() - .map(|x| CategoryData { - icon: x.icon, - name: x.category, - project_type: x.project_type, - header: x.header, - }) - .collect::>(); - - Ok(HttpResponse::Ok().json(results)) + v3::tags::category_list( + pool, + redis, + ).await } #[derive(serde::Serialize, serde::Deserialize)] @@ -150,17 +141,7 @@ pub struct License { #[get("license")] pub async fn license_list() -> HttpResponse { - let licenses = spdx::identifiers::LICENSES; - let mut results: Vec = Vec::with_capacity(licenses.len()); - - for (short, name, _) in licenses { - results.push(License { - short: short.to_string(), - name: name.to_string(), - }); - } - - HttpResponse::Ok().json(results) + v3::tags::license_list().await } #[derive(serde::Serialize)] @@ -171,27 +152,9 @@ pub struct LicenseText { #[get("license/{id}")] pub async fn license_text(params: web::Path<(String,)>) -> Result { - let license_id = params.into_inner().0; - - if license_id == *crate::models::projects::DEFAULT_LICENSE_ID { - return Ok(HttpResponse::Ok().json(LicenseText { - title: "All Rights Reserved".to_string(), - body: "All rights reserved unless explicitly stated.".to_string(), - })); - } - - if let Some(license) = spdx::license_id(&license_id) { - return Ok(HttpResponse::Ok().json(LicenseText { - title: license.full_name.to_string(), - body: license.text().to_string(), - })); - } - - Err(ApiError::InvalidInput( - "Invalid SPDX identifier specified".to_string(), - )) + v3::tags::license_text(params).await } - + #[derive(serde::Serialize)] pub struct DonationPlatformQueryData { short: String, @@ -203,15 +166,10 @@ pub async fn donation_platform_list( pool: web::Data, redis: web::Data, ) -> Result { - let results: Vec = DonationPlatform::list(&**pool, &redis) - .await? - .into_iter() - .map(|x| DonationPlatformQueryData { - short: x.short, - name: x.name, - }) - .collect(); - Ok(HttpResponse::Ok().json(results)) + v3::tags::donation_platform_list( + pool, + redis, + ).await } #[get("report_type")] @@ -219,8 +177,10 @@ pub async fn report_type_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = ReportType::list(&**pool, &redis).await?; - Ok(HttpResponse::Ok().json(results)) + v3::tags::report_type_list( + pool, + redis, + ).await } #[get("project_type")] @@ -228,8 +188,10 @@ pub async fn project_type_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = ProjectType::list(&**pool, &redis).await?; - Ok(HttpResponse::Ok().json(results)) + v3::tags::project_type_list( + pool, + redis, + ).await } #[get("side_type")] diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index 254e6609..39d75fa9 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -1,15 +1,8 @@ -use crate::auth::{get_user_from_headers, is_authorized}; -use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::team_item::TeamAssociationId; -use crate::database::models::{Organization, Team, TeamMember, User}; use crate::database::redis::RedisPool; -use crate::database::Project; -use crate::models::notifications::NotificationBody; -use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId}; use crate::models::users::UserId; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::{ApiError, v3}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -41,75 +34,7 @@ pub async fn team_members_get_project( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - let project_data = crate::database::models::Project::get(&string, &**pool, &redis).await?; - - if let Some(project) = project_data { - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - if !is_authorized(&project.inner, ¤t_user, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - let mut members_data = - TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; - let mut member_user_ids = members_data.iter().map(|x| x.user_id).collect::>(); - - // Adds the organization's team members to the list of members, if the project is associated with an organization - if let Some(oid) = project.inner.organization_id { - let organization_data = Organization::get_id(oid, &**pool, &redis).await?; - if let Some(organization_data) = organization_data { - let org_team = - TeamMember::get_from_team_full(organization_data.team_id, &**pool, &redis) - .await?; - for member in org_team { - if !member_user_ids.contains(&member.user_id) { - member_user_ids.push(member.user_id); - members_data.push(member); - } - } - } - } - - let users = - crate::database::models::User::get_many_ids(&member_user_ids, &**pool, &redis).await?; - - let user_id = current_user.as_ref().map(|x| x.id.into()); - - let logged_in = current_user - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| { - logged_in - || x.accepted - || user_id - .map(|y: crate::database::models::UserId| y == x.user_id) - .unwrap_or(false) - }) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }) - .collect(); - Ok(HttpResponse::Ok().json(team_members)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::teams::team_members_get_project(req, info, pool, redis, session_queue).await } #[get("{id}/members")] @@ -120,61 +45,7 @@ pub async fn team_members_get_organization( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0; - let organization_data = - crate::database::models::Organization::get(&string, &**pool, &redis).await?; - - if let Some(organization) = organization_data { - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::ORGANIZATION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let members_data = - TeamMember::get_from_team_full(organization.team_id, &**pool, &redis).await?; - let users = crate::database::models::User::get_many_ids( - &members_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - - let user_id = current_user.as_ref().map(|x| x.id.into()); - - let logged_in = current_user - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| { - logged_in - || x.accepted - || user_id - .map(|y: crate::database::models::UserId| y == x.user_id) - .unwrap_or(false) - }) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }) - .collect(); - - Ok(HttpResponse::Ok().json(team_members)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::teams::team_members_get_organization(req, info, pool, redis, session_queue).await } // Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) @@ -186,53 +57,7 @@ pub async fn team_members_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let id = info.into_inner().0; - let members_data = TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?; - let users = crate::database::models::User::get_many_ids( - &members_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - let user_id = current_user.as_ref().map(|x| x.id.into()); - - let logged_in = current_user - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| { - logged_in - || x.accepted - || user_id - .map(|y: crate::database::models::UserId| y == x.user_id) - .unwrap_or(false) - }) - .flat_map(|data| { - users - .iter() - .find(|x| x.id == data.user_id) - .map(|user| crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)) - }) - .collect(); - - Ok(HttpResponse::Ok().json(team_members)) + v3::teams::team_members_get(req, info, pool, redis, session_queue).await } #[derive(Serialize, Deserialize)] @@ -248,61 +73,9 @@ pub async fn teams_get( redis: web::Data, session_queue: web::Data, ) -> Result { - use itertools::Itertools; - - let team_ids = serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect::>(); - - let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; - let users = crate::database::models::User::get_many_ids( - &teams_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let teams_groups = teams_data.into_iter().group_by(|data| data.team_id.0); - - let mut teams: Vec> = vec![]; - - for (_, member_data) in &teams_groups { - let members = member_data.collect::>(); - - let logged_in = current_user - .as_ref() - .and_then(|user| { - members - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - - let team_members = members - .into_iter() - .filter(|x| logged_in || x.accepted) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }); - - teams.push(team_members.collect()); - } - - Ok(HttpResponse::Ok().json(teams)) + v3::teams::teams_get(req, web::Query(v3::teams::TeamIds { + ids: ids.ids, + }), pool, redis, session_queue).await } #[post("{id}/join")] @@ -313,53 +86,7 @@ pub async fn join_team( redis: web::Data, session_queue: web::Data, ) -> Result { - let team_id = info.into_inner().0.into(); - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - let member = - TeamMember::get_from_user_id_pending(team_id, current_user.id.into(), &**pool).await?; - - if let Some(member) = member { - if member.accepted { - return Err(ApiError::InvalidInput( - "You are already a member of this team".to_string(), - )); - } - let mut transaction = pool.begin().await?; - - // Edit Team Member to set Accepted to True - TeamMember::edit_team_member( - team_id, - current_user.id.into(), - None, - None, - None, - Some(true), - None, - None, - &mut transaction, - ) - .await?; - - User::clear_project_cache(&[current_user.id.into()], &redis).await?; - TeamMember::clear_cache(team_id, &redis).await?; - - transaction.commit().await?; - } else { - return Err(ApiError::InvalidInput( - "There is no pending request from this team".to_string(), - )); - } - - Ok(HttpResponse::NoContent().body("")) + v3::teams::join_team(req, info, pool, redis, session_queue).await } fn default_role() -> String { @@ -394,165 +121,14 @@ pub async fn add_team_member( redis: web::Data, session_queue: web::Data, ) -> Result { - let team_id = info.into_inner().0.into(); - - let mut transaction = pool.begin().await?; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - let team_association = Team::get_association(team_id, &**pool) - .await? - .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; - let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?; - match team_association { - // If team is associated with a project, check if they have permissions to invite users to that project - TeamAssociationId::Project(pid) => { - let organization = - Organization::get_associated_organization_project_id(pid, &**pool).await?; - let organization_team_member = if let Some(organization) = &organization { - TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) - .await? - } else { - None - }; - let permissions = ProjectPermissions::get_permissions_by_role( - ¤t_user.role, - &member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::MANAGE_INVITES) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to invite users to this team".to_string(), - )); - } - if !permissions.contains(new_member.permissions) { - return Err(ApiError::InvalidInput( - "The new member has permissions that you don't have".to_string(), - )); - } - - if new_member.organization_permissions.is_some() { - return Err(ApiError::InvalidInput( - "The organization permissions of a project team member cannot be set" - .to_string(), - )); - } - } - // If team is associated with an organization, check if they have permissions to invite users to that organization - TeamAssociationId::Organization(_) => { - let organization_permissions = - OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) - .unwrap_or_default(); - if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to invite users to this organization".to_string(), - )); - } - if !organization_permissions - .contains(new_member.organization_permissions.unwrap_or_default()) - { - return Err(ApiError::InvalidInput( - "The new member has organization permissions that you don't have".to_string(), - )); - } - if !organization_permissions - .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) - && !new_member.permissions.is_empty() - { - return Err(ApiError::CustomAuthentication( - "You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)." - .to_string(), - )); - } - } - } - - if new_member.role == crate::models::teams::OWNER_ROLE { - return Err(ApiError::InvalidInput( - "The `Owner` role is restricted to one person".to_string(), - )); - } - - if new_member.payouts_split < Decimal::ZERO || new_member.payouts_split > Decimal::from(5000) { - return Err(ApiError::InvalidInput( - "Payouts split must be between 0 and 5000!".to_string(), - )); - } - - let request = - TeamMember::get_from_user_id_pending(team_id, new_member.user_id.into(), &**pool).await?; - - if let Some(req) = request { - if req.accepted { - return Err(ApiError::InvalidInput( - "The user is already a member of that team".to_string(), - )); - } else { - return Err(ApiError::InvalidInput( - "There is already a pending member request for this user".to_string(), - )); - } - } - crate::database::models::User::get_id(new_member.user_id.into(), &**pool, &redis) - .await? - .ok_or_else(|| ApiError::InvalidInput("An invalid User ID specified".to_string()))?; - - let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?; - TeamMember { - id: new_id, - team_id, - user_id: new_member.user_id.into(), + v3::teams::add_team_member(req, info, pool, web::Json(v3::teams::NewTeamMember{ + user_id: new_member.user_id, role: new_member.role.clone(), permissions: new_member.permissions, organization_permissions: new_member.organization_permissions, - accepted: false, payouts_split: new_member.payouts_split, ordering: new_member.ordering, - } - .insert(&mut transaction) - .await?; - - match team_association { - TeamAssociationId::Project(pid) => { - NotificationBuilder { - body: NotificationBody::TeamInvite { - project_id: pid.into(), - team_id: team_id.into(), - invited_by: current_user.id, - role: new_member.role.clone(), - }, - } - .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; - } - TeamAssociationId::Organization(oid) => { - NotificationBuilder { - body: NotificationBody::OrganizationInvite { - organization_id: oid.into(), - team_id: team_id.into(), - invited_by: current_user.id, - role: new_member.role.clone(), - }, - } - .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; - } - } - - TeamMember::clear_cache(team_id, &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + }), redis, session_queue).await } #[derive(Serialize, Deserialize, Clone)] @@ -573,143 +149,13 @@ pub async fn edit_team_member( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = info.into_inner(); - let id = ids.0.into(); - let user_id = ids.1.into(); - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - let team_association = Team::get_association(id, &**pool) - .await? - .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; - let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; - let edit_member_db = TeamMember::get_from_user_id_pending(id, user_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - ) - })?; - - let mut transaction = pool.begin().await?; - - if &*edit_member_db.role == crate::models::teams::OWNER_ROLE - && (edit_member.role.is_some() || edit_member.permissions.is_some()) - { - return Err(ApiError::InvalidInput( - "The owner's permission and role of a team cannot be edited".to_string(), - )); - } - - match team_association { - TeamAssociationId::Project(project_id) => { - let organization = - Organization::get_associated_organization_project_id(project_id, &**pool).await?; - let organization_team_member = if let Some(organization) = &organization { - TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) - .await? - } else { - None - }; - let permissions = ProjectPermissions::get_permissions_by_role( - ¤t_user.role, - &member.clone(), - &organization_team_member, - ) - .unwrap_or_default(); - if !permissions.contains(ProjectPermissions::EDIT_MEMBER) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - )); - } - - if let Some(new_permissions) = edit_member.permissions { - if !permissions.contains(new_permissions) { - return Err(ApiError::InvalidInput( - "The new permissions have permissions that you don't have".to_string(), - )); - } - } - - if edit_member.organization_permissions.is_some() { - return Err(ApiError::InvalidInput( - "The organization permissions of a project team member cannot be edited" - .to_string(), - )); - } - } - TeamAssociationId::Organization(_) => { - let organization_permissions = - OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) - .unwrap_or_default(); - - if !organization_permissions.contains(OrganizationPermissions::EDIT_MEMBER) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - )); - } - - if let Some(new_permissions) = edit_member.organization_permissions { - if !organization_permissions.contains(new_permissions) { - return Err(ApiError::InvalidInput( - "The new organization permissions have permissions that you don't have" - .to_string(), - )); - } - } - - if edit_member.permissions.is_some() - && !organization_permissions - .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) - { - return Err(ApiError::CustomAuthentication( - "You do not have permission to give this user default project permissions." - .to_string(), - )); - } - } - } - - if let Some(payouts_split) = edit_member.payouts_split { - if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000) { - return Err(ApiError::InvalidInput( - "Payouts split must be between 0 and 5000!".to_string(), - )); - } - } - - if edit_member.role.as_deref() == Some(crate::models::teams::OWNER_ROLE) { - return Err(ApiError::InvalidInput( - "The `Owner` role is restricted to one person".to_string(), - )); - } - - TeamMember::edit_team_member( - id, - user_id, - edit_member.permissions, - edit_member.organization_permissions, - edit_member.role.clone(), - None, - edit_member.payouts_split, - edit_member.ordering, - &mut transaction, - ) - .await?; - - TeamMember::clear_cache(id, &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::teams::edit_team_member(req, info, pool, web::Json(v3::teams::EditTeamMember{ + permissions: edit_member.permissions, + organization_permissions: edit_member.organization_permissions, + role: edit_member.role.clone(), + payouts_split: edit_member.payouts_split, + ordering: edit_member.ordering, + }), redis, session_queue).await } #[derive(Deserialize)] @@ -726,94 +172,9 @@ pub async fn transfer_ownership( redis: web::Data, session_queue: web::Data, ) -> Result { - let id = info.into_inner().0; - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - // Forbid transferring ownership of a project team that is owned by an organization - // These are owned by the organization owner, and must be removed from the organization first - let pid = Team::get_association(id.into(), &**pool).await?; - if let Some(TeamAssociationId::Project(pid)) = pid { - let result = Project::get_id(pid, &**pool, &redis).await?; - if let Some(project_item) = result { - if project_item.inner.organization_id.is_some() { - return Err(ApiError::InvalidInput( - "You cannot transfer ownership of a project team that is owend by an organization" - .to_string(), - )); - } - } - } - - if !current_user.role.is_admin() { - let member = TeamMember::get_from_user_id(id.into(), current_user.id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team".to_string(), - ) - })?; - - if member.role != crate::models::teams::OWNER_ROLE { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit the ownership of this team".to_string(), - )); - } - } - - let new_member = TeamMember::get_from_user_id(id.into(), new_owner.user_id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The new owner specified does not exist".to_string()) - })?; - - if !new_member.accepted { - return Err(ApiError::InvalidInput( - "You can only transfer ownership to members who are currently in your team".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - - TeamMember::edit_team_member( - id.into(), - current_user.id.into(), - None, - None, - Some(crate::models::teams::DEFAULT_ROLE.to_string()), - None, - None, - None, - &mut transaction, - ) - .await?; - - TeamMember::edit_team_member( - id.into(), - new_owner.user_id.into(), - Some(ProjectPermissions::all()), - Some(OrganizationPermissions::all()), - Some(crate::models::teams::OWNER_ROLE.to_string()), - None, - None, - None, - &mut transaction, - ) - .await?; - - TeamMember::clear_cache(id.into(), &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::teams::transfer_ownership(req, info, pool, web::Json(v3::teams::TransferOwnership{ + user_id: new_owner.user_id, + }), redis, session_queue).await } #[delete("{id}/members/{user_id}")] @@ -824,126 +185,5 @@ pub async fn remove_team_member( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = info.into_inner(); - let id = ids.0.into(); - let user_id = ids.1.into(); - - let current_user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_WRITE]), - ) - .await? - .1; - - let team_association = Team::get_association(id, &**pool) - .await? - .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; - let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; - - let delete_member = TeamMember::get_from_user_id_pending(id, user_id, &**pool).await?; - - if let Some(delete_member) = delete_member { - if delete_member.role == crate::models::teams::OWNER_ROLE { - // The owner cannot be removed from a team - return Err(ApiError::CustomAuthentication( - "The owner can't be removed from a team".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - - // Organization attached to a project this team is attached to - match team_association { - TeamAssociationId::Project(pid) => { - let organization = - Organization::get_associated_organization_project_id(pid, &**pool).await?; - let organization_team_member = if let Some(organization) = &organization { - TeamMember::get_from_user_id( - organization.team_id, - current_user.id.into(), - &**pool, - ) - .await? - } else { - None - }; - let permissions = ProjectPermissions::get_permissions_by_role( - ¤t_user.role, - &member, - &organization_team_member, - ) - .unwrap_or_default(); - - if delete_member.accepted { - // Members other than the owner can either leave the team, or be - // removed by a member with the REMOVE_MEMBER permission. - if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) - || permissions.contains(ProjectPermissions::REMOVE_MEMBER) - // true as if the permission exists, but the member does not, they are part of an org - { - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to remove a member from this team" - .to_string(), - )); - } - } else if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) - || permissions.contains(ProjectPermissions::MANAGE_INVITES) - // true as if the permission exists, but the member does not, they are part of an org - { - // This is a pending invite rather than a member, so the - // user being invited or team members with the MANAGE_INVITES - // permission can remove it. - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to cancel a team invite".to_string(), - )); - } - } - TeamAssociationId::Organization(_) => { - let organization_permissions = - OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) - .unwrap_or_default(); - // Organization teams requires a TeamMember, so we can 'unwrap' - if delete_member.accepted { - // Members other than the owner can either leave the team, or be - // removed by a member with the REMOVE_MEMBER permission. - if Some(delete_member.user_id) == member.map(|m| m.user_id) - || organization_permissions.contains(OrganizationPermissions::REMOVE_MEMBER) - { - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to remove a member from this organization" - .to_string(), - )); - } - } else if Some(delete_member.user_id) == member.map(|m| m.user_id) - || organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) - { - // This is a pending invite rather than a member, so the - // user being invited or team members with the MANAGE_INVITES - // permission can remove it. - TeamMember::delete(id, user_id, &mut transaction).await?; - } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to cancel an organization invite".to_string(), - )); - } - } - } - - TeamMember::clear_cache(id, &redis).await?; - User::clear_project_cache(&[delete_member.user_id], &redis).await?; - - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::teams::remove_team_member(req, info, pool, redis, session_queue).await } diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index 44e76efc..c9c5d8ef 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -1,23 +1,12 @@ use std::sync::Arc; -use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; -use crate::database; -use crate::database::models::image_item; -use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::ThreadMessageId; -use crate::models::images::{Image, ImageContext}; -use crate::models::notifications::NotificationBody; -use crate::models::pats::Scopes; -use crate::models::projects::ProjectStatus; -use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType}; -use crate::models::users::User; +use crate::models::threads::{MessageBody, ThreadId}; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::{ApiError, v3}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; -use futures::TryStreamExt; use serde::Deserialize; use sqlx::PgPool; @@ -33,194 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(threads_get); } -pub async fn is_authorized_thread( - thread: &database::models::Thread, - user: &User, - pool: &PgPool, -) -> Result { - if user.role.is_mod() { - return Ok(true); - } - - let user_id: database::models::UserId = user.id.into(); - Ok(match thread.type_ { - ThreadType::Report => { - if let Some(report_id) = thread.report_id { - let report_exists = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)", - report_id as database::models::ids::ReportId, - user_id as database::models::ids::UserId, - ) - .fetch_one(pool) - .await? - .exists; - - report_exists.unwrap_or(false) - } else { - false - } - } - ThreadType::Project => { - if let Some(project_id) = thread.project_id { - let project_exists = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)", - project_id as database::models::ids::ProjectId, - user_id as database::models::ids::UserId, - ) - .fetch_one(pool) - .await? - .exists; - - project_exists.unwrap_or(false) - } else { - false - } - } - ThreadType::DirectMessage => thread.members.contains(&user_id), - }) -} - -pub async fn filter_authorized_threads( - threads: Vec, - user: &User, - pool: &web::Data, - redis: &RedisPool, -) -> Result, ApiError> { - let user_id: database::models::UserId = user.id.into(); - - let mut return_threads = Vec::new(); - let mut check_threads = Vec::new(); - - for thread in threads { - if user.role.is_mod() - || (thread.type_ == ThreadType::DirectMessage && thread.members.contains(&user_id)) - { - return_threads.push(thread); - } else { - check_threads.push(thread); - } - } - - if !check_threads.is_empty() { - let project_thread_ids = check_threads - .iter() - .filter(|x| x.type_ == ThreadType::Project) - .flat_map(|x| x.project_id.map(|x| x.0)) - .collect::>(); - - if !project_thread_ids.is_empty() { - sqlx::query!( - " - SELECT m.id FROM mods m - INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 - WHERE m.id = ANY($1) - ", - &*project_thread_ids, - user_id as database::models::ids::UserId, - ) - .fetch_many(&***pool) - .try_for_each(|e| { - if let Some(row) = e.right() { - check_threads.retain(|x| { - let bool = x.project_id.map(|x| x.0) == Some(row.id); - - if bool { - return_threads.push(x.clone()); - } - - !bool - }); - } - - futures::future::ready(Ok(())) - }) - .await?; - } - - let report_thread_ids = check_threads - .iter() - .filter(|x| x.type_ == ThreadType::Report) - .flat_map(|x| x.report_id.map(|x| x.0)) - .collect::>(); - - if !report_thread_ids.is_empty() { - sqlx::query!( - " - SELECT id FROM reports - WHERE id = ANY($1) AND reporter = $2 - ", - &*report_thread_ids, - user_id as database::models::ids::UserId, - ) - .fetch_many(&***pool) - .try_for_each(|e| { - if let Some(row) = e.right() { - check_threads.retain(|x| { - let bool = x.report_id.map(|x| x.0) == Some(row.id); - - if bool { - return_threads.push(x.clone()); - } - - !bool - }); - } - - futures::future::ready(Ok(())) - }) - .await?; - } - } - - let mut user_ids = return_threads - .iter() - .flat_map(|x| x.members.clone()) - .collect::>(); - user_ids.append( - &mut return_threads - .iter() - .flat_map(|x| { - x.messages - .iter() - .filter_map(|x| x.author_id) - .collect::>() - }) - .collect::>(), - ); - - let users: Vec = database::models::User::get_many_ids(&user_ids, &***pool, redis) - .await? - .into_iter() - .map(From::from) - .collect(); - - let mut final_threads = Vec::new(); - - for thread in return_threads { - let mut authors = thread.members.clone(); - - authors.append( - &mut thread - .messages - .iter() - .filter_map(|x| x.author_id) - .collect::>(), - ); - - final_threads.push(Thread::from( - thread, - users - .iter() - .filter(|x| authors.contains(&x.id.into())) - .cloned() - .collect(), - user, - )); - } - - Ok(final_threads) -} - #[get("{id}")] pub async fn thread_get( req: HttpRequest, @@ -229,42 +30,7 @@ pub async fn thread_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let string = info.into_inner().0.into(); - - let thread_data = database::models::Thread::get(string, &**pool).await?; - - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_READ]), - ) - .await? - .1; - - if let Some(mut data) = thread_data { - if is_authorized_thread(&data, &user, &pool).await? { - let authors = &mut data.members; - - authors.append( - &mut data - .messages - .iter() - .filter_map(|x| x.author_id) - .collect::>(), - ); - - let users: Vec = database::models::User::get_many_ids(authors, &**pool, &redis) - .await? - .into_iter() - .map(From::from) - .collect(); - - return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user))); - } - } - Ok(HttpResponse::NotFound().body("")) + v3::threads::thread_get(req, info, pool, redis, session_queue).await } #[derive(Deserialize)] @@ -280,27 +46,11 @@ pub async fn threads_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_READ]), - ) - .await? - .1; - - let thread_ids: Vec = - serde_json::from_str::>(&ids.ids)? - .into_iter() - .map(|x| x.into()) - .collect(); - - let threads_data = database::models::Thread::get_many(&thread_ids, &**pool).await?; - - let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?; - - Ok(HttpResponse::Ok().json(threads)) + v3::threads::threads_get(req, web::Query( + v3::threads::ThreadIds { + ids: ids.ids + }, + ), pool, redis, session_queue).await } #[derive(Deserialize)] @@ -317,193 +67,12 @@ pub async fn thread_send_message( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_WRITE]), - ) - .await? - .1; - - let string: database::models::ThreadId = info.into_inner().0.into(); - - if let MessageBody::Text { - body, - replying_to, - private, - .. - } = &new_message.body - { - if body.len() > 65536 { - return Err(ApiError::InvalidInput( - "Input body is too long!".to_string(), - )); - } - - if *private && !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You are not allowed to send private messages!".to_string(), - )); - } - - if let Some(replying_to) = replying_to { - let thread_message = - database::models::ThreadMessage::get((*replying_to).into(), &**pool).await?; - - if let Some(thread_message) = thread_message { - if thread_message.thread_id != string { - return Err(ApiError::InvalidInput( - "Message replied to is from another thread!".to_string(), - )); - } - } else { - return Err(ApiError::InvalidInput( - "Message replied to does not exist!".to_string(), - )); - } - } - } else { - return Err(ApiError::InvalidInput( - "You may only send text messages through this route!".to_string(), - )); - } - - let result = database::models::Thread::get(string, &**pool).await?; - - if let Some(thread) = result { - if !is_authorized_thread(&thread, &user, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - let mut transaction = pool.begin().await?; - - let id = ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: new_message.body.clone(), - thread_id: thread.id, - } - .insert(&mut transaction) - .await?; - - let mod_notif = if let Some(project_id) = thread.project_id { - let project = database::models::Project::get_id(project_id, &**pool, &redis).await?; - - if let Some(project) = project { - if project.inner.status != ProjectStatus::Processing && user.role.is_mod() { - let members = database::models::TeamMember::get_from_team_full( - project.inner.team_id, - &**pool, - &redis, - ) - .await?; - - NotificationBuilder { - body: NotificationBody::ModeratorMessage { - thread_id: thread.id.into(), - message_id: id.into(), - project_id: Some(project.inner.id.into()), - report_id: None, - }, - } - .insert_many( - members.into_iter().map(|x| x.user_id).collect(), - &mut transaction, - &redis, - ) - .await?; - } - } - - !user.role.is_mod() - } else if let Some(report_id) = thread.report_id { - let report = database::models::report_item::Report::get(report_id, &**pool).await?; - - if let Some(report) = report { - if report.closed && !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You may not reply to a closed report".to_string(), - )); - } - - if user.id != report.reporter.into() { - NotificationBuilder { - body: NotificationBody::ModeratorMessage { - thread_id: thread.id.into(), - message_id: id.into(), - project_id: None, - report_id: Some(report.id.into()), - }, - } - .insert(report.reporter, &mut transaction, &redis) - .await?; - } - } - - !user.role.is_mod() - } else { - false - }; - - sqlx::query!( - " - UPDATE threads - SET show_in_mod_inbox = $1 - WHERE id = $2 - ", - mod_notif, - thread.id.0, - ) - .execute(&mut *transaction) - .await?; - - if let MessageBody::Text { - associated_images, .. - } = &new_message.body - { - for image_id in associated_images { - if let Some(db_image) = - image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await? - { - let image: Image = db_image.into(); - if !matches!(image.context, ImageContext::ThreadMessage { .. }) - || image.context.inner_id().is_some() - { - return Err(ApiError::InvalidInput(format!( - "Image {} is not unused and in the 'thread_message' context", - image_id - ))); - } - - sqlx::query!( - " - UPDATE uploaded_images - SET thread_message_id = $1 - WHERE id = $2 - ", - thread.id.0, - image_id.0 as i64 - ) - .execute(&mut *transaction) - .await?; - - image_item::Image::clear_cache(image.id.into(), &redis).await?; - } else { - return Err(ApiError::InvalidInput(format!( - "Image {} does not exist", - image_id - ))); - } - } - } - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + let new_message = new_message.into_inner(); + v3::threads::thread_send_message(req, info, pool, web::Json( + v3::threads::NewThreadMessage { + body: new_message.body + }, + ), redis, session_queue).await } #[get("inbox")] @@ -513,30 +82,7 @@ pub async fn moderation_inbox( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_READ]), - ) - .await?; - - let ids = sqlx::query!( - " - SELECT id - FROM threads - WHERE show_in_mod_inbox = TRUE - " - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ThreadId(m.id))) }) - .try_collect::>() - .await?; - - let threads_data = database::models::Thread::get_many(&ids, &**pool).await?; - let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?; - Ok(HttpResponse::Ok().json(threads)) + v3::threads::moderation_inbox(req, pool, redis, session_queue).await } #[post("{id}/read")] @@ -547,32 +93,7 @@ pub async fn thread_read( redis: web::Data, session_queue: web::Data, ) -> Result { - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_READ]), - ) - .await?; - - let id = info.into_inner().0; - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE threads - SET show_in_mod_inbox = FALSE - WHERE id = $1 - ", - id.0 as i64, - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) + v3::threads::thread_read(req, info, pool, redis, session_queue).await } #[delete("{id}")] @@ -584,45 +105,5 @@ pub async fn message_delete( session_queue: web::Data, file_host: web::Data>, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::THREAD_WRITE]), - ) - .await? - .1; - - let result = database::models::ThreadMessage::get(info.into_inner().0.into(), &**pool).await?; - - if let Some(thread) = result { - if !user.role.is_mod() && thread.author_id != Some(user.id.into()) { - return Err(ApiError::CustomAuthentication( - "You cannot delete this message!".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - - let context = ImageContext::ThreadMessage { - thread_message_id: Some(thread.id.into()), - }; - let images = database::Image::get_many_contexted(context, &mut transaction).await?; - let cdn_url = dotenvy::var("CDN_URL")?; - for image in images { - let name = image.url.split(&format!("{cdn_url}/")).nth(1); - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - database::Image::remove(image.id, &mut transaction, &redis).await?; - } - - database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?; - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::threads::message_delete(req, info, pool, redis, session_queue, file_host).await } diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index f113ce52..e9b2d594 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -1,26 +1,18 @@ -use crate::auth::get_user_from_headers; -use crate::database::models::User; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; -use crate::models::collections::{Collection, CollectionStatus}; -use crate::models::notifications::Notification; -use crate::models::pats::Scopes; use crate::models::projects::Project; use crate::models::users::{ - Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData, + Badges, Role, }; use crate::models::v2::projects::LegacyProject; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::{v2_reroute, v3, ApiError}; -use crate::util::routes::read_from_payload; -use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use lazy_static::lazy_static; use regex::Regex; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use serde_json::json; use sqlx::PgPool; use std::sync::Arc; use tokio::sync::Mutex; @@ -53,24 +45,7 @@ pub async fn user_auth_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let (scopes, mut user) = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_READ]), - ) - .await?; - - if !scopes.contains(Scopes::USER_READ_EMAIL) { - user.email = None; - } - - if !scopes.contains(Scopes::PAYOUTS_READ) { - user.payout_data = None; - } - - Ok(HttpResponse::Ok().json(user)) + v3::users::user_auth_get(req, pool, redis, session_queue).await } #[derive(Serialize, Deserialize)] @@ -84,13 +59,9 @@ pub async fn users_get( pool: web::Data, redis: web::Data, ) -> Result { - let user_ids = serde_json::from_str::>(&ids.ids)?; - - let users_data = User::get_many(&user_ids, &**pool, &redis).await?; - - let users: Vec = users_data.into_iter().map(From::from).collect(); - - Ok(HttpResponse::Ok().json(users)) + v3::users::users_get(web::Query(v3::users::UserIds { + ids: ids.ids, + }), pool, redis).await } #[get("{id}")] @@ -99,14 +70,7 @@ pub async fn user_get( pool: web::Data, redis: web::Data, ) -> Result { - let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(data) = user_data { - let response: crate::models::users::User = data.into(); - Ok(HttpResponse::Ok().json(response)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_get(info, pool, redis).await } #[get("{user_id}/projects")] @@ -138,40 +102,7 @@ pub async fn collections_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::COLLECTION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - let user_id: UserId = id.into(); - - let can_view_private = user - .map(|y| y.role.is_mod() || y.id == user_id) - .unwrap_or(false); - - let project_data = User::get_collections(id, &**pool).await?; - - let response: Vec<_> = - crate::database::models::Collection::get_many(&project_data, &**pool, &redis) - .await? - .into_iter() - .filter(|x| can_view_private || matches!(x.status, CollectionStatus::Listed)) - .map(Collection::from) - .collect(); - - Ok(HttpResponse::Ok().json(response)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::collections_list(req, info, pool, redis, session_queue).await } lazy_static! { @@ -209,137 +140,16 @@ pub async fn user_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let (_scopes, user) = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_WRITE]), - ) - .await?; - - new_user - .validate() - .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(actual_user) = id_option { - let id = actual_user.id; - let user_id: UserId = id.into(); - - if user.id == user_id || user.role.is_mod() { - let mut transaction = pool.begin().await?; - - if let Some(username) = &new_user.username { - let existing_user_id_option = User::get(username, &**pool, &redis).await?; - - if existing_user_id_option - .map(|x| UserId::from(x.id)) - .map(|id| id == user.id) - .unwrap_or(true) - { - sqlx::query!( - " - UPDATE users - SET username = $1 - WHERE (id = $2) - ", - username, - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } else { - return Err(ApiError::InvalidInput(format!( - "Username {username} is taken!" - ))); - } - } - - if let Some(name) = &new_user.name { - sqlx::query!( - " - UPDATE users - SET name = $1 - WHERE (id = $2) - ", - name.as_deref(), - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(bio) = &new_user.bio { - sqlx::query!( - " - UPDATE users - SET bio = $1 - WHERE (id = $2) - ", - bio.as_deref(), - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(role) = &new_user.role { - if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the role of this user!" - .to_string(), - )); - } - - let role = role.to_string(); - - sqlx::query!( - " - UPDATE users - SET role = $1 - WHERE (id = $2) - ", - role, - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(badges) = &new_user.badges { - if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the badges of this user!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE users - SET badges = $1 - WHERE (id = $2) - ", - badges.bits() as i64, - id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } - - User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?; - transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this user!".to_string(), - )) + let new_user = new_user.into_inner(); + v3::users::user_edit(req, info, web::Json( + v3::users::EditUser { + username: new_user.username, + name: new_user.name, + bio: new_user.bio, + role: new_user.role, + badges: new_user.badges, } - } else { - Ok(HttpResponse::NotFound().body("")) - } + ), pool, redis, session_queue).await } #[derive(Serialize, Deserialize)] @@ -356,75 +166,19 @@ pub async fn user_icon_edit( pool: web::Data, redis: web::Data, file_host: web::Data>, - mut payload: web::Payload, + payload: web::Payload, session_queue: web::Data, ) -> Result { - if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { - let cdn_url = dotenvy::var("CDN_URL")?; - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_WRITE]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(actual_user) = id_option { - if user.id != actual_user.id.into() && !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "You don't have permission to edit this user's icon.".to_string(), - )); - } - - let icon_url = actual_user.avatar_url; - let user_id: UserId = actual_user.id.into(); - - if let Some(icon) = icon_url { - let name = icon.split(&format!("{cdn_url}/")).nth(1); - - if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; - } - } - - let bytes = - read_from_payload(&mut payload, 2097152, "Icons must be smaller than 2MiB").await?; - - let hash = sha1::Sha1::from(&bytes).hexdigest(); - let upload_data = file_host - .upload_file( - content_type, - &format!("user/{}/{}.{}", user_id, hash, ext.ext), - bytes.freeze(), - ) - .await?; - - sqlx::query!( - " - UPDATE users - SET avatar_url = $1 - WHERE (id = $2) - ", - format!("{}/{}", cdn_url, upload_data.file_name), - actual_user.id as crate::database::models::ids::UserId, - ) - .execute(&**pool) - .await?; - User::clear_caches(&[(actual_user.id, None)], &redis).await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } - } else { - Err(ApiError::InvalidInput(format!( - "Invalid format for user icon: {}", - ext.ext - ))) - } + v3::users::user_icon_edit( + web::Query(v3::users::Extension { ext: ext.ext }), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ).await } #[derive(Deserialize)] @@ -446,44 +200,11 @@ pub async fn user_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_DELETE]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to delete this user!".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - - let result = User::remove( - id, - removal_type.removal_type == "full", - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + let removal_type = removal_type.into_inner(); + v3::users::user_delete(req, info, pool, + web::Query(v3::users::RemovalType { + removal_type: removal_type.removal_type, + }), redis, session_queue).await } #[get("{id}/follows")] @@ -494,52 +215,7 @@ pub async fn user_follows( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::USER_READ]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to see the projects this user follows!".to_string(), - )); - } - - use futures::TryStreamExt; - - let project_ids = sqlx::query!( - " - SELECT mf.mod_id FROM mod_follows mf - WHERE mf.follower_id = $1 - ", - id as crate::database::models::ids::UserId, - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right() - .map(|m| crate::database::models::ProjectId(m.mod_id))) - }) - .try_collect::>() - .await?; - - let projects: Vec<_> = - crate::database::Project::get_many_ids(&project_ids, &**pool, &redis) - .await? - .into_iter() - .map(Project::from) - .collect(); - - Ok(HttpResponse::Ok().json(projects)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_follows(req, info, pool, redis, session_queue).await } #[get("{id}/notifications")] @@ -550,39 +226,7 @@ pub async fn user_notifications( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::NOTIFICATION_READ]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to see the notifications of this user!".to_string(), - )); - } - - let mut notifications: Vec = - crate::database::models::notification_item::Notification::get_many_user( - id, &**pool, &redis, - ) - .await? - .into_iter() - .map(Into::into) - .collect(); - - notifications.sort_by(|a, b| b.created.cmp(&a.created)); - - Ok(HttpResponse::Ok().json(notifications)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_notifications(req, info, pool, redis, session_queue).await } #[get("{id}/payouts")] @@ -593,74 +237,7 @@ pub async fn user_payouts( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_READ]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to see the payouts of this user!".to_string(), - )); - } - - let (all_time, last_month, payouts) = futures::future::try_join3( - sqlx::query!( - " - SELECT SUM(pv.amount) amount - FROM payouts_values pv - WHERE pv.user_id = $1 - ", - id as crate::database::models::UserId - ) - .fetch_one(&**pool), - sqlx::query!( - " - SELECT SUM(pv.amount) amount - FROM payouts_values pv - WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval - ", - id as crate::database::models::UserId - ) - .fetch_one(&**pool), - sqlx::query!( - " - SELECT hp.created, hp.amount, hp.status - FROM historical_payouts hp - WHERE hp.user_id = $1 - ORDER BY hp.created DESC - ", - id as crate::database::models::UserId - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right().map(|row| Payout { - created: row.created, - amount: row.amount, - status: PayoutStatus::from_string(&row.status), - })) - }) - .try_collect::>(), - ) - .await?; - - use futures::TryStreamExt; - - Ok(HttpResponse::Ok().json(json!({ - "all_time": all_time.amount, - "last_month": last_month.amount, - "payouts": payouts, - }))) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_payouts(req, info, pool, redis, session_queue).await } #[derive(Deserialize)] @@ -678,44 +255,15 @@ pub async fn user_payouts_fees( session_queue: web::Data, payouts_queue: web::Data>, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_READ]), - ) - .await? - .1; - let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(actual_user) = actual_user { - if !user.role.is_admin() && user.id != actual_user.id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to request payouts of this user!".to_string(), - )); - } - - if let Some(UserPayoutData { - trolley_id: Some(trolley_id), - .. - }) = user.payout_data - { - let payouts = payouts_queue - .lock() - .await - .get_estimated_fees(&trolley_id, amount.amount) - .await?; - - Ok(HttpResponse::Ok().json(payouts)) - } else { - Err(ApiError::InvalidInput( - "You must set up your trolley account first!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_payouts_fees( + req, + info, + web::Query(v3::users::FeeEstimateAmount { amount: amount.amount }), + pool, + redis, + session_queue, + payouts_queue, + ).await } #[derive(Deserialize)] @@ -733,87 +281,13 @@ pub async fn user_payouts_request( redis: web::Data, session_queue: web::Data, ) -> Result { - let mut payouts_queue = payouts_queue.lock().await; - - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_WRITE]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to request payouts of this user!".to_string(), - )); - } - - if let Some(UserPayoutData { - trolley_id: Some(trolley_id), - trolley_status: Some(trolley_status), - balance, - .. - }) = user.payout_data - { - if trolley_status == RecipientStatus::Active { - return if data.amount < balance { - let mut transaction = pool.begin().await?; - - let (batch_id, payment_id) = - payouts_queue.send_payout(&trolley_id, data.amount).await?; - - sqlx::query!( - " - INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id) - VALUES ($1, $2, $3, $4, $5) - ", - id as crate::database::models::ids::UserId, - data.amount, - "processing", - batch_id, - payment_id, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - UPDATE users - SET balance = balance - $1 - WHERE id = $2 - ", - data.amount, - id as crate::database::models::ids::UserId - ) - .execute(&mut *transaction) - .await?; - - User::clear_caches(&[(id, None)], &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput( - "You do not have enough funds to make this payout!".to_string(), - )) - }; - } else { - return Err(ApiError::InvalidInput( - "Please complete payout information via the trolley dashboard!".to_string(), - )); - } - } - - Err(ApiError::InvalidInput( - "You are not enrolled in the payouts program yet!".to_string(), - )) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::user_payouts_request( + req, + info, + pool, + web::Json(v3::users::PayoutData { amount: data.amount }), + payouts_queue, + redis, + session_queue, + ).await } diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 7c3c56bb..13dd654a 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -1,11 +1,6 @@ use super::ApiError; -use crate::auth::{get_user_from_headers, is_authorized_version}; -use crate::database; - use crate::database::redis::RedisPool; -use crate::models::pats::Scopes; use crate::models::projects::{Project, Version, VersionType}; -use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::{LegacyProject, LegacyVersion}; use crate::queue::session::AuthQueue; use crate::routes::v3::version_file::{default_algorithm, HashQuery}; @@ -57,11 +52,6 @@ pub async fn get_version_from_hash( } } -#[derive(Serialize, Deserialize)] -pub struct DownloadRedirect { - pub url: String, -} - // under /api/v1/version_file/{hash}/download #[get("{version_id}/download")] pub async fn download_version( @@ -72,44 +62,8 @@ pub async fn download_version( hash_query: web::Query, session_queue: web::Data, ) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let hash = info.into_inner().0.to_lowercase(); - let file = database::models::Version::get_file_from_hash( - hash_query.algorithm.clone(), - hash, - hash_query.version_id.map(|x| x.into()), - &**pool, - &redis, - ) - .await?; - - if let Some(file) = file { - let version = database::models::Version::get(file.version_id, &**pool, &redis).await?; - - if let Some(version) = version { - if !is_authorized_version(&version.inner, &user_option, &pool).await? { - return Ok(HttpResponse::NotFound().body("")); - } - - Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*file.url)) - .json(DownloadRedirect { url: file.url })) - } else { - Ok(HttpResponse::NotFound().body("")) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::version_file::download_version(req, info, pool, redis, hash_query, session_queue) + .await } // under /api/v1/version_file/{hash} @@ -122,110 +76,7 @@ pub async fn delete_file( hash_query: web::Query, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_WRITE]), - ) - .await? - .1; - - let hash = info.into_inner().0.to_lowercase(); - - let file = database::models::Version::get_file_from_hash( - hash_query.algorithm.clone(), - hash, - hash_query.version_id.map(|x| x.into()), - &**pool, - &redis, - ) - .await?; - - if let Some(row) = file { - if !user.role.is_admin() { - let team_member = database::models::TeamMember::get_from_user_id_version( - row.version_id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let organization = - database::models::Organization::get_associated_organization_project_id( - row.project_id, - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let organization_team_member = if let Some(organization) = &organization { - database::models::TeamMember::get_from_user_id_organization( - organization.id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)? - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::DELETE_VERSION) { - return Err(ApiError::CustomAuthentication( - "You don't have permission to delete this file!".to_string(), - )); - } - } - - let version = database::models::Version::get(row.version_id, &**pool, &redis).await?; - if let Some(version) = version { - if version.files.len() < 2 { - return Err(ApiError::InvalidInput( - "Versions must have at least one file uploaded to them".to_string(), - )); - } - - database::models::Version::clear_cache(&version, &redis).await?; - } - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - DELETE FROM hashes - WHERE file_id = $1 - ", - row.id.0 - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - DELETE FROM files - WHERE files.id = $1 - ", - row.id.0, - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::version_file::delete_file(req, info, pool, redis, hash_query, session_queue).await } #[derive(Serialize, Deserialize)] diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index f4d6717c..8276d133 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -1,16 +1,10 @@ use std::collections::HashMap; use super::ApiError; -use crate::auth::get_user_from_headers; -use crate::database; -use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; use crate::models::ids::VersionId; -use crate::models::images::ImageContext; -use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, Version, VersionStatus, VersionType}; -use crate::models::teams::ProjectPermissions; use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; use crate::routes::{v2_reroute, v3}; @@ -273,92 +267,17 @@ pub async fn version_schedule( scheduling_data: web::Json, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_WRITE]), - ) - .await? - .1; - - if scheduling_data.time < Utc::now() { - return Err(ApiError::InvalidInput( - "You cannot schedule a version to be released in the past!".to_string(), - )); - } - - if !scheduling_data.requested_status.can_be_requested() { - return Err(ApiError::InvalidInput( - "Specified requested status cannot be requested!".to_string(), - )); - } - - let string = info.into_inner().0; - let result = database::models::Version::get(string.into(), &**pool, &redis).await?; - - if let Some(version_item) = result { - let team_member = database::models::TeamMember::get_from_user_id_project( - version_item.inner.project_id, - user.id.into(), - &**pool, - ) - .await?; - - let organization_item = - database::models::Organization::get_associated_organization_project_id( - version_item.inner.project_id, - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let organization_team_member = if let Some(organization) = &organization_item { - database::models::TeamMember::get_from_user_id( - organization.team_id, - user.id.into(), - &**pool, - ) - .await? - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to edit this version's scheduling data!".to_string(), - )); - } - - let mut transaction = pool.begin().await?; - sqlx::query!( - " - UPDATE versions - SET status = $1, date_published = $2 - WHERE (id = $3) - ", - VersionStatus::Scheduled.as_str(), - scheduling_data.time, - version_item.inner.id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - database::models::Version::clear_cache(&version_item, &redis).await?; - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::versions::version_schedule( + req, + info, + pool, + redis, + web::Json(v3::versions::SchedulingData { + time: scheduling_data.time, + requested_status: scheduling_data.requested_status, + }), + session_queue, + ).await } #[delete("{version_id}")] @@ -369,81 +288,5 @@ pub async fn version_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::VERSION_DELETE]), - ) - .await? - .1; - let id = info.into_inner().0; - - let version = database::models::Version::get(id.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("The specified version does not exist!".to_string()) - })?; - - if !user.role.is_admin() { - let team_member = database::models::TeamMember::get_from_user_id_project( - version.inner.project_id, - user.id.into(), - &**pool, - ) - .await - .map_err(ApiError::Database)?; - - let organization = - Organization::get_associated_organization_project_id(version.inner.project_id, &**pool) - .await?; - - let organization_team_member = if let Some(organization) = &organization { - database::models::TeamMember::get_from_user_id( - organization.team_id, - user.id.into(), - &**pool, - ) - .await? - } else { - None - }; - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ) - .unwrap_or_default(); - - if !permissions.contains(ProjectPermissions::DELETE_VERSION) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to delete versions in this team".to_string(), - )); - } - } - - let mut transaction = pool.begin().await?; - let context = ImageContext::Version { - version_id: Some(version.inner.id.into()), - }; - let uploaded_images = - database::models::Image::get_many_contexted(context, &mut transaction).await?; - for image in uploaded_images { - image_item::Image::remove(image.id, &mut transaction, &redis).await?; - } - - let result = - database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?; - - database::models::Project::clear_cache(version.inner.project_id, None, Some(true), &redis) - .await?; - - transaction.commit().await?; - - if result.is_some() { - Ok(HttpResponse::NoContent().body("")) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::versions::version_delete(req, info, pool, redis, session_queue).await } diff --git a/src/routes/v3/analytics_get.rs b/src/routes/v3/analytics_get.rs new file mode 100644 index 00000000..82a13467 --- /dev/null +++ b/src/routes/v3/analytics_get.rs @@ -0,0 +1,599 @@ +use super::ApiError; +use crate::database::redis::RedisPool; +use crate::{ + auth::{filter_authorized_projects, filter_authorized_versions, get_user_from_headers}, + database::models::{project_item, user_item, version_item}, + models::{ + ids::{ + base62_impl::{parse_base62, to_base62}, + ProjectId, VersionId, + }, + pats::Scopes, + }, + queue::session::AuthQueue, +}; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::types::PgInterval; +use sqlx::PgPool; +use std::collections::HashMap; +use std::convert::TryInto; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("analytics") + .route("playtime", web::get().to(playtimes_get)) + .route("views", web::get().to(views_get)) + .route("downloads", web::get().to(downloads_get)) + .route("revenue", web::get().to(revenue_get)) + .route("countries/downloads", web::get().to(countries_downloads_get)) + .route("countries/views", web::get().to(countries_views_get)), + ); +} + +/// The json data to be passed to fetch analytic data +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// start_date and end_date are optional, and default to two weeks ago, and the maximum date respectively. +/// resolution_minutes is optional. This refers to the window by which we are looking (every day, every minute, etc) and defaults to 1440 (1 day) +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GetData { + // only one of project_ids or version_ids should be used + // if neither are provided, all projects the user has access to will be used + pub project_ids: Option, + pub version_ids: Option, + + pub start_date: Option>, // defaults to 2 weeks ago + pub end_date: Option>, // defaults to now + + pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) +} + +/// Get playtime data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to playtime data +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 23 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +#[derive(Serialize, Deserialize, Clone)] +pub struct FetchedPlaytime { + pub time: u64, + pub total_seconds: u64, + pub loader_seconds: HashMap, + pub game_version_seconds: HashMap, + pub parent_seconds: HashMap, +} +pub async fn playtimes_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + let version_ids = data + .version_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, version_ids) = + filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; + + // Get the views + let playtimes = crate::clickhouse::fetch_playtimes( + project_ids, + version_ids, + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for playtime in playtimes { + let id_string = to_base62(playtime.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(playtime.time, playtime.total_seconds); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get view data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to views +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 1090 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +pub async fn views_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + let version_ids = data + .version_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, version_ids) = + filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; + + // Get the views + let views = crate::clickhouse::fetch_views( + project_ids, + version_ids, + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in views { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.time, views.total_views); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get download data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 32 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +pub async fn downloads_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + let version_ids = data + .version_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, version_ids) = + filter_allowed_ids(project_ids, version_ids, user_option, &pool, &redis).await?; + + // Get the downloads + let downloads = crate::clickhouse::fetch_downloads( + project_ids, + version_ids, + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for downloads in downloads { + let id_string = to_base62(downloads.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(downloads.time, downloads.total_downloads); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get payout data for a set of projects +/// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 0.001 +/// } +///} +/// ONLY project IDs can be used. Unauthorized projects will be filtered out. +pub async fn revenue_get( + req: HttpRequest, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, _) = filter_allowed_ids(project_ids, None, user, &pool, &redis).await?; + + let duration: PgInterval = Duration::minutes(resolution_minutes as i64) + .try_into() + .unwrap(); + // Get the revenue data + let payouts_values = sqlx::query!( + " + SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start + FROM payouts_values + WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3 + GROUP by mod_id, interval_start ORDER BY interval_start + ", + &project_ids.unwrap_or_default().into_iter().map(|x| x.0 as i64).collect::>(), + start_date, + end_date, + duration, + ) + .fetch_all(&**pool) + .await?; + + let mut hm = HashMap::new(); + for value in payouts_values { + if let Some(mod_id) = value.mod_id { + if let Some(amount) = value.amount_sum { + if let Some(interval_start) = value.interval_start { + let id_string = to_base62(mod_id as u64); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(interval_start.timestamp(), amount); + } + } + } + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get country data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads. +/// Unknown countries are labeled "". +/// This is usuable to see significant performing countries per project +/// eg: +/// { +/// "4N1tEhnO": { +/// "CAN": 22 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +pub async fn countries_downloads_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + let version_ids = data + .version_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, version_ids) = + filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; + + // Get the countries + let countries = crate::clickhouse::fetch_countries( + project_ids, + version_ids, + start_date, + end_date, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in countries { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.country, views.total_downloads); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get country data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views. +/// Unknown countries are labeled "". +/// This is usuable to see significant performing countries per project +/// eg: +/// { +/// "4N1tEhnO": { +/// "CAN": 56165 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +pub async fn countries_views_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + let version_ids = data + .version_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let (project_ids, version_ids) = + filter_allowed_ids(project_ids, version_ids, user, &pool, &redis).await?; + + // Get the countries + let countries = crate::clickhouse::fetch_countries( + project_ids, + version_ids, + start_date, + end_date, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in countries { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.country, views.total_views); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +async fn filter_allowed_ids( + mut project_ids: Option>, + version_ids: Option>, + user: crate::models::users::User, + pool: &web::Data, + redis: &RedisPool, +) -> Result<(Option>, Option>), ApiError> { + if project_ids.is_some() && version_ids.is_some() { + return Err(ApiError::InvalidInput( + "Only one of 'project_ids' or 'version_ids' should be used.".to_string(), + )); + } + + // If no project_ids or version_ids are provided, we default to all projects the user has access to + if project_ids.is_none() && version_ids.is_none() { + project_ids = Some( + user_item::User::get_projects(user.id.into(), &***pool, redis) + .await? + .into_iter() + .map(|x| ProjectId::from(x).to_string()) + .collect(), + ); + } + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + + let project_ids = if let Some(project_ids) = project_ids { + // Submitted project_ids are filtered by the user's permissions + let ids = project_ids + .iter() + .map(|id| Ok(ProjectId(parse_base62(id)?).into())) + .collect::, ApiError>>()?; + let projects = project_item::Project::get_many_ids(&ids, &***pool, redis).await?; + let ids: Vec = filter_authorized_projects(projects, &Some(user.clone()), pool) + .await? + .into_iter() + .map(|x| x.id) + .collect::>(); + Some(ids) + } else { + None + }; + let version_ids = if let Some(version_ids) = version_ids { + // Submitted version_ids are filtered by the user's permissions + let ids = version_ids + .iter() + .map(|id| Ok(VersionId(parse_base62(id)?).into())) + .collect::, ApiError>>()?; + let versions = version_item::Version::get_many(&ids, &***pool, redis).await?; + let ids: Vec = filter_authorized_versions(versions, &Some(user), pool) + .await? + .into_iter() + .map(|x| x.id) + .collect::>(); + Some(ids) + } else { + None + }; + + // Only one of project_ids or version_ids will be Some + Ok((project_ids, version_ids)) +} diff --git a/src/routes/v3/collections.rs b/src/routes/v3/collections.rs new file mode 100644 index 00000000..5ee0d823 --- /dev/null +++ b/src/routes/v3/collections.rs @@ -0,0 +1,538 @@ +use crate::auth::checks::{filter_authorized_collections, is_authorized_collection}; +use crate::auth::get_user_from_headers; +use crate::database::models::{collection_item, generate_collection_id, project_item}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::collections::{Collection, CollectionStatus}; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::{CollectionId, ProjectId}; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::routes::ApiError; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; +use actix_web::web::Data; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("collections", web::get().to(collections_get)); + cfg.route("collection", web::post().to(collection_create)); + + cfg.service( + web::scope("collection") + .route("{id}", web::get().to(collection_get)) + .route("{id}", web::delete().to(collection_delete)) + .route("{id}", web::patch().to(collection_edit)) + .route("{id}/icon", web::patch().to(collection_icon_edit)) + .route("{id}/icon", web::delete().to(delete_collection_icon)), + ); +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct CollectionCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + /// The title or name of the project. + pub title: String, + #[validate(length(min = 3, max = 255))] + /// A short description of the collection. + pub description: String, + #[validate(length(max = 32))] + #[serde(default = "Vec::new")] + /// A list of initial projects to use with the created collection + pub projects: Vec, +} + +pub async fn collection_create( + req: HttpRequest, + collection_create_data: web::Json, + client: Data, + redis: Data, + session_queue: Data, +) -> Result { + let collection_create_data = collection_create_data.into_inner(); + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_CREATE]), + ) + .await? + .1; + + collection_create_data + .validate() + .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + + let mut transaction = client.begin().await?; + + let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into(); + + let initial_project_ids = project_item::Project::get_many( + &collection_create_data.projects, + &mut *transaction, + &redis, + ) + .await? + .into_iter() + .map(|x| x.inner.id.into()) + .collect::>(); + + let collection_builder_actual = collection_item::CollectionBuilder { + collection_id: collection_id.into(), + user_id: current_user.id.into(), + title: collection_create_data.title, + description: collection_create_data.description, + status: CollectionStatus::Listed, + projects: initial_project_ids + .iter() + .copied() + .map(|x| x.into()) + .collect(), + }; + let collection_builder = collection_builder_actual.clone(); + + let now = Utc::now(); + collection_builder_actual.insert(&mut transaction).await?; + + let response = crate::models::collections::Collection { + id: collection_id, + user: collection_builder.user_id.into(), + title: collection_builder.title.clone(), + description: collection_builder.description.clone(), + created: now, + updated: now, + icon_url: None, + color: None, + status: collection_builder.status, + projects: initial_project_ids, + }; + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Serialize, Deserialize)] +pub struct CollectionIds { + pub ids: String, +} +pub async fn collections_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let ids = ids + .into_iter() + .map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64))) + .collect::, _>>()?; + + let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?; + + Ok(HttpResponse::Ok().json(collections)) +} + +pub async fn collection_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_data = database::models::Collection::get(id, &**pool, &redis).await?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = collection_data { + if is_authorized_collection(&data, &user_option).await? { + return Ok(HttpResponse::Ok().json(Collection::from(data))); + } + } + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize, Validate)] +pub struct EditCollection { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + #[validate(length(min = 3, max = 256))] + pub description: Option, + pub status: Option, + #[validate(length(max = 64))] + pub new_projects: Option>, +} + +pub async fn collection_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + new_collection: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + new_collection + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let result = database::models::Collection::get(id, &**pool, &redis).await?; + + if let Some(collection_item) = result { + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let id = collection_item.id; + + let mut transaction = pool.begin().await?; + + if let Some(title) = &new_collection.title { + sqlx::query!( + " + UPDATE collections + SET title = $1 + WHERE (id = $2) + ", + title.trim(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_collection.description { + sqlx::query!( + " + UPDATE collections + SET description = $1 + WHERE (id = $2) + ", + description, + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_collection.status { + if !(user.role.is_mod() + || collection_item.status.is_approved() && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE collections + SET status = $1 + WHERE (id = $2) + ", + status.to_string(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(new_project_ids) = &new_collection.new_projects { + // Delete all existing projects + sqlx::query!( + " + DELETE FROM collections_mods + WHERE collection_id = $1 + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + let collection_item_ids = new_project_ids + .iter() + .map(|_| collection_item.id.0) + .collect_vec(); + let mut validated_project_ids = Vec::new(); + for project_id in new_project_ids { + let project = database::models::Project::get(project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "The specified project {project_id} does not exist!" + )) + })?; + validated_project_ids.push(project.inner.id.0); + } + // Insert- don't throw an error if it already exists + sqlx::query!( + " + INSERT INTO collections_mods (collection_id, mod_id) + SELECT * FROM UNNEST ($1::int8[], $2::int8[]) + ON CONFLICT DO NOTHING + ", + &collection_item_ids[..], + &validated_project_ids[..], + ) + .execute(&mut *transaction) + .await?; + } + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn collection_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + if let Some(icon) = collection_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let collection_id: CollectionId = collection_item.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", collection_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for collection icon: {}", + ext.ext + ))) + } +} + +pub async fn delete_collection_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = collection_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn collection_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_DELETE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + if !can_modify_collection(&collection, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + let mut transaction = pool.begin().await?; + + let result = + database::models::Collection::remove(collection.id, &mut transaction, &redis).await?; + database::models::Collection::clear_cache(collection.id, &redis).await?; + + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +fn can_modify_collection( + collection: &database::models::Collection, + user: &models::users::User, +) -> bool { + collection.user_id == user.id.into() || user.role.is_mod() +} diff --git a/src/routes/v3/images.rs b/src/routes/v3/images.rs new file mode 100644 index 00000000..e287426a --- /dev/null +++ b/src/routes/v3/images.rs @@ -0,0 +1,234 @@ +use std::sync::Arc; + +use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version}; +use crate::database; +use crate::database::models::{project_item, report_item, thread_item, version_item}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::{ThreadMessageId, VersionId}; +use crate::models::images::{Image, ImageContext}; +use crate::models::reports::ReportId; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::routes::read_from_payload; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use super::threads::is_authorized_thread; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("image", web::post().to(images_add)); +} + +#[derive(Serialize, Deserialize)] +pub struct ImageUpload { + pub ext: String, + + // Context must be an allowed context + // currently: project, version, thread_message, report + pub context: String, + + // Optional context id to associate with + pub project_id: Option, // allow slug or id + pub version_id: Option, + pub thread_message_id: Option, + pub report_id: Option, +} + +pub async fn images_add( + req: HttpRequest, + web::Query(data): web::Query, + file_host: web::Data>, + mut payload: web::Payload, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) { + let mut context = ImageContext::from_str(&data.context, None); + + let scopes = vec![context.relevant_scope()]; + + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes)) + .await? + .1; + + // Attempt to associated a supplied id with the context + // If the context cannot be found, or the user is not authorized to upload images for the context, return an error + match &mut context { + ImageContext::Project { project_id } => { + if let Some(id) = data.project_id { + let project = project_item::Project::get(&id, &**pool, &redis).await?; + if let Some(project) = project { + if is_authorized(&project.inner, &Some(user.clone()), &pool).await? { + *project_id = Some(project.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this project" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The project could not be found.".to_string(), + )); + } + } + } + ImageContext::Version { version_id } => { + if let Some(id) = data.version_id { + let version = version_item::Version::get(id.into(), &**pool, &redis).await?; + if let Some(version) = version { + if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await? + { + *version_id = Some(version.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this version" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The version could not be found.".to_string(), + )); + } + } + } + ImageContext::ThreadMessage { thread_message_id } => { + if let Some(id) = data.thread_message_id { + let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread message could not found.".to_string(), + ) + })?; + let thread = thread_item::Thread::get(thread_message.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the thread message could not be found" + .to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *thread_message_id = Some(thread_message.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this thread message" + .to_string(), + )); + } + } + } + ImageContext::Report { report_id } => { + if let Some(id) = data.report_id { + let report = report_item::Report::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The report could not be found.".to_string()) + })?; + let thread = thread_item::Thread::get(report.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the report could not be found." + .to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *report_id = Some(report.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this report".to_string(), + )); + } + } + } + ImageContext::Unknown => { + return Err(ApiError::InvalidInput( + "Context must be one of: project, version, thread_message, report".to_string(), + )); + } + } + + // Upload the image to the file host + let bytes = + read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/cached_images/{}.{}", hash, data.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let db_image: database::models::Image = database::models::Image { + id: database::models::generate_image_id(&mut transaction).await?, + url: format!("{}/{}", cdn_url, upload_data.file_name), + size: upload_data.content_length as u64, + created: chrono::Utc::now(), + owner_id: database::models::UserId::from(user.id), + context: context.context_as_str().to_string(), + project_id: if let ImageContext::Project { + project_id: Some(id), + } = context + { + Some(database::models::ProjectId::from(id)) + } else { + None + }, + version_id: if let ImageContext::Version { + version_id: Some(id), + } = context + { + Some(database::models::VersionId::from(id)) + } else { + None + }, + thread_message_id: if let ImageContext::ThreadMessage { + thread_message_id: Some(id), + } = context + { + Some(database::models::ThreadMessageId::from(id)) + } else { + None + }, + report_id: if let ImageContext::Report { + report_id: Some(id), + } = context + { + Some(database::models::ReportId::from(id)) + } else { + None + }, + }; + + // Insert + db_image.insert(&mut transaction).await?; + + let image = Image { + id: db_image.id.into(), + url: db_image.url, + size: db_image.size, + created: db_image.created, + owner_id: db_image.owner_id.into(), + context, + }; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(image)) + } else { + Err(ApiError::InvalidInput( + "The specified file is not an image!".to_string(), + )) + } +} diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index f2e4b12d..a313b6d0 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -3,10 +3,19 @@ use crate::util::cors::default_cors; use actix_web::{web, HttpResponse}; use serde_json::json; +pub mod analytics_get; +pub mod collections; +pub mod images; pub mod organizations; +pub mod moderation; +pub mod notifications; pub mod project_creation; pub mod projects; +pub mod reports; +pub mod statistics; pub mod tags; +pub mod teams; +pub mod threads; pub mod users; pub mod version_creation; pub mod version_file; @@ -16,10 +25,16 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("v3") .wrap(default_cors()) + .configure(analytics_get::config) + .configure(collections::config) + .configure(images::config) .configure(organizations::config) .configure(project_creation::config) .configure(projects::config) + .configure(reports::config) .configure(tags::config) + .configure(teams::config) + .configure(threads::config) .configure(version_file::config) .configure(versions::config), ); diff --git a/src/routes/v3/moderation.rs b/src/routes/v3/moderation.rs new file mode 100644 index 00000000..8b72e036 --- /dev/null +++ b/src/routes/v3/moderation.rs @@ -0,0 +1,65 @@ +use super::ApiError; +use crate::database; +use crate::database::redis::RedisPool; +use crate::models::projects::ProjectStatus; +use crate::queue::session::AuthQueue; +use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("moderation/projects", web::get().to(get_projects)); +} + +#[derive(Deserialize)] +pub struct ResultCount { + #[serde(default = "default_count")] + pub count: i16, +} + +fn default_count() -> i16 { + 100 +} + +pub async fn get_projects( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await?; + + use futures::stream::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT id FROM mods + WHERE status = $1 + ORDER BY queued ASC + LIMIT $2; + ", + ProjectStatus::Processing.as_str(), + count.count as i64 + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ProjectId(m.id))) }) + .try_collect::>() + .await?; + + let projects: Vec<_> = database::Project::get_many_ids(&project_ids, &**pool, &redis) + .await? + .into_iter() + .map(crate::models::projects::Project::from) + .collect(); + + Ok(HttpResponse::Ok().json(projects)) +} diff --git a/src/routes/v3/notifications.rs b/src/routes/v3/notifications.rs new file mode 100644 index 00000000..a12db1f5 --- /dev/null +++ b/src/routes/v3/notifications.rs @@ -0,0 +1,289 @@ +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::redis::RedisPool; +use crate::models::ids::NotificationId; +use crate::models::notifications::Notification; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("notifications",web::get().to(notifications_get)); + cfg.route("notifications",web::patch().to(notifications_read)); + cfg.route("notifications",web::delete().to(notifications_delete)); + + cfg.service( + web::scope("notification") + .route("{id}", web::get().to(notification_get)) + .route("{id}", web::patch().to(notification_read)) + .route("{id}", web::delete().to(notification_delete)) + ); +} + +#[derive(Serialize, Deserialize)] +pub struct NotificationIds { + pub ids: String, +} + +pub async fn notifications_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + + use database::models::notification_item::Notification as DBNotification; + use database::models::NotificationId as DBNotificationId; + + let notification_ids: Vec = + serde_json::from_str::>(ids.ids.as_str())? + .into_iter() + .map(DBNotificationId::from) + .collect(); + + let notifications_data: Vec = + database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) + .await?; + + let notifications: Vec = notifications_data + .into_iter() + .filter(|n| n.user_id == user.id.into() || user.role.is_admin()) + .map(Notification::from) + .collect(); + + Ok(HttpResponse::Ok().json(notifications)) +} + +pub async fn notification_get( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get(id.into(), &**pool).await?; + + if let Some(data) = notification_data { + if user.id == data.user_id.into() || user.role.is_admin() { + Ok(HttpResponse::Ok().json(Notification::from(data))) + } else { + Ok(HttpResponse::NotFound().body("")) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn notification_read( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get(id.into(), &**pool).await?; + + if let Some(data) = notification_data { + if data.user_id == user.id.into() || user.role.is_admin() { + let mut transaction = pool.begin().await?; + + database::models::notification_item::Notification::read( + id.into(), + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You are not authorized to read this notification!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn notification_delete( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get(id.into(), &**pool).await?; + + if let Some(data) = notification_data { + if data.user_id == user.id.into() || user.role.is_admin() { + let mut transaction = pool.begin().await?; + + database::models::notification_item::Notification::remove( + id.into(), + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You are not authorized to delete this notification!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn notifications_read( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let notification_ids = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let mut transaction = pool.begin().await?; + + let notifications_data = + database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) + .await?; + + let mut notifications: Vec = Vec::new(); + + for notification in notifications_data { + if notification.user_id == user.id.into() || user.role.is_admin() { + notifications.push(notification.id); + } + } + + database::models::notification_item::Notification::read_many( + ¬ifications, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn notifications_delete( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let notification_ids = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let mut transaction = pool.begin().await?; + + let notifications_data = + database::models::notification_item::Notification::get_many(¬ification_ids, &**pool) + .await?; + + let mut notifications: Vec = Vec::new(); + + for notification in notifications_data { + if notification.user_id == user.id.into() || user.role.is_admin() { + notifications.push(notification.id); + } + } + + database::models::notification_item::Notification::remove_many( + ¬ifications, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/src/routes/v3/organizations.rs b/src/routes/v3/organizations.rs index 6ad018eb..3205978d 100644 --- a/src/routes/v3/organizations.rs +++ b/src/routes/v3/organizations.rs @@ -1,18 +1,39 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::{get_user_from_headers, filter_authorized_projects}; +use crate::database::models::team_item::TeamMember; +use crate::database::models::{generate_organization_id, team_item, Organization}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::organizations::OrganizationId; +use crate::models::pats::Scopes; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; use actix_web::{web, HttpRequest, HttpResponse}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; - -use crate::{ - auth::{filter_authorized_projects, get_user_from_headers}, - database::redis::RedisPool, - models::{ids::base62_impl::parse_base62, pats::Scopes}, - queue::session::AuthQueue, -}; - +use validator::Validate; use super::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("organization").route("{id}/projects", web::get().to(organization_projects_get)), + web::scope("organization") + .route("{id}/projects", web::get().to(organization_projects_get)) + .route("{id}", web::get().to(organization_get)) + .route("{id}", web::patch().to(organizations_edit)) + .route("{id}", web::delete().to(organization_delete)) + .route("{id}/projects", web::post().to(organization_projects_add)) + .route("{id}/projects", web::delete().to(organization_projects_remove)) + .route("{id}/icon", web::patch().to(organization_icon_edit)) + .route("{id}/icon", web::delete().to(delete_organization_icon)) + .route("{id}/members", web::get().to(super::teams::team_members_get_organization)) ); } @@ -58,3 +79,834 @@ pub async fn organization_projects_get( let projects = filter_authorized_projects(projects_data, ¤t_user, &pool).await?; Ok(HttpResponse::Ok().json(projects)) } + +#[derive(Deserialize, Validate)] +pub struct NewOrganization { + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + // Title of the organization, also used as slug + pub title: String, + #[validate(length(min = 3, max = 256))] + pub description: String, +} + +pub async fn organization_create( + req: HttpRequest, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_CREATE]), + ) + .await? + .1; + + new_organization + .validate() + .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; + + let mut transaction = pool.begin().await?; + + // Try title + let title_organization_id_option: Option = + serde_json::from_str(&format!("\"{}\"", new_organization.title)).ok(); + let mut organization_strings = vec![]; + if let Some(title_organization_id) = title_organization_id_option { + organization_strings.push(title_organization_id.to_string()); + } + organization_strings.push(new_organization.title.clone()); + let results = Organization::get_many(&organization_strings, &mut *transaction, &redis).await?; + if !results.is_empty() { + return Err(CreateError::SlugCollision); + } + + let organization_id = generate_organization_id(&mut transaction).await?; + + // Create organization managerial team + let team = team_item::TeamBuilder { + members: vec![team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + role: crate::models::teams::OWNER_ROLE.to_owned(), + permissions: ProjectPermissions::all(), + organization_permissions: Some(OrganizationPermissions::all()), + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }], + }; + let team_id = team.insert(&mut transaction).await?; + + // Create organization + let organization = Organization { + id: organization_id, + title: new_organization.title.clone(), + description: new_organization.description.clone(), + team_id, + icon_url: None, + color: None, + }; + organization.clone().insert(&mut transaction).await?; + transaction.commit().await?; + + // Only member is the owner, the logged in one + let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis) + .await? + .into_iter() + .next(); + let members_data = if let Some(member_data) = member_data { + vec![crate::models::teams::TeamMember::from_model( + member_data, + current_user.clone(), + false, + )] + } else { + return Err(CreateError::InvalidInput( + "Failed to get created team.".to_owned(), // should never happen + )); + }; + + let organization = models::organizations::Organization::from(organization, members_data); + + Ok(HttpResponse::Ok().json(organization)) +} + +pub async fn organization_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let organization_data = Organization::get(&id, &**pool, &redis).await?; + if let Some(data) = organization_data { + let members_data = TeamMember::get_from_team_full(data.team_id, &**pool, &redis).await?; + + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = models::organizations::Organization::from(data, team_members); + return Ok(HttpResponse::Ok().json(organization)); + } + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize)] +pub struct OrganizationIds { + pub ids: String, +} + +pub async fn organizations_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let organizations_data = Organization::get_many(&ids, &**pool, &redis).await?; + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let mut organizations = vec![]; + + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = models::organizations::Organization::from(data, team_members); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct OrganizationEdit { + #[validate(length(min = 3, max = 256))] + pub description: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + // Title of the organization, also used as slug + pub title: Option, +} + +pub async fn organizations_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + new_organization + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let string = info.into_inner().0; + let result = database::models::Organization::get(&string, &**pool, &redis).await?; + if let Some(organization_item) = result { + let id = organization_item.id; + + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member); + + if let Some(perms) = permissions { + let mut transaction = pool.begin().await?; + if let Some(description) = &new_organization.description { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description of this organization!" + .to_string(), + )); + } + sqlx::query!( + " + UPDATE organizations + SET description = $1 + WHERE (id = $2) + ", + description, + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(title) = &new_organization.title { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the title of this organization!" + .to_string(), + )); + } + + let title_organization_id_option: Option = parse_base62(title).ok(); + if let Some(title_organization_id) = title_organization_id_option { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1) + ", + title_organization_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Title collides with other organization's id!".to_string(), + )); + } + } + + // Make sure the new title is different from the old one + // We are able to unwrap here because the title is always set + if !title.eq(&organization_item.title.clone()) { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE title = LOWER($1)) + ", + title + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Title collides with other organization's id!".to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE organizations + SET title = LOWER($1) + WHERE (id = $2) + ", + Some(title), + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this organization!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn organization_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_DELETE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_admin() { + let team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &Some(team_member)) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this organization!".to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let result = + database::models::Organization::remove(organization.id, &mut transaction, &redis).await?; + + transaction.commit().await?; + + database::models::Organization::clear_cache(organization.id, Some(organization.title), &redis) + .await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Deserialize)] +pub struct OrganizationProjectAdd { + pub project_id: String, // Also allow title/slug +} +pub async fn organization_projects_add( + req: HttpRequest, + info: web::Path<(String,)>, + project_info: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = database::models::Organization::get(&info, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let project_item = database::models::Project::get(&project_info.project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "The specified project is already owned by an organization!".to_string(), + )); + } + + let project_team_member = database::models::TeamMember::get_from_user_id_project( + project_item.inner.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| ApiError::InvalidInput("You are not a member of this project!".to_string()))?; + + let organization_team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("You are not a member of this organization!".to_string()) + })?; + + // Require ownership of a project to add it to an organization + if !current_user.role.is_admin() + && !project_team_member + .role + .eq(crate::models::teams::OWNER_ROLE) + { + return Err(ApiError::CustomAuthentication( + "You need to be an owner of a project to add it to an organization!".to_string(), + )); + } + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::ADD_PROJECT) { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE mods + SET organization_id = $1 + WHERE (id = $2) + ", + organization.id as database::models::OrganizationId, + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!".to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +pub async fn organization_projects_remove( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (organization_id, project_id) = info.into_inner(); + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = database::models::Organization::get(&organization_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + let project_item = database::models::Project::get(&project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !project_item + .inner + .organization_id + .eq(&Some(organization.id)) + { + return Err(ApiError::InvalidInput( + "The specified project is not owned by this organization!".to_string(), + )); + } + + let organization_team_member = database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("You are not a member of this organization!".to_string()) + })?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE mods + SET organization_id = NULL + WHERE (id = $1) + ", + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!".to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn organization_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon.".to_string(), + )); + } + } + + if let Some(icon) = organization_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let organization_id: OrganizationId = organization_item.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", organization_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for project icon: {}", + ext.ext + ))) + } +} + +pub async fn delete_organization_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified organization does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = + OrganizationPermissions::get_permissions_by_role(&user.role, &team_member) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon.".to_string(), + )); + } + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = organization_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.title), + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 40b6f624..37d42ff9 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -1,10 +1,13 @@ +use std::sync::Arc; + use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; -use crate::database::models as db_models; -use crate::database::models::ids as db_ids; +use crate::database::{models as db_models, self}; +use crate::database::models::{ids as db_ids, image_item}; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::project_item::ModCategory; +use crate::database::models::project_item::{ModCategory, GalleryItem}; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::base62_impl::parse_base62; use crate::models::images::ImageContext; @@ -19,32 +22,54 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::{search_for_project, SearchConfig, SearchError}; use crate::util::img; +use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{get, web, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Utc}; use futures::TryStreamExt; use meilisearch_sdk::indexes::IndexesResults; use serde::{Deserialize, Serialize}; +use serde_json::json; use sqlx::PgPool; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("search", web::get().to(project_search)); + 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("projects", web::get().to(projects_get)) - .route("{id}", web::patch().to(project_edit)) + .route("{id}", web::get().to(project_get)) + .route("{id}/check", web::get().to(project_get_check)) + .route("{id}", web::delete().to(project_get)) + .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}/schedule", web::post().to(project_schedule)) .service( web::scope("{project_id}") + .route("members", web::get().to(super::teams::team_members_get_project)) .route("versions", 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) + ) ), ); } + #[derive(Deserialize, Validate)] pub struct RandomProjects { #[validate(range(min = 1, max = 100))] @@ -967,7 +992,6 @@ pub async fn edit_project_categories( Ok(()) } -#[get("search")] pub async fn project_search( web::Query(info): web::Query, config: web::Data, @@ -990,3 +1014,1480 @@ pub async fn delete_from_index( Ok(()) } + +//checks the validity of a project id or slug +pub async fn project_get_check( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let slug = info.into_inner().0; + + let project_data = db_models::Project::get(&slug, &**pool, &redis).await?; + + if let Some(project) = project_data { + Ok(HttpResponse::Ok().json(json! ({ + "id": models::ids::ProjectId::from(project.inner.id) + }))) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize)] +struct DependencyInfo { + pub projects: Vec, + pub versions: Vec, +} + +pub async fn dependency_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_authorized(&project.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let dependencies = + database::Project::get_dependencies(project.inner.id, &**pool, &redis).await?; + + let project_ids = dependencies + .iter() + .filter_map(|x| { + if x.0.is_none() { + if let Some(mod_dependency_id) = x.2 { + Some(mod_dependency_id) + } else { + x.1 + } + } else { + x.1 + } + }) + .collect::>(); + + let dep_version_ids = dependencies + .iter() + .filter_map(|x| x.0) + .collect::>(); + let (projects_result, versions_result) = futures::future::try_join( + database::Project::get_many_ids(&project_ids, &**pool, &redis), + database::Version::get_many(&dep_version_ids, &**pool, &redis), + ) + .await?; + + let mut projects = projects_result + .into_iter() + .map(models::projects::Project::from) + .collect::>(); + let mut versions = versions_result + .into_iter() + .map(models::projects::Version::from) + .collect::>(); + + projects.sort_by(|a, b| b.published.cmp(&a.published)); + projects.dedup_by(|a, b| a.id == b.id); + + versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + versions.dedup_by(|a, b| a.id == b.id); + + Ok(HttpResponse::Ok().json(DependencyInfo { projects, versions })) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(derive_new::new)] +pub struct CategoryChanges<'a> { + pub categories: &'a Option>, + pub add_categories: &'a Option>, + pub remove_categories: &'a Option>, +} + +#[derive(Deserialize, Validate)] +pub struct BulkEditProject { + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 3))] + pub add_categories: Option>, + pub remove_categories: Option>, + + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[validate(length(max = 3))] + pub add_additional_categories: Option>, + pub remove_additional_categories: Option>, + + #[validate] + pub donation_urls: Option>, + #[validate] + pub add_donation_urls: Option>, + #[validate] + pub remove_donation_urls: Option>, + + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub issues_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub source_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub wiki_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub discord_url: Option>, +} + +pub async fn projects_edit( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + bulk_edit_project: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + bulk_edit_project + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let project_ids: Vec = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let projects_data = db_models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; + + if let Some(id) = project_ids + .iter() + .find(|x| !projects_data.iter().any(|y| x == &&y.inner.id)) + { + return Err(ApiError::InvalidInput(format!( + "Project {} not found", + ProjectId(id.0 as u64) + ))); + } + + let team_ids = projects_data + .iter() + .map(|x| x.inner.team_id) + .collect::>(); + let team_members = + db_models::TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = + db_models::Organization::get_many_ids(&organization_ids, &**pool, &redis).await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = + db_models::TeamMember::get_from_team_full_many(&organization_team_ids, &**pool, &redis) + .await?; + + let categories = db_models::categories::Category::list(&**pool, &redis).await?; + let donation_platforms = db_models::categories::DonationPlatform::list(&**pool, &redis).await?; + + let mut transaction = pool.begin().await?; + + for project in projects_data { + if !user.role.is_mod() { + let team_member = team_members + .iter() + .find(|x| x.team_id == project.inner.team_id && x.user_id == user.id.into()); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = if let Some(organization) = organization { + organization_team_members + .iter() + .find(|x| x.team_id == organization.team_id && x.user_id == user.id.into()) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + if team_member.is_some() { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication(format!( + "You do not have the permissions to bulk edit project {}!", + project.inner.title + ))); + } + } else if project.inner.status.is_hidden() { + return Err(ApiError::InvalidInput(format!( + "Project {} not found", + ProjectId(project.inner.id.0 as u64) + ))); + } else { + return Err(ApiError::CustomAuthentication(format!( + "You are not a member of project {}!", + project.inner.title + ))); + }; + } + + bulk_edit_project_categories( + &categories, + &project.categories, + project.inner.id as db_ids::ProjectId, + CategoryChanges::new( + &bulk_edit_project.categories, + &bulk_edit_project.add_categories, + &bulk_edit_project.remove_categories, + ), + 3, + false, + &mut transaction, + ) + .await?; + + bulk_edit_project_categories( + &categories, + &project.additional_categories, + project.inner.id as db_ids::ProjectId, + CategoryChanges::new( + &bulk_edit_project.additional_categories, + &bulk_edit_project.add_additional_categories, + &bulk_edit_project.remove_additional_categories, + ), + 256, + true, + &mut transaction, + ) + .await?; + + let project_donations: Vec = project + .donation_urls + .into_iter() + .map(|d| DonationLink { + id: d.platform_short, + platform: d.platform_name, + url: d.url, + }) + .collect(); + let mut set_donation_links = + if let Some(donation_links) = bulk_edit_project.donation_urls.clone() { + donation_links + } else { + project_donations.clone() + }; + + if let Some(delete_donations) = &bulk_edit_project.remove_donation_urls { + for donation in delete_donations { + if let Some(pos) = set_donation_links + .iter() + .position(|x| donation.url == x.url && donation.id == x.id) + { + set_donation_links.remove(pos); + } + } + } + + if let Some(add_donations) = &bulk_edit_project.add_donation_urls { + set_donation_links.append(&mut add_donations.clone()); + } + + if set_donation_links != project_donations { + sqlx::query!( + " + DELETE FROM mods_donations + WHERE joining_mod_id = $1 + ", + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + for donation in set_donation_links { + let platform_id = donation_platforms + .iter() + .find(|x| x.short == donation.id) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Platform {} does not exist.", + donation.id.clone() + )) + })? + .id; + + sqlx::query!( + " + INSERT INTO mods_donations (joining_mod_id, joining_platform_id, url) + VALUES ($1, $2, $3) + ", + project.inner.id as db_ids::ProjectId, + platform_id as db_ids::DonationPlatformId, + donation.url + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(issues_url) = &bulk_edit_project.issues_url { + sqlx::query!( + " + UPDATE mods + SET issues_url = $1 + WHERE (id = $2) + ", + issues_url.as_deref(), + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(source_url) = &bulk_edit_project.source_url { + sqlx::query!( + " + UPDATE mods + SET source_url = $1 + WHERE (id = $2) + ", + source_url.as_deref(), + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(wiki_url) = &bulk_edit_project.wiki_url { + sqlx::query!( + " + UPDATE mods + SET wiki_url = $1 + WHERE (id = $2) + ", + wiki_url.as_deref(), + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(discord_url) = &bulk_edit_project.discord_url { + sqlx::query!( + " + UPDATE mods + SET discord_url = $1 + WHERE (id = $2) + ", + discord_url.as_deref(), + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + db_models::Project::clear_cache(project.inner.id, project.inner.slug, None, &redis).await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn bulk_edit_project_categories( + all_db_categories: &[db_models::categories::Category], + project_categories: &Vec, + project_id: db_ids::ProjectId, + bulk_changes: CategoryChanges<'_>, + max_num_categories: usize, + is_additional: bool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), ApiError> { + let mut set_categories = if let Some(categories) = bulk_changes.categories.clone() { + categories + } else { + project_categories.clone() + }; + + if let Some(delete_categories) = &bulk_changes.remove_categories { + for category in delete_categories { + if let Some(pos) = set_categories.iter().position(|x| x == category) { + set_categories.remove(pos); + } + } + } + + if let Some(add_categories) = &bulk_changes.add_categories { + for category in add_categories { + if set_categories.len() < max_num_categories { + set_categories.push(category.clone()); + } else { + break; + } + } + } + + if &set_categories != project_categories { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = $2 + ", + project_id as db_ids::ProjectId, + is_additional + ) + .execute(&mut **transaction) + .await?; + + let mut mod_categories = Vec::new(); + for category in set_categories { + let category_id = all_db_categories + .iter() + .find(|x| x.category == category) + .ok_or_else(|| { + ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) + })? + .id; + mod_categories.push(ModCategory::new(project_id, category_id, is_additional)); + } + ModCategory::insert_many(mod_categories, &mut *transaction).await?; + } + + Ok(()) +} + +#[derive(Deserialize)] +pub struct SchedulingData { + pub time: DateTime, + pub requested_status: ProjectStatus, +} + +pub async fn project_schedule( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + scheduling_data: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + if scheduling_data.time < Utc::now() { + return Err(ApiError::InvalidInput( + "You cannot schedule a project to be released in the past!".to_string(), + )); + } + + if !scheduling_data.requested_status.can_be_requested() { + return Err(ApiError::InvalidInput( + "Specified requested status cannot be requested!".to_string(), + )); + } + + let string = info.into_inner().0; + let result = db_models::Project::get(&string, &**pool, &redis).await?; + + if let Some(project_item) = result { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.clone(), + &organization_team_member.clone(), + ) + .unwrap_or_default(); + + if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to edit this project's scheduling data!".to_string(), + )); + } + + if !project_item.inner.status.is_approved() { + return Err(ApiError::InvalidInput( + "This project has not been approved yet. Submit to the queue with the private status to schedule it in the future!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET status = $1, approved = $2 + WHERE (id = $3) + ", + ProjectStatus::Scheduled.as_str(), + scheduling_data.time, + project_item.inner.id as db_ids::ProjectId, + ) + .execute(&**pool) + .await?; + + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn project_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's icon.".to_string(), + )); + } + } + + if let Some(icon) = project_item.inner.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let project_id: ProjectId = project_item.inner.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", project_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + project_item.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for project icon: {}", + ext.ext + ))) + } +} + +pub async fn delete_project_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's icon.".to_string(), + )); + } + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = project_item.inner.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + project_item.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryCreateQuery { + pub featured: bool, + #[validate(length(min = 1, max = 255))] + pub title: Option, + #[validate(length(min = 1, max = 2048))] + pub description: Option, + pub ordering: Option, +} + +#[allow(clippy::too_many_arguments)] +pub async fn add_gallery_item( + web::Query(ext): web::Query, + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + item.validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if project_item.gallery_items.len() > 64 { + return Err(ApiError::CustomAuthentication( + "You have reached the maximum of gallery images to upload.".to_string(), + )); + } + + if !user.role.is_admin() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery.".to_string(), + )); + } + } + + let bytes = read_from_payload( + &mut payload, + 5 * (1 << 20), + "Gallery image exceeds the maximum of 5MiB.", + ) + .await?; + let hash = sha1::Sha1::from(&bytes).hexdigest(); + + let id: ProjectId = project_item.inner.id.into(); + let url = format!("data/{}/images/{}.{}", id, hash, &*ext.ext); + + let file_url = format!("{cdn_url}/{url}"); + if project_item + .gallery_items + .iter() + .any(|x| x.image_url == file_url) + { + return Err(ApiError::InvalidInput( + "You may not upload duplicate gallery images!".to_string(), + )); + } + + file_host + .upload_file(content_type, &url, bytes.freeze()) + .await?; + + let mut transaction = pool.begin().await?; + + if item.featured { + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE mod_id = $1 + ", + project_item.inner.id as db_ids::ProjectId, + false, + ) + .execute(&mut *transaction) + .await?; + } + + let gallery_item = vec![db_models::project_item::GalleryItem { + image_url: file_url, + featured: item.featured, + title: item.title, + description: item.description, + created: Utc::now(), + ordering: item.ordering.unwrap_or(0), + }]; + GalleryItem::insert_many(gallery_item, project_item.inner.id, &mut transaction).await?; + + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for gallery image: {}", + ext.ext + ))) + } +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryEditQuery { + /// The url of the gallery item to edit + pub url: String, + pub featured: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 255))] + pub title: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 2048))] + pub description: Option>, + pub ordering: Option, +} + +pub async fn edit_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + item.validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery.".to_string(), + )); + } + } + let mut transaction = pool.begin().await?; + + let id = sqlx::query!( + " + SELECT id FROM mods_gallery + WHERE image_url = $1 + ", + item.url + ) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Gallery item at URL {} is not part of the project's gallery.", + item.url + )) + })? + .id; + + let mut transaction = pool.begin().await?; + + if let Some(featured) = item.featured { + if featured { + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE mod_id = $1 + ", + project_item.inner.id as db_ids::ProjectId, + false, + ) + .execute(&mut *transaction) + .await?; + } + + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE id = $1 + ", + id, + featured + ) + .execute(&mut *transaction) + .await?; + } + if let Some(title) = item.title { + sqlx::query!( + " + UPDATE mods_gallery + SET title = $2 + WHERE id = $1 + ", + id, + title + ) + .execute(&mut *transaction) + .await?; + } + if let Some(description) = item.description { + sqlx::query!( + " + UPDATE mods_gallery + SET description = $2 + WHERE id = $1 + ", + id, + description + ) + .execute(&mut *transaction) + .await?; + } + if let Some(ordering) = item.ordering { + sqlx::query!( + " + UPDATE mods_gallery + SET ordering = $2 + WHERE id = $1 + ", + id, + ordering + ) + .execute(&mut *transaction) + .await?; + } + + db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize)] +pub struct GalleryDeleteQuery { + pub url: String, +} + +pub async fn delete_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery.".to_string(), + )); + } + } + let mut transaction = pool.begin().await?; + + let id = sqlx::query!( + " + SELECT id FROM mods_gallery + WHERE image_url = $1 + ", + item.url + ) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Gallery item at URL {} is not part of the project's gallery.", + item.url + )) + })? + .id; + + let cdn_url = dotenvy::var("CDN_URL")?; + let name = item.url.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM mods_gallery + WHERE id = $1 + ", + id + ) + .execute(&mut *transaction) + .await?; + + db_models::Project::clear_cache(project_item.inner.id, project_item.inner.slug, None, &redis) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn project_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + config: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_DELETE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + if !user.role.is_admin() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_PROJECT) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this project!".to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let context = ImageContext::Project { + project_id: Some(project.inner.id.into()), + }; + let uploaded_images = db_models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + sqlx::query!( + " + DELETE FROM collections_mods + WHERE mod_id = $1 + ", + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + let result = db_models::Project::remove(project.inner.id, &mut transaction, &redis).await?; + + transaction.commit().await?; + + delete_from_index(project.inner.id.into(), config).await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn project_follow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + let user_id: db_ids::UserId = user.id.into(); + let project_id: db_ids::ProjectId = result.inner.id; + + if !is_authorized(&result.inner, &Some(user), &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let following = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if !following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET follows = follows + 1 + WHERE id = $1 + ", + project_id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + INSERT INTO mod_follows (follower_id, mod_id) + VALUES ($1, $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput( + "You are already following this project!".to_string(), + )) + } +} + +pub async fn project_unfollow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified project does not exist!".to_string()) + })?; + + let user_id: db_ids::UserId = user.id.into(); + let project_id = result.inner.id; + + let following = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET follows = follows - 1 + WHERE id = $1 + ", + project_id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE follower_id = $1 AND mod_id = $2 + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput( + "You are not following this project!".to_string(), + )) + } +} diff --git a/src/routes/v3/reports.rs b/src/routes/v3/reports.rs new file mode 100644 index 00000000..04e69c8c --- /dev/null +++ b/src/routes/v3/reports.rs @@ -0,0 +1,524 @@ +use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; +use crate::database; +use crate::database::models::image_item; +use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder}; +use crate::database::redis::RedisPool; +use crate::models::ids::ImageId; +use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId}; +use crate::models::images::{Image, ImageContext}; +use crate::models::pats::Scopes; +use crate::models::reports::{ItemType, Report}; +use crate::models::threads::{MessageBody, ThreadType}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::img; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::StreamExt; +use serde::Deserialize; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("report", web::post().to(report_create)); + cfg.route("report", web::get().to(reports)); + cfg.route("reports", web::get().to(reports_get)); + cfg.route("report/{id}", web::get().to(report_get)); + cfg.route("report/{id}", web::patch().to(report_edit)); + cfg.route("report/{id}", web::delete().to(report_delete)); +} + +#[derive(Deserialize, Validate)] +pub struct CreateReport { + pub report_type: String, + pub item_id: String, + pub item_type: ItemType, + pub body: String, + // Associations to uploaded images + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, +} + +pub async fn report_create( + req: HttpRequest, + pool: web::Data, + mut body: web::Payload, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let mut transaction = pool.begin().await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_CREATE]), + ) + .await? + .1; + + let mut bytes = web::BytesMut::new(); + while let Some(item) = body.next().await { + bytes.extend_from_slice(&item.map_err(|_| { + ApiError::InvalidInput("Error while parsing request payload!".to_string()) + })?); + } + let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?; + + let id = crate::database::models::generate_report_id(&mut transaction).await?; + let report_type = crate::database::models::categories::ReportType::get_id( + &new_report.report_type, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!("Invalid report type: {}", new_report.report_type)) + })?; + + let mut report = crate::database::models::report_item::Report { + id, + report_type_id: report_type, + project_id: None, + version_id: None, + user_id: None, + body: new_report.body.clone(), + reporter: current_user.id.into(), + created: Utc::now(), + closed: false, + }; + + match new_report.item_type { + ItemType::Project => { + let project_id = ProjectId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", + project_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "Project could not be found: {}", + new_report.item_id + ))); + } + + report.project_id = Some(project_id.into()) + } + ItemType::Version => { + let version_id = VersionId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)", + version_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "Version could not be found: {}", + new_report.item_id + ))); + } + + report.version_id = Some(version_id.into()) + } + ItemType::User => { + let user_id = UserId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", + user_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "User could not be found: {}", + new_report.item_id + ))); + } + + report.user_id = Some(user_id.into()) + } + ItemType::Unknown => { + return Err(ApiError::InvalidInput(format!( + "Invalid report item type: {}", + new_report.item_type.as_str() + ))) + } + } + + report.insert(&mut transaction).await?; + + for image_id in new_report.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, &redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'report' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET report_id = $1 + WHERE id = $2 + ", + id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis).await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} could not be found", + image_id + ))); + } + } + + let thread_id = ThreadBuilder { + type_: ThreadType::Report, + members: vec![], + project_id: None, + report_id: Some(report.id), + } + .insert(&mut transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(Report { + id: id.into(), + report_type: new_report.report_type.clone(), + item_id: new_report.item_id.clone(), + item_type: new_report.item_type.clone(), + reporter: current_user.id, + body: new_report.body.clone(), + created: Utc::now(), + closed: false, + thread_id: thread_id.into(), + })) +} + +#[derive(Deserialize)] +pub struct ReportsRequestOptions { + #[serde(default = "default_count")] + pub count: i16, + #[serde(default = "default_all")] + pub all: bool, +} + +fn default_count() -> i16 { + 100 +} +fn default_all() -> bool { + true +} + +pub async fn reports( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + + use futures::stream::TryStreamExt; + + let report_ids = if user.role.is_mod() && count.all { + sqlx::query!( + " + SELECT id FROM reports + WHERE closed = FALSE + ORDER BY created ASC + LIMIT $1; + ", + count.count as i64 + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::ids::ReportId(m.id))) + }) + .try_collect::>() + .await? + } else { + sqlx::query!( + " + SELECT id FROM reports + WHERE closed = FALSE AND reporter = $1 + ORDER BY created ASC + LIMIT $2; + ", + user.id.0 as i64, + count.count as i64 + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::ids::ReportId(m.id))) + }) + .try_collect::>() + .await? + }; + + let query_reports = + crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?; + + let mut reports: Vec = Vec::new(); + + for x in query_reports { + reports.push(x.into()); + } + + Ok(HttpResponse::Ok().json(reports)) +} + +#[derive(Deserialize)] +pub struct ReportIds { + pub ids: String, +} + +pub async fn reports_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let report_ids: Vec = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let reports_data = + crate::database::models::report_item::Report::get_many(&report_ids, &**pool).await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + + let all_reports = reports_data + .into_iter() + .filter(|x| user.role.is_mod() || x.reporter == user.id.into()) + .map(|x| x.into()) + .collect::>(); + + Ok(HttpResponse::Ok().json(all_reports)) +} + +pub async fn report_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + let id = info.into_inner().0.into(); + + let report = crate::database::models::report_item::Report::get(id, &**pool).await?; + + if let Some(report) = report { + if !user.role.is_mod() && report.reporter != user.id.into() { + return Ok(HttpResponse::NotFound().body("")); + } + + let report: Report = report.into(); + Ok(HttpResponse::Ok().json(report)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Deserialize, Validate)] +pub struct EditReport { + #[validate(length(max = 65536))] + pub body: Option, + pub closed: Option, +} + +pub async fn report_edit( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, + edit_report: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_WRITE]), + ) + .await? + .1; + let id = info.into_inner().0.into(); + + let report = crate::database::models::report_item::Report::get(id, &**pool).await?; + + if let Some(report) = report { + if !user.role.is_mod() && report.reporter != user.id.into() { + return Ok(HttpResponse::NotFound().body("")); + } + + let mut transaction = pool.begin().await?; + + if let Some(edit_body) = &edit_report.body { + sqlx::query!( + " + UPDATE reports + SET body = $1 + WHERE (id = $2) + ", + edit_body, + id as crate::database::models::ids::ReportId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(edit_closed) = edit_report.closed { + if !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You cannot reopen a report!".to_string(), + )); + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: if !edit_closed && report.closed { + MessageBody::ThreadReopen + } else { + MessageBody::ThreadClosure + }, + thread_id: report.thread_id, + } + .insert(&mut transaction) + .await?; + + sqlx::query!( + " + UPDATE reports + SET closed = $1 + WHERE (id = $2) + ", + edit_closed, + id as crate::database::models::ids::ReportId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE threads + SET show_in_mod_inbox = $1 + WHERE id = $2 + ", + !(edit_closed || report.closed), + report.thread_id.0, + ) + .execute(&mut *transaction) + .await?; + } + + // delete any images no longer in the body + let checkable_strings: Vec<&str> = vec![&edit_report.body] + .into_iter() + .filter_map(|x: &Option| x.as_ref().map(|y| y.as_str())) + .collect(); + let image_context = ImageContext::Report { + report_id: Some(id.into()), + }; + img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn report_delete( + req: HttpRequest, + pool: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + redis: web::Data, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_DELETE]), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let id = info.into_inner().0; + let context = ImageContext::Report { + report_id: Some(id), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let result = + crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction) + .await?; + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/statistics.rs b/src/routes/v3/statistics.rs new file mode 100644 index 00000000..7a0def23 --- /dev/null +++ b/src/routes/v3/statistics.rs @@ -0,0 +1,85 @@ +use crate::routes::ApiError; +use actix_web::{web, HttpResponse}; +use serde_json::json; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("statistics", web::get().to(get_stats)); +} + +pub async fn get_stats(pool: web::Data) -> Result { + let projects = sqlx::query!( + " + SELECT COUNT(id) + FROM mods + WHERE status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let versions = sqlx::query!( + " + SELECT COUNT(v.id) + FROM versions v + INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) + WHERE v.status = ANY($2) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let authors = sqlx::query!( + " + SELECT COUNT(DISTINCT u.id) + FROM users u + INNER JOIN team_members tm on u.id = tm.user_id AND tm.accepted = TRUE + INNER JOIN mods m on tm.team_id = m.team_id AND m.status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let files = sqlx::query!( + " + SELECT COUNT(f.id) FROM files f + INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2) + INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let json = json!({ + "projects": projects.count, + "versions": versions.count, + "authors": authors.count, + "files": files.count, + }); + + Ok(HttpResponse::Ok().json(json)) +} diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index a7c54fcd..77645cc4 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use super::ApiError; +use crate::database::models::categories::{Category, DonationPlatform, ReportType, ProjectType}; use crate::database::models::loader_fields::{ Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, }; @@ -10,8 +11,42 @@ use serde_json::Value; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("tag").route("loader", web::get().to(loader_list))) - .route("loader_fields", web::get().to(loader_fields_list)); + cfg.service( + web::scope("tag") + .route("category", web::get().to(category_list)) + .route("loader", web::get().to(loader_list))) + .route("loader_fields", web::get().to(loader_fields_list)) + .route("license", web::get().to(license_list)) + .route("license/{id}", web::get().to(license_text)) + .route("donation_platform", web::get().to(donation_platform_list)) + .route("report_type", web::get().to(report_type_list)) + .route("project_type", web::get().to(project_type_list)); +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct CategoryData { + pub icon: String, + pub name: String, + pub project_type: String, + pub header: String, +} + +pub async fn category_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = Category::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| CategoryData { + icon: x.icon, + name: x.category, + project_type: x.project_type, + header: x.header, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(results)) } #[derive(serde::Serialize, serde::Deserialize)] @@ -90,3 +125,88 @@ pub async fn loader_fields_list( Ok(HttpResponse::Ok().json(results)) } + +#[derive(serde::Serialize)] +pub struct License { + short: String, + name: String, +} + +pub async fn license_list() -> HttpResponse { + let licenses = spdx::identifiers::LICENSES; + let mut results: Vec = Vec::with_capacity(licenses.len()); + + for (short, name, _) in licenses { + results.push(License { + short: short.to_string(), + name: name.to_string(), + }); + } + + HttpResponse::Ok().json(results) +} + +#[derive(serde::Serialize)] +pub struct LicenseText { + title: String, + body: String, +} + +pub async fn license_text(params: web::Path<(String,)>) -> Result { + let license_id = params.into_inner().0; + + if license_id == *crate::models::projects::DEFAULT_LICENSE_ID { + return Ok(HttpResponse::Ok().json(LicenseText { + title: "All Rights Reserved".to_string(), + body: "All rights reserved unless explicitly stated.".to_string(), + })); + } + + if let Some(license) = spdx::license_id(&license_id) { + return Ok(HttpResponse::Ok().json(LicenseText { + title: license.full_name.to_string(), + body: license.text().to_string(), + })); + } + + Err(ApiError::InvalidInput( + "Invalid SPDX identifier specified".to_string(), + )) +} + +#[derive(serde::Serialize)] +pub struct DonationPlatformQueryData { + short: String, + name: String, +} + +pub async fn donation_platform_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results: Vec = DonationPlatform::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| DonationPlatformQueryData { + short: x.short, + name: x.name, + }) + .collect(); + Ok(HttpResponse::Ok().json(results)) +} + +pub async fn report_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = ReportType::list(&**pool, &redis).await?; + Ok(HttpResponse::Ok().json(results)) +} + +pub async fn project_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = ProjectType::list(&**pool, &redis).await?; + Ok(HttpResponse::Ok().json(results)) +} diff --git a/src/routes/v3/teams.rs b/src/routes/v3/teams.rs new file mode 100644 index 00000000..31470bc8 --- /dev/null +++ b/src/routes/v3/teams.rs @@ -0,0 +1,940 @@ +use crate::auth::{get_user_from_headers, is_authorized}; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::team_item::TeamAssociationId; +use crate::database::models::{Organization, Team, TeamMember, User}; +use crate::database::redis::RedisPool; +use crate::database::Project; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId}; +use crate::models::users::UserId; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("teams", web::get().to(teams_get)); + + cfg.service( + web::scope("team") + .route("{id}/members", web::get().to(team_members_get)) + .route("{id}/members/{user_id}", web::patch().to(edit_team_member)) + .route("{id}/members/{user_id}", web::delete().to(remove_team_member)) + .route("{id}/members", web::post().to(add_team_member)) + .route("{id}/join", web::post().to(join_team)) + .route("{id}/owner", web::patch().to(transfer_ownership)) + ); +} + +// Returns all members of a project, +// including the team members of the project's team, but +// 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) +pub async fn team_members_get_project( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let project_data = crate::database::models::Project::get(&string, &**pool, &redis).await?; + + if let Some(project) = project_data { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_authorized(&project.inner, ¤t_user, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + let mut members_data = + TeamMember::get_from_team_full(project.inner.team_id, &**pool, &redis).await?; + let mut member_user_ids = members_data.iter().map(|x| x.user_id).collect::>(); + + // Adds the organization's team members to the list of members, if the project is associated with an organization + if let Some(oid) = project.inner.organization_id { + let organization_data = Organization::get_id(oid, &**pool, &redis).await?; + if let Some(organization_data) = organization_data { + let org_team = + TeamMember::get_from_team_full(organization_data.team_id, &**pool, &redis) + .await?; + for member in org_team { + if !member_user_ids.contains(&member.user_id) { + member_user_ids.push(member.user_id); + members_data.push(member); + } + } + } + } + + let users = + crate::database::models::User::get_many_ids(&member_user_ids, &**pool, &redis).await?; + + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(team_members)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn team_members_get_organization( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let organization_data = + crate::database::models::Organization::get(&string, &**pool, &redis).await?; + + if let Some(organization) = organization_data { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let members_data = + TeamMember::get_from_team_full(organization.team_id, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) +pub async fn team_members_get( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let members_data = TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users + .iter() + .find(|x| x.id == data.user_id) + .map(|user| crate::models::teams::TeamMember::from(data, user.clone(), !logged_in)) + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) +} + +#[derive(Serialize, Deserialize)] +pub struct TeamIds { + pub ids: String, +} + +pub async fn teams_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + use itertools::Itertools; + + let team_ids = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let teams_groups = teams_data.into_iter().group_by(|data| data.team_id.0); + + let mut teams: Vec> = vec![]; + + for (_, member_data) in &teams_groups { + let members = member_data.collect::>(); + + let logged_in = current_user + .as_ref() + .and_then(|user| { + members + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members = members + .into_iter() + .filter(|x| logged_in || x.accepted) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }); + + teams.push(team_members.collect()); + } + + Ok(HttpResponse::Ok().json(teams)) +} + +pub async fn join_team( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let team_id = info.into_inner().0.into(); + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let member = + TeamMember::get_from_user_id_pending(team_id, current_user.id.into(), &**pool).await?; + + if let Some(member) = member { + if member.accepted { + return Err(ApiError::InvalidInput( + "You are already a member of this team".to_string(), + )); + } + let mut transaction = pool.begin().await?; + + // Edit Team Member to set Accepted to True + TeamMember::edit_team_member( + team_id, + current_user.id.into(), + None, + None, + None, + Some(true), + None, + None, + &mut transaction, + ) + .await?; + + User::clear_project_cache(&[current_user.id.into()], &redis).await?; + TeamMember::clear_cache(team_id, &redis).await?; + + transaction.commit().await?; + } else { + return Err(ApiError::InvalidInput( + "There is no pending request from this team".to_string(), + )); + } + + Ok(HttpResponse::NoContent().body("")) +} + +fn default_role() -> String { + "Member".to_string() +} + +fn default_ordering() -> i64 { + 0 +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NewTeamMember { + pub user_id: UserId, + #[serde(default = "default_role")] + pub role: String, + #[serde(default)] + pub permissions: ProjectPermissions, + #[serde(default)] + pub organization_permissions: Option, + #[serde(default)] + pub payouts_split: Decimal, + #[serde(default = "default_ordering")] + pub ordering: i64, +} + +pub async fn add_team_member( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let team_id = info.into_inner().0.into(); + + let mut transaction = pool.begin().await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let team_association = Team::get_association(team_id, &**pool) + .await? + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool).await?; + match team_association { + // If team is associated with a project, check if they have permissions to invite users to that project + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id(pid, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this team".to_string(), + )); + } + if !permissions.contains(new_member.permissions) { + return Err(ApiError::InvalidInput( + "The new member has permissions that you don't have".to_string(), + )); + } + + if new_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be set" + .to_string(), + )); + } + } + // If team is associated with an organization, check if they have permissions to invite users to that organization + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + if !organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this organization".to_string(), + )); + } + if !organization_permissions + .contains(new_member.organization_permissions.unwrap_or_default()) + { + return Err(ApiError::InvalidInput( + "The new member has organization permissions that you don't have".to_string(), + )); + } + if !organization_permissions + .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) + && !new_member.permissions.is_empty() + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)." + .to_string(), + )); + } + } + } + + if new_member.role == crate::models::teams::OWNER_ROLE { + return Err(ApiError::InvalidInput( + "The `Owner` role is restricted to one person".to_string(), + )); + } + + if new_member.payouts_split < Decimal::ZERO || new_member.payouts_split > Decimal::from(5000) { + return Err(ApiError::InvalidInput( + "Payouts split must be between 0 and 5000!".to_string(), + )); + } + + let request = + TeamMember::get_from_user_id_pending(team_id, new_member.user_id.into(), &**pool).await?; + + if let Some(req) = request { + if req.accepted { + return Err(ApiError::InvalidInput( + "The user is already a member of that team".to_string(), + )); + } else { + return Err(ApiError::InvalidInput( + "There is already a pending member request for this user".to_string(), + )); + } + } + crate::database::models::User::get_id(new_member.user_id.into(), &**pool, &redis) + .await? + .ok_or_else(|| ApiError::InvalidInput("An invalid User ID specified".to_string()))?; + + let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?; + TeamMember { + id: new_id, + team_id, + user_id: new_member.user_id.into(), + role: new_member.role.clone(), + permissions: new_member.permissions, + organization_permissions: new_member.organization_permissions, + accepted: false, + payouts_split: new_member.payouts_split, + ordering: new_member.ordering, + } + .insert(&mut transaction) + .await?; + + match team_association { + TeamAssociationId::Project(pid) => { + NotificationBuilder { + body: NotificationBody::TeamInvite { + project_id: pid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; + } + TeamAssociationId::Organization(oid) => { + NotificationBuilder { + body: NotificationBody::OrganizationInvite { + organization_id: oid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; + } + } + + TeamMember::clear_cache(team_id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EditTeamMember { + pub permissions: Option, + pub organization_permissions: Option, + pub role: Option, + pub payouts_split: Option, + pub ordering: Option, +} + +pub async fn edit_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + edit_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let team_association = Team::get_association(id, &**pool) + .await? + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; + let edit_member_db = TeamMember::get_from_user_id_pending(id, user_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::CustomAuthentication( + "You don't have permission to edit members of this team".to_string(), + ) + })?; + + let mut transaction = pool.begin().await?; + + if &*edit_member_db.role == crate::models::teams::OWNER_ROLE + && (edit_member.role.is_some() || edit_member.permissions.is_some()) + { + return Err(ApiError::InvalidInput( + "The owner's permission and role of a team cannot be edited".to_string(), + )); + } + + match team_association { + TeamAssociationId::Project(project_id) => { + let organization = + Organization::get_associated_organization_project_id(project_id, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id(organization.team_id, current_user.id.into(), &**pool) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member.clone(), + &organization_team_member, + ) + .unwrap_or_default(); + if !permissions.contains(ProjectPermissions::EDIT_MEMBER) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit members of this team".to_string(), + )); + } + + if let Some(new_permissions) = edit_member.permissions { + if !permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new permissions have permissions that you don't have".to_string(), + )); + } + } + + if edit_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be edited" + .to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + + if !organization_permissions.contains(OrganizationPermissions::EDIT_MEMBER) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit members of this team".to_string(), + )); + } + + if let Some(new_permissions) = edit_member.organization_permissions { + if !organization_permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new organization permissions have permissions that you don't have" + .to_string(), + )); + } + } + + if edit_member.permissions.is_some() + && !organization_permissions + .contains(OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to give this user default project permissions." + .to_string(), + )); + } + } + } + + if let Some(payouts_split) = edit_member.payouts_split { + if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000) { + return Err(ApiError::InvalidInput( + "Payouts split must be between 0 and 5000!".to_string(), + )); + } + } + + if edit_member.role.as_deref() == Some(crate::models::teams::OWNER_ROLE) { + return Err(ApiError::InvalidInput( + "The `Owner` role is restricted to one person".to_string(), + )); + } + + TeamMember::edit_team_member( + id, + user_id, + edit_member.permissions, + edit_member.organization_permissions, + edit_member.role.clone(), + None, + edit_member.payouts_split, + edit_member.ordering, + &mut transaction, + ) + .await?; + + TeamMember::clear_cache(id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Deserialize)] +pub struct TransferOwnership { + pub user_id: UserId, +} + +pub async fn transfer_ownership( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_owner: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + // Forbid transferring ownership of a project team that is owned by an organization + // These are owned by the organization owner, and must be removed from the organization first + let pid = Team::get_association(id.into(), &**pool).await?; + if let Some(TeamAssociationId::Project(pid)) = pid { + let result = Project::get_id(pid, &**pool, &redis).await?; + if let Some(project_item) = result { + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "You cannot transfer ownership of a project team that is owend by an organization" + .to_string(), + )); + } + } + } + + if !current_user.role.is_admin() { + let member = TeamMember::get_from_user_id(id.into(), current_user.id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::CustomAuthentication( + "You don't have permission to edit members of this team".to_string(), + ) + })?; + + if member.role != crate::models::teams::OWNER_ROLE { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit the ownership of this team".to_string(), + )); + } + } + + let new_member = TeamMember::get_from_user_id(id.into(), new_owner.user_id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The new owner specified does not exist".to_string()) + })?; + + if !new_member.accepted { + return Err(ApiError::InvalidInput( + "You can only transfer ownership to members who are currently in your team".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + TeamMember::edit_team_member( + id.into(), + current_user.id.into(), + None, + None, + Some(crate::models::teams::DEFAULT_ROLE.to_string()), + None, + None, + None, + &mut transaction, + ) + .await?; + + TeamMember::edit_team_member( + id.into(), + new_owner.user_id.into(), + Some(ProjectPermissions::all()), + Some(OrganizationPermissions::all()), + Some(crate::models::teams::OWNER_ROLE.to_string()), + None, + None, + None, + &mut transaction, + ) + .await?; + + TeamMember::clear_cache(id.into(), &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn remove_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let team_association = Team::get_association(id, &**pool) + .await? + .ok_or_else(|| ApiError::InvalidInput("The team specified does not exist".to_string()))?; + let member = TeamMember::get_from_user_id(id, current_user.id.into(), &**pool).await?; + + let delete_member = TeamMember::get_from_user_id_pending(id, user_id, &**pool).await?; + + if let Some(delete_member) = delete_member { + if delete_member.role == crate::models::teams::OWNER_ROLE { + // The owner cannot be removed from a team + return Err(ApiError::CustomAuthentication( + "The owner can't be removed from a team".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + // Organization attached to a project this team is attached to + match team_association { + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id(pid, &**pool).await?; + let organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, + ) + .unwrap_or_default(); + + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) + || permissions.contains(ProjectPermissions::REMOVE_MEMBER) + // true as if the permission exists, but the member does not, they are part of an org + { + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this team" + .to_string(), + )); + } + } else if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) + || permissions.contains(ProjectPermissions::MANAGE_INVITES) + // true as if the permission exists, but the member does not, they are part of an org + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel a team invite".to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role(¤t_user.role, &member) + .unwrap_or_default(); + // Organization teams requires a TeamMember, so we can 'unwrap' + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if Some(delete_member.user_id) == member.map(|m| m.user_id) + || organization_permissions.contains(OrganizationPermissions::REMOVE_MEMBER) + { + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this organization" + .to_string(), + )); + } + } else if Some(delete_member.user_id) == member.map(|m| m.user_id) + || organization_permissions.contains(OrganizationPermissions::MANAGE_INVITES) + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel an organization invite".to_string(), + )); + } + } + } + + TeamMember::clear_cache(id, &redis).await?; + User::clear_project_cache(&[delete_member.user_id], &redis).await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/threads.rs b/src/routes/v3/threads.rs new file mode 100644 index 00000000..ca75c025 --- /dev/null +++ b/src/routes/v3/threads.rs @@ -0,0 +1,622 @@ +use std::sync::Arc; + +use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; +use crate::database; +use crate::database::models::image_item; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::ThreadMessageId; +use crate::models::images::{Image, ImageContext}; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::projects::ProjectStatus; +use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType}; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use futures::TryStreamExt; +use serde::Deserialize; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("thread") + .route("{id}", web::get().to(thread_get)) + .route("inbox", web::get().to(moderation_inbox)) + .route("{id}", web::post().to(thread_send_message)) + .route("{id}/read", web::post().to(thread_read)) + ); + cfg.service(web::scope("message").route("{id}", web::delete().to(message_delete))); + cfg.route("threads", web::get().to(threads_get)); +} + +pub async fn is_authorized_thread( + thread: &database::models::Thread, + user: &User, + pool: &PgPool, +) -> Result { + if user.role.is_mod() { + return Ok(true); + } + + let user_id: database::models::UserId = user.id.into(); + Ok(match thread.type_ { + ThreadType::Report => { + if let Some(report_id) = thread.report_id { + let report_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)", + report_id as database::models::ids::ReportId, + user_id as database::models::ids::UserId, + ) + .fetch_one(pool) + .await? + .exists; + + report_exists.unwrap_or(false) + } else { + false + } + } + ThreadType::Project => { + if let Some(project_id) = thread.project_id { + let project_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)", + project_id as database::models::ids::ProjectId, + user_id as database::models::ids::UserId, + ) + .fetch_one(pool) + .await? + .exists; + + project_exists.unwrap_or(false) + } else { + false + } + } + ThreadType::DirectMessage => thread.members.contains(&user_id), + }) +} + +pub async fn filter_authorized_threads( + threads: Vec, + user: &User, + pool: &web::Data, + redis: &RedisPool, +) -> Result, ApiError> { + let user_id: database::models::UserId = user.id.into(); + + let mut return_threads = Vec::new(); + let mut check_threads = Vec::new(); + + for thread in threads { + if user.role.is_mod() + || (thread.type_ == ThreadType::DirectMessage && thread.members.contains(&user_id)) + { + return_threads.push(thread); + } else { + check_threads.push(thread); + } + } + + if !check_threads.is_empty() { + let project_thread_ids = check_threads + .iter() + .filter(|x| x.type_ == ThreadType::Project) + .flat_map(|x| x.project_id.map(|x| x.0)) + .collect::>(); + + if !project_thread_ids.is_empty() { + sqlx::query!( + " + SELECT m.id FROM mods m + INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 + WHERE m.id = ANY($1) + ", + &*project_thread_ids, + user_id as database::models::ids::UserId, + ) + .fetch_many(&***pool) + .try_for_each(|e| { + if let Some(row) = e.right() { + check_threads.retain(|x| { + let bool = x.project_id.map(|x| x.0) == Some(row.id); + + if bool { + return_threads.push(x.clone()); + } + + !bool + }); + } + + futures::future::ready(Ok(())) + }) + .await?; + } + + let report_thread_ids = check_threads + .iter() + .filter(|x| x.type_ == ThreadType::Report) + .flat_map(|x| x.report_id.map(|x| x.0)) + .collect::>(); + + if !report_thread_ids.is_empty() { + sqlx::query!( + " + SELECT id FROM reports + WHERE id = ANY($1) AND reporter = $2 + ", + &*report_thread_ids, + user_id as database::models::ids::UserId, + ) + .fetch_many(&***pool) + .try_for_each(|e| { + if let Some(row) = e.right() { + check_threads.retain(|x| { + let bool = x.report_id.map(|x| x.0) == Some(row.id); + + if bool { + return_threads.push(x.clone()); + } + + !bool + }); + } + + futures::future::ready(Ok(())) + }) + .await?; + } + } + + let mut user_ids = return_threads + .iter() + .flat_map(|x| x.members.clone()) + .collect::>(); + user_ids.append( + &mut return_threads + .iter() + .flat_map(|x| { + x.messages + .iter() + .filter_map(|x| x.author_id) + .collect::>() + }) + .collect::>(), + ); + + let users: Vec = database::models::User::get_many_ids(&user_ids, &***pool, redis) + .await? + .into_iter() + .map(From::from) + .collect(); + + let mut final_threads = Vec::new(); + + for thread in return_threads { + let mut authors = thread.members.clone(); + + authors.append( + &mut thread + .messages + .iter() + .filter_map(|x| x.author_id) + .collect::>(), + ); + + final_threads.push(Thread::from( + thread, + users + .iter() + .filter(|x| authors.contains(&x.id.into())) + .cloned() + .collect(), + user, + )); + } + + Ok(final_threads) +} + +pub async fn thread_get( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0.into(); + + let thread_data = database::models::Thread::get(string, &**pool).await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await? + .1; + + if let Some(mut data) = thread_data { + if is_authorized_thread(&data, &user, &pool).await? { + let authors = &mut data.members; + + authors.append( + &mut data + .messages + .iter() + .filter_map(|x| x.author_id) + .collect::>(), + ); + + let users: Vec = database::models::User::get_many_ids(authors, &**pool, &redis) + .await? + .into_iter() + .map(From::from) + .collect(); + + return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user))); + } + } + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize)] +pub struct ThreadIds { + pub ids: String, +} + +pub async fn threads_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await? + .1; + + let thread_ids: Vec = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let threads_data = database::models::Thread::get_many(&thread_ids, &**pool).await?; + + let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?; + + Ok(HttpResponse::Ok().json(threads)) +} + +#[derive(Deserialize)] +pub struct NewThreadMessage { + pub body: MessageBody, +} + +pub async fn thread_send_message( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + new_message: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_WRITE]), + ) + .await? + .1; + + let string: database::models::ThreadId = info.into_inner().0.into(); + + if let MessageBody::Text { + body, + replying_to, + private, + .. + } = &new_message.body + { + if body.len() > 65536 { + return Err(ApiError::InvalidInput( + "Input body is too long!".to_string(), + )); + } + + if *private && !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You are not allowed to send private messages!".to_string(), + )); + } + + if let Some(replying_to) = replying_to { + let thread_message = + database::models::ThreadMessage::get((*replying_to).into(), &**pool).await?; + + if let Some(thread_message) = thread_message { + if thread_message.thread_id != string { + return Err(ApiError::InvalidInput( + "Message replied to is from another thread!".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "Message replied to does not exist!".to_string(), + )); + } + } + } else { + return Err(ApiError::InvalidInput( + "You may only send text messages through this route!".to_string(), + )); + } + + let result = database::models::Thread::get(string, &**pool).await?; + + if let Some(thread) = result { + if !is_authorized_thread(&thread, &user, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + let mut transaction = pool.begin().await?; + + let id = ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: new_message.body.clone(), + thread_id: thread.id, + } + .insert(&mut transaction) + .await?; + + let mod_notif = if let Some(project_id) = thread.project_id { + let project = database::models::Project::get_id(project_id, &**pool, &redis).await?; + + if let Some(project) = project { + if project.inner.status != ProjectStatus::Processing && user.role.is_mod() { + let members = database::models::TeamMember::get_from_team_full( + project.inner.team_id, + &**pool, + &redis, + ) + .await?; + + NotificationBuilder { + body: NotificationBody::ModeratorMessage { + thread_id: thread.id.into(), + message_id: id.into(), + project_id: Some(project.inner.id.into()), + report_id: None, + }, + } + .insert_many( + members.into_iter().map(|x| x.user_id).collect(), + &mut transaction, + &redis, + ) + .await?; + } + } + + !user.role.is_mod() + } else if let Some(report_id) = thread.report_id { + let report = database::models::report_item::Report::get(report_id, &**pool).await?; + + if let Some(report) = report { + if report.closed && !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You may not reply to a closed report".to_string(), + )); + } + + if user.id != report.reporter.into() { + NotificationBuilder { + body: NotificationBody::ModeratorMessage { + thread_id: thread.id.into(), + message_id: id.into(), + project_id: None, + report_id: Some(report.id.into()), + }, + } + .insert(report.reporter, &mut transaction, &redis) + .await?; + } + } + + !user.role.is_mod() + } else { + false + }; + + sqlx::query!( + " + UPDATE threads + SET show_in_mod_inbox = $1 + WHERE id = $2 + ", + mod_notif, + thread.id.0, + ) + .execute(&mut *transaction) + .await?; + + if let MessageBody::Text { + associated_images, .. + } = &new_message.body + { + for image_id in associated_images { + if let Some(db_image) = + image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::ThreadMessage { .. }) + || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'thread_message' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET thread_message_id = $1 + WHERE id = $2 + ", + thread.id.0, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis).await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn moderation_inbox( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await?; + + let ids = sqlx::query!( + " + SELECT id + FROM threads + WHERE show_in_mod_inbox = TRUE + " + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { Ok(e.right().map(|m| database::models::ThreadId(m.id))) }) + .try_collect::>() + .await?; + + let threads_data = database::models::Thread::get_many(&ids, &**pool).await?; + let threads = filter_authorized_threads(threads_data, &user, &pool, &redis).await?; + Ok(HttpResponse::Ok().json(threads)) +} + +pub async fn thread_read( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await?; + + let id = info.into_inner().0; + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE threads + SET show_in_mod_inbox = FALSE + WHERE id = $1 + ", + id.0 as i64, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn message_delete( + req: HttpRequest, + info: web::Path<(ThreadMessageId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + file_host: web::Data>, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_WRITE]), + ) + .await? + .1; + + let result = database::models::ThreadMessage::get(info.into_inner().0.into(), &**pool).await?; + + if let Some(thread) = result { + if !user.role.is_mod() && thread.author_id != Some(user.id.into()) { + return Err(ApiError::CustomAuthentication( + "You cannot delete this message!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + let context = ImageContext::ThreadMessage { + thread_message_id: Some(thread.id.into()), + }; + let images = database::Image::get_many_contexted(context, &mut transaction).await?; + let cdn_url = dotenvy::var("CDN_URL")?; + for image in images { + let name = image.url.split(&format!("{cdn_url}/")).nth(1); + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + database::Image::remove(image.id, &mut transaction, &redis).await?; + } + + database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs index 4faf6937..468270f2 100644 --- a/src/routes/v3/users.rs +++ b/src/routes/v3/users.rs @@ -1,19 +1,46 @@ +use std::sync::{Arc}; + use actix_web::{web, HttpRequest, HttpResponse}; +use regex::Regex; +use rust_decimal::Decimal; +use serde::{Serialize, Deserialize}; +use serde_json::json; use sqlx::PgPool; +use lazy_static::lazy_static; +use tokio::sync::Mutex; +use validator::Validate; use crate::{ auth::get_user_from_headers, - database::{models::User, redis::RedisPool}, - models::{ids::UserId, pats::Scopes, projects::Project}, - queue::session::AuthQueue, + database::{models::{User}, redis::RedisPool}, + models::{ids::UserId, pats::Scopes, projects::Project, users::{Badges, Role, Payout, PayoutStatus, UserPayoutData, RecipientStatus}, collections::{CollectionStatus, Collection}, notifications::Notification}, + queue::{session::AuthQueue, payouts::PayoutsQueue}, file_hosting::FileHost, util::{routes::read_from_payload, validate::validation_errors_to_string}, }; use super::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(web::scope("user").route("{user_id}/projects", web::get().to(projects_list))); + cfg.route("user", web::get().to(user_auth_get)); + cfg.route("users", web::get().to(users_get)); + + cfg.service( + web::scope("user") + .route("{user_id}/projects", web::get().to(projects_list)) + .route("{id}", web::get().to(user_get)) + .route("{user_id}/collections", web::get().to(collections_list)) + .route("{id}", web::patch().to(user_edit)) + .route("{id}/icon", web::patch().to(user_icon_edit)) + .route("{id}", web::delete().to(user_delete)) + .route("{id}/follows", web::get().to(user_follows)) + .route("{id}/notifications", web::get().to(user_notifications)) + .route("{id}/payouts", web::get().to(user_payouts)) + .route("{id}/payouts_fees", web::get().to(user_payouts_fees)) + .route("{id}/payouts", web::post().to(user_payouts_request)) + + ); } + pub async fn projects_list( req: HttpRequest, info: web::Path<(String,)>, @@ -56,3 +83,742 @@ pub async fn projects_list( Ok(HttpResponse::NotFound().body("")) } } + +pub async fn user_auth_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (scopes, mut user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_READ]), + ) + .await?; + + if !scopes.contains(Scopes::USER_READ_EMAIL) { + user.email = None; + } + + if !scopes.contains(Scopes::PAYOUTS_READ) { + user.payout_data = None; + } + + Ok(HttpResponse::Ok().json(user)) +} + +#[derive(Serialize, Deserialize)] +pub struct UserIds { + pub ids: String, +} + +pub async fn users_get( + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_ids = serde_json::from_str::>(&ids.ids)?; + + let users_data = User::get_many(&user_ids, &**pool, &redis).await?; + + let users: Vec = users_data.into_iter().map(From::from).collect(); + + Ok(HttpResponse::Ok().json(users)) +} + +pub async fn user_get( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(data) = user_data { + let response: crate::models::users::User = data.into(); + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn collections_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let user_id: UserId = id.into(); + + let can_view_private = user + .map(|y| y.role.is_mod() || y.id == user_id) + .unwrap_or(false); + + let project_data = User::get_collections(id, &**pool).await?; + + let response: Vec<_> = + crate::database::models::Collection::get_many(&project_data, &**pool, &redis) + .await? + .into_iter() + .filter(|x| can_view_private || matches!(x.status, CollectionStatus::Listed)) + .map(Collection::from) + .collect(); + + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +lazy_static! { + static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditUser { + #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] + pub username: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 64), regex = "RE_URL_SAFE")] + pub name: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 160))] + pub bio: Option>, + pub role: Option, + pub badges: Option, +} + +pub async fn user_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_user: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (_scopes, user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await?; + + new_user + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(actual_user) = id_option { + let id = actual_user.id; + let user_id: UserId = id.into(); + + if user.id == user_id || user.role.is_mod() { + let mut transaction = pool.begin().await?; + + if let Some(username) = &new_user.username { + let existing_user_id_option = User::get(username, &**pool, &redis).await?; + + if existing_user_id_option + .map(|x| UserId::from(x.id)) + .map(|id| id == user.id) + .unwrap_or(true) + { + sqlx::query!( + " + UPDATE users + SET username = $1 + WHERE (id = $2) + ", + username, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Username {username} is taken!" + ))); + } + } + + if let Some(name) = &new_user.name { + sqlx::query!( + " + UPDATE users + SET name = $1 + WHERE (id = $2) + ", + name.as_deref(), + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(bio) = &new_user.bio { + sqlx::query!( + " + UPDATE users + SET bio = $1 + WHERE (id = $2) + ", + bio.as_deref(), + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(role) = &new_user.role { + if !user.role.is_admin() { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the role of this user!" + .to_string(), + )); + } + + let role = role.to_string(); + + sqlx::query!( + " + UPDATE users + SET role = $1 + WHERE (id = $2) + ", + role, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(badges) = &new_user.badges { + if !user.role.is_admin() { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the badges of this user!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?; + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this user!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn user_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(actual_user) = id_option { + if user.id != actual_user.id.into() && !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this user's icon.".to_string(), + )); + } + + let icon_url = actual_user.avatar_url; + let user_id: UserId = actual_user.id.into(); + + if let Some(icon) = icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 2097152, "Icons must be smaller than 2MiB").await?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let upload_data = file_host + .upload_file( + content_type, + &format!("user/{}/{}.{}", user_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + sqlx::query!( + " + UPDATE users + SET avatar_url = $1 + WHERE (id = $2) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + actual_user.id as crate::database::models::ids::UserId, + ) + .execute(&**pool) + .await?; + User::clear_caches(&[(actual_user.id, None)], &redis).await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for user icon: {}", + ext.ext + ))) + } +} + +#[derive(Deserialize)] +pub struct RemovalType { + #[serde(default = "default_removal")] + pub removal_type: String, +} + +fn default_removal() -> String { + "partial".into() +} + +pub async fn user_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + removal_type: web::Query, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_DELETE]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this user!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + let result = User::remove( + id, + removal_type.removal_type == "full", + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn user_follows( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_READ]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the projects this user follows!".to_string(), + )); + } + + use futures::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT mf.mod_id FROM mod_follows mf + WHERE mf.follower_id = $1 + ", + id as crate::database::models::ids::UserId, + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::ProjectId(m.mod_id))) + }) + .try_collect::>() + .await?; + + let projects: Vec<_> = + crate::database::Project::get_many_ids(&project_ids, &**pool, &redis) + .await? + .into_iter() + .map(Project::from) + .collect(); + + Ok(HttpResponse::Ok().json(projects)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn user_notifications( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the notifications of this user!".to_string(), + )); + } + + let mut notifications: Vec = + crate::database::models::notification_item::Notification::get_many_user( + id, &**pool, &redis, + ) + .await? + .into_iter() + .map(Into::into) + .collect(); + + notifications.sort_by(|a, b| b.created.cmp(&a.created)); + println!("notifications: {:?}", serde_json::to_string(¬ifications).unwrap()); + Ok(HttpResponse::Ok().json(notifications)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn user_payouts( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the payouts of this user!".to_string(), + )); + } + + let (all_time, last_month, payouts) = futures::future::try_join3( + sqlx::query!( + " + SELECT SUM(pv.amount) amount + FROM payouts_values pv + WHERE pv.user_id = $1 + ", + id as crate::database::models::UserId + ) + .fetch_one(&**pool), + sqlx::query!( + " + SELECT SUM(pv.amount) amount + FROM payouts_values pv + WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval + ", + id as crate::database::models::UserId + ) + .fetch_one(&**pool), + sqlx::query!( + " + SELECT hp.created, hp.amount, hp.status + FROM historical_payouts hp + WHERE hp.user_id = $1 + ORDER BY hp.created DESC + ", + id as crate::database::models::UserId + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right().map(|row| Payout { + created: row.created, + amount: row.amount, + status: PayoutStatus::from_string(&row.status), + })) + }) + .try_collect::>(), + ) + .await?; + + use futures::TryStreamExt; + + Ok(HttpResponse::Ok().json(json!({ + "all_time": all_time.amount, + "last_month": last_month.amount, + "payouts": payouts, + }))) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Deserialize)] +pub struct FeeEstimateAmount { + pub amount: Decimal, +} + +pub async fn user_payouts_fees( + req: HttpRequest, + info: web::Path<(String,)>, + web::Query(amount): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + payouts_queue: web::Data>, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await? + .1; + let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(actual_user) = actual_user { + if !user.role.is_admin() && user.id != actual_user.id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to request payouts of this user!".to_string(), + )); + } + + if let Some(UserPayoutData { + trolley_id: Some(trolley_id), + .. + }) = user.payout_data + { + let payouts = payouts_queue + .lock() + .await + .get_estimated_fees(&trolley_id, amount.amount) + .await?; + + Ok(HttpResponse::Ok().json(payouts)) + } else { + Err(ApiError::InvalidInput( + "You must set up your trolley account first!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Deserialize)] +pub struct PayoutData { + pub amount: Decimal, +} + +pub async fn user_payouts_request( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + data: web::Json, + payouts_queue: web::Data>, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let mut payouts_queue = payouts_queue.lock().await; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_WRITE]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to request payouts of this user!".to_string(), + )); + } + + if let Some(UserPayoutData { + trolley_id: Some(trolley_id), + trolley_status: Some(trolley_status), + balance, + .. + }) = user.payout_data + { + if trolley_status == RecipientStatus::Active { + return if data.amount < balance { + let mut transaction = pool.begin().await?; + + let (batch_id, payment_id) = + payouts_queue.send_payout(&trolley_id, data.amount).await?; + + sqlx::query!( + " + INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id) + VALUES ($1, $2, $3, $4, $5) + ", + id as crate::database::models::ids::UserId, + data.amount, + "processing", + batch_id, + payment_id, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE users + SET balance = balance - $1 + WHERE id = $2 + ", + data.amount, + id as crate::database::models::ids::UserId + ) + .execute(&mut *transaction) + .await?; + + User::clear_caches(&[(id, None)], &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput( + "You do not have enough funds to make this payout!".to_string(), + )) + }; + } else { + return Err(ApiError::InvalidInput( + "Please complete payout information via the trolley dashboard!".to_string(), + )); + } + } + + Err(ApiError::InvalidInput( + "You are not enrolled in the payouts program yet!".to_string(), + )) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 4c64ee1f..428409ac 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -7,6 +7,7 @@ use crate::database::redis::RedisPool; use crate::models::ids::VersionId; use crate::models::pats::Scopes; use crate::models::projects::VersionType; +use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::{database, models}; use actix_web::{web, HttpRequest, HttpResponse}; @@ -20,7 +21,9 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("version_file") .route("version_id", web::get().to(get_version_from_hash)) .route("{version_id}/update", web::post().to(get_update_from_hash)) - .route("project", web::post().to(get_projects_from_hashes)), + .route("project", web::post().to(get_projects_from_hashes)) + .route("{version_id}", web::delete().to(delete_file)) + .route("{version_id}/download", web::get().to(download_version)), ); cfg.service( web::scope("version_files") @@ -492,3 +495,173 @@ pub async fn update_individual_files( Ok(HttpResponse::Ok().json(response)) } + +// under /api/v1/version_file/{hash} +pub async fn delete_file( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + let hash = info.into_inner().0.to_lowercase(); + + let file = database::models::Version::get_file_from_hash( + hash_query.algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + + if let Some(row) = file { + if !user.role.is_admin() { + let team_member = database::models::TeamMember::get_from_user_id_version( + row.version_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization = + database::models::Organization::get_associated_organization_project_id( + row.project_id, + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this file!".to_string(), + )); + } + } + + let version = database::models::Version::get(row.version_id, &**pool, &redis).await?; + if let Some(version) = version { + if version.files.len() < 2 { + return Err(ApiError::InvalidInput( + "Versions must have at least one file uploaded to them".to_string(), + )); + } + + database::models::Version::clear_cache(&version, &redis).await?; + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM hashes + WHERE file_id = $1 + ", + row.id.0 + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM files + WHERE files.id = $1 + ", + row.id.0, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize, Deserialize)] +pub struct DownloadRedirect { + pub url: String, +} + + +// under /api/v1/version_file/{hash}/download +pub async fn download_version( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let hash = info.into_inner().0.to_lowercase(); + let file = database::models::Version::get_file_from_hash( + hash_query.algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + + if let Some(file) = file { + let version = database::models::Version::get(file.version_id, &**pool, &redis).await?; + + if let Some(version) = version { + if !is_authorized_version(&version.inner, &user_option, &pool).await? { + return Ok(HttpResponse::NotFound().body("")); + } + + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*file.url)) + .json(DownloadRedirect { url: file.url })) + } else { + Ok(HttpResponse::NotFound().body("")) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 5d4a1e91..70ea7390 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -7,7 +7,7 @@ use crate::auth::{ use crate::database; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; -use crate::database::models::Organization; +use crate::database::models::{Organization, image_item}; use crate::database::redis::RedisPool; use crate::models; use crate::models::ids::base62_impl::parse_base62; @@ -21,6 +21,7 @@ use crate::queue::session::AuthQueue; use crate::util::img; use crate::util::validate::validation_errors_to_string; use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; @@ -36,6 +37,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("version") .route("{id}", web::get().to(version_get)) .route("{id}", web::patch().to(version_edit)) + .route("{id}", web::delete().to(version_delete)) + .route("{id}/schedule", web::post().to(version_schedule)) .route( "{version_id}/file", web::post().to(super::version_creation::upload_file_to_version), @@ -802,3 +805,191 @@ pub async fn version_list( Ok(HttpResponse::NotFound().body("")) } } + +#[derive(Deserialize)] +pub struct SchedulingData { + pub time: DateTime, + pub requested_status: VersionStatus, +} + +pub async fn version_schedule( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + scheduling_data: web::Json, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + if scheduling_data.time < Utc::now() { + return Err(ApiError::InvalidInput( + "You cannot schedule a version to be released in the past!".to_string(), + )); + } + + if !scheduling_data.requested_status.can_be_requested() { + return Err(ApiError::InvalidInput( + "Specified requested status cannot be requested!".to_string(), + )); + } + + let string = info.into_inner().0; + let result = database::models::Version::get(string.into(), &**pool, &redis).await?; + + if let Some(version_item) = result { + let team_member = database::models::TeamMember::get_from_user_id_project( + version_item.inner.project_id, + user.id.into(), + &**pool, + ) + .await?; + + let organization_item = + database::models::Organization::get_associated_organization_project_id( + version_item.inner.project_id, + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization_team_member = if let Some(organization) = &organization_item { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !user.role.is_mod() && !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to edit this version's scheduling data!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE versions + SET status = $1, date_published = $2 + WHERE (id = $3) + ", + VersionStatus::Scheduled.as_str(), + scheduling_data.time, + version_item.inner.id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Version::clear_cache(&version_item, &redis).await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +pub async fn version_delete( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_DELETE]), + ) + .await? + .1; + let id = info.into_inner().0; + + let version = database::models::Version::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified version does not exist!".to_string()) + })?; + + if !user.role.is_admin() { + let team_member = database::models::TeamMember::get_from_user_id_project( + version.inner.project_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization = + Organization::get_associated_organization_project_id(version.inner.project_id, &**pool) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete versions in this team".to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let context = ImageContext::Version { + version_id: Some(version.inner.id.into()), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let result = + database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?; + + database::models::Project::clear_cache(version.inner.project_id, None, Some(true), &redis) + .await?; + + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/tests/common/api_v2/team.rs b/tests/common/api_v2/team.rs index f1d6ef73..17c6f7fc 100644 --- a/tests/common/api_v2/team.rs +++ b/tests/common/api_v2/team.rs @@ -124,6 +124,9 @@ impl ApiV2 { .append_header(("Authorization", pat)) .to_request(); let resp = self.call(req).await; + println!("resp: {:?}", resp.response().body()); + assert_eq!(resp.status(), 200); + println!("Got past"); test::read_body_json(resp).await } From 82bd3067b4610e5e9df52d90d1d58467d112f26b Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Tue, 31 Oct 2023 16:52:53 -0700 Subject: [PATCH 23/31] fixes; tests passing --- src/routes/v2/project_creation.rs | 641 +----------------------------- src/routes/v2/users.rs | 75 +--- src/routes/v3/oauth_clients.rs | 2 +- src/routes/v3/users.rs | 86 +++- tests/common/dummy_data.rs | 21 +- 5 files changed, 103 insertions(+), 722 deletions(-) diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 229244fe..509beb91 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -213,636 +213,19 @@ pub async fn project_create( redis.clone(), file_host, session_queue, - Some(&[Scopes::PROJECT_CREATE]), ) - .await? - .1; - - let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); - - let project_create_data; - let mut versions; - let mut versions_map = std::collections::HashMap::new(); - let mut gallery_urls = Vec::new(); - - let all_game_versions = - models::categories::GameVersion::list(&mut **transaction, redis).await?; - let all_loaders = models::categories::Loader::list(&mut **transaction, redis).await?; - - { - // The first multipart field must be named "data" and contain a - // JSON `ProjectCreateData` object. - - let mut field = payload - .next() - .await - .map(|m| m.map_err(CreateError::MultipartError)) - .unwrap_or_else(|| { - Err(CreateError::MissingValueError(String::from( - "No `data` field in multipart upload", - ))) - })?; - - let content_disposition = field.content_disposition(); - let name = content_disposition - .get_name() - .ok_or_else(|| CreateError::MissingValueError(String::from("Missing content name")))?; - - if name != "data" { - return Err(CreateError::InvalidInput(String::from( - "`data` field must come before file fields", - ))); - } - let mut data = Vec::new(); - while let Some(chunk) = field.next().await { - data.extend_from_slice(&chunk.map_err(CreateError::MultipartError)?); - } - let create_data: ProjectCreateData = serde_json::from_slice(&data)?; - - create_data - .validate() - .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - - let slug_project_id_option: Option = parse_base62(&create_data.slug).ok(); - - if let Some(slug_project_id) = slug_project_id_option { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) - ", - slug_project_id as i64 - ) - .fetch_one(&mut **transaction) - .await - .map_err(|e| CreateError::DatabaseError(e.into()))?; - - if results.exists.unwrap_or(false) { - return Err(CreateError::SlugCollision); - } - } - - { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) - ", - create_data.slug - ) - .fetch_one(&mut **transaction) - .await - .map_err(|e| CreateError::DatabaseError(e.into()))?; - - if results.exists.unwrap_or(false) { - return Err(CreateError::SlugCollision); - } - } - - // Create VersionBuilders for the versions specified in `initial_versions` - versions = Vec::with_capacity(create_data.initial_versions.len()); - for (i, data) in create_data.initial_versions.iter().enumerate() { - // Create a map of multipart field names to version indices - for name in &data.file_parts { - if versions_map.insert(name.to_owned(), i).is_some() { - // If the name is already used - return Err(CreateError::InvalidInput(String::from( - "Duplicate multipart field name", - ))); - } - } - versions.push( - create_initial_version( - data, - project_id, - current_user.id, - &all_game_versions, - &all_loaders, - &create_data.project_type, - transaction, - ) - .await?, - ); - } - project_create_data = create_data; - } - - let project_type_id = models::categories::ProjectType::get_id( - project_create_data.project_type.as_str(), - &mut **transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Project Type {} does not exist.", - project_create_data.project_type.clone() - )) - })?; - - let mut icon_data = None; - - let mut error = None; - while let Some(item) = payload.next().await { - let mut field: Field = item?; - - if error.is_some() { - continue; - } - - let result = async { - let content_disposition = field.content_disposition().clone(); - - let name = content_disposition.get_name().ok_or_else(|| { - CreateError::MissingValueError("Missing content name".to_string()) - })?; - - let (file_name, file_extension) = - super::version_creation::get_name_ext(&content_disposition)?; - - if name == "icon" { - if icon_data.is_some() { - return Err(CreateError::InvalidInput(String::from( - "Projects can only have one icon", - ))); - } - // Upload the icon to the cdn - icon_data = Some( - process_icon_upload( - uploaded_files, - project_id.0, - file_extension, - file_host, - field, - &cdn_url, - ) - .await?, - ); - return Ok(()); - } - - if let Some(gallery_items) = &project_create_data.gallery_items { - if gallery_items.iter().filter(|a| a.featured).count() > 1 { - return Err(CreateError::InvalidInput(String::from( - "Only one gallery image can be featured.", - ))); - } - - if let Some(item) = gallery_items.iter().find(|x| x.item == name) { - let data = read_from_field( - &mut field, - 5 * (1 << 20), - "Gallery image exceeds the maximum of 5MiB.", - ) - .await?; - - let hash = sha1::Sha1::from(&data).hexdigest(); - let (_, file_extension) = - super::version_creation::get_name_ext(&content_disposition)?; - let content_type = crate::util::ext::get_image_content_type(file_extension) - .ok_or_else(|| { - CreateError::InvalidIconFormat(file_extension.to_string()) - })?; - - let url = format!("data/{project_id}/images/{hash}.{file_extension}"); - let upload_data = file_host - .upload_file(content_type, &url, data.freeze()) - .await?; - - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: upload_data.file_name, - }); - - gallery_urls.push(crate::models::projects::GalleryItem { - url: format!("{cdn_url}/{url}"), - featured: item.featured, - title: item.title.clone(), - description: item.description.clone(), - created: Utc::now(), - ordering: item.ordering, - }); - - return Ok(()); - } - } + .await?; - let index = if let Some(i) = versions_map.get(name) { - *i - } else { - return Err(CreateError::InvalidInput(format!( - "File `{file_name}` (field {name}) isn't specified in the versions data" - ))); + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => version_item::Version::get((*vid).into(), &**client, &redis).await?, + None => None, }; - - // `index` is always valid for these lists - let created_version = versions.get_mut(index).unwrap(); - let version_data = project_create_data.initial_versions.get(index).unwrap(); - - // Upload the new jar file - super::version_creation::upload_file( - &mut field, - file_host, - version_data.file_parts.len(), - uploaded_files, - &mut created_version.files, - &mut created_version.dependencies, - &cdn_url, - &content_disposition, - project_id, - created_version.version_id.into(), - &project_create_data.project_type, - version_data.loaders.clone(), - version_data.game_versions.clone(), - all_game_versions.clone(), - version_data.primary_file.is_some(), - version_data.primary_file.as_deref() == Some(name), - None, - transaction, - ) - .await?; - - Ok(()) - } - .await; - - if result.is_err() { - error = result.err(); - } - } - - if let Some(error) = error { - return Err(error); - } - - { - // Check to make sure that all specified files were uploaded - for (version_data, builder) in project_create_data - .initial_versions - .iter() - .zip(versions.iter()) - { - if version_data.file_parts.len() != builder.files.len() { - return Err(CreateError::InvalidInput(String::from( - "Some files were specified in initial_versions but not uploaded", - ))); - } - } - - // Convert the list of category names to actual categories - let mut categories = Vec::with_capacity(project_create_data.categories.len()); - for category in &project_create_data.categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut **transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - categories.push(id); - } - - let mut additional_categories = - Vec::with_capacity(project_create_data.additional_categories.len()); - for category in &project_create_data.additional_categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut **transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - additional_categories.push(id); - } - - let team = models::team_item::TeamBuilder { - members: vec![models::team_item::TeamMemberBuilder { - user_id: current_user.id.into(), - role: crate::models::teams::OWNER_ROLE.to_owned(), - // Allow all permissions for project creator, even if attached to a project - permissions: ProjectPermissions::all(), - organization_permissions: None, - accepted: true, - payouts_split: Decimal::ONE_HUNDRED, - ordering: 0, - }], - }; - - let team_id = team.insert(transaction).await?; - - let status; - if project_create_data.is_draft.unwrap_or(false) { - status = ProjectStatus::Draft; - } else { - status = ProjectStatus::Processing; - - if project_create_data.initial_versions.is_empty() { - return Err(CreateError::InvalidInput(String::from( - "Project submitted for review with no initial versions", - ))); - } - } - - if !project_create_data.requested_status.can_be_requested() { - return Err(CreateError::InvalidInput(String::from( - "Specified requested status is not allowed to be requested", - ))); - } - - let client_side_id = models::categories::SideType::get_id( - project_create_data.client_side.as_str(), - &mut **transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("Client side type specified does not exist.".to_string()) - })?; - - let server_side_id = models::categories::SideType::get_id( - project_create_data.server_side.as_str(), - &mut **transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("Server side type specified does not exist.".to_string()) - })?; - - let license_id = - spdx::Expression::parse(&project_create_data.license_id).map_err(|err| { - CreateError::InvalidInput(format!("Invalid SPDX license identifier: {err}")) - })?; - - let mut donation_urls = vec![]; - - if let Some(urls) = &project_create_data.donation_urls { - for url in urls { - let platform_id = - models::categories::DonationPlatform::get_id(&url.id, &mut **transaction) - .await? - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Donation platform {} does not exist.", - url.id.clone() - )) - })?; - - donation_urls.push(models::project_item::DonationUrl { - platform_id, - platform_short: "".to_string(), - platform_name: "".to_string(), - url: url.url.clone(), - }) - } - } - - let project_builder_actual = models::project_item::ProjectBuilder { - project_id: project_id.into(), - project_type_id, - team_id, - organization_id: project_create_data.organization_id, - title: project_create_data.title, - description: project_create_data.description, - body: project_create_data.body, - icon_url: icon_data.clone().map(|x| x.0), - issues_url: project_create_data.issues_url, - source_url: project_create_data.source_url, - wiki_url: project_create_data.wiki_url, - - license_url: project_create_data.license_url, - discord_url: project_create_data.discord_url, - categories, - additional_categories, - initial_versions: versions, - status, - requested_status: Some(project_create_data.requested_status), - client_side: client_side_id, - server_side: server_side_id, - license: license_id.to_string(), - slug: Some(project_create_data.slug), - donation_urls, - gallery_items: gallery_urls - .iter() - .map(|x| models::project_item::GalleryItem { - image_url: x.url.clone(), - featured: x.featured, - title: x.title.clone(), - description: x.description.clone(), - created: x.created, - ordering: x.ordering, - }) - .collect(), - color: icon_data.and_then(|x| x.1), - monetization_status: MonetizationStatus::Monetized, - }; - let project_builder = project_builder_actual.clone(); - - let now = Utc::now(); - - let id = project_builder_actual.insert(transaction).await?; - User::clear_project_cache(&[current_user.id.into()], redis).await?; - - for image_id in project_create_data.uploaded_images { - if let Some(db_image) = - image_item::Image::get(image_id.into(), &mut **transaction, redis).await? - { - let image: Image = db_image.into(); - if !matches!(image.context, ImageContext::Project { .. }) - || image.context.inner_id().is_some() - { - return Err(CreateError::InvalidInput(format!( - "Image {} is not unused and in the 'project' context", - image_id - ))); - } - - sqlx::query!( - " - UPDATE uploaded_images - SET mod_id = $1 - WHERE id = $2 - ", - id as models::ids::ProjectId, - image_id.0 as i64 - ) - .execute(&mut **transaction) - .await?; - - image_item::Image::clear_cache(image.id.into(), redis).await?; - } else { - return Err(CreateError::InvalidInput(format!( - "Image {} does not exist", - image_id - ))); - } - } - - let thread_id = ThreadBuilder { - type_: ThreadType::Project, - members: vec![], - project_id: Some(id), - report_id: None, - } - .insert(transaction) - .await?; - - let response = crate::models::projects::Project { - id: project_id, - slug: project_builder.slug.clone(), - project_type: project_create_data.project_type.clone(), - team: team_id.into(), - organization: project_create_data.organization_id.map(|x| x.into()), - title: project_builder.title.clone(), - description: project_builder.description.clone(), - body: project_builder.body.clone(), - body_url: None, - published: now, - updated: now, - approved: None, - queued: None, - status, - requested_status: project_builder.requested_status, - moderator_message: None, - license: License { - id: project_create_data.license_id.clone(), - name: "".to_string(), - url: project_builder.license_url.clone(), - }, - client_side: project_create_data.client_side, - server_side: project_create_data.server_side, - downloads: 0, - followers: 0, - categories: project_create_data.categories, - additional_categories: project_create_data.additional_categories, - game_versions: vec![], - loaders: vec![], - versions: project_builder - .initial_versions - .iter() - .map(|v| v.version_id.into()) - .collect::>(), - icon_url: project_builder.icon_url.clone(), - issues_url: project_builder.issues_url.clone(), - source_url: project_builder.source_url.clone(), - wiki_url: project_builder.wiki_url.clone(), - discord_url: project_builder.discord_url.clone(), - donation_urls: project_create_data.donation_urls.clone(), - gallery: gallery_urls, - color: project_builder.color, - thread_id: thread_id.into(), - monetization_status: MonetizationStatus::Monetized, - }; - - Ok(HttpResponse::Ok().json(response)) - } -} - -async fn create_initial_version( - version_data: &InitialVersionData, - project_id: ProjectId, - author: UserId, - all_game_versions: &[models::categories::GameVersion], - all_loaders: &[models::categories::Loader], - project_type: &str, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, -) -> Result { - if version_data.project_id.is_some() { - return Err(CreateError::InvalidInput(String::from( - "Found project id in initial version for new project", - ))); - } - - version_data - .validate() - .map_err(|err| CreateError::ValidationError(validation_errors_to_string(err, None)))?; - - // Randomly generate a new id to be used for the version - let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - - let game_versions = version_data - .game_versions - .iter() - .map(|x| { - all_game_versions - .iter() - .find(|y| y.version == x.0) - .ok_or_else(|| CreateError::InvalidGameVersion(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let loaders = version_data - .loaders - .iter() - .map(|x| { - all_loaders - .iter() - .find(|y| { - y.loader == x.0 - && y.supported_project_types - .contains(&project_type.to_string()) - }) - .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; - - let dependencies = version_data - .dependencies - .iter() - .map(|d| models::version_item::DependencyBuilder { - version_id: d.version_id.map(|x| x.into()), - project_id: d.project_id.map(|x| x.into()), - dependency_type: d.dependency_type.to_string(), - file_name: None, - }) - .collect::>(); - - let version = models::version_item::VersionBuilder { - version_id: version_id.into(), - project_id: project_id.into(), - author_id: author.into(), - name: version_data.version_title.clone(), - version_number: version_data.version_number.clone(), - changelog: version_data.version_body.clone().unwrap_or_default(), - files: Vec::new(), - dependencies, - game_versions, - loaders, - featured: version_data.featured, - status: VersionStatus::Listed, - version_type: version_data.release_channel.to_string(), - requested_status: None, - }; - - Ok(version) -} - -async fn process_icon_upload( - uploaded_files: &mut Vec, - id: u64, - file_extension: &str, - file_host: &dyn FileHost, - mut field: Field, - cdn_url: &str, -) -> Result<(String, Option), CreateError> { - if let Some(content_type) = crate::util::ext::get_image_content_type(file_extension) { - let data = read_from_field(&mut field, 262144, "Icons must be smaller than 256KiB").await?; - - let color = crate::util::img::get_color_from_img(&data)?; - - let hash = sha1::Sha1::from(&data).hexdigest(); - let upload_data = file_host - .upload_file( - content_type, - &format!("data/{id}/{hash}.{file_extension}"), - data.freeze(), - ) - .await?; - - uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: upload_data.file_name.clone(), - }); - - Ok((format!("{}/{}", cdn_url, upload_data.file_name), color)) - } else { - Err(CreateError::InvalidIconFormat(file_extension.to_string())) + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) + }, + Err(response) => Ok(response), } -} +} \ No newline at end of file diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 76951725..38790e36 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -14,7 +14,6 @@ use regex::Regex; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use validator::Validate; @@ -115,79 +114,7 @@ pub async fn orgs_list( redis: web::Data, session_queue: web::Data, ) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PROJECT_READ]), - ) - .await - .map(|x| x.1) - .ok(); - - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - let org_data = User::get_organizations(id, &**pool).await?; - - let organizations_data = - crate::database::models::organization_item::Organization::get_many_ids( - &org_data, &**pool, &redis, - ) - .await?; - - let team_ids = organizations_data - .iter() - .map(|x| x.team_id) - .collect::>(); - - let teams_data = crate::database::models::TeamMember::get_from_team_full_many( - &team_ids, &**pool, &redis, - ) - .await?; - let users = User::get_many_ids( - &teams_data.iter().map(|x| x.user_id).collect::>(), - &**pool, - &redis, - ) - .await?; - - let mut organizations = vec![]; - let mut team_groups = HashMap::new(); - for item in teams_data { - team_groups.entry(item.team_id).or_insert(vec![]).push(item); - } - - for data in organizations_data { - let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); - let logged_in = user - .as_ref() - .and_then(|user| { - members_data - .iter() - .find(|x| x.user_id == user.id.into() && x.accepted) - }) - .is_some(); - - let team_members: Vec<_> = members_data - .into_iter() - .filter(|x| logged_in || x.accepted || id == x.user_id) - .flat_map(|data| { - users.iter().find(|x| x.id == data.user_id).map(|user| { - crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) - }) - }) - .collect(); - - let organization = crate::models::organizations::Organization::from(data, team_members); - organizations.push(organization); - } - - Ok(HttpResponse::Ok().json(organizations)) - } else { - Ok(HttpResponse::NotFound().body("")) - } + v3::users::orgs_list(req, info, pool, redis, session_queue).await } lazy_static! { diff --git a/src/routes/v3/oauth_clients.rs b/src/routes/v3/oauth_clients.rs index 5f3839b6..46cb8e98 100644 --- a/src/routes/v3/oauth_clients.rs +++ b/src/routes/v3/oauth_clients.rs @@ -34,7 +34,7 @@ use crate::{ pats::Scopes, }, queue::session::AuthQueue, - routes::v2::project_creation::CreateError, + routes::v3::project_creation::CreateError, util::validate::validation_errors_to_string, }; diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs index 468270f2..e7be0326 100644 --- a/src/routes/v3/users.rs +++ b/src/routes/v3/users.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc}; +use std::{sync::{Arc}, collections::HashMap}; use actix_web::{web, HttpRequest, HttpResponse}; use regex::Regex; @@ -28,6 +28,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("{user_id}/projects", web::get().to(projects_list)) .route("{id}", web::get().to(user_get)) .route("{user_id}/collections", web::get().to(collections_list)) + .route("{user_id}/organizations", web::get().to(orgs_list)) .route("{id}", web::patch().to(user_edit)) .route("{id}/icon", web::patch().to(user_icon_edit)) .route("{id}", web::delete().to(user_delete)) @@ -187,6 +188,89 @@ pub async fn collections_list( } } +pub async fn orgs_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let org_data = User::get_organizations(id, &**pool).await?; + + let organizations_data = + crate::database::models::organization_item::Organization::get_many_ids( + &org_data, &**pool, &redis, + ) + .await?; + + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = crate::database::models::TeamMember::get_from_team_full_many( + &team_ids, &**pool, &redis, + ) + .await?; + let users = User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut organizations = vec![]; + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| logged_in || x.accepted || id == x.user_id) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = crate::models::organizations::Organization::from(data, team_members); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + + lazy_static! { static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index 1ffecad2..da063c07 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -19,15 +19,10 @@ use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegment use super::{environment::TestEnvironment, request_data::get_public_project_creation_data}; -use crate::common::{actix::AppendsMultipart, database::USER_USER_PAT}; - use super::{ - actix::{MultipartSegment, MultipartSegmentData}, asserts::assert_status, database::USER_USER_ID, - environment::TestEnvironment, get_json_val_str, - request_data::get_public_project_creation_data, }; pub const DUMMY_DATA_UPDATE: i64 = 3; @@ -180,10 +175,10 @@ pub struct DummyData { impl DummyData { pub fn new( - project_alpha: Project, - project_alpha_version: Version, - project_beta: Project, - project_beta_version: Version, + project_alpha: LegacyProject, + project_alpha_version: LegacyVersion, + project_beta: LegacyProject, + project_beta_version: LegacyVersion, organization_zeta: Organization, oauth_client_alpha: OAuthClient, ) -> Self { @@ -466,14 +461,6 @@ pub async fn get_oauth_client_alpha(test_env: &TestEnvironment) -> OAuthClient { oauth_clients.into_iter().next().unwrap() } -pub async fn get_oauth_client_alpha(test_env: &TestEnvironment) -> OAuthClient { - let oauth_clients = test_env - .v3 - .get_user_oauth_clients(USER_USER_ID, USER_USER_PAT) - .await; - oauth_clients.into_iter().next().unwrap() -} - impl TestFile { pub fn filename(&self) -> String { match self { From 2ca7ce99c17516bd584bb6a9a1942afd38831375 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 6 Nov 2023 17:02:02 -0800 Subject: [PATCH 24/31] project type changes --- migrations/20231005230721_dynamic-fields.sql | 43 +++++- src/database/models/categories.rs | 18 ++- src/database/models/ids.rs | 4 +- src/database/models/loader_fields.rs | 25 ++-- src/database/models/project_item.rs | 38 +++-- src/database/models/version_item.rs | 10 ++ src/models/projects.rs | 17 ++- src/models/v2/projects.rs | 12 +- src/routes/maven.rs | 46 +++--- src/routes/v2/project_creation.rs | 37 +++-- src/routes/v2/projects.rs | 9 ++ src/routes/v2/tags.rs | 31 ++-- src/routes/v2/version_creation.rs | 9 ++ src/routes/v3/organizations.rs | 3 + src/routes/v3/project_creation.rs | 95 ++++++------- src/routes/v3/projects.rs | 22 ++- src/routes/v3/tags.rs | 15 +- src/routes/v3/version_creation.rs | 142 ++++++++----------- src/routes/v3/versions.rs | 13 +- src/search/indexing/local_import.rs | 10 +- src/util/webhook.rs | 7 +- src/validate/mod.rs | 20 ++- tests/common/api_v2/project.rs | 2 + tests/common/api_v2/tags.rs | 5 +- tests/common/permissions.rs | 2 + tests/files/dummy_data.sql | 30 ++-- tests/project.rs | 1 + tests/search.rs | 4 + tests/tags.rs | 6 +- 29 files changed, 394 insertions(+), 282 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 74b11373..42977690 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -6,10 +6,42 @@ CREATE TABLE games ( INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); -ALTER TABLE loaders ADD CONSTRAINT unique_loader_name UNIQUE (loader); +-- we are creating a new loader type- 'mrpack'- for minecraft modpacks +INSERT INTO loaders (loader) VALUES ('mrpack'); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) SELECT DISTINCT l.id, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader = 'mrpack'; + +-- We create 'modpack' categories for every loader +-- That way we keep information like "this modpack is a fabric modpack" +INSERT INTO categories (category, project_type) +SELECT DISTINCT l.loader, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader != 'mrpack'; + +-- insert the loader of every modpack mod as a category +INSERT INTO mods_categories (joining_mod_id, joining_category_id) +SELECT DISTINCT m.id, c.id +FROM mods m +LEFT JOIN versions v ON m.id = v.mod_id +LEFT JOIN loaders_versions lv ON v.id = lv.version_id +LEFT JOIN loaders l ON lv.loader_id = l.id +CROSS JOIN categories c +WHERE m.project_type = (SELECT id FROM project_types WHERE name = 'modpack') AND c.category = l.loader; + +-- Non mrpack loaders no longer support modpacks +DELETE FROM loaders_project_types WHERE joining_loader_id != (SELECT id FROM loaders WHERE loader = 'mrpack') AND joining_project_type_id = (SELECT id FROM project_types WHERE name = 'modpack'); + +CREATE TABLE loaders_project_types_games ( + loader_id integer REFERENCES loaders NOT NULL, + project_type_id integer REFERENCES project_types NOT NULL, + game_id integer REFERENCES games NOT NULL, + PRIMARY KEY (loader_id, project_type_id, game_id) +); + +-- all past loader_project_types are minecraft-java as the only game before this migration is minecraft-java +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types; -ALTER TABLE mods ADD COLUMN game_id integer REFERENCES games NOT NULL DEFAULT 1; -- all past ones are minecraft-java -ALTER TABLE loaders ADD COLUMN game_id integer REFERENCES games NOT NULL DEFAULT 1; -- all past ones are minecraft-java +-- Now that loaders are inferred, we can drop the project_type column from mods +ALTER TABLE mods DROP COLUMN project_type; + +ALTER TABLE loaders ADD CONSTRAINT unique_loader_name UNIQUE (loader); CREATE TABLE loader_field_enums ( id serial PRIMARY KEY, @@ -110,7 +142,4 @@ DROP TABLE game_versions_versions; DROP TABLE game_versions; -- Drop original_id columns -ALTER TABLE loader_field_enum_values DROP COLUMN original_id; - --- drop 'minecraft-java' as default -ALTER TABLE loaders ALTER COLUMN game_id DROP DEFAULT; +ALTER TABLE loader_field_enum_values DROP COLUMN original_id; \ No newline at end of file diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs index ae9e11d8..a033e276 100644 --- a/src/database/models/categories.rs +++ b/src/database/models/categories.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::database::redis::RedisPool; use super::ids::*; @@ -34,21 +36,29 @@ pub struct DonationPlatform { } impl Category { - pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + // Gets hashmap of category ids matching a name + // Multiple categories can have the same name, but different project types, so we need to return a hashmap + // ProjectTypeId -> CategoryId + pub async fn get_ids<'a, E>(name: &str, exec: E) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let result = sqlx::query!( " - SELECT id FROM categories + SELECT id, project_type FROM categories WHERE category = $1 ", name, ) - .fetch_optional(exec) + .fetch_all(exec) .await?; - Ok(result.map(|r| CategoryId(r.id))) + let mut map = HashMap::new(); + for r in result { + map.insert(ProjectTypeId(r.project_type), CategoryId(r.id)); + } + + Ok(map) } pub async fn get_id_project<'a, E>( diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 6d9d30bb..03463976 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -202,7 +202,7 @@ pub struct OrganizationId(pub i64); #[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct ProjectId(pub i64); -#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] #[sqlx(transparent)] pub struct ProjectTypeId(pub i32); @@ -219,7 +219,7 @@ pub struct DonationPlatformId(pub i32); #[derive(Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct VersionId(pub i64); -#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] #[sqlx(transparent)] pub struct LoaderId(pub i32); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index ea6069bb..207c5811 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -41,12 +41,13 @@ impl Game { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct Loader { pub id: LoaderId, pub loader: String, pub icon: String, pub supported_project_types: Vec, + pub supported_games: Vec, } impl Loader { @@ -84,17 +85,15 @@ impl Loader { } pub async fn list<'a, E>( - game: Game, exec: E, redis: &RedisPool, ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let game_name = game.name(); let cached_loaders: Option> = redis - .get_deserialized_from_json(LOADERS_LIST_NAMESPACE, game_name) + .get_deserialized_from_json(LOADERS_LIST_NAMESPACE, "all") .await?; if let Some(cached_loaders) = cached_loaders { return Ok(cached_loaders); @@ -103,15 +102,15 @@ impl Loader { let result = sqlx::query!( " SELECT l.id id, l.loader loader, l.icon icon, - ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types - FROM loaders l - INNER JOIN games g ON l.game_id = g.id + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games + FROM loaders l LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id - WHERE g.name = $1 + LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id + LEFT OUTER JOIN games g ON lptg.game_id = g.id GROUP BY l.id; ", - game_name, ) .fetch_many(exec) .try_filter_map(|e| async { @@ -125,13 +124,19 @@ impl Loader { .iter() .map(|x| x.to_string()) .collect(), + supported_games: x + .games + .unwrap_or_default() + .iter() + .filter_map(|x| Game::from_name(x)) + .collect(), })) }) .try_collect::>() .await?; redis - .set_serialized_to_json(LOADERS_LIST_NAMESPACE, game_name, &result, None) + .set_serialized_to_json(LOADERS_LIST_NAMESPACE, "all", &result, None) .await?; Ok(result) diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index ec1cc119..4b647b6f 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -1,4 +1,3 @@ -use super::loader_fields::Game; use super::{ids::*, User}; use crate::database::models; use crate::database::models::DatabaseError; @@ -142,8 +141,6 @@ impl ModCategory { #[derive(Clone)] pub struct ProjectBuilder { pub project_id: ProjectId, - pub game: Game, - pub project_type_id: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, pub title: String, @@ -175,8 +172,6 @@ impl ProjectBuilder { ) -> Result { let project_struct = Project { id: self.project_id, - game: self.game, - project_type: self.project_type_id, team_id: self.team_id, organization_id: self.organization_id, title: self.title, @@ -248,8 +243,6 @@ impl ProjectBuilder { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Project { pub id: ProjectId, - pub game: Game, - pub project_type: ProjectTypeId, pub team_id: TeamId, pub organization_id: Option, pub title: String, @@ -292,14 +285,14 @@ impl Project { published, downloads, icon_url, issues_url, source_url, wiki_url, status, requested_status, discord_url, license_url, license, - slug, project_type, color, monetization_status + slug, color, monetization_status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, LOWER($17), $18, - $19, $20 + $15, $16, + LOWER($17), $18, $19 ) ", self.id as ProjectId, @@ -319,7 +312,6 @@ impl Project { self.license_url.as_ref(), &self.license, self.slug.as_ref(), - self.project_type as ProjectTypeId, self.color.map(|x| x as i32), self.monetization_status.as_str(), ) @@ -570,22 +562,22 @@ impl Project { let db_projects: Vec = sqlx::query!( " - SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, + SELECT m.id id, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.body body, m.published published, m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status, m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, m.license_url license_url, 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, - pt.name project_type_name, m.webhook_sent, m.color, + m.webhook_sent, m.color, t.id thread_id, m.monetization_status monetization_status, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games, 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, JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions, JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery, JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations - FROM mods m - INNER JOIN games g ON g.id = m.game_id - INNER JOIN project_types pt ON pt.id = m.project_type + FROM mods m INNER JOIN threads t ON t.mod_id = m.id LEFT JOIN mods_gallery mg ON mg.mod_id = m.id LEFT JOIN mods_donations md ON md.joining_mod_id = m.id @@ -595,8 +587,12 @@ impl Project { LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3) LEFT JOIN loaders_versions lv ON lv.version_id = v.id LEFT JOIN loaders l on lv.loader_id = l.id + LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id + LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id + LEFT JOIN games g ON lptg.game_id = g.id WHERE m.id = ANY($1) OR m.slug = ANY($2) - GROUP BY pt.id, t.id, m.id, g.name; + GROUP BY t.id, m.id; ", &project_ids_parsed, &remaining_strings.into_iter().map(|x| x.to_string().to_lowercase()).collect::>(), @@ -609,8 +605,6 @@ impl Project { Some(QueryProject { inner: Project { id: ProjectId(id), - game: m.name.and_then(|g| Game::from_name(&g))?, - project_type: ProjectTypeId(m.project_type), team_id: TeamId(m.team_id), organization_id: m.organization_id.map(OrganizationId), title: m.title.clone(), @@ -646,9 +640,10 @@ impl Project { ), loaders: m.loaders.unwrap_or_default(), }, - project_type: m.project_type_name, categories: m.categories.unwrap_or_default(), additional_categories: m.additional_categories.unwrap_or_default(), + project_types: m.project_types.unwrap_or_default(), + games: m.games.unwrap_or_default(), versions: { #[derive(Deserialize)] struct Version { @@ -781,10 +776,11 @@ impl Project { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct QueryProject { pub inner: Project, - pub project_type: String, pub categories: Vec, pub additional_categories: Vec, pub versions: Vec, + pub project_types: Vec, + pub games: Vec, pub donation_urls: Vec, pub gallery_items: Vec, pub thread_id: ThreadId, diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 763889e9..e98abb7a 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -519,6 +519,8 @@ impl Version { 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, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games, JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files, JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes, JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies, @@ -557,6 +559,10 @@ impl Version { FROM versions v LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id LEFT OUTER JOIN loaders l on lv.loader_id = l.id + LEFT OUTER JOIN loaders_project_types lpt on l.id = lpt.joining_loader_id + LEFT JOIN project_types pt on lpt.joining_project_type_id = pt.id + LEFT OUTER JOIN loaders_project_types_games lptg on l.id = lptg.loader_id AND pt.id = lptg.project_type_id + LEFT JOIN games g on lptg.game_id = g.id LEFT OUTER JOIN files f on v.id = f.version_id LEFT OUTER JOIN hashes h on f.id = h.file_id LEFT OUTER JOIN dependencies d on v.id = d.dependent_id @@ -658,6 +664,8 @@ impl Version { }, version_fields: VersionField::from_query_json(v.id, v.loader_fields, v.version_fields, v.loader_field_enum_values), loaders: v.loaders.unwrap_or_default(), + project_types: v.project_types.unwrap_or_default(), + games: v.games.unwrap_or_default(), dependencies: serde_json::from_value( v.dependencies.unwrap_or_default(), ) @@ -838,6 +846,8 @@ pub struct QueryVersion { pub files: Vec, pub version_fields: Vec, pub loaders: Vec, + pub project_types: Vec, + pub games: Vec, pub dependencies: Vec, } diff --git a/src/models/projects.rs b/src/models/projects.rs index 203b1851..d1b7dd50 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -29,8 +29,10 @@ pub struct Project { pub id: ProjectId, /// The slug of a project, used for vanity URLs pub slug: Option, - /// The project type of the project - pub project_type: String, + /// The aggregated project typs of the versions of this project + pub project_types: Vec, + /// The aggregated games of the versions of this project + pub games: Vec, /// The team of people that has ownership of this project. pub team: TeamId, /// The optional organization of people that have ownership of this project. @@ -115,7 +117,8 @@ impl From for Project { Self { id: m.id.into(), slug: m.slug, - project_type: data.project_type, + project_types: data.project_types, + games: data.games, team: m.team_id.into(), organization: m.organization_id.map(|i| i.into()), title: m.title, @@ -454,11 +457,14 @@ pub struct Version { pub author_id: UserId, /// Whether the version is featured or not pub featured: bool, - /// The name of this version pub name: String, /// The version number. Ideally will follow semantic versioning pub version_number: String, + /// Project types for which this version is compatible with, extracted from Loader + pub project_types: Vec, + /// Games for which this version is compatible with, extracted from Loader/Project types + pub games: Vec, /// The changelog for this version of the project. pub changelog: String, /// A link to the changelog for this version of the project. Deprecated, always None @@ -506,10 +512,11 @@ impl From for Version { id: v.id.into(), project_id: v.project_id.into(), author_id: v.author_id.into(), - featured: v.featured, name: v.name, version_number: v.version_number, + project_types: data.project_types, + games: data.games, changelog: v.changelog, changelog_url: None, date_published: v.date_published, diff --git a/src/models/v2/projects.rs b/src/models/v2/projects.rs index 2d8df231..cb8d436e 100644 --- a/src/models/v2/projects.rs +++ b/src/models/v2/projects.rs @@ -71,6 +71,11 @@ impl LegacyProject { let mut client_side = LegacySideType::Unknown; let mut server_side = LegacySideType::Unknown; let mut game_versions = Vec::new(); + + // TODO: extract modpack changes + // - if loader is mrpack, this is a modpack + // the loaders are whatever the corresponding cateogires are + if let Some(versions_item) = versions_item { client_side = versions_item .version_fields @@ -100,10 +105,15 @@ impl LegacyProject { .map(|v| v.into_iter().map(|v| v.version).collect()) .unwrap_or(Vec::new()); } + + // V2 projects only have one project type- v3 ones can rarely have multiple. + // We'll just use the first one. + let project_type = data.project_types.get(0).cloned().unwrap_or_default(); + Self { id: data.id, slug: data.slug, - project_type: data.project_type, + project_type, team: data.team, organization: data.organization, title: data.title, diff --git a/src/routes/maven.rs b/src/routes/maven.rs index 9fd344e1..a2009a34 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -1,5 +1,5 @@ use crate::database::models::legacy_loader_fields::MinecraftGameVersion; -use crate::database::models::loader_fields::{Game, Loader}; +use crate::database::models::loader_fields::Loader; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::{QueryFile, QueryVersion}; use crate::database::redis::RedisPool; @@ -23,6 +23,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(version_file); } +// TODO: These were modified in v3 and should be tested + #[derive(Default, Debug, Clone, YaSerialize)] #[yaserde(root = "metadata", rename = "metadata")] pub struct Metadata { @@ -180,8 +182,7 @@ async fn find_version( return Ok(exact_matches.get(0).map(|x| (*x).clone())); }; - // Hardcoded to minecraft-java - let db_loaders: HashSet = Loader::list(Game::MinecraftJava, pool, redis) + let db_loaders: HashSet = Loader::list(pool, redis) .await? .into_iter() .map(|x| x.loader) @@ -229,7 +230,6 @@ async fn find_version( fn find_file<'a>( project_id: &str, vcoords: &str, - project: &QueryProject, version: &'a QueryVersion, file: &str, ) -> Option<&'a QueryFile> { @@ -237,21 +237,27 @@ fn find_file<'a>( return Some(selected_file); } - let fileext = match project.project_type.as_str() { - "mod" => "jar", - "modpack" => "mrpack", - _ => return None, - }; + // Minecraft mods are not going to be both a mod and a modpack, so this minecraft-specific handling is fine + // As there can be multiple project types, returns the first allowable match + let mut fileexts = vec![]; + for project_type in version.project_types.iter() { + match project_type.as_str() { + "mod" => fileexts.push("jar"), + "modpack" => fileexts.push("mrpack"), + _ => (), + } + } - if file == format!("{}-{}.{}", &project_id, &vcoords, fileext) { - version - .files - .iter() - .find(|x| x.primary) - .or_else(|| version.files.iter().last()) - } else { - None + for fileext in fileexts { + if file == format!("{}-{}.{}", &project_id, &vcoords, fileext) { + return version + .files + .iter() + .find(|x| x.primary) + .or_else(|| version.files.iter().last()); + } } + None } #[route( @@ -310,7 +316,7 @@ pub async fn version_file( return Ok(HttpResponse::Ok() .content_type("text/xml") .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?)); - } else if let Some(selected_file) = find_file(&project_id, &vnum, &project, &version, &file) { + } else if let Some(selected_file) = find_file(&project_id, &vnum, &version, &file) { return Ok(HttpResponse::TemporaryRedirect() .append_header(("location", &*selected_file.url)) .body("")); @@ -355,7 +361,7 @@ pub async fn version_file_sha1( return Ok(HttpResponse::NotFound().body("")); } - Ok(find_file(&project_id, &vnum, &project, &version, &file) + Ok(find_file(&project_id, &vnum, &version, &file) .and_then(|file| file.hashes.get("sha1")) .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) .unwrap_or_else(|| HttpResponse::NotFound().body(""))) @@ -397,7 +403,7 @@ pub async fn version_file_sha512( return Ok(HttpResponse::NotFound().body("")); } - Ok(find_file(&project_id, &vnum, &project, &version, &file) + Ok(find_file(&project_id, &vnum, &version, &file) .and_then(|file| file.hashes.get("sha512")) .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) .unwrap_or_else(|| HttpResponse::NotFound().body(""))) diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 509beb91..1e550392 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -2,9 +2,8 @@ use crate::database::models::version_item; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; -use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::ImageId; -use crate::models::projects::{DonationLink, Project, ProjectStatus, SideType}; +use crate::models::projects::{DonationLink, Project, ProjectStatus, SideType, Loader}; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::default_project_type; @@ -13,6 +12,7 @@ use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::postgres::PgPool; @@ -148,19 +148,31 @@ pub async fn project_create( payload, req.headers().clone(), |legacy_create: ProjectCreateData| { - // Set game name (all v2 projects are minecraft-java) - let game_name = "minecraft-java".to_string(); - // Side types will be applied to each version let client_side = legacy_create.client_side; let server_side = legacy_create.server_side; + let project_type = legacy_create.project_type; + // Modpacks now use the "mrpack" loader, and loaders are converted to categories. + // Setting of 'project_type' directly is removed, it's loader-based now. + let mut additional_categories = legacy_create.additional_categories; + let initial_versions = legacy_create.initial_versions.into_iter().map(|v| { let mut fields = HashMap::new(); fields.insert("client_side".to_string(), json!(client_side)); fields.insert("server_side".to_string(), json!(server_side)); fields.insert("game_versions".to_string(), json!(v.game_versions)); + if project_type == "modpack" { + additional_categories.extend(v.loaders.iter().map(|l| l.0.clone())); + } + + let loaders = if project_type == "modpack" { + vec![Loader("mrpack".to_string())] + } else { + v.loaders + }; + v3::version_creation::InitialVersionData { project_id: v.project_id, file_parts: v.file_parts, @@ -169,7 +181,7 @@ pub async fn project_create( version_body: v.version_body, dependencies: v.dependencies, release_channel: v.release_channel, - loaders: v.loaders, + loaders, featured: v.featured, primary_file: v.primary_file, status: v.status, @@ -177,17 +189,18 @@ pub async fn project_create( uploaded_images: v.uploaded_images, fields, } - }); + }).collect(); + + let additional_categories = additional_categories.into_iter().unique().collect::>(); + println!("additional_categories: {:?}", additional_categories); Ok(v3::project_creation::ProjectCreateData { title: legacy_create.title, - project_type: legacy_create.project_type, slug: legacy_create.slug, description: legacy_create.description, body: legacy_create.body, - game_name, - initial_versions: initial_versions.collect(), + initial_versions, categories: legacy_create.categories, - additional_categories: legacy_create.additional_categories, + additional_categories, issues_url: legacy_create.issues_url, source_url: legacy_create.source_url, wiki_url: legacy_create.wiki_url, @@ -216,9 +229,11 @@ pub async fn project_create( ) .await?; + println!("did a little test <3"); // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { Ok(project) => { + println!("Just finished doing a project create, looking at repsonse: {:?}", serde_json::to_string(&project).unwrap()); let version_item = match project.versions.first() { Some(vid) => version_item::Version::get((*vid).into(), &**client, &redis).await?, None => None, diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index 88db982f..ab82807c 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -326,6 +326,15 @@ pub async fn project_edit( let server_side = v2_new_project.server_side.clone(); let new_slug = v2_new_project.slug.clone(); + // TODO: Some kind of handling here to ensure project type is fine. + // We expect the version uploaded to be of loader type modpack, but there might not be a way to check here for that. + // After all, theoretically, they could be creating a genuine 'fabric' mod, and modpack no longer carries information on whether its a mod or modpack, + // as those are out to the versions. + + // Ideally this would, if the project 'should' be a modpack: + // - change the loaders to mrpack only + // - add categories to the project for the corresponding loaders + let new_project = v3::projects::EditProject { title: v2_new_project.title, description: v2_new_project.description, diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 7a560844..407d58c8 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::loader_fields::{Game, LoaderFieldEnumValue}; +use crate::database::models::loader_fields::LoaderFieldEnumValue; use crate::database::redis::RedisPool; -use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderList}; +use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderData as LoaderDataV3}; use crate::routes::{v2_reroute, v3}; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; @@ -45,9 +45,9 @@ pub async fn category_list( #[derive(serde::Serialize, serde::Deserialize)] pub struct LoaderData { - icon: String, - name: String, - supported_project_types: Vec, + pub icon: String, + pub name: String, + pub supported_project_types: Vec, } #[get("loader")] @@ -56,14 +56,27 @@ pub async fn loader_list( redis: web::Data, ) -> Result { let response = v3::tags::loader_list( - web::Query(LoaderList { - game: Game::MinecraftJava.name().to_string(), - }), pool, redis, ) .await?; - Ok(response) + + // Convert to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(loaders) => { + let loaders = loaders + .into_iter() + .map(|l| LoaderData { + icon: l.icon, + name: l.name, + supported_project_types: l + .supported_project_types, + }) + .collect::>(); + Ok(HttpResponse::Ok().json(loaders)) + } + Err(response) => Ok(response), + } } #[derive(serde::Serialize, serde::Deserialize)] diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index e6cea47c..1323ccb2 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -92,6 +92,15 @@ pub async fn version_create( json!(legacy_create.game_versions), ); + // TODO: Some kind of handling here to ensure project type is fine. + // We expect the version uploaded to be of loader type modpack, but there might not be a way to check here for that. + // After all, theoretically, they could be creating a genuine 'fabric' mod, and modpack no longer carries information on whether its a mod or modpack, + // as those are out to the versions. + + // Ideally this would, if the project 'should' be a modpack: + // - change the loaders to mrpack only + // - add categories to the project for the corresponding loaders + Ok(v3::version_creation::InitialVersionData { project_id: legacy_create.project_id, file_parts: legacy_create.file_parts, diff --git a/src/routes/v3/organizations.rs b/src/routes/v3/organizations.rs index 3205978d..e8bc7311 100644 --- a/src/routes/v3/organizations.rs +++ b/src/routes/v3/organizations.rs @@ -473,6 +473,7 @@ pub async fn organization_delete( redis: web::Data, session_queue: web::Data, ) -> Result { + println!("DELETE ORGANIZATION"); let user = get_user_from_headers( &req, &**pool, @@ -483,6 +484,7 @@ pub async fn organization_delete( .await? .1; let string = info.into_inner().0; + println!("string: {}", string); let organization = database::models::Organization::get(&string, &**pool, &redis) .await? @@ -490,6 +492,7 @@ pub async fn organization_delete( ApiError::InvalidInput("The specified organization does not exist!".to_string()) })?; + println!("organization: {:?}", organization); if !user.role.is_admin() { let team_member = database::models::TeamMember::get_from_user_id_organization( organization.id, diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 005d3435..667b01f2 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -1,7 +1,7 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::loader_fields::{ - Game, LoaderField, LoaderFieldEnumValue, VersionField, + LoaderField, LoaderFieldEnumValue, VersionField, Loader, }; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, image_item, User}; @@ -28,6 +28,7 @@ use actix_web::{HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; use image::ImageError; +use itertools::Itertools; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; @@ -159,14 +160,6 @@ pub struct ProjectCreateData { #[serde(alias = "mod_name")] /// The title or name of the project. pub title: String, - /// The name of the game that the project is for. - /// This must be a valid game name. - #[validate(length(min = 1, max = 64))] - pub game_name: String, - #[validate(length(min = 1, max = 64))] - #[serde(default = "default_project_type")] - /// The project type of this mod - pub project_type: String, #[validate( length(min = 3, max = 64), regex = "crate::util::validate::RE_URL_SAFE" @@ -377,9 +370,9 @@ async fn project_create_inner( .1; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); + let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?; let project_create_data: ProjectCreateData; - let game; let mut versions; let mut versions_map = std::collections::HashMap::new(); let mut gallery_urls = Vec::new(); @@ -454,14 +447,6 @@ async fn project_create_inner( } } - // Check game exists, and get loaders for it - let game_name = &create_data.game_name; - game = Game::from_name(&create_data.game_name).ok_or_else(|| { - CreateError::InvalidInput(format!("Game '{game_name}' is currently unsupported.")) - })?; - let all_loaders = - models::loader_fields::Loader::list(game, &mut **transaction, redis).await?; - // Create VersionBuilders for the versions specified in `initial_versions` versions = Vec::with_capacity(create_data.initial_versions.len()); for (i, data) in create_data.initial_versions.iter().enumerate() { @@ -480,7 +465,6 @@ async fn project_create_inner( project_id, current_user.id, &all_loaders, - &create_data.project_type, transaction, redis, ) @@ -491,18 +475,6 @@ async fn project_create_inner( project_create_data = create_data; } - let project_type_id = models::categories::ProjectType::get_id( - project_create_data.project_type.as_str(), - &mut **transaction, - ) - .await? - .ok_or_else(|| { - CreateError::InvalidInput(format!( - "Project Type {} does not exist.", - project_create_data.project_type.clone() - )) - })?; - let mut icon_data = None; let mut error = None; @@ -592,6 +564,8 @@ async fn project_create_inner( // `index` is always valid for these lists let created_version = versions.get_mut(index).unwrap(); let version_data = project_create_data.initial_versions.get(index).unwrap(); + // TODO: maybe redundant is this calculation done elsewhere? + // Upload the new jar file super::version_creation::upload_file( &mut field, @@ -602,11 +576,9 @@ async fn project_create_inner( &mut created_version.dependencies, &cdn_url, &content_disposition, - game, project_id, created_version.version_id.into(), &created_version.version_fields, - &project_create_data.project_type, version_data.loaders.clone(), version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), @@ -646,27 +618,26 @@ async fn project_create_inner( // Convert the list of category names to actual categories let mut categories = Vec::with_capacity(project_create_data.categories.len()); for category in &project_create_data.categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut **transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - categories.push(id); + let ids = models::categories::Category::get_ids(category, &mut **transaction).await?; + if ids.is_empty() { + return Err(CreateError::InvalidCategory(category.clone())); + } + + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + categories.extend(ids.values()); } let mut additional_categories = Vec::with_capacity(project_create_data.additional_categories.len()); for category in &project_create_data.additional_categories { - let id = models::categories::Category::get_id_project( - category, - project_type_id, - &mut **transaction, - ) - .await? - .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; - additional_categories.push(id); + let ids = models::categories::Category::get_ids(category, &mut **transaction).await?; + if ids.is_empty() { + return Err(CreateError::InvalidCategory(category.clone())); + } + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + additional_categories.extend(ids.values()); } let team = models::team_item::TeamBuilder { @@ -726,8 +697,6 @@ async fn project_create_inner( let project_builder_actual = models::project_item::ProjectBuilder { project_id: project_id.into(), - game, - project_type_id, team_id, organization_id: project_create_data.organization_id.map(|x| x.into()), title: project_create_data.title, @@ -813,10 +782,29 @@ async fn project_create_inner( .insert(&mut *transaction) .await?; + let loaders = project_builder + .initial_versions + .iter() + .flat_map(|v| v.loaders.clone()) + .unique() + .collect::>(); + let (project_types, games) = Loader::list(&mut **transaction, &redis).await?.into_iter().fold( + (Vec::new(), Vec::new()), + |(mut project_types, mut games), loader| { + if loaders.contains(&loader.id) { + project_types.extend(loader.supported_project_types); + games.extend(loader.supported_games.iter().map(|x| x.name().to_string())); + } + (project_types, games) + }, + ); + + let response = crate::models::projects::Project { id: project_id, slug: project_builder.slug.clone(), - project_type: project_create_data.project_type.clone(), + project_types, + games, team: team_id.into(), organization: project_create_data.organization_id, title: project_builder.title.clone(), @@ -866,7 +854,6 @@ async fn create_initial_version( project_id: ProjectId, author: UserId, all_loaders: &[models::loader_fields::Loader], - project_type: &String, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result { @@ -891,8 +878,6 @@ async fn create_initial_version( .iter() .find(|y| { y.loader == x.0 - && y.supported_project_types - .contains(&project_type.to_string()) }) .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) .map(|y| y.id) diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 37d42ff9..cfb2fdb1 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -275,6 +275,7 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { + println!("project_edit"); let user = get_user_from_headers( &req, &**pool, @@ -284,14 +285,17 @@ pub async fn project_edit( ) .await? .1; + println!("serde user {}", serde_json::to_string(&user)?); new_project .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + println!("new project {}", serde_json::to_string(&new_project)?); let string = info.into_inner().0; + println!("string {}", string); let result = db_models::Project::get(&string, &**pool, &redis).await?; - + println!("result {}", serde_json::to_string(&result)?); if let Some(project_item) = result { let id = project_item.inner.id; @@ -980,12 +984,16 @@ pub async fn edit_project_categories( let mut mod_categories = Vec::new(); for category in categories { - let category_id = db_models::categories::Category::get_id(category, &mut **transaction) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!("Category {} does not exist.", category.clone())) - })?; - mod_categories.push(ModCategory::new(project_id, category_id, additional)); + let category_ids = db_models::categories::Category::get_ids(category, &mut **transaction) + .await?; + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + + let mcategories = category_ids + .values() + .map(|x| ModCategory::new(project_id, *x, additional)) + .collect::>(); + mod_categories.extend(mcategories); } ModCategory::insert_many(mod_categories, &mut *transaction).await?; diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 77645cc4..861f2d8e 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use super::ApiError; use crate::database::models::categories::{Category, DonationPlatform, ReportType, ProjectType}; use crate::database::models::loader_fields::{ - Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, + Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, }; use crate::database::redis::RedisPool; use actix_web::{web, HttpResponse}; @@ -54,28 +54,21 @@ pub struct LoaderData { pub icon: String, pub name: String, pub supported_project_types: Vec, -} - -#[derive(serde::Deserialize)] -pub struct LoaderList { - pub game: String, + pub supported_games: Vec, } pub async fn loader_list( - data: web::Query, pool: web::Data, redis: web::Data, ) -> Result { - let game = Game::from_name(&data.game).ok_or_else(|| { - ApiError::InvalidInput(format!("'{}' is not a supported game.", data.game)) - })?; - let mut results = Loader::list(game, &**pool, &redis) + let mut results = Loader::list(&**pool, &redis) .await? .into_iter() .map(|x| LoaderData { icon: x.icon, name: x.loader, supported_project_types: x.supported_project_types, + supported_games: x.supported_games.iter().map(|x| x.name().to_string()).collect(), }) .collect::>(); diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 728ccfaa..c8fd74ab 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,7 +1,7 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; use crate::database::models::loader_fields::{ - Game, LoaderField, LoaderFieldEnumValue, VersionField, + LoaderField, LoaderFieldEnumValue, VersionField, }; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ @@ -14,9 +14,9 @@ use crate::models::images::{Image, ImageContext, ImageId}; use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; -use crate::models::projects::skip_nulls; +use crate::models::projects::{skip_nulls, DependencyType}; use crate::models::projects::{ - Dependency, DependencyType, FileType, Loader, ProjectId, Version, VersionFile, VersionId, + Dependency, FileType, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus, VersionType, }; use crate::models::teams::ProjectPermissions; @@ -29,6 +29,7 @@ use actix_web::web::Data; use actix_web::{web, HttpRequest, HttpResponse}; use chrono::Utc; use futures::stream::StreamExt; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::collections::HashMap; @@ -148,7 +149,7 @@ async fn version_create_inner( let mut initial_version_data = None; let mut version_builder = None; - let mut game = None; + let mut selected_loaders = None; let user = get_user_from_headers( &req, @@ -202,11 +203,12 @@ async fn version_create_inner( let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); // Ensure that the project this version is being added to exists - let project = models::Project::get_id(project_id, &mut **transaction, redis) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("An invalid project id was supplied".to_string()) - })?; + if models::Project::get_id(project_id, &mut **transaction, redis) + .await?.is_none() { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } // Check that the user creating this version is a team member // of the project the version is being added to. @@ -250,25 +252,11 @@ async fn version_create_inner( let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - project_id as models::ProjectId, - ) - .fetch_one(&mut **transaction) - .await? - .name; - let all_loaders = models::loader_fields::Loader::list( - project.inner.game, &mut **transaction, redis, ) .await?; - game = Some(project.inner.game); let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?; let mut version_fields = vec![]; @@ -307,12 +295,15 @@ async fn version_create_inner( all_loaders .iter() .find(|y| { - y.loader == x.0 && y.supported_project_types.contains(&project_type) + y.loader == x.0 }) + .cloned() .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - .map(|y| y.id) - }) - .collect::, CreateError>>()?; + }).collect::, _>>()?; + selected_loaders = Some(loaders.clone()); + let loader_ids = loaders.iter() + .map(|y| y.id) + .collect_vec(); let dependencies = version_create_data .dependencies @@ -324,7 +315,7 @@ async fn version_create_inner( file_name: None, }) .collect::>(); - + version_builder = Some(VersionBuilder { version_id: version_id.into(), project_id, @@ -334,7 +325,7 @@ async fn version_create_inner( changelog: version_create_data.version_body.clone().unwrap_or_default(), files: Vec::new(), dependencies, - loaders, + loaders: loader_ids, version_fields, version_type: version_create_data.release_channel.to_string(), featured: version_create_data.featured, @@ -348,22 +339,11 @@ async fn version_create_inner( let version = version_builder.as_mut().ok_or_else(|| { CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; - let game = game.ok_or_else(|| { + let loaders = selected_loaders.as_ref().ok_or_else(|| { CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; - - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - version.project_id as models::ProjectId, - ) - .fetch_one(&mut **transaction) - .await? - .name; - + let loaders = loaders.iter().map(|x| Loader(x.loader.clone())).collect::>(); + let version_data = initial_version_data .clone() .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; @@ -377,12 +357,10 @@ async fn version_create_inner( &mut version.dependencies, &cdn_url, &content_disposition, - game, version.project_id.into(), version.version_id.into(), &version.version_fields, - &project_type, - version_data.loaders, + loaders, version_data.primary_file.is_some(), version_data.primary_file.as_deref() == Some(name), version_data.file_types.get(name).copied().flatten(), @@ -441,6 +419,13 @@ async fn version_create_inner( .insert_many(users, &mut *transaction, redis) .await?; + let loader_structs = selected_loaders.unwrap_or_default(); + let (all_project_types, all_games) : (Vec, Vec) = loader_structs.iter().fold((vec![], vec![]), |mut acc, x| { + acc.0.extend_from_slice(&x.supported_project_types); + acc.1.extend(x.supported_games.iter().map(|x| x.name().to_string())); + acc + }); + let response = Version { id: builder.version_id.into(), project_id: builder.project_id.into(), @@ -448,6 +433,8 @@ async fn version_create_inner( featured: builder.featured, name: builder.name.clone(), version_number: builder.version_number.clone(), + project_types: all_project_types, + games: all_games, changelog: builder.changelog.clone(), changelog_url: None, date_published: Utc::now(), @@ -609,11 +596,29 @@ async fn upload_file_to_version_inner( } }; - let project = models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) - .await? - .ok_or_else(|| { - CreateError::InvalidInput("Version contained an invalid project id".to_string()) - })?; + let all_loaders = models::loader_fields::Loader::list(&mut **transaction, &redis).await?; + + // TODO: this coded is reused a lot, it should be refactored into a function + let selected_loaders = version + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| { + &y.loader == x + }) + .cloned() + .ok_or_else(|| CreateError::InvalidLoader(x.clone())) + }).collect::, _>>()?; + + if models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) + .await?.is_none() { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } + if !user.role.is_admin() { let team_member = models::TeamMember::get_from_user_id_project( @@ -655,19 +660,6 @@ async fn upload_file_to_version_inner( } let project_id = ProjectId(version.inner.project_id.0 as u64); - - let project_type = sqlx::query!( - " - SELECT name FROM project_types pt - INNER JOIN mods ON mods.project_type = pt.id - WHERE mods.id = $1 - ", - version.inner.project_id as models::ProjectId, - ) - .fetch_one(&mut **transaction) - .await? - .name; - let mut error = None; while let Some(item) = payload.next().await { let mut field: Field = item?; @@ -697,6 +689,8 @@ async fn upload_file_to_version_inner( CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; + let loaders = selected_loaders.iter().map(|x| Loader(x.loader.clone())).collect::>(); + let mut dependencies = version .dependencies .iter() @@ -717,12 +711,10 @@ async fn upload_file_to_version_inner( &mut dependencies, &cdn_url, &content_disposition, - project.inner.game, project_id, version_id.into(), &version.version_fields, - &project_type, - version.loaders.clone().into_iter().map(Loader).collect(), + loaders, true, false, file_data.file_types.get(name).copied().flatten(), @@ -770,12 +762,10 @@ pub async fn upload_file( dependencies: &mut Vec, cdn_url: &str, content_disposition: &actix_web::http::header::ContentDisposition, - game: Game, project_id: ProjectId, version_id: VersionId, version_fields: &[VersionField], - project_type: &str, - loaders: Vec, + loaders : Vec, ignore_primary: bool, force_primary: bool, file_type: Option, @@ -822,10 +812,8 @@ pub async fn upload_file( } let validation_result = validate_file( - game, data.clone().into(), file_extension.to_string(), - project_type.to_string(), loaders.clone(), file_type, version_fields.to_vec(), @@ -899,9 +887,7 @@ pub async fn upload_file( } let data = data.freeze(); - - let primary = (validation_result.is_passed() - && version_files.iter().all(|x| !x.primary) + let primary = (version_files.iter().all(|x| !x.primary) && !ignore_primary) || force_primary || total_files_len == 1; @@ -936,12 +922,6 @@ pub async fn upload_file( )); } - if let ValidationResult::Warning(msg) = validation_result { - if primary { - return Err(CreateError::InvalidInput(msg.to_string())); - } - } - version_files.push(VersionFileBuilder { filename: file_name.to_string(), url: format!("{cdn_url}/{file_path_encode}"), diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 70ea7390..382c5a8b 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -265,10 +265,6 @@ pub async fn version_edit_helper( let result = database::models::Version::get(id, &**pool, &redis).await?; if let Some(version_item) = result { - let project_item = - database::models::Project::get_id(version_item.inner.project_id, &**pool, &redis) - .await?; - let team_member = database::models::TeamMember::get_from_user_id_project( version_item.inner.project_id, user.id.into(), @@ -351,8 +347,9 @@ pub async fn version_edit_helper( } if let Some(dependencies) = &new_version.dependencies { - if let Some(project) = project_item { - if project.project_type != "modpack" { + // TODO: Re-add this exclusions when modpack also has separate dependency retrieval that was removed from validators + // if let Some(project) = project_item { + // if project.project_type != "modpack" { sqlx::query!( " DELETE FROM dependencies WHERE dependent_id = $1 @@ -378,8 +375,8 @@ pub async fn version_edit_helper( &mut transaction, ) .await?; - } - } + // } + // } } if !new_version.fields.is_empty() { diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 630e2cd5..49089848 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -19,13 +19,15 @@ pub async fn index_local( let uploads = sqlx::query!( " - SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, + SELECT m.id id, v.id version_id, m.title title, m.description description, m.downloads downloads, m.follows follows, m.icon_url icon_url, m.published published, m.approved approved, m.updated updated, m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color, pt.name project_type_name, u.username username, 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 lo.loader) filter (where lo.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( @@ -59,15 +61,17 @@ pub async fn index_local( ) ) filter (where lfev.id is not null) loader_field_enum_values - FROM versions v INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2) LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id + LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = lo.id + LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id + LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = lo.id AND lptg.project_type_id = pt.id + LEFT JOIN games g ON lptg.game_id = g.id LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id - INNER JOIN project_types pt ON pt.id = m.project_type INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE INNER JOIN users u ON tm.user_id = u.id LEFT OUTER JOIN version_fields vf on v.id = vf.version_id diff --git a/src/util/webhook.rs b/src/util/webhook.rs index d7fb3994..aa8c7480 100644 --- a/src/util/webhook.rs +++ b/src/util/webhook.rs @@ -90,6 +90,8 @@ pub async fn send_discord_webhook( pt.name project_type, u.username username, u.avatar_url avatar_url, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories, ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery, ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery, JSONB_AGG( @@ -128,8 +130,11 @@ pub async fn send_discord_webhook( LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2) LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id + LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = lo.id + LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id + LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = lo.id AND lptg.project_type_id = pt.id + LEFT JOIN games g ON lptg.game_id = g.id LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id - INNER JOIN project_types pt ON pt.id = m.project_type INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE INNER JOIN users u ON tm.user_id = u.id LEFT OUTER JOIN version_fields vf on v.id = vf.version_id diff --git a/src/validate/mod.rs b/src/validate/mod.rs index 22a7f0d3..1ddaa33c 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -1,5 +1,5 @@ use crate::database::models::legacy_loader_fields::MinecraftGameVersion; -use crate::database::models::loader_fields::{Game, VersionField}; +use crate::database::models::loader_fields::VersionField; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; use crate::models::pack::PackFormat; @@ -110,18 +110,23 @@ static VALIDATORS: &[&dyn Validator] = &[ /// The return value is whether this file should be marked as primary or not, based on the analysis of the file #[allow(clippy::too_many_arguments)] pub async fn validate_file( - game: Game, data: bytes::Bytes, file_extension: String, - project_type: String, loaders: Vec, file_type: Option, version_fields: Vec, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result { - match game { - Game::MinecraftJava => { + // TODO: This needs to be revisited or removed with v3. + // Currently, it checks if the loader is the modpack loader, and extracts the pack data from it. + // This (and the funnction that calls this) should be refactored such that + // - validators are removed (or altogether reworked) + // - if a mrpack is uploaded, the pack data is extracted and usable to extract dependencies automatically + + // TODO: A test needs to be written for this. + match loaders { + loaders if loaders == vec![Loader("mrpack".to_string())] => { let game_versions = version_fields .into_iter() .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()) @@ -131,14 +136,15 @@ pub async fn validate_file( validate_minecraft_file( data, file_extension, - project_type, + "modpack".to_string(), loaders, game_versions, all_game_versions, file_type, ) .await - } + }, + _ => Ok(ValidationResult::Pass), } } diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index 0ac2c420..acec20fd 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -34,6 +34,8 @@ impl ApiV2 { let resp = self.call(req).await; assert_status(&resp, StatusCode::OK); + println!("Added body: {:?}", resp.response().body()); + // Approve as a moderator. let req = TestRequest::patch() .uri(&format!("/v2/project/{}", creation_data.slug)) diff --git a/tests/common/api_v2/tags.rs b/tests/common/api_v2/tags.rs index 53df709b..f220b17b 100644 --- a/tests/common/api_v2/tags.rs +++ b/tests/common/api_v2/tags.rs @@ -2,10 +2,7 @@ use actix_web::{ dev::ServiceResponse, test::{self, TestRequest}, }; -use labrinth::routes::{ - v2::tags::{CategoryData, GameVersionQueryData}, - v3::tags::LoaderData, -}; +use labrinth::routes::v2::tags::{CategoryData, GameVersionQueryData, LoaderData}; use crate::common::database::ADMIN_USER_PAT; diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 08d0b552..22dbdd8a 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -175,6 +175,7 @@ impl<'a> PermissionsTest<'a> { let resp = test_env.call(request).await; if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + println!("Body: {:?}", resp.response().body()); return Err(format!( "Failure permissions test failed. Expected failure codes {} got {}", self.allowed_failure_codes @@ -206,6 +207,7 @@ impl<'a> PermissionsTest<'a> { let resp = test_env.call(request).await; if !resp.status().is_success() { + println!("Body: {:?}", resp.response().body()); return Err(format!( "Success permissions test failed. Expected success, got {}", resp.status().as_u16() diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 778039b6..6ab48457 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -19,13 +19,17 @@ INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00'); INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); -INSERT INTO loaders (id, loader, game_id) VALUES (1, 'fabric', 1); -INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,1); -INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (1,2); +INSERT INTO loaders (id, loader) VALUES (5, 'fabric'); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5,1); -INSERT INTO loaders (id, loader, game_id) VALUES (2, 'forge', 1); -INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (2,1); -INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (2,2); +INSERT INTO loaders (id, loader) VALUES (6, 'forge'); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (6,1); + +INSERT INTO categories (category, project_type) SELECT 'forge', id FROM project_types WHERE name = 'modpack'; +INSERT INTO categories (category, project_type) SELECT 'fabric', id FROM project_types WHERE name = 'modpack'; + +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 5; +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 6; -- Sample game versions, loaders, categories -- Game versions is '2' @@ -45,13 +49,13 @@ SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = ' INSERT INTO categories (id, category, project_type) VALUES - (1, 'combat', 1), - (2, 'decoration', 1), - (3, 'economy', 1), - (4, 'food', 1), - (5, 'magic', 1), - (6, 'mobs', 1), - (7, 'optimization', 1); + (51, 'combat', 1), + (52, 'decoration', 1), + (53, 'economy', 1), + (54, 'food', 1), + (55, 'magic', 1), + (56, 'mobs', 1), + (57, 'optimization', 1); INSERT INTO categories (id, category, project_type) VALUES (101, 'combat', 2), diff --git a/tests/project.rs b/tests/project.rs index 0e7d5539..b0982504 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -958,6 +958,7 @@ async fn permissions_manage_invites() { #[actix_rt::test] async fn permissions_delete_project() { + println!("doing:"); // Add member, remove member, edit member let test_env = TestEnvironment::build(None).await; diff --git a/tests/search.rs b/tests/search.rs index 71de5ee3..61d6bb5b 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -274,6 +274,10 @@ async fn search_projects() { .collect(); expected_project_ids.sort(); found_project_ids.sort(); + println!( + "facets: {:?}, expected: {:?}, found: {:?}", + facets, expected_project_ids, found_project_ids + ); assert_eq!(found_project_ids, expected_project_ids); } }) diff --git a/tests/tags.rs b/tests/tags.rs index 467858b3..60aebdfb 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -29,7 +29,7 @@ async fn get_tags() { let loader_names = loaders.into_iter().map(|x| x.name).collect::>(); assert_eq!( loader_names, - ["fabric", "forge"].iter().map(|s| s.to_string()).collect() + ["fabric", "forge", "mrpack"].iter().map(|s| s.to_string()).collect() ); let side_type_names = side_types.into_iter().collect::>(); @@ -54,7 +54,9 @@ async fn get_tags() { "optimization", "decoration", "mobs", - "magic" + "magic", + "fabric", + "forge" ] .iter() .map(|s| s.to_string()) From 63826272fb73917b7dc9a04a88ba05ac44d5fc20 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 6 Nov 2023 17:33:55 -0800 Subject: [PATCH 25/31] moved files over --- src/models/mod.rs | 35 ++++++++++++++-------------- src/models/{ => v3}/analytics.rs | 0 src/models/{ => v3}/collections.rs | 0 src/models/{ => v3}/error.rs | 0 src/models/{ => v3}/ids.rs | 0 src/models/{ => v3}/images.rs | 0 src/models/v3/mod.rs | 16 +++++++++++++ src/models/{ => v3}/notifications.rs | 0 src/models/{ => v3}/oauth_clients.rs | 0 src/models/{ => v3}/organizations.rs | 0 src/models/{ => v3}/pack.rs | 0 src/models/{ => v3}/pats.rs | 0 src/models/{ => v3}/projects.rs | 0 src/models/{ => v3}/reports.rs | 0 src/models/{ => v3}/sessions.rs | 0 src/models/{ => v3}/teams.rs | 0 src/models/{ => v3}/threads.rs | 0 src/models/{ => v3}/users.rs | 0 src/search/mod.rs | 10 -------- 19 files changed, 34 insertions(+), 27 deletions(-) rename src/models/{ => v3}/analytics.rs (100%) rename src/models/{ => v3}/collections.rs (100%) rename src/models/{ => v3}/error.rs (100%) rename src/models/{ => v3}/ids.rs (100%) rename src/models/{ => v3}/images.rs (100%) create mode 100644 src/models/v3/mod.rs rename src/models/{ => v3}/notifications.rs (100%) rename src/models/{ => v3}/oauth_clients.rs (100%) rename src/models/{ => v3}/organizations.rs (100%) rename src/models/{ => v3}/pack.rs (100%) rename src/models/{ => v3}/pats.rs (100%) rename src/models/{ => v3}/projects.rs (100%) rename src/models/{ => v3}/reports.rs (100%) rename src/models/{ => v3}/sessions.rs (100%) rename src/models/{ => v3}/teams.rs (100%) rename src/models/{ => v3}/threads.rs (100%) rename src/models/{ => v3}/users.rs (100%) diff --git a/src/models/mod.rs b/src/models/mod.rs index bcdb1c88..d2111891 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,18 +1,19 @@ -pub mod analytics; -pub mod collections; -pub mod error; -pub mod ids; -pub mod images; -pub mod notifications; -pub mod oauth_clients; -pub mod organizations; -pub mod pack; -pub mod pats; -pub mod projects; -pub mod reports; -pub mod sessions; -pub mod teams; -pub mod threads; -pub mod users; - pub mod v2; +pub mod v3; + +pub use v3::analytics; +pub use v3::collections; +pub use v3::error; +pub use v3::ids; +pub use v3::images; +pub use v3::notifications; +pub use v3::oauth_clients; +pub use v3::organizations; +pub use v3::pack; +pub use v3::pats; +pub use v3::projects; +pub use v3::reports; +pub use v3::sessions; +pub use v3::teams; +pub use v3::threads; +pub use v3::users; \ No newline at end of file diff --git a/src/models/analytics.rs b/src/models/v3/analytics.rs similarity index 100% rename from src/models/analytics.rs rename to src/models/v3/analytics.rs diff --git a/src/models/collections.rs b/src/models/v3/collections.rs similarity index 100% rename from src/models/collections.rs rename to src/models/v3/collections.rs diff --git a/src/models/error.rs b/src/models/v3/error.rs similarity index 100% rename from src/models/error.rs rename to src/models/v3/error.rs diff --git a/src/models/ids.rs b/src/models/v3/ids.rs similarity index 100% rename from src/models/ids.rs rename to src/models/v3/ids.rs diff --git a/src/models/images.rs b/src/models/v3/images.rs similarity index 100% rename from src/models/images.rs rename to src/models/v3/images.rs diff --git a/src/models/v3/mod.rs b/src/models/v3/mod.rs new file mode 100644 index 00000000..04dc7237 --- /dev/null +++ b/src/models/v3/mod.rs @@ -0,0 +1,16 @@ +pub mod analytics; +pub mod collections; +pub mod error; +pub mod ids; +pub mod images; +pub mod notifications; +pub mod oauth_clients; +pub mod organizations; +pub mod pack; +pub mod pats; +pub mod projects; +pub mod reports; +pub mod sessions; +pub mod teams; +pub mod threads; +pub mod users; \ No newline at end of file diff --git a/src/models/notifications.rs b/src/models/v3/notifications.rs similarity index 100% rename from src/models/notifications.rs rename to src/models/v3/notifications.rs diff --git a/src/models/oauth_clients.rs b/src/models/v3/oauth_clients.rs similarity index 100% rename from src/models/oauth_clients.rs rename to src/models/v3/oauth_clients.rs diff --git a/src/models/organizations.rs b/src/models/v3/organizations.rs similarity index 100% rename from src/models/organizations.rs rename to src/models/v3/organizations.rs diff --git a/src/models/pack.rs b/src/models/v3/pack.rs similarity index 100% rename from src/models/pack.rs rename to src/models/v3/pack.rs diff --git a/src/models/pats.rs b/src/models/v3/pats.rs similarity index 100% rename from src/models/pats.rs rename to src/models/v3/pats.rs diff --git a/src/models/projects.rs b/src/models/v3/projects.rs similarity index 100% rename from src/models/projects.rs rename to src/models/v3/projects.rs diff --git a/src/models/reports.rs b/src/models/v3/reports.rs similarity index 100% rename from src/models/reports.rs rename to src/models/v3/reports.rs diff --git a/src/models/sessions.rs b/src/models/v3/sessions.rs similarity index 100% rename from src/models/sessions.rs rename to src/models/v3/sessions.rs diff --git a/src/models/teams.rs b/src/models/v3/teams.rs similarity index 100% rename from src/models/teams.rs rename to src/models/v3/teams.rs diff --git a/src/models/threads.rs b/src/models/v3/threads.rs similarity index 100% rename from src/models/threads.rs rename to src/models/v3/threads.rs diff --git a/src/models/users.rs b/src/models/v3/users.rs similarity index 100% rename from src/models/users.rs rename to src/models/v3/users.rs diff --git a/src/search/mod.rs b/src/search/mod.rs index 1510fce3..becf32aa 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -99,16 +99,6 @@ pub struct UploadSearchProject { pub color: Option, #[serde(flatten)] - /* - Version fields are stored as: - "loader_field": ["value1", "value2", ...] - By convention, first underline separates the loader from the field name, - and any subsequent underlines may be part of the field name. - eg: - "fabric_game_versions": ["1.21", "1.22"] - "fabric_client_side": ["required"] - "fabric_server_side": ["optional"] - */ pub loader_fields: HashMap>, } From 65c380f3a856a42560fcd394af47071cbdc5ee44 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Mon, 6 Nov 2023 17:37:52 -0800 Subject: [PATCH 26/31] fmt, clippy, prepare, etc --- ...ab462fa4e716f92c8700c436dee9472101aa4.json | 130 ------------- ...78905a8439a12c9ec94042016e607c9788c98.json | 174 ----------------- ...7f825f9f5fe2d538b06337ef182ab1a553398.json | 22 --- ...cb33806e14c33bb76d63c2711f822c44261f6.json | 22 --- ...f400c06f9c8a8132fd7a05dcc55c6eab5d2e7.json | 126 ++++++++++++ ...fcee16c613fb93ea74d6eb0da684363ca7b13.json | 28 +++ ...9e7e5ebfd8d34985a1a8209d6628e66490f37.json | 22 --- ...2df15841d35b96fbdcadc7d5af8d6b4671f9e.json | 44 +++++ ...a6de53aeadacb33bd3134dae2d9c8f8a18651.json | 142 ++++++++++++++ ...bf9633f160dfb783cefa751ff400655e8660f.json | 114 ----------- ...dbd027fc0bd427bfba5e6eb99c989af53b680.json | 180 ++++++++++++++++++ ...a2134e2f26eed002ff9ea5626ea3e23518594.json | 22 --- ...cde031df280039cd8e435cfca5e15ed3d1c4.json} | 98 +++++----- ...c3b0d629c470f654f1e30250bd8773ed04f5b.json | 40 ---- ...707d698fc8b4dbb66d2830f4ec0229bc1019.json} | 5 +- src/database/models/categories.rs | 7 +- src/database/models/loader_fields.rs | 6 +- src/database/models/project_item.rs | 6 +- src/models/mod.rs | 2 +- src/models/v3/mod.rs | 2 +- src/routes/maven.rs | 2 +- src/routes/v2/analytics_get.rs | 27 +-- src/routes/v2/collections.rs | 48 ++--- src/routes/v2/images.rs | 27 ++- src/routes/v2/moderation.rs | 11 +- src/routes/v2/notifications.rs | 35 +--- src/routes/v2/organizations.rs | 40 ++-- src/routes/v2/project_creation.rs | 88 +++++---- src/routes/v2/projects.rs | 40 ++-- src/routes/v2/reports.rs | 36 +--- src/routes/v2/statistics.rs | 2 +- src/routes/v2/tags.rs | 33 +--- src/routes/v2/teams.rs | 73 ++++--- src/routes/v2/threads.rs | 31 +-- src/routes/v2/users.rs | 50 +++-- src/routes/v2/version_file.rs | 3 +- src/routes/v2/versions.rs | 3 +- src/routes/v3/analytics_get.rs | 5 +- src/routes/v3/mod.rs | 2 +- src/routes/v3/notifications.rs | 12 +- src/routes/v3/organizations.rs | 28 +-- src/routes/v3/project_creation.rs | 36 ++-- src/routes/v3/projects.rs | 45 +++-- src/routes/v3/tags.rs | 25 ++- src/routes/v3/teams.rs | 15 +- src/routes/v3/threads.rs | 6 +- src/routes/v3/users.rs | 53 +++--- src/routes/v3/version_creation.rs | 85 +++++---- src/routes/v3/version_file.rs | 1 - src/routes/v3/versions.rs | 48 +++-- src/validate/mod.rs | 4 +- tests/common/dummy_data.rs | 13 +- tests/tags.rs | 5 +- 53 files changed, 1063 insertions(+), 1061 deletions(-) delete mode 100644 .sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json delete mode 100644 .sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json delete mode 100644 .sqlx/query-1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398.json delete mode 100644 .sqlx/query-21ef50f46b7b3e62b91e7d067c1cb33806e14c33bb76d63c2711f822c44261f6.json create mode 100644 .sqlx/query-3afbc93a8945e7ae07e39a88752f400c06f9c8a8132fd7a05dcc55c6eab5d2e7.json create mode 100644 .sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json delete mode 100644 .sqlx/query-72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37.json create mode 100644 .sqlx/query-923d1d1e5e9b879479a244479952df15841d35b96fbdcadc7d5af8d6b4671f9e.json create mode 100644 .sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json delete mode 100644 .sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json create mode 100644 .sqlx/query-cab90ea34929643f9e9814150c4dbd027fc0bd427bfba5e6eb99c989af53b680.json delete mode 100644 .sqlx/query-ef59f99fc0ab66ff5779d0e71c4a2134e2f26eed002ff9ea5626ea3e23518594.json rename .sqlx/{query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json => query-f73ffab12a96eb9480615e333d40cde031df280039cd8e435cfca5e15ed3d1c4.json} (58%) delete mode 100644 .sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json rename .sqlx/{query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json => query-fefb4f07a0f0c0cf74e554d120f8707d698fc8b4dbb66d2830f4ec0229bc1019.json} (63%) diff --git a/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json b/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json deleted file mode 100644 index ddbe02ed..00000000 --- a/.sqlx/query-168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "version_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "version_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "featured", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, - "name": "files", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "hashes", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "dependencies", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "version_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "loader_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "loader_field_enum_values", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "168b16d302b780700c2ad909aecab462fa4e716f92c8700c436dee9472101aa4" -} diff --git a/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json b/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json deleted file mode 100644 index 413d087f..00000000 --- a/.sqlx/query-1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98.json +++ /dev/null @@ -1,174 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, v.id version_id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n\n \n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "version_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "project_type", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "title", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "follows", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "published", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "approved", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "updated", - "type_info": "Timestamptz" - }, - { - "ordinal": 11, - "name": "team_id", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "license", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "status_name", - "type_info": "Varchar" - }, - { - "ordinal": 15, - "name": "color", - "type_info": "Int4" - }, - { - "ordinal": 16, - "name": "project_type_name", - "type_info": "Varchar" - }, - { - "ordinal": 17, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 19, - "name": "additional_categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 20, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 21, - "name": "gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 22, - "name": "featured_gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 23, - "name": "version_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 24, - "name": "loader_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 25, - "name": "loader_field_enum_values", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - false, - true, - false, - false, - false, - true, - false, - true, - false, - false, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "1cd4d592f7c230b3587a9f6d0ec78905a8439a12c9ec94042016e607c9788c98" -} diff --git a/.sqlx/query-1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398.json b/.sqlx/query-1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398.json deleted file mode 100644 index 5b3eb4aa..00000000 --- a/.sqlx/query-1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT name FROM project_types pt\n INNER JOIN mods ON mods.project_type = pt.id\n WHERE mods.id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "name", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398" -} diff --git a/.sqlx/query-21ef50f46b7b3e62b91e7d067c1cb33806e14c33bb76d63c2711f822c44261f6.json b/.sqlx/query-21ef50f46b7b3e62b91e7d067c1cb33806e14c33bb76d63c2711f822c44261f6.json deleted file mode 100644 index 2212ad16..00000000 --- a/.sqlx/query-21ef50f46b7b3e62b91e7d067c1cb33806e14c33bb76d63c2711f822c44261f6.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT name FROM project_types pt\n INNER JOIN mods ON mods.project_type = pt.id\n WHERE mods.id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "name", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "21ef50f46b7b3e62b91e7d067c1cb33806e14c33bb76d63c2711f822c44261f6" -} diff --git a/.sqlx/query-3afbc93a8945e7ae07e39a88752f400c06f9c8a8132fd7a05dcc55c6eab5d2e7.json b/.sqlx/query-3afbc93a8945e7ae07e39a88752f400c06f9c8a8132fd7a05dcc55c6eab5d2e7.json new file mode 100644 index 00000000..0ecfb803 --- /dev/null +++ b/.sqlx/query-3afbc93a8945e7ae07e39a88752f400c06f9c8a8132fd7a05dcc55c6eab5d2e7.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = lo.id\n LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = lo.id AND lptg.project_type_id = pt.id\n LEFT JOIN games g ON lptg.game_id = g.id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "project_type", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "avatar_url", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 10, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 11, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 12, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 13, + "name": "gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 14, + "name": "featured_gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 15, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "3afbc93a8945e7ae07e39a88752f400c06f9c8a8132fd7a05dcc55c6eab5d2e7" +} diff --git a/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json b/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json new file mode 100644 index 00000000..b079a30f --- /dev/null +++ b/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_type FROM categories\n WHERE category = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_type", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13" +} diff --git a/.sqlx/query-72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37.json b/.sqlx/query-72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37.json deleted file mode 100644 index abc96eab..00000000 --- a/.sqlx/query-72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id FROM categories\n WHERE category = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37" -} diff --git a/.sqlx/query-923d1d1e5e9b879479a244479952df15841d35b96fbdcadc7d5af8d6b4671f9e.json b/.sqlx/query-923d1d1e5e9b879479a244479952df15841d35b96fbdcadc7d5af8d6b4671f9e.json new file mode 100644 index 00000000..65c31f42 --- /dev/null +++ b/.sqlx/query-923d1d1e5e9b879479a244479952df15841d35b96fbdcadc7d5af8d6b4671f9e.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games\n FROM loaders l \n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "loader", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "icon", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "games", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + null, + null + ] + }, + "hash": "923d1d1e5e9b879479a244479952df15841d35b96fbdcadc7d5af8d6b4671f9e" +} diff --git a/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json b/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json new file mode 100644 index 00000000..e57c1530 --- /dev/null +++ b/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json @@ -0,0 +1,142 @@ +{ + "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,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN loaders_project_types lpt on l.id = lpt.joining_loader_id\n LEFT JOIN project_types pt on lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg on l.id = lptg.loader_id AND pt.id = lptg.project_type_id\n LEFT JOIN games g on lptg.game_id = g.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "changelog", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "version_type", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 13, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 14, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 15, + "name": "files", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "hashes", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "dependencies", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651" +} diff --git a/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json b/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json deleted file mode 100644 index 5bc6e3d1..00000000 --- a/.sqlx/query-bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.title title, m.description description, m.color color,\n m.icon_url icon_url, m.slug slug,\n pt.name project_type, u.username username, u.avatar_url avatar_url,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null) categories,\n ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n FROM mods m\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id AND mc.is_additional = FALSE\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN versions v ON v.mod_id = m.id AND v.status != ALL($2)\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE m.id = $1\n GROUP BY m.id, pt.id, u.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "title", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "description", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "color", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "icon_url", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "project_type", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "avatar_url", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "categories", - "type_info": "VarcharArray" - }, - { - "ordinal": 10, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 11, - "name": "gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 12, - "name": "featured_gallery", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, - "name": "version_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "loader_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 15, - "name": "loader_field_enum_values", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8", - "TextArray", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - true, - true, - true, - false, - false, - true, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "bc662b93312190ce868135248dfbf9633f160dfb783cefa751ff400655e8660f" -} diff --git a/.sqlx/query-cab90ea34929643f9e9814150c4dbd027fc0bd427bfba5e6eb99c989af53b680.json b/.sqlx/query-cab90ea34929643f9e9814150c4dbd027fc0bd427bfba5e6eb99c989af53b680.json new file mode 100644 index 00000000..0951bcc2 --- /dev/null +++ b/.sqlx/query-cab90ea34929643f9e9814150c4dbd027fc0bd427bfba5e6eb99c989af53b680.json @@ -0,0 +1,180 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, v.id version_id, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.published published, m.approved approved, m.updated updated,\n m.team_id team_id, m.license license, m.slug slug, m.status status_name, m.color color,\n pt.name project_type_name, u.username username,\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 ARRAY_AGG(DISTINCT lo.loader) filter (where lo.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is false) gallery,\n ARRAY_AGG(DISTINCT mg.image_url) filter (where mg.image_url is not null and mg.featured is true) featured_gallery,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', lo.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n\n FROM versions v\n INNER JOIN mods m ON v.mod_id = m.id AND m.status = ANY($2)\n LEFT OUTER JOIN mods_categories mc ON joining_mod_id = m.id\n LEFT OUTER JOIN categories c ON mc.joining_category_id = c.id\n LEFT OUTER JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT OUTER JOIN loaders lo ON lo.id = lv.loader_id\n LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = lo.id\n LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = lo.id AND lptg.project_type_id = pt.id\n LEFT JOIN games g ON lptg.game_id = g.id\n LEFT OUTER JOIN mods_gallery mg ON mg.mod_id = m.id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.role = $3 AND tm.accepted = TRUE\n INNER JOIN users u ON tm.user_id = u.id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfev.enum_id = lfe.id\n WHERE v.status != ANY($1)\n GROUP BY v.id, m.id, pt.id, u.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "follows", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "approved", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "license", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "status_name", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 15, + "name": "project_type_name", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 18, + "name": "additional_categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 19, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 20, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 21, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 22, + "name": "gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 23, + "name": "featured_gallery", + "type_info": "VarcharArray" + }, + { + "ordinal": 24, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 25, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "cab90ea34929643f9e9814150c4dbd027fc0bd427bfba5e6eb99c989af53b680" +} diff --git a/.sqlx/query-ef59f99fc0ab66ff5779d0e71c4a2134e2f26eed002ff9ea5626ea3e23518594.json b/.sqlx/query-ef59f99fc0ab66ff5779d0e71c4a2134e2f26eed002ff9ea5626ea3e23518594.json deleted file mode 100644 index 101e5838..00000000 --- a/.sqlx/query-ef59f99fc0ab66ff5779d0e71c4a2134e2f26eed002ff9ea5626ea3e23518594.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT name FROM project_types pt\n INNER JOIN mods ON mods.project_type = pt.id\n WHERE mods.id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "name", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ef59f99fc0ab66ff5779d0e71c4a2134e2f26eed002ff9ea5626ea3e23518594" -} diff --git a/.sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json b/.sqlx/query-f73ffab12a96eb9480615e333d40cde031df280039cd8e435cfca5e15ed3d1c4.json similarity index 58% rename from .sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json rename to .sqlx/query-f73ffab12a96eb9480615e333d40cde031df280039cd8e435cfca5e15ed3d1c4.json index e077f28a..91e7b818 100644 --- a/.sqlx/query-baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca.json +++ b/.sqlx/query-f73ffab12a96eb9480615e333d40cde031df280039cd8e435cfca5e15ed3d1c4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, g.name, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 pt.name project_type_name, m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m\n INNER JOIN games g ON g.id = m.game_id\n INNER JOIN project_types pt ON pt.id = m.project_type\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n LEFT JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT JOIN loaders l on lv.loader_id = l.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY pt.id, t.id, m.id, g.name;\n ", + "query": "\n SELECT m.id id, m.title title, m.description description, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.body body, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.issues_url issues_url, m.source_url source_url, m.wiki_url wiki_url, m.discord_url discord_url, 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 ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\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 JSONB_AGG(DISTINCT jsonb_build_object('id', v.id, 'date_published', v.date_published)) filter (where v.id is not null) versions,\n JSONB_AGG(DISTINCT jsonb_build_object('image_url', mg.image_url, 'featured', mg.featured, 'title', mg.title, 'description', mg.description, 'created', mg.created, 'ordering', mg.ordering)) filter (where mg.image_url is not null) gallery,\n JSONB_AGG(DISTINCT jsonb_build_object('platform_id', md.joining_platform_id, 'platform_short', dp.short, 'platform_name', dp.name,'url', md.url)) filter (where md.joining_platform_id is not null) donations\n FROM mods m \n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_gallery mg ON mg.mod_id = m.id\n LEFT JOIN mods_donations md ON md.joining_mod_id = m.id\n LEFT JOIN donation_platforms dp ON md.joining_platform_id = dp.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 LEFT JOIN versions v ON v.mod_id = m.id AND v.status = ANY($3)\n LEFT JOIN loaders_versions lv ON lv.version_id = v.id\n LEFT JOIN loaders l on lv.loader_id = l.id\n LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n LEFT JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n LEFT JOIN games g ON lptg.game_id = g.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", "describe": { "columns": [ { @@ -10,181 +10,176 @@ }, { "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "project_type", - "type_info": "Int4" - }, - { - "ordinal": 3, "name": "title", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 2, "name": "description", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 3, "name": "downloads", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 4, "name": "follows", "type_info": "Int4" }, { - "ordinal": 7, + "ordinal": 5, "name": "icon_url", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 6, "name": "body", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 7, "name": "published", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 8, "name": "updated", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 9, "name": "approved", "type_info": "Timestamptz" }, { - "ordinal": 12, + "ordinal": 10, "name": "queued", "type_info": "Timestamptz" }, { - "ordinal": 13, + "ordinal": 11, "name": "status", "type_info": "Varchar" }, { - "ordinal": 14, + "ordinal": 12, "name": "requested_status", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 13, "name": "issues_url", "type_info": "Varchar" }, { - "ordinal": 16, + "ordinal": 14, "name": "source_url", "type_info": "Varchar" }, { - "ordinal": 17, + "ordinal": 15, "name": "wiki_url", "type_info": "Varchar" }, { - "ordinal": 18, + "ordinal": 16, "name": "discord_url", "type_info": "Varchar" }, { - "ordinal": 19, + "ordinal": 17, "name": "license_url", "type_info": "Varchar" }, { - "ordinal": 20, + "ordinal": 18, "name": "team_id", "type_info": "Int8" }, { - "ordinal": 21, + "ordinal": 19, "name": "organization_id", "type_info": "Int8" }, { - "ordinal": 22, + "ordinal": 20, "name": "license", "type_info": "Varchar" }, { - "ordinal": 23, + "ordinal": 21, "name": "slug", "type_info": "Varchar" }, { - "ordinal": 24, + "ordinal": 22, "name": "moderation_message", "type_info": "Varchar" }, { - "ordinal": 25, + "ordinal": 23, "name": "moderation_message_body", "type_info": "Varchar" }, { - "ordinal": 26, - "name": "project_type_name", - "type_info": "Varchar" - }, - { - "ordinal": 27, + "ordinal": 24, "name": "webhook_sent", "type_info": "Bool" }, { - "ordinal": 28, + "ordinal": 25, "name": "color", "type_info": "Int4" }, { - "ordinal": 29, + "ordinal": 26, "name": "thread_id", "type_info": "Int8" }, { - "ordinal": 30, + "ordinal": 27, "name": "monetization_status", "type_info": "Varchar" }, { - "ordinal": 31, + "ordinal": 28, "name": "loaders", "type_info": "VarcharArray" }, { - "ordinal": 32, + "ordinal": 29, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 30, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 31, "name": "categories", "type_info": "VarcharArray" }, { - "ordinal": 33, + "ordinal": 32, "name": "additional_categories", "type_info": "VarcharArray" }, { - "ordinal": 34, + "ordinal": 33, "name": "versions", "type_info": "Jsonb" }, { - "ordinal": 35, + "ordinal": 34, "name": "gallery", "type_info": "Jsonb" }, { - "ordinal": 36, + "ordinal": 35, "name": "donations", "type_info": "Jsonb" } @@ -197,8 +192,6 @@ ] }, "nullable": [ - false, - true, false, false, false, @@ -224,7 +217,6 @@ true, true, false, - false, true, false, false, @@ -233,8 +225,10 @@ null, null, null, + null, + null, null ] }, - "hash": "baa0615e8abc97e5529cff39ffed4e740b51b303aaf533662285061e7f5c0bca" + "hash": "f73ffab12a96eb9480615e333d40cde031df280039cd8e435cfca5e15ed3d1c4" } diff --git a/.sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json b/.sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json deleted file mode 100644 index faaf49de..00000000 --- a/.sqlx/query-fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM loaders l\n INNER JOIN games g ON l.game_id = g.id\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n WHERE g.name = $1\n GROUP BY l.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "loader", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "icon", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "project_types", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - null - ] - }, - "hash": "fd6c1accdafbeab3142b7fab397c3b0d629c470f654f1e30250bd8773ed04f5b" -} diff --git a/.sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json b/.sqlx/query-fefb4f07a0f0c0cf74e554d120f8707d698fc8b4dbb66d2830f4ec0229bc1019.json similarity index 63% rename from .sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json rename to .sqlx/query-fefb4f07a0f0c0cf74e554d120f8707d698fc8b4dbb66d2830f4ec0229bc1019.json index 09221ca6..e2b9c106 100644 --- a/.sqlx/query-b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3.json +++ b/.sqlx/query-fefb4f07a0f0c0cf74e554d120f8707d698fc8b4dbb66d2830f4ec0229bc1019.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, LOWER($17), $18,\n $19, $20\n )\n ", + "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n license_url, license,\n slug, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, \n LOWER($17), $18, $19\n )\n ", "describe": { "columns": [], "parameters": { @@ -23,11 +23,10 @@ "Varchar", "Text", "Int4", - "Int4", "Varchar" ] }, "nullable": [] }, - "hash": "b26c1b47aefa6df4a0daf39a83151e7168f7cc483d219813e4c70f5f805e84a3" + "hash": "fefb4f07a0f0c0cf74e554d120f8707d698fc8b4dbb66d2830f4ec0229bc1019" } diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs index a033e276..95d054f2 100644 --- a/src/database/models/categories.rs +++ b/src/database/models/categories.rs @@ -39,7 +39,10 @@ impl Category { // Gets hashmap of category ids matching a name // Multiple categories can have the same name, but different project types, so we need to return a hashmap // ProjectTypeId -> CategoryId - pub async fn get_ids<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + pub async fn get_ids<'a, E>( + name: &str, + exec: E, + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { @@ -57,7 +60,7 @@ impl Category { for r in result { map.insert(ProjectTypeId(r.project_type), CategoryId(r.id)); } - + Ok(map) } diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 207c5811..c755d1c1 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -84,14 +84,10 @@ impl Loader { Ok(result) } - pub async fn list<'a, E>( - exec: E, - redis: &RedisPool, - ) -> Result, DatabaseError> + pub async fn list<'a, E>(exec: E, redis: &RedisPool) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { - let cached_loaders: Option> = redis .get_deserialized_from_json(LOADERS_LIST_NAMESPACE, "all") .await?; diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index 4b647b6f..a7589a3d 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -600,9 +600,9 @@ impl Project { ) .fetch_many(exec) .try_filter_map(|e| async { - Ok(e.right().and_then(|m| { + Ok(e.right().map(|m| { let id = m.id; - Some(QueryProject { + QueryProject { inner: Project { id: ProjectId(id), team_id: TeamId(m.team_id), @@ -673,7 +673,7 @@ impl Project { m.donations.unwrap_or_default(), ).ok().unwrap_or_default(), thread_id: ThreadId(m.thread_id), - })})) + }})) }) .try_collect::>() .await?; diff --git a/src/models/mod.rs b/src/models/mod.rs index d2111891..c4ff81a2 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -16,4 +16,4 @@ pub use v3::reports; pub use v3::sessions; pub use v3::teams; pub use v3::threads; -pub use v3::users; \ No newline at end of file +pub use v3::users; diff --git a/src/models/v3/mod.rs b/src/models/v3/mod.rs index 04dc7237..7c97ad31 100644 --- a/src/models/v3/mod.rs +++ b/src/models/v3/mod.rs @@ -13,4 +13,4 @@ pub mod reports; pub mod sessions; pub mod teams; pub mod threads; -pub mod users; \ No newline at end of file +pub mod users; diff --git a/src/routes/maven.rs b/src/routes/maven.rs index a2009a34..a50ec7f2 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -255,7 +255,7 @@ fn find_file<'a>( .iter() .find(|x| x.primary) .or_else(|| version.files.iter().last()); - } + } } None } diff --git a/src/routes/v2/analytics_get.rs b/src/routes/v2/analytics_get.rs index d719010a..91de3bb6 100644 --- a/src/routes/v2/analytics_get.rs +++ b/src/routes/v2/analytics_get.rs @@ -1,12 +1,7 @@ use super::ApiError; use crate::database::redis::RedisPool; use crate::routes::v3; -use crate::{ - models:: - ids:: - VersionId, - queue::session::AuthQueue, -}; +use crate::{models::ids::VersionId, queue::session::AuthQueue}; use actix_web::{get, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -82,7 +77,8 @@ pub async fn playtimes_get( session_queue, pool, redis, - ).await + ) + .await } /// Get view data for a set of projects or versions @@ -117,7 +113,8 @@ pub async fn views_get( session_queue, pool, redis, - ).await + ) + .await } /// Get download data for a set of projects or versions @@ -152,7 +149,8 @@ pub async fn downloads_get( session_queue, pool, redis, - ).await + ) + .await } /// Get payout data for a set of projects @@ -185,7 +183,8 @@ pub async fn revenue_get( session_queue, pool, redis, - ).await + ) + .await } /// Get country data for a set of projects or versions @@ -223,7 +222,8 @@ pub async fn countries_downloads_get( session_queue, pool, redis, - ).await + ) + .await } /// Get country data for a set of projects or versions @@ -261,5 +261,6 @@ pub async fn countries_views_get( session_queue, pool, redis, - ).await -} \ No newline at end of file + ) + .await +} diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs index 0bdfb2bf..32412ab5 100644 --- a/src/routes/v2/collections.rs +++ b/src/routes/v2/collections.rs @@ -3,7 +3,7 @@ use crate::file_hosting::FileHost; use crate::models::collections::CollectionStatus; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::web::Data; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; @@ -60,7 +60,8 @@ pub async fn collection_create( client, redis, session_queue, - ).await + ) + .await } #[derive(Serialize, Deserialize)] @@ -75,9 +76,14 @@ pub async fn collections_get( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::collections::collections_get(req, web::Query(v3::collections::CollectionIds{ - ids: ids.ids - }), pool, redis, session_queue).await + v3::collections::collections_get( + req, + web::Query(v3::collections::CollectionIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await } #[get("{id}")] @@ -115,14 +121,20 @@ pub async fn collection_edit( session_queue: web::Data, ) -> Result { let new_collection = new_collection.into_inner(); - v3::collections::collection_edit(req, info, pool, web::Json( - v3::collections::EditCollection { + v3::collections::collection_edit( + req, + info, + pool, + web::Json(v3::collections::EditCollection { title: new_collection.title, description: new_collection.description, status: new_collection.status, new_projects: new_collection.new_projects, - } - ), redis, session_queue).await + }), + redis, + session_queue, + ) + .await } #[derive(Serialize, Deserialize)] @@ -143,9 +155,7 @@ pub async fn collection_icon_edit( session_queue: web::Data, ) -> Result { v3::collections::collection_icon_edit( - web::Query(v3::collections::Extension { - ext: ext.ext - }), + web::Query(v3::collections::Extension { ext: ext.ext }), req, info, pool, @@ -153,7 +163,8 @@ pub async fn collection_icon_edit( file_host, payload, session_queue, - ).await + ) + .await } #[delete("{id}/icon")] @@ -165,14 +176,7 @@ pub async fn delete_collection_icon( file_host: web::Data>, session_queue: web::Data, ) -> Result { - v3::collections::delete_collection_icon( - req, - info, - pool, - redis, - file_host, - session_queue, - ).await + v3::collections::delete_collection_icon(req, info, pool, redis, file_host, session_queue).await } #[delete("{id}")] @@ -184,4 +188,4 @@ pub async fn collection_delete( session_queue: web::Data, ) -> Result { v3::collections::collection_delete(req, info, pool, redis, session_queue).await -} \ No newline at end of file +} diff --git a/src/routes/v2/images.rs b/src/routes/v2/images.rs index 71a64e13..da6d2aea 100644 --- a/src/routes/v2/images.rs +++ b/src/routes/v2/images.rs @@ -5,7 +5,7 @@ use crate::file_hosting::FileHost; use crate::models::ids::{ThreadMessageId, VersionId}; use crate::models::reports::ReportId; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::{post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -39,12 +39,21 @@ pub async fn images_add( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::images::images_add(req, web::Query(v3::images::ImageUpload { - ext: data.ext, - context: data.context, - project_id: data.project_id, - version_id: data.version_id, - thread_message_id: data.thread_message_id, - report_id: data.report_id, - }), file_host, payload, pool, redis, session_queue).await + v3::images::images_add( + req, + web::Query(v3::images::ImageUpload { + ext: data.ext, + context: data.context, + project_id: data.project_id, + version_id: data.version_id, + thread_message_id: data.thread_message_id, + report_id: data.report_id, + }), + file_host, + payload, + pool, + redis, + session_queue, + ) + .await } diff --git a/src/routes/v2/moderation.rs b/src/routes/v2/moderation.rs index b1287eeb..f44214de 100644 --- a/src/routes/v2/moderation.rs +++ b/src/routes/v2/moderation.rs @@ -28,7 +28,12 @@ pub async fn get_projects( count: web::Query, session_queue: web::Data, ) -> Result { - v3::moderation::get_projects(req, pool, redis, - web::Query(v3::moderation::ResultCount { - count: count.count}), session_queue).await + v3::moderation::get_projects( + req, + pool, + redis, + web::Query(v3::moderation::ResultCount { count: count.count }), + session_queue, + ) + .await } diff --git a/src/routes/v2/notifications.rs b/src/routes/v2/notifications.rs index 9bb10c67..af04cafb 100644 --- a/src/routes/v2/notifications.rs +++ b/src/routes/v2/notifications.rs @@ -1,7 +1,7 @@ -use crate::routes::v3; use crate::database::redis::RedisPool; use crate::models::ids::NotificationId; use crate::queue::session::AuthQueue; +use crate::routes::v3; use crate::routes::ApiError; use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; @@ -39,7 +39,8 @@ pub async fn notifications_get( pool, redis, session_queue, - ).await + ) + .await } #[get("{id}")] @@ -50,13 +51,7 @@ pub async fn notification_get( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::notifications::notification_get( - req, - info, - pool, - redis, - session_queue, - ).await + v3::notifications::notification_get(req, info, pool, redis, session_queue).await } #[patch("{id}")] @@ -67,13 +62,7 @@ pub async fn notification_read( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::notifications::notification_read( - req, - info, - pool, - redis, - session_queue, - ).await + v3::notifications::notification_read(req, info, pool, redis, session_queue).await } #[delete("{id}")] @@ -84,13 +73,7 @@ pub async fn notification_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::notifications::notification_delete( - req, - info, - pool, - redis, - session_queue, - ).await + v3::notifications::notification_delete(req, info, pool, redis, session_queue).await } #[patch("notifications")] @@ -107,7 +90,8 @@ pub async fn notifications_read( pool, redis, session_queue, - ).await + ) + .await } #[delete("notifications")] @@ -124,5 +108,6 @@ pub async fn notifications_delete( pool, redis, session_queue, - ).await + ) + .await } diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index ef4e4b36..15ea4e6f 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::projects::Project; @@ -9,6 +8,7 @@ use crate::routes::{v2_reroute, v3, ApiError}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use std::sync::Arc; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { @@ -57,7 +57,8 @@ pub async fn organization_create( pool.clone(), redis.clone(), session_queue, - ).await + ) + .await } #[get("{id}")] @@ -68,13 +69,7 @@ pub async fn organization_get( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::organizations::organization_get( - req, - info, - pool.clone(), - redis.clone(), - session_queue, - ).await + v3::organizations::organization_get(req, info, pool.clone(), redis.clone(), session_queue).await } #[derive(Deserialize)] @@ -95,7 +90,8 @@ pub async fn organizations_get( pool, redis, session_queue, - ).await + ) + .await } #[derive(Serialize, Deserialize, Validate)] @@ -130,7 +126,8 @@ pub async fn organizations_edit( pool.clone(), redis.clone(), session_queue, - ).await + ) + .await } #[delete("{id}")] @@ -141,13 +138,8 @@ pub async fn organization_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::organizations::organization_delete( - req, - info, - pool.clone(), - redis.clone(), - session_queue, - ).await + v3::organizations::organization_delete(req, info, pool.clone(), redis.clone(), session_queue) + .await } #[get("{id}/projects")] @@ -200,7 +192,8 @@ pub async fn organization_projects_add( pool.clone(), redis.clone(), session_queue, - ).await + ) + .await } #[delete("{organization_id}/projects/{project_id}")] @@ -217,7 +210,8 @@ pub async fn organization_projects_remove( pool.clone(), redis.clone(), session_queue, - ).await + ) + .await } #[derive(Serialize, Deserialize)] @@ -246,7 +240,8 @@ pub async fn organization_icon_edit( file_host, payload, session_queue, - ).await + ) + .await } #[delete("{id}/icon")] @@ -265,5 +260,6 @@ pub async fn delete_organization_icon( redis.clone(), file_host, session_queue, - ).await + ) + .await } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 1e550392..1d628eee 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -3,7 +3,7 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::ImageId; -use crate::models::projects::{DonationLink, Project, ProjectStatus, SideType, Loader}; +use crate::models::projects::{DonationLink, Loader, Project, ProjectStatus, SideType}; use crate::models::v2::projects::LegacyProject; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::default_project_type; @@ -157,41 +157,48 @@ pub async fn project_create( // Setting of 'project_type' directly is removed, it's loader-based now. let mut additional_categories = legacy_create.additional_categories; - let initial_versions = legacy_create.initial_versions.into_iter().map(|v| { - let mut fields = HashMap::new(); - fields.insert("client_side".to_string(), json!(client_side)); - fields.insert("server_side".to_string(), json!(server_side)); - fields.insert("game_versions".to_string(), json!(v.game_versions)); - - if project_type == "modpack" { - additional_categories.extend(v.loaders.iter().map(|l| l.0.clone())); - } - - let loaders = if project_type == "modpack" { - vec![Loader("mrpack".to_string())] - } else { - v.loaders - }; - - v3::version_creation::InitialVersionData { - project_id: v.project_id, - file_parts: v.file_parts, - version_number: v.version_number, - version_title: v.version_title, - version_body: v.version_body, - dependencies: v.dependencies, - release_channel: v.release_channel, - loaders, - featured: v.featured, - primary_file: v.primary_file, - status: v.status, - file_types: v.file_types, - uploaded_images: v.uploaded_images, - fields, - } - }).collect(); - - let additional_categories = additional_categories.into_iter().unique().collect::>(); + let initial_versions = legacy_create + .initial_versions + .into_iter() + .map(|v| { + let mut fields = HashMap::new(); + fields.insert("client_side".to_string(), json!(client_side)); + fields.insert("server_side".to_string(), json!(server_side)); + fields.insert("game_versions".to_string(), json!(v.game_versions)); + + if project_type == "modpack" { + additional_categories.extend(v.loaders.iter().map(|l| l.0.clone())); + } + + let loaders = if project_type == "modpack" { + vec![Loader("mrpack".to_string())] + } else { + v.loaders + }; + + v3::version_creation::InitialVersionData { + project_id: v.project_id, + file_parts: v.file_parts, + version_number: v.version_number, + version_title: v.version_title, + version_body: v.version_body, + dependencies: v.dependencies, + release_channel: v.release_channel, + loaders, + featured: v.featured, + primary_file: v.primary_file, + status: v.status, + file_types: v.file_types, + uploaded_images: v.uploaded_images, + fields, + } + }) + .collect(); + + let additional_categories = additional_categories + .into_iter() + .unique() + .collect::>(); println!("additional_categories: {:?}", additional_categories); Ok(v3::project_creation::ProjectCreateData { title: legacy_create.title, @@ -233,14 +240,17 @@ pub async fn project_create( // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { Ok(project) => { - println!("Just finished doing a project create, looking at repsonse: {:?}", serde_json::to_string(&project).unwrap()); + println!( + "Just finished doing a project create, looking at repsonse: {:?}", + serde_json::to_string(&project).unwrap() + ); let version_item = match project.versions.first() { Some(vid) => version_item::Version::get((*vid).into(), &**client, &redis).await?, None => None, }; let project = LegacyProject::from(project, version_item); Ok(HttpResponse::Ok().json(project)) - }, + } Err(response) => Ok(response), } -} \ No newline at end of file +} diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index ab82807c..f85f785c 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -491,7 +491,8 @@ pub async fn projects_edit( }), redis, session_queue, - ).await + ) + .await } #[derive(Deserialize)] @@ -550,7 +551,8 @@ pub async fn project_icon_edit( file_host, payload, session_queue, - ).await + ) + .await } #[delete("{id}/icon")] @@ -603,7 +605,8 @@ pub async fn add_gallery_item( file_host, payload, session_queue, - ).await + ) + .await } #[derive(Serialize, Deserialize, Validate)] @@ -650,7 +653,8 @@ pub async fn edit_gallery_item( pool, redis, session_queue, - ).await + ) + .await } #[derive(Serialize, Deserialize)] @@ -676,7 +680,8 @@ pub async fn delete_gallery_item( redis, file_host, session_queue, - ).await + ) + .await } #[delete("{id}")] @@ -688,14 +693,7 @@ pub async fn project_delete( config: web::Data, session_queue: web::Data, ) -> Result { - v3::projects::project_delete( - req, - info, - pool, - redis, - config, - session_queue, - ).await + v3::projects::project_delete(req, info, pool, redis, config, session_queue).await } #[post("{id}/follow")] @@ -706,13 +704,7 @@ pub async fn project_follow( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::projects::project_follow( - req, - info, - pool, - redis, - session_queue, - ).await + v3::projects::project_follow(req, info, pool, redis, session_queue).await } #[delete("{id}/follow")] @@ -723,11 +715,5 @@ pub async fn project_unfollow( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::projects::project_unfollow( - req, - info, - pool, - redis, - session_queue, - ).await + v3::projects::project_unfollow(req, info, pool, redis, session_queue).await } diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index 7c3f4a3f..f167eceb 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -2,7 +2,7 @@ use crate::database::redis::RedisPool; use crate::models::ids::ImageId; use crate::models::reports::ItemType; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; @@ -37,13 +37,7 @@ pub async fn report_create( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::reports::report_create( - req, - pool, - body, - redis, - session_queue, - ).await + v3::reports::report_create(req, pool, body, redis, session_queue).await } #[derive(Deserialize)] @@ -69,7 +63,6 @@ pub async fn reports( count: web::Query, session_queue: web::Data, ) -> Result { - v3::reports::reports( req, pool, @@ -79,7 +72,8 @@ pub async fn reports( all: count.all, }), session_queue, - ).await + ) + .await } #[derive(Deserialize)] @@ -101,7 +95,8 @@ pub async fn reports_get( pool, redis, session_queue, - ).await + ) + .await } #[get("report/{id}")] @@ -112,13 +107,7 @@ pub async fn report_get( info: web::Path<(crate::models::reports::ReportId,)>, session_queue: web::Data, ) -> Result { - v3::reports::report_get( - req, - pool, - redis, - info, - session_queue, - ).await + v3::reports::report_get(req, pool, redis, info, session_queue).await } #[derive(Deserialize, Validate)] @@ -148,7 +137,8 @@ pub async fn report_edit( body: edit_report.body, closed: edit_report.closed, }), - ).await + ) + .await } #[delete("report/{id}")] @@ -159,11 +149,5 @@ pub async fn report_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::reports::report_delete( - req, - pool, - info, - redis, - session_queue, - ).await + v3::reports::report_delete(req, pool, info, redis, session_queue).await } diff --git a/src/routes/v2/statistics.rs b/src/routes/v2/statistics.rs index cb2c3280..962bc39f 100644 --- a/src/routes/v2/statistics.rs +++ b/src/routes/v2/statistics.rs @@ -1,4 +1,4 @@ -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::{get, web, HttpResponse}; use sqlx::PgPool; diff --git a/src/routes/v2/tags.rs b/src/routes/v2/tags.rs index 407d58c8..2f4075ea 100644 --- a/src/routes/v2/tags.rs +++ b/src/routes/v2/tags.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use super::ApiError; use crate::database::models::loader_fields::LoaderFieldEnumValue; use crate::database::redis::RedisPool; -use crate::routes::v3::tags::{LoaderFieldsEnumQuery, LoaderData as LoaderDataV3}; +use crate::routes::v3::tags::{LoaderData as LoaderDataV3, LoaderFieldsEnumQuery}; use crate::routes::{v2_reroute, v3}; use actix_web::{get, web, HttpResponse}; use chrono::{DateTime, Utc}; @@ -37,10 +37,7 @@ pub async fn category_list( pool: web::Data, redis: web::Data, ) -> Result { - v3::tags::category_list( - pool, - redis, - ).await + v3::tags::category_list(pool, redis).await } #[derive(serde::Serialize, serde::Deserialize)] @@ -55,11 +52,7 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::tags::loader_list( - pool, - redis, - ) - .await?; + let response = v3::tags::loader_list(pool, redis).await?; // Convert to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -69,8 +62,7 @@ pub async fn loader_list( .map(|l| LoaderData { icon: l.icon, name: l.name, - supported_project_types: l - .supported_project_types, + supported_project_types: l.supported_project_types, }) .collect::>(); Ok(HttpResponse::Ok().json(loaders)) @@ -167,7 +159,7 @@ pub struct LicenseText { pub async fn license_text(params: web::Path<(String,)>) -> Result { v3::tags::license_text(params).await } - + #[derive(serde::Serialize)] pub struct DonationPlatformQueryData { short: String, @@ -179,10 +171,7 @@ pub async fn donation_platform_list( pool: web::Data, redis: web::Data, ) -> Result { - v3::tags::donation_platform_list( - pool, - redis, - ).await + v3::tags::donation_platform_list(pool, redis).await } #[get("report_type")] @@ -190,10 +179,7 @@ pub async fn report_type_list( pool: web::Data, redis: web::Data, ) -> Result { - v3::tags::report_type_list( - pool, - redis, - ).await + v3::tags::report_type_list(pool, redis).await } #[get("project_type")] @@ -201,10 +187,7 @@ pub async fn project_type_list( pool: web::Data, redis: web::Data, ) -> Result { - v3::tags::project_type_list( - pool, - redis, - ).await + v3::tags::project_type_list(pool, redis).await } #[get("side_type")] diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index 39d75fa9..87b2df16 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -2,7 +2,7 @@ use crate::database::redis::RedisPool; use crate::models::teams::{OrganizationPermissions, ProjectPermissions, TeamId}; use crate::models::users::UserId; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -73,9 +73,14 @@ pub async fn teams_get( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::teams::teams_get(req, web::Query(v3::teams::TeamIds { - ids: ids.ids, - }), pool, redis, session_queue).await + v3::teams::teams_get( + req, + web::Query(v3::teams::TeamIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await } #[post("{id}/join")] @@ -121,14 +126,22 @@ pub async fn add_team_member( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::teams::add_team_member(req, info, pool, web::Json(v3::teams::NewTeamMember{ - user_id: new_member.user_id, - role: new_member.role.clone(), - permissions: new_member.permissions, - organization_permissions: new_member.organization_permissions, - payouts_split: new_member.payouts_split, - ordering: new_member.ordering, - }), redis, session_queue).await + v3::teams::add_team_member( + req, + info, + pool, + web::Json(v3::teams::NewTeamMember { + user_id: new_member.user_id, + role: new_member.role.clone(), + permissions: new_member.permissions, + organization_permissions: new_member.organization_permissions, + payouts_split: new_member.payouts_split, + ordering: new_member.ordering, + }), + redis, + session_queue, + ) + .await } #[derive(Serialize, Deserialize, Clone)] @@ -149,13 +162,21 @@ pub async fn edit_team_member( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::teams::edit_team_member(req, info, pool, web::Json(v3::teams::EditTeamMember{ - permissions: edit_member.permissions, - organization_permissions: edit_member.organization_permissions, - role: edit_member.role.clone(), - payouts_split: edit_member.payouts_split, - ordering: edit_member.ordering, - }), redis, session_queue).await + v3::teams::edit_team_member( + req, + info, + pool, + web::Json(v3::teams::EditTeamMember { + permissions: edit_member.permissions, + organization_permissions: edit_member.organization_permissions, + role: edit_member.role.clone(), + payouts_split: edit_member.payouts_split, + ordering: edit_member.ordering, + }), + redis, + session_queue, + ) + .await } #[derive(Deserialize)] @@ -172,9 +193,17 @@ pub async fn transfer_ownership( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::teams::transfer_ownership(req, info, pool, web::Json(v3::teams::TransferOwnership{ - user_id: new_owner.user_id, - }), redis, session_queue).await + v3::teams::transfer_ownership( + req, + info, + pool, + web::Json(v3::teams::TransferOwnership { + user_id: new_owner.user_id, + }), + redis, + session_queue, + ) + .await } #[delete("{id}/members/{user_id}")] diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index c9c5d8ef..6b7e8c2b 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -5,7 +5,7 @@ use crate::file_hosting::FileHost; use crate::models::ids::ThreadMessageId; use crate::models::threads::{MessageBody, ThreadId}; use crate::queue::session::AuthQueue; -use crate::routes::{ApiError, v3}; +use crate::routes::{v3, ApiError}; use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; use serde::Deserialize; use sqlx::PgPool; @@ -46,11 +46,14 @@ pub async fn threads_get( redis: web::Data, session_queue: web::Data, ) -> Result { - v3::threads::threads_get(req, web::Query( - v3::threads::ThreadIds { - ids: ids.ids - }, - ), pool, redis, session_queue).await + v3::threads::threads_get( + req, + web::Query(v3::threads::ThreadIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await } #[derive(Deserialize)] @@ -68,11 +71,17 @@ pub async fn thread_send_message( session_queue: web::Data, ) -> Result { let new_message = new_message.into_inner(); - v3::threads::thread_send_message(req, info, pool, web::Json( - v3::threads::NewThreadMessage { - body: new_message.body - }, - ), redis, session_queue).await + v3::threads::thread_send_message( + req, + info, + pool, + web::Json(v3::threads::NewThreadMessage { + body: new_message.body, + }), + redis, + session_queue, + ) + .await } #[get("inbox")] diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 38790e36..830d81a3 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -1,9 +1,7 @@ use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::projects::Project; -use crate::models::users::{ - Badges, Role, -}; +use crate::models::users::{Badges, Role}; use crate::models::v2::projects::LegacyProject; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; @@ -60,9 +58,7 @@ pub async fn users_get( pool: web::Data, redis: web::Data, ) -> Result { - v3::users::users_get(web::Query(v3::users::UserIds { - ids: ids.ids, - }), pool, redis).await + v3::users::users_get(web::Query(v3::users::UserIds { ids: ids.ids }), pool, redis).await } #[get("{id}")] @@ -153,15 +149,21 @@ pub async fn user_edit( session_queue: web::Data, ) -> Result { let new_user = new_user.into_inner(); - v3::users::user_edit(req, info, web::Json( - v3::users::EditUser { + v3::users::user_edit( + req, + info, + web::Json(v3::users::EditUser { username: new_user.username, name: new_user.name, bio: new_user.bio, role: new_user.role, badges: new_user.badges, - } - ), pool, redis, session_queue).await + }), + pool, + redis, + session_queue, + ) + .await } #[derive(Serialize, Deserialize)] @@ -190,7 +192,8 @@ pub async fn user_icon_edit( file_host, payload, session_queue, - ).await + ) + .await } #[derive(Deserialize)] @@ -213,10 +216,17 @@ pub async fn user_delete( session_queue: web::Data, ) -> Result { let removal_type = removal_type.into_inner(); - v3::users::user_delete(req, info, pool, + v3::users::user_delete( + req, + info, + pool, web::Query(v3::users::RemovalType { removal_type: removal_type.removal_type, - }), redis, session_queue).await + }), + redis, + session_queue, + ) + .await } #[get("{id}/follows")] @@ -270,12 +280,15 @@ pub async fn user_payouts_fees( v3::users::user_payouts_fees( req, info, - web::Query(v3::users::FeeEstimateAmount { amount: amount.amount }), + web::Query(v3::users::FeeEstimateAmount { + amount: amount.amount, + }), pool, redis, session_queue, payouts_queue, - ).await + ) + .await } #[derive(Deserialize)] @@ -297,9 +310,12 @@ pub async fn user_payouts_request( req, info, pool, - web::Json(v3::users::PayoutData { amount: data.amount }), + web::Json(v3::users::PayoutData { + amount: data.amount, + }), payouts_queue, redis, session_queue, - ).await + ) + .await } diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 13dd654a..36906347 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -62,8 +62,7 @@ pub async fn download_version( hash_query: web::Query, session_queue: web::Data, ) -> Result { - v3::version_file::download_version(req, info, pool, redis, hash_query, session_queue) - .await + v3::version_file::download_version(req, info, pool, redis, hash_query, session_queue).await } // under /api/v1/version_file/{hash} diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 8276d133..ffce00e2 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -277,7 +277,8 @@ pub async fn version_schedule( requested_status: scheduling_data.requested_status, }), session_queue, - ).await + ) + .await } #[delete("{version_id}")] diff --git a/src/routes/v3/analytics_get.rs b/src/routes/v3/analytics_get.rs index 82a13467..5b971318 100644 --- a/src/routes/v3/analytics_get.rs +++ b/src/routes/v3/analytics_get.rs @@ -27,7 +27,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("views", web::get().to(views_get)) .route("downloads", web::get().to(downloads_get)) .route("revenue", web::get().to(revenue_get)) - .route("countries/downloads", web::get().to(countries_downloads_get)) + .route( + "countries/downloads", + web::get().to(countries_downloads_get), + ) .route("countries/views", web::get().to(countries_views_get)), ); } diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index e8828655..c715d85b 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -6,9 +6,9 @@ use serde_json::json; pub mod analytics_get; pub mod collections; pub mod images; -pub mod organizations; pub mod moderation; pub mod notifications; +pub mod organizations; pub mod project_creation; pub mod projects; pub mod reports; diff --git a/src/routes/v3/notifications.rs b/src/routes/v3/notifications.rs index a12db1f5..3eda349e 100644 --- a/src/routes/v3/notifications.rs +++ b/src/routes/v3/notifications.rs @@ -11,15 +11,15 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.route("notifications",web::get().to(notifications_get)); - cfg.route("notifications",web::patch().to(notifications_read)); - cfg.route("notifications",web::delete().to(notifications_delete)); + cfg.route("notifications", web::get().to(notifications_get)); + cfg.route("notifications", web::patch().to(notifications_read)); + cfg.route("notifications", web::delete().to(notifications_delete)); cfg.service( web::scope("notification") - .route("{id}", web::get().to(notification_get)) - .route("{id}", web::patch().to(notification_read)) - .route("{id}", web::delete().to(notification_delete)) + .route("{id}", web::get().to(notification_get)) + .route("{id}", web::patch().to(notification_read)) + .route("{id}", web::delete().to(notification_delete)), ); } diff --git a/src/routes/v3/organizations.rs b/src/routes/v3/organizations.rs index e8bc7311..64eb1f74 100644 --- a/src/routes/v3/organizations.rs +++ b/src/routes/v3/organizations.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::auth::{get_user_from_headers, filter_authorized_projects}; +use super::ApiError; +use crate::auth::{filter_authorized_projects, get_user_from_headers}; use crate::database::models::team_item::TeamMember; use crate::database::models::{generate_organization_id, team_item, Organization}; use crate::database::redis::RedisPool; @@ -20,20 +21,25 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; -use super::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("organization") - .route("{id}/projects", web::get().to(organization_projects_get)) - .route("{id}", web::get().to(organization_get)) - .route("{id}", web::patch().to(organizations_edit)) - .route("{id}", web::delete().to(organization_delete)) - .route("{id}/projects", web::post().to(organization_projects_add)) - .route("{id}/projects", web::delete().to(organization_projects_remove)) - .route("{id}/icon", web::patch().to(organization_icon_edit)) - .route("{id}/icon", web::delete().to(delete_organization_icon)) - .route("{id}/members", web::get().to(super::teams::team_members_get_organization)) + .route("{id}/projects", web::get().to(organization_projects_get)) + .route("{id}", web::get().to(organization_get)) + .route("{id}", web::patch().to(organizations_edit)) + .route("{id}", web::delete().to(organization_delete)) + .route("{id}/projects", web::post().to(organization_projects_add)) + .route( + "{id}/projects", + web::delete().to(organization_projects_remove), + ) + .route("{id}/icon", web::patch().to(organization_icon_edit)) + .route("{id}/icon", web::delete().to(delete_organization_icon)) + .route( + "{id}/members", + web::get().to(super::teams::team_members_get_organization), + ), ); } diff --git a/src/routes/v3/project_creation.rs b/src/routes/v3/project_creation.rs index 667b01f2..66890599 100644 --- a/src/routes/v3/project_creation.rs +++ b/src/routes/v3/project_creation.rs @@ -1,7 +1,7 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::loader_fields::{ - LoaderField, LoaderFieldEnumValue, VersionField, Loader, + Loader, LoaderField, LoaderFieldEnumValue, VersionField, }; use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, image_item, User}; @@ -370,7 +370,7 @@ async fn project_create_inner( .1; let project_id: ProjectId = models::generate_project_id(transaction).await?.into(); - let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?; + let all_loaders = models::loader_fields::Loader::list(&mut **transaction, redis).await?; let project_create_data: ProjectCreateData; let mut versions; @@ -565,7 +565,7 @@ async fn project_create_inner( let created_version = versions.get_mut(index).unwrap(); let version_data = project_create_data.initial_versions.get(index).unwrap(); // TODO: maybe redundant is this calculation done elsewhere? - + // Upload the new jar file super::version_creation::upload_file( &mut field, @@ -622,7 +622,7 @@ async fn project_create_inner( if ids.is_empty() { return Err(CreateError::InvalidCategory(category.clone())); } - + // TODO: We should filter out categories that don't match the project type of any of the versions // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version categories.extend(ids.values()); @@ -788,17 +788,19 @@ async fn project_create_inner( .flat_map(|v| v.loaders.clone()) .unique() .collect::>(); - let (project_types, games) = Loader::list(&mut **transaction, &redis).await?.into_iter().fold( - (Vec::new(), Vec::new()), - |(mut project_types, mut games), loader| { - if loaders.contains(&loader.id) { - project_types.extend(loader.supported_project_types); - games.extend(loader.supported_games.iter().map(|x| x.name().to_string())); - } - (project_types, games) - }, - ); - + let (project_types, games) = Loader::list(&mut **transaction, redis) + .await? + .into_iter() + .fold( + (Vec::new(), Vec::new()), + |(mut project_types, mut games), loader| { + if loaders.contains(&loader.id) { + project_types.extend(loader.supported_project_types); + games.extend(loader.supported_games.iter().map(|x| x.name().to_string())); + } + (project_types, games) + }, + ); let response = crate::models::projects::Project { id: project_id, @@ -876,9 +878,7 @@ async fn create_initial_version( .map(|x| { all_loaders .iter() - .find(|y| { - y.loader == x.0 - }) + .find(|y| y.loader == x.0) .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) .map(|y| y.id) }) diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index cfb2fdb1..1cc40a19 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; -use crate::database::{models as db_models, self}; -use crate::database::models::{ids as db_ids, image_item}; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::project_item::{ModCategory, GalleryItem}; +use crate::database::models::project_item::{GalleryItem, ModCategory}; use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::models::{ids as db_ids, image_item}; use crate::database::redis::RedisPool; +use crate::database::{self, models as db_models}; use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::base62_impl::parse_base62; @@ -41,35 +41,34 @@ pub fn config(cfg: &mut web::ServiceConfig) { 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_get)) - .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}/schedule", web::post().to(project_schedule)) + .route("{id}", web::get().to(project_get)) + .route("{id}/check", web::get().to(project_get_check)) + .route("{id}", web::delete().to(project_get)) + .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}/schedule", web::post().to(project_schedule)) .service( web::scope("{project_id}") - .route("members", web::get().to(super::teams::team_members_get_project)) + .route( + "members", + web::get().to(super::teams::team_members_get_project), + ) .route("versions", 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) - ) + .route("dependencies", web::get().to(dependency_list)), ), ); } - #[derive(Deserialize, Validate)] pub struct RandomProjects { #[validate(range(min = 1, max = 100))] @@ -984,8 +983,8 @@ pub async fn edit_project_categories( let mut mod_categories = Vec::new(); for category in categories { - let category_ids = db_models::categories::Category::get_ids(category, &mut **transaction) - .await?; + let category_ids = + db_models::categories::Category::get_ids(category, &mut **transaction).await?; // TODO: We should filter out categories that don't match the project type of any of the versions // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version diff --git a/src/routes/v3/tags.rs b/src/routes/v3/tags.rs index 861f2d8e..a82beaf1 100644 --- a/src/routes/v3/tags.rs +++ b/src/routes/v3/tags.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use super::ApiError; -use crate::database::models::categories::{Category, DonationPlatform, ReportType, ProjectType}; +use crate::database::models::categories::{Category, DonationPlatform, ProjectType, ReportType}; use crate::database::models::loader_fields::{ Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, }; @@ -13,14 +13,15 @@ use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("tag") - .route("category", web::get().to(category_list)) - .route("loader", web::get().to(loader_list))) - .route("loader_fields", web::get().to(loader_fields_list)) - .route("license", web::get().to(license_list)) - .route("license/{id}", web::get().to(license_text)) - .route("donation_platform", web::get().to(donation_platform_list)) - .route("report_type", web::get().to(report_type_list)) - .route("project_type", web::get().to(project_type_list)); + .route("category", web::get().to(category_list)) + .route("loader", web::get().to(loader_list)), + ) + .route("loader_fields", web::get().to(loader_fields_list)) + .route("license", web::get().to(license_list)) + .route("license/{id}", web::get().to(license_text)) + .route("donation_platform", web::get().to(donation_platform_list)) + .route("report_type", web::get().to(report_type_list)) + .route("project_type", web::get().to(project_type_list)); } #[derive(serde::Serialize, serde::Deserialize)] @@ -68,7 +69,11 @@ pub async fn loader_list( icon: x.icon, name: x.loader, supported_project_types: x.supported_project_types, - supported_games: x.supported_games.iter().map(|x| x.name().to_string()).collect(), + supported_games: x + .supported_games + .iter() + .map(|x| x.name().to_string()) + .collect(), }) .collect::>(); diff --git a/src/routes/v3/teams.rs b/src/routes/v3/teams.rs index 31470bc8..05f19c69 100644 --- a/src/routes/v3/teams.rs +++ b/src/routes/v3/teams.rs @@ -20,12 +20,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("team") - .route("{id}/members", web::get().to(team_members_get)) - .route("{id}/members/{user_id}", web::patch().to(edit_team_member)) - .route("{id}/members/{user_id}", web::delete().to(remove_team_member)) - .route("{id}/members", web::post().to(add_team_member)) - .route("{id}/join", web::post().to(join_team)) - .route("{id}/owner", web::patch().to(transfer_ownership)) + .route("{id}/members", web::get().to(team_members_get)) + .route("{id}/members/{user_id}", web::patch().to(edit_team_member)) + .route( + "{id}/members/{user_id}", + web::delete().to(remove_team_member), + ) + .route("{id}/members", web::post().to(add_team_member)) + .route("{id}/join", web::post().to(join_team)) + .route("{id}/owner", web::patch().to(transfer_ownership)), ); } diff --git a/src/routes/v3/threads.rs b/src/routes/v3/threads.rs index ca75c025..9f10c2b5 100644 --- a/src/routes/v3/threads.rs +++ b/src/routes/v3/threads.rs @@ -24,11 +24,11 @@ use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("thread") - .route("{id}", web::get().to(thread_get)) + .route("{id}", web::get().to(thread_get)) .route("inbox", web::get().to(moderation_inbox)) .route("{id}", web::post().to(thread_send_message)) - .route("{id}/read", web::post().to(thread_read)) - ); + .route("{id}/read", web::post().to(thread_read)), + ); cfg.service(web::scope("message").route("{id}", web::delete().to(message_delete))); cfg.route("threads", web::get().to(threads_get)); } diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs index e7be0326..7e9f3a32 100644 --- a/src/routes/v3/users.rs +++ b/src/routes/v3/users.rs @@ -1,20 +1,29 @@ -use std::{sync::{Arc}, collections::HashMap}; +use std::{collections::HashMap, sync::Arc}; use actix_web::{web, HttpRequest, HttpResponse}; +use lazy_static::lazy_static; use regex::Regex; use rust_decimal::Decimal; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; -use lazy_static::lazy_static; use tokio::sync::Mutex; use validator::Validate; use crate::{ auth::get_user_from_headers, - database::{models::{User}, redis::RedisPool}, - models::{ids::UserId, pats::Scopes, projects::Project, users::{Badges, Role, Payout, PayoutStatus, UserPayoutData, RecipientStatus}, collections::{CollectionStatus, Collection}, notifications::Notification}, - queue::{session::AuthQueue, payouts::PayoutsQueue}, file_hosting::FileHost, util::{routes::read_from_payload, validate::validation_errors_to_string}, + database::{models::User, redis::RedisPool}, + file_hosting::FileHost, + models::{ + collections::{Collection, CollectionStatus}, + ids::UserId, + notifications::Notification, + pats::Scopes, + projects::Project, + users::{Badges, Payout, PayoutStatus, RecipientStatus, Role, UserPayoutData}, + }, + queue::{payouts::PayoutsQueue, session::AuthQueue}, + util::{routes::read_from_payload, validate::validation_errors_to_string}, }; use super::ApiError; @@ -25,23 +34,21 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("user") - .route("{user_id}/projects", web::get().to(projects_list)) - .route("{id}", web::get().to(user_get)) - .route("{user_id}/collections", web::get().to(collections_list)) - .route("{user_id}/organizations", web::get().to(orgs_list)) - .route("{id}", web::patch().to(user_edit)) - .route("{id}/icon", web::patch().to(user_icon_edit)) - .route("{id}", web::delete().to(user_delete)) - .route("{id}/follows", web::get().to(user_follows)) - .route("{id}/notifications", web::get().to(user_notifications)) - .route("{id}/payouts", web::get().to(user_payouts)) - .route("{id}/payouts_fees", web::get().to(user_payouts_fees)) - .route("{id}/payouts", web::post().to(user_payouts_request)) - + .route("{user_id}/projects", web::get().to(projects_list)) + .route("{id}", web::get().to(user_get)) + .route("{user_id}/collections", web::get().to(collections_list)) + .route("{user_id}/organizations", web::get().to(orgs_list)) + .route("{id}", web::patch().to(user_edit)) + .route("{id}/icon", web::patch().to(user_icon_edit)) + .route("{id}", web::delete().to(user_delete)) + .route("{id}/follows", web::get().to(user_follows)) + .route("{id}/notifications", web::get().to(user_notifications)) + .route("{id}/payouts", web::get().to(user_payouts)) + .route("{id}/payouts_fees", web::get().to(user_payouts_fees)) + .route("{id}/payouts", web::post().to(user_payouts_request)), ); } - pub async fn projects_list( req: HttpRequest, info: web::Path<(String,)>, @@ -270,7 +277,6 @@ pub async fn orgs_list( } } - lazy_static! { static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); } @@ -670,7 +676,10 @@ pub async fn user_notifications( .collect(); notifications.sort_by(|a, b| b.created.cmp(&a.created)); - println!("notifications: {:?}", serde_json::to_string(¬ifications).unwrap()); + println!( + "notifications: {:?}", + serde_json::to_string(¬ifications).unwrap() + ); Ok(HttpResponse::Ok().json(notifications)) } else { Ok(HttpResponse::NotFound().body("")) diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index c8fd74ab..3176a103 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -1,8 +1,6 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models::loader_fields::{ - LoaderField, LoaderFieldEnumValue, VersionField, -}; +use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, @@ -16,8 +14,8 @@ use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; use crate::models::projects::{skip_nulls, DependencyType}; use crate::models::projects::{ - Dependency, FileType, Loader, ProjectId, Version, VersionFile, VersionId, - VersionStatus, VersionType, + Dependency, FileType, Loader, ProjectId, Version, VersionFile, VersionId, VersionStatus, + VersionType, }; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; @@ -204,7 +202,9 @@ async fn version_create_inner( // Ensure that the project this version is being added to exists if models::Project::get_id(project_id, &mut **transaction, redis) - .await?.is_none() { + .await? + .is_none() + { return Err(CreateError::InvalidInput( "An invalid project id was supplied".to_string(), )); @@ -252,11 +252,8 @@ async fn version_create_inner( let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - let all_loaders = models::loader_fields::Loader::list( - &mut **transaction, - redis, - ) - .await?; + let all_loaders = + models::loader_fields::Loader::list(&mut **transaction, redis).await?; let loader_fields = LoaderField::get_fields(&mut **transaction, redis).await?; let mut version_fields = vec![]; @@ -294,16 +291,13 @@ async fn version_create_inner( .map(|x| { all_loaders .iter() - .find(|y| { - y.loader == x.0 - }) + .find(|y| y.loader == x.0) .cloned() .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) - }).collect::, _>>()?; + }) + .collect::, _>>()?; selected_loaders = Some(loaders.clone()); - let loader_ids = loaders.iter() - .map(|y| y.id) - .collect_vec(); + let loader_ids = loaders.iter().map(|y| y.id).collect_vec(); let dependencies = version_create_data .dependencies @@ -315,7 +309,7 @@ async fn version_create_inner( file_name: None, }) .collect::>(); - + version_builder = Some(VersionBuilder { version_id: version_id.into(), project_id, @@ -342,8 +336,11 @@ async fn version_create_inner( let loaders = selected_loaders.as_ref().ok_or_else(|| { CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; - let loaders = loaders.iter().map(|x| Loader(x.loader.clone())).collect::>(); - + let loaders = loaders + .iter() + .map(|x| Loader(x.loader.clone())) + .collect::>(); + let version_data = initial_version_data .clone() .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; @@ -420,11 +417,13 @@ async fn version_create_inner( .await?; let loader_structs = selected_loaders.unwrap_or_default(); - let (all_project_types, all_games) : (Vec, Vec) = loader_structs.iter().fold((vec![], vec![]), |mut acc, x| { - acc.0.extend_from_slice(&x.supported_project_types); - acc.1.extend(x.supported_games.iter().map(|x| x.name().to_string())); - acc - }); + let (all_project_types, all_games): (Vec, Vec) = + loader_structs.iter().fold((vec![], vec![]), |mut acc, x| { + acc.0.extend_from_slice(&x.supported_project_types); + acc.1 + .extend(x.supported_games.iter().map(|x| x.name().to_string())); + acc + }); let response = Version { id: builder.version_id.into(), @@ -605,20 +604,20 @@ async fn upload_file_to_version_inner( .map(|x| { all_loaders .iter() - .find(|y| { - &y.loader == x - }) + .find(|y| &y.loader == x) .cloned() .ok_or_else(|| CreateError::InvalidLoader(x.clone())) - }).collect::, _>>()?; - - if models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) - .await?.is_none() { - return Err(CreateError::InvalidInput( - "An invalid project id was supplied".to_string(), - )); - } + }) + .collect::, _>>()?; + if models::Project::get_id(version.inner.project_id, &mut **transaction, &redis) + .await? + .is_none() + { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } if !user.role.is_admin() { let team_member = models::TeamMember::get_from_user_id_project( @@ -689,8 +688,11 @@ async fn upload_file_to_version_inner( CreateError::InvalidInput(String::from("`data` field must come before file fields")) })?; - let loaders = selected_loaders.iter().map(|x| Loader(x.loader.clone())).collect::>(); - + let loaders = selected_loaders + .iter() + .map(|x| Loader(x.loader.clone())) + .collect::>(); + let mut dependencies = version .dependencies .iter() @@ -765,7 +767,7 @@ pub async fn upload_file( project_id: ProjectId, version_id: VersionId, version_fields: &[VersionField], - loaders : Vec, + loaders: Vec, ignore_primary: bool, force_primary: bool, file_type: Option, @@ -887,8 +889,7 @@ pub async fn upload_file( } let data = data.freeze(); - let primary = (version_files.iter().all(|x| !x.primary) - && !ignore_primary) + let primary = (version_files.iter().all(|x| !x.primary) && !ignore_primary) || force_primary || total_files_len == 1; diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 428409ac..28375d70 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -616,7 +616,6 @@ pub struct DownloadRedirect { pub url: String, } - // under /api/v1/version_file/{hash}/download pub async fn download_version( req: HttpRequest, diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 382c5a8b..dfe128f4 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -7,7 +7,7 @@ use crate::auth::{ use crate::database; use crate::database::models::loader_fields::{LoaderField, LoaderFieldEnumValue, VersionField}; use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; -use crate::database::models::{Organization, image_item}; +use crate::database::models::{image_item, Organization}; use crate::database::redis::RedisPool; use crate::models; use crate::models::ids::base62_impl::parse_base62; @@ -349,33 +349,29 @@ pub async fn version_edit_helper( if let Some(dependencies) = &new_version.dependencies { // TODO: Re-add this exclusions when modpack also has separate dependency retrieval that was removed from validators // if let Some(project) = project_item { - // if project.project_type != "modpack" { - sqlx::query!( - " + // if project.project_type != "modpack" { + sqlx::query!( + " DELETE FROM dependencies WHERE dependent_id = $1 ", - id as database::models::ids::VersionId, - ) - .execute(&mut *transaction) - .await?; - - let builders = dependencies - .iter() - .map(|x| database::models::version_item::DependencyBuilder { - project_id: x.project_id.map(|x| x.into()), - version_id: x.version_id.map(|x| x.into()), - file_name: x.file_name.clone(), - dependency_type: x.dependency_type.to_string(), - }) - .collect::>(); - - DependencyBuilder::insert_many( - builders, - version_item.inner.id, - &mut transaction, - ) - .await?; - // } + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let builders = dependencies + .iter() + .map(|x| database::models::version_item::DependencyBuilder { + project_id: x.project_id.map(|x| x.into()), + version_id: x.version_id.map(|x| x.into()), + file_name: x.file_name.clone(), + dependency_type: x.dependency_type.to_string(), + }) + .collect::>(); + + DependencyBuilder::insert_many(builders, version_item.inner.id, &mut transaction) + .await?; + // } // } } diff --git a/src/validate/mod.rs b/src/validate/mod.rs index 1ddaa33c..90507927 100644 --- a/src/validate/mod.rs +++ b/src/validate/mod.rs @@ -118,7 +118,7 @@ pub async fn validate_file( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result { - // TODO: This needs to be revisited or removed with v3. + // TODO: This needs to be revisited or removed with v3. // Currently, it checks if the loader is the modpack loader, and extracts the pack data from it. // This (and the funnction that calls this) should be refactored such that // - validators are removed (or altogether reworked) @@ -143,7 +143,7 @@ pub async fn validate_file( file_type, ) .await - }, + } _ => Ok(ValidationResult::Pass), } } diff --git a/tests/common/dummy_data.rs b/tests/common/dummy_data.rs index da063c07..619579a0 100644 --- a/tests/common/dummy_data.rs +++ b/tests/common/dummy_data.rs @@ -4,11 +4,10 @@ use std::io::{Cursor, Write}; use actix_http::StatusCode; use actix_web::test::{self, TestRequest}; use labrinth::models::{ - - oauth_clients::OAuthClient, organizations::Organization, + oauth_clients::OAuthClient, + organizations::Organization, pats::Scopes, - v2::projects::{LegacyProject, LegacyVersion, - }, + v2::projects::{LegacyProject, LegacyVersion}, }; use serde_json::json; use sqlx::Executor; @@ -19,11 +18,7 @@ use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegment use super::{environment::TestEnvironment, request_data::get_public_project_creation_data}; -use super::{ - asserts::assert_status, - database::USER_USER_ID, - get_json_val_str, -}; +use super::{asserts::assert_status, database::USER_USER_ID, get_json_val_str}; pub const DUMMY_DATA_UPDATE: i64 = 3; diff --git a/tests/tags.rs b/tests/tags.rs index 60aebdfb..51f408ca 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -29,7 +29,10 @@ async fn get_tags() { let loader_names = loaders.into_iter().map(|x| x.name).collect::>(); assert_eq!( loader_names, - ["fabric", "forge", "mrpack"].iter().map(|s| s.to_string()).collect() + ["fabric", "forge", "mrpack"] + .iter() + .map(|s| s.to_string()) + .collect() ); let side_type_names = side_types.into_iter().collect::>(); From ea95f7e6243c7ede2a7fa285e32c2196efa46cde Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 8 Nov 2023 15:12:09 -0800 Subject: [PATCH 27/31] loaders to loader_fields, added tests --- migrations/20231005230721_dynamic-fields.sql | 78 +++++++++++--------- src/database/models/loader_fields.rs | 10 +-- src/models/v2/projects.rs | 49 +++++++++--- src/routes/v2/project_creation.rs | 20 +---- src/routes/v2/version_creation.rs | 2 +- src/routes/v2_reroute.rs | 2 +- src/routes/v3/analytics_get.rs | 2 +- src/routes/v3/organizations.rs | 3 - src/routes/v3/projects.rs | 5 -- src/routes/v3/users.rs | 4 - src/search/indexing/local_import.rs | 11 ++- tests/common/api_v2/project.rs | 2 - tests/files/dummy_data.sql | 6 +- tests/project.rs | 55 +++++++++++++- tests/search.rs | 20 +++-- tests/tags.rs | 4 +- 16 files changed, 174 insertions(+), 99 deletions(-) diff --git a/migrations/20231005230721_dynamic-fields.sql b/migrations/20231005230721_dynamic-fields.sql index 42977690..7bf0052f 100644 --- a/migrations/20231005230721_dynamic-fields.sql +++ b/migrations/20231005230721_dynamic-fields.sql @@ -6,41 +6,6 @@ CREATE TABLE games ( INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); --- we are creating a new loader type- 'mrpack'- for minecraft modpacks -INSERT INTO loaders (loader) VALUES ('mrpack'); -INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) SELECT DISTINCT l.id, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader = 'mrpack'; - --- We create 'modpack' categories for every loader --- That way we keep information like "this modpack is a fabric modpack" -INSERT INTO categories (category, project_type) -SELECT DISTINCT l.loader, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader != 'mrpack'; - --- insert the loader of every modpack mod as a category -INSERT INTO mods_categories (joining_mod_id, joining_category_id) -SELECT DISTINCT m.id, c.id -FROM mods m -LEFT JOIN versions v ON m.id = v.mod_id -LEFT JOIN loaders_versions lv ON v.id = lv.version_id -LEFT JOIN loaders l ON lv.loader_id = l.id -CROSS JOIN categories c -WHERE m.project_type = (SELECT id FROM project_types WHERE name = 'modpack') AND c.category = l.loader; - --- Non mrpack loaders no longer support modpacks -DELETE FROM loaders_project_types WHERE joining_loader_id != (SELECT id FROM loaders WHERE loader = 'mrpack') AND joining_project_type_id = (SELECT id FROM project_types WHERE name = 'modpack'); - -CREATE TABLE loaders_project_types_games ( - loader_id integer REFERENCES loaders NOT NULL, - project_type_id integer REFERENCES project_types NOT NULL, - game_id integer REFERENCES games NOT NULL, - PRIMARY KEY (loader_id, project_type_id, game_id) -); - --- all past loader_project_types are minecraft-java as the only game before this migration is minecraft-java -INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types; - --- Now that loaders are inferred, we can drop the project_type column from mods -ALTER TABLE mods DROP COLUMN project_type; - ALTER TABLE loaders ADD CONSTRAINT unique_loader_name UNIQUE (loader); CREATE TABLE loader_field_enums ( @@ -129,7 +94,7 @@ INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (2, 'game_version INSERT INTO loader_field_enum_values (original_id, enum_id, value, created, metadata) SELECT id, 2, version, created, json_build_object('type', type, 'major', major) FROM game_versions; -INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('game_versions', 'array_enum', 2, false, 1); +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('game_versions', 'array_enum', 2, false, 0); INSERT INTO version_fields(version_id, field_id, enum_value) SELECT gvv.joining_version_id, 2, lfev.id @@ -141,5 +106,46 @@ ALTER TABLE mods DROP COLUMN game_versions; DROP TABLE game_versions_versions; DROP TABLE game_versions; +-- Convert project types +-- we are creating a new loader type- 'mrpack'- for minecraft modpacks +INSERT INTO loaders (loader) VALUES ('mrpack'); + +-- For the loader 'mrpack', we create loader fields for every loader +-- That way we keep information like "this modpack is a fabric modpack" +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (3, 'mrpack_loaders', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value) SELECT id, 2, loader FROM loaders WHERE loader != 'mrpack'; +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('mrpack_loaders', 'array_enum', 3, false, 0); +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'mrpack_loaders' AND l.loader = 'mrpack'; + +INSERT INTO version_fields(version_id, field_id, enum_value) +SELECT v.id, lf.id, lfev.id +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loaders_versions lv ON v.id = lv.version_id +INNER JOIN loaders l ON lv.loader_id = l.id +CROSS JOIN loader_fields lf +LEFT JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id +WHERE m.project_type = (SELECT id FROM project_types WHERE name = 'modpack') AND lf.field = 'mrpack_loaders'; + +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) SELECT DISTINCT l.id, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader = 'mrpack'; + +--- Non-mrpack loaders no longer support modpacks +DELETE FROM loaders_project_types WHERE joining_loader_id != (SELECT id FROM loaders WHERE loader = 'mrpack') AND joining_project_type_id = (SELECT id FROM project_types WHERE name = 'modpack'); + +CREATE TABLE loaders_project_types_games ( + loader_id integer REFERENCES loaders NOT NULL, + project_type_id integer REFERENCES project_types NOT NULL, + game_id integer REFERENCES games NOT NULL, + PRIMARY KEY (loader_id, project_type_id, game_id) +); + +-- all past loader_project_types are minecraft-java as the only game before this migration is minecraft-java +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types; + +-- Now that loaders are inferred, we can drop the project_type column from mods +ALTER TABLE mods DROP COLUMN project_type; + + -- Drop original_id columns ALTER TABLE loader_field_enum_values DROP COLUMN original_id; \ No newline at end of file diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index c755d1c1..308ae691 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -650,14 +650,14 @@ impl VersionField { } let query_loader_fields: Vec = loader_fields - .and_then(|x| serde_json::from_value(x).unwrap()) + .and_then(|x| serde_json::from_value(x).ok()) .unwrap_or_default(); let query_version_field_combined: Vec = version_fields - .and_then(|x| serde_json::from_value(x).unwrap()) + .and_then(|x| serde_json::from_value(x).ok()) .unwrap_or_default(); let query_loader_field_enum_values: Vec = loader_field_enum_values - .and_then(|x| serde_json::from_value(x).unwrap()) + .and_then(|x| serde_json::from_value(x).ok()) .unwrap_or_default(); let version_id = VersionId(version_id); query_loader_fields @@ -918,9 +918,9 @@ impl VersionFieldValue { } } - // For conversion to an interanl string, such as for search facets or filtering + // For conversion to an interanl string(s), such as for search facets, filtering, or direct hardcoding // No matter the type, it will be converted to a Vec, whre the non-array types will have a single element - pub fn as_search_strings(&self) -> Vec { + pub fn as_strings(&self) -> Vec { match self { VersionFieldValue::Integer(i) => vec![i.to_string()], VersionFieldValue::Text(s) => vec![s.clone()], diff --git a/src/models/v2/projects.rs b/src/models/v2/projects.rs index cb8d436e..b5d9f967 100644 --- a/src/models/v2/projects.rs +++ b/src/models/v2/projects.rs @@ -66,15 +66,19 @@ pub struct LegacyProject { impl LegacyProject { // Convert from a standard V3 project to a V2 project // Requires any queried versions to be passed in, to get access to certain version fields contained within. - // It's safe to use a db version_item for this as the only info is side types and game versions, which used to be public on project anyway. + // - This can be any version, because the fields are ones that used to be on the project itself. + // - Its conceivable that certain V3 projects that have many different ones may not have the same fields on all of them. + // TODO: Should this return an error instead for v2 users? + // It's safe to use a db version_item for this as the only info is side types, game versions, and loader fields (for loaders), which used to be public on project anyway. pub fn from(data: Project, versions_item: Option) -> Self { let mut client_side = LegacySideType::Unknown; let mut server_side = LegacySideType::Unknown; let mut game_versions = Vec::new(); - // TODO: extract modpack changes - // - if loader is mrpack, this is a modpack - // the loaders are whatever the corresponding cateogires are + // V2 versions only have one project type- v3 versions can rarely have multiple. + // We'll just use the first one. + let mut project_type = data.project_types.get(0).cloned().unwrap_or_default(); + let mut loaders = data.loaders; if let Some(versions_item) = versions_item { client_side = versions_item @@ -104,11 +108,20 @@ impl LegacyProject { .and_then(|f| MinecraftGameVersion::try_from_version_field(f).ok()) .map(|v| v.into_iter().map(|v| v.version).collect()) .unwrap_or(Vec::new()); - } - // V2 projects only have one project type- v3 ones can rarely have multiple. - // We'll just use the first one. - let project_type = data.project_types.get(0).cloned().unwrap_or_default(); + // - if loader is mrpack, this is a modpack + // the loaders are whatever the corresponding loader fields are + if versions_item.loaders == vec!["mrpack".to_string()] { + project_type = "modpack".to_string(); + if let Some(mrpack_loaders) = versions_item + .version_fields + .iter() + .find(|f| f.field_name == "mrpack_loaders") + { + loaders = mrpack_loaders.value.as_strings(); + } + } + } Self { id: data.id, @@ -132,7 +145,7 @@ impl LegacyProject { followers: data.followers, categories: data.categories, additional_categories: data.additional_categories, - loaders: data.loaders, + loaders, versions: data.versions, icon_url: data.icon_url, issues_url: data.issues_url, @@ -251,6 +264,22 @@ impl From for LegacyVersion { } } + // - if loader is mrpack, this is a modpack + // the v2 loaders are whatever the corresponding loader fields are + let mut loaders = data.loaders.into_iter().map(|l| l.0).collect::>(); + if loaders == vec!["mrpack".to_string()] { + if let Some((_, mrpack_loaders)) = data + .fields + .into_iter() + .find(|(key, _)| key == "mrpack_loaders") + { + if let Ok(mrpack_loaders) = serde_json::from_value(mrpack_loaders) { + loaders = mrpack_loaders; + } + } + } + let loaders = loaders.into_iter().map(Loader).collect::>(); + Self { id: data.id, project_id: data.project_id, @@ -268,7 +297,7 @@ impl From for LegacyVersion { files: data.files, dependencies: data.dependencies, game_versions, - loaders: data.loaders, + loaders, } } } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 1d628eee..d60921b0 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -12,7 +12,6 @@ use crate::routes::{v2_reroute, v3}; use actix_multipart::Multipart; use actix_web::web::Data; use actix_web::{post, HttpRequest, HttpResponse}; -use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::postgres::PgPool; @@ -153,9 +152,6 @@ pub async fn project_create( let server_side = legacy_create.server_side; let project_type = legacy_create.project_type; - // Modpacks now use the "mrpack" loader, and loaders are converted to categories. - // Setting of 'project_type' directly is removed, it's loader-based now. - let mut additional_categories = legacy_create.additional_categories; let initial_versions = legacy_create .initial_versions @@ -166,8 +162,10 @@ pub async fn project_create( fields.insert("server_side".to_string(), json!(server_side)); fields.insert("game_versions".to_string(), json!(v.game_versions)); + // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. + // Setting of 'project_type' directly is removed, it's loader-based now. if project_type == "modpack" { - additional_categories.extend(v.loaders.iter().map(|l| l.0.clone())); + fields.insert("mrpack_loaders".to_string(), json!(v.loaders.clone())); } let loaders = if project_type == "modpack" { @@ -195,11 +193,6 @@ pub async fn project_create( }) .collect(); - let additional_categories = additional_categories - .into_iter() - .unique() - .collect::>(); - println!("additional_categories: {:?}", additional_categories); Ok(v3::project_creation::ProjectCreateData { title: legacy_create.title, slug: legacy_create.slug, @@ -207,7 +200,7 @@ pub async fn project_create( body: legacy_create.body, initial_versions, categories: legacy_create.categories, - additional_categories, + additional_categories: legacy_create.additional_categories, issues_url: legacy_create.issues_url, source_url: legacy_create.source_url, wiki_url: legacy_create.wiki_url, @@ -236,14 +229,9 @@ pub async fn project_create( ) .await?; - println!("did a little test <3"); // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { Ok(project) => { - println!( - "Just finished doing a project create, looking at repsonse: {:?}", - serde_json::to_string(&project).unwrap() - ); let version_item = match project.versions.first() { Some(vid) => version_item::Version::get((*vid).into(), &**client, &redis).await?, None => None, diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 1323ccb2..117fe6d0 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -99,7 +99,7 @@ pub async fn version_create( // Ideally this would, if the project 'should' be a modpack: // - change the loaders to mrpack only - // - add categories to the project for the corresponding loaders + // - add loader fields to the project for the corresponding loaders Ok(v3::version_creation::InitialVersionData { project_id: legacy_create.project_id, diff --git a/src/routes/v2_reroute.rs b/src/routes/v2_reroute.rs index 48ef7aee..7e2c16cf 100644 --- a/src/routes/v2_reroute.rs +++ b/src/routes/v2_reroute.rs @@ -102,7 +102,7 @@ where headers.insert(key, value); } Err(err) => { - panic!("Error inserting test header: {:?}.", err); + CreateError::InvalidInput(format!("Error inserting test header: {:?}.", err)); } }; diff --git a/src/routes/v3/analytics_get.rs b/src/routes/v3/analytics_get.rs index 5b971318..cc0529c5 100644 --- a/src/routes/v3/analytics_get.rs +++ b/src/routes/v3/analytics_get.rs @@ -340,7 +340,7 @@ pub async fn revenue_get( let duration: PgInterval = Duration::minutes(resolution_minutes as i64) .try_into() - .unwrap(); + .map_err(|_| ApiError::InvalidInput("Invalid resolution_minutes".to_string()))?; // Get the revenue data let payouts_values = sqlx::query!( " diff --git a/src/routes/v3/organizations.rs b/src/routes/v3/organizations.rs index 64eb1f74..a61e33b4 100644 --- a/src/routes/v3/organizations.rs +++ b/src/routes/v3/organizations.rs @@ -479,7 +479,6 @@ pub async fn organization_delete( redis: web::Data, session_queue: web::Data, ) -> Result { - println!("DELETE ORGANIZATION"); let user = get_user_from_headers( &req, &**pool, @@ -490,7 +489,6 @@ pub async fn organization_delete( .await? .1; let string = info.into_inner().0; - println!("string: {}", string); let organization = database::models::Organization::get(&string, &**pool, &redis) .await? @@ -498,7 +496,6 @@ pub async fn organization_delete( ApiError::InvalidInput("The specified organization does not exist!".to_string()) })?; - println!("organization: {:?}", organization); if !user.role.is_admin() { let team_member = database::models::TeamMember::get_from_user_id_organization( organization.id, diff --git a/src/routes/v3/projects.rs b/src/routes/v3/projects.rs index 1cc40a19..d35182aa 100644 --- a/src/routes/v3/projects.rs +++ b/src/routes/v3/projects.rs @@ -274,7 +274,6 @@ pub async fn project_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - println!("project_edit"); let user = get_user_from_headers( &req, &**pool, @@ -284,17 +283,13 @@ pub async fn project_edit( ) .await? .1; - println!("serde user {}", serde_json::to_string(&user)?); new_project .validate() .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; - println!("new project {}", serde_json::to_string(&new_project)?); let string = info.into_inner().0; - println!("string {}", string); let result = db_models::Project::get(&string, &**pool, &redis).await?; - println!("result {}", serde_json::to_string(&result)?); if let Some(project_item) = result { let id = project_item.inner.id; diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs index 7e9f3a32..96b666dd 100644 --- a/src/routes/v3/users.rs +++ b/src/routes/v3/users.rs @@ -676,10 +676,6 @@ pub async fn user_notifications( .collect(); notifications.sort_by(|a, b| b.created.cmp(&a.created)); - println!( - "notifications: {:?}", - serde_json::to_string(¬ifications).unwrap() - ); Ok(HttpResponse::Ok().json(notifications)) } else { Ok(HttpResponse::NotFound().body("")) diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index 49089848..3b3c80f8 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -101,7 +101,7 @@ pub async fn index_local( let version_fields = VersionField::from_query_json(m.id, m.loader_fields, m.version_fields, m.loader_field_enum_values); let loader_fields : HashMap> = version_fields.into_iter().map(|vf| { - (vf.field_name, vf.value.as_search_strings()) + (vf.field_name, vf.value.as_strings()) }).collect(); for v in loader_fields.keys().cloned() { @@ -121,6 +121,15 @@ pub async fn index_local( _ => false, }; + // SPECIAL BEHAVIOUR + // Todo: revisit. + // For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category. + // These were previously considered the loader, and in v2, the loader is a category for searching. + // So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories. + // The loaders are kept in loader_fields as well, so that no information is lost on retrieval. + let mrpack_loaders = loader_fields.get("mrpack_loaders").cloned().unwrap_or_default(); + categories.extend(mrpack_loaders); + UploadSearchProject { version_id: version_id.to_string(), project_id: project_id.to_string(), diff --git a/tests/common/api_v2/project.rs b/tests/common/api_v2/project.rs index acec20fd..0ac2c420 100644 --- a/tests/common/api_v2/project.rs +++ b/tests/common/api_v2/project.rs @@ -34,8 +34,6 @@ impl ApiV2 { let resp = self.call(req).await; assert_status(&resp, StatusCode::OK); - println!("Added body: {:?}", resp.response().body()); - // Approve as a moderator. let req = TestRequest::patch() .uri(&format!("/v2/project/{}", creation_data.slug)) diff --git a/tests/files/dummy_data.sql b/tests/files/dummy_data.sql index 6ab48457..1a26659e 100644 --- a/tests/files/dummy_data.sql +++ b/tests/files/dummy_data.sql @@ -25,8 +25,9 @@ INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) V INSERT INTO loaders (id, loader) VALUES (6, 'forge'); INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (6,1); -INSERT INTO categories (category, project_type) SELECT 'forge', id FROM project_types WHERE name = 'modpack'; -INSERT INTO categories (category, project_type) SELECT 'fabric', id FROM project_types WHERE name = 'modpack'; +-- Adds dummies to mrpack_loaders +INSERT INTO loader_field_enum_values (enum_id, value) SELECT id, 'fabric' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; +INSERT INTO loader_field_enum_values (enum_id, value) SELECT id, 'forge' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 5; INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 6; @@ -47,7 +48,6 @@ VALUES (2, '1.20.5', '{"type":"release","major":true}'); INSERT INTO loader_fields_loaders(loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'game_versions' OR lf.field = 'client_side' OR lf.field = 'server_side'; - INSERT INTO categories (id, category, project_type) VALUES (51, 'combat', 1), (52, 'decoration', 1), diff --git a/tests/project.rs b/tests/project.rs index b0982504..5bdf5890 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -5,15 +5,16 @@ use chrono::{Duration, Utc}; use common::environment::{with_test_environment, TestEnvironment}; use common::permissions::{PermissionsTest, PermissionsTestContext}; use futures::StreamExt; +use itertools::Itertools; use labrinth::database::models::project_item::{PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE}; use labrinth::models::ids::base62_impl::parse_base62; use labrinth::models::teams::ProjectPermissions; use labrinth::util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}; use serde_json::json; -use crate::common::database::*; +use crate::common::{database::*, request_data}; -use crate::common::dummy_data::DUMMY_CATEGORIES; +use crate::common::dummy_data::{TestFile, DUMMY_CATEGORIES}; // importing common module. mod common; @@ -278,6 +279,55 @@ async fn test_add_remove_project() { test_env.cleanup().await; } +#[actix_rt::test] +async fn test_project_type_sanity() { + let test_env = TestEnvironment::build(None).await; + let api = &test_env.v2; + + // Perform all other patch tests on both 'mod' and 'modpack' + let test_creation_mod = request_data::get_public_project_creation_data( + "test-mod", + Some(TestFile::build_random_jar()), + ); + let test_creation_modpack = request_data::get_public_project_creation_data( + "test-modpack", + Some(TestFile::build_random_mrpack()), + ); + for (mod_or_modpack, test_creation_data) in [ + ("mod", test_creation_mod), + ("modpack", test_creation_modpack), + ] { + let (test_project, test_version) = api + .add_public_project(test_creation_data, USER_USER_PAT) + .await; + let test_project_slug = test_project.slug.as_ref().unwrap(); + + assert_eq!(test_project.project_type, mod_or_modpack); + assert_eq!(test_project.loaders, vec!["fabric"]); + assert_eq!( + test_version[0].loaders.iter().map(|x| &x.0).collect_vec(), + vec!["fabric"] + ); + + let project = api + .get_project_deserialized(test_project_slug, USER_USER_PAT) + .await; + assert_eq!(test_project.loaders, vec!["fabric"]); + assert_eq!(project.project_type, mod_or_modpack); + + let version = api + .get_version_deserialized(&test_version[0].id.to_string(), USER_USER_PAT) + .await; + assert_eq!( + version.loaders.iter().map(|x| &x.0).collect_vec(), + vec!["fabric"] + ); + } + + // TODO: as we get more complicated strucures with v3 testing, and alpha/beta get more complicated, we should add more tests here, + // to ensure that projects created with v3 routes are still valid and work with v2 routes. +} + #[actix_rt::test] pub async fn test_patch_project() { let test_env = TestEnvironment::build(None).await; @@ -958,7 +1008,6 @@ async fn permissions_manage_invites() { #[actix_rt::test] async fn permissions_delete_project() { - println!("doing:"); // Add member, remove member, edit member let test_env = TestEnvironment::build(None).await; diff --git a/tests/search.rs b/tests/search.rs index 61d6bb5b..9e87ee18 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -234,7 +234,6 @@ async fn search_projects() { (json!([[r#"title:'Mysterious Project'"#]]), vec![2, 3]), (json!([["author:user"]]), vec![0, 1, 2, 4, 5, 7]), (json!([["versions:1.20.5"]]), vec![4, 5]), - // text search // bug fix ( json!([ @@ -245,6 +244,21 @@ async fn search_projects() { ]), vec![], ), + // Project type change + // Modpack should still be able to search based on former loader, even though technically the loader is 'mrpack' + (json!([["categories:mrpack"]]), vec![4]), + ( + json!([["categories:mrpack"], ["categories:fabric"]]), + vec![4], + ), + ( + json!([ + ["categories:mrpack"], + ["categories:fabric"], + ["project_type:modpack"] + ]), + vec![4], + ), ]; // TODO: versions, game versions // Untested: @@ -274,10 +288,6 @@ async fn search_projects() { .collect(); expected_project_ids.sort(); found_project_ids.sort(); - println!( - "facets: {:?}, expected: {:?}, found: {:?}", - facets, expected_project_ids, found_project_ids - ); assert_eq!(found_project_ids, expected_project_ids); } }) diff --git a/tests/tags.rs b/tests/tags.rs index 51f408ca..5563b635 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -57,9 +57,7 @@ async fn get_tags() { "optimization", "decoration", "mobs", - "magic", - "fabric", - "forge" + "magic" ] .iter() .map(|s| s.to_string()) From 68a92000e53696dd33898b6d21783e7d4a778c4e Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 8 Nov 2023 17:26:31 -0800 Subject: [PATCH 28/31] fmt, clippy, prepare --- ...99e41fbe8fcd836e926daf3e73aa3bb5552a6.json | 124 --------------- ...a6de53aeadacb33bd3134dae2d9c8f8a18651.json | 142 ----------------- ...0ae5753aaa3373c3ac9bc04adb3087781b49f.json | 148 ++++++++++++++++++ src/database/models/loader_fields.rs | 2 +- src/lib.rs | 3 +- src/queue/analytics.rs | 12 +- src/routes/v3/analytics_get.rs | 2 +- src/routes/v3/version_creation.rs | 2 +- src/routes/v3/versions.rs | 2 +- tests/common/api_v2/version.rs | 3 +- tests/common/asserts.rs | 5 +- 11 files changed, 161 insertions(+), 284 deletions(-) delete mode 100644 .sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json delete mode 100644 .sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json create mode 100644 .sqlx/query-f7aee6fbd3415c7819d9ae1a75a0ae5753aaa3373c3ac9bc04adb3087781b49f.json diff --git a/.sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json b/.sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json deleted file mode 100644 index bdf80942..00000000 --- a/.sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.ordering ASC NULLS LAST, v.date_published ASC;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "version_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "version_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "featured", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "ordering", - "type_info": "Int4" - }, - { - "ordinal": 13, - "name": "game_versions", - "type_info": "Jsonb" - }, - { - "ordinal": 14, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 15, - "name": "files", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "hashes", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "dependencies", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - true, - null, - null, - null, - null, - null - ] - }, - "hash": "1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6" -} diff --git a/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json b/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json deleted file mode 100644 index e57c1530..00000000 --- a/.sqlx/query-a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN loaders_project_types lpt on l.id = lpt.joining_loader_id\n LEFT JOIN project_types pt on lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg on l.id = lptg.loader_id AND pt.id = lptg.project_type_id\n LEFT JOIN games g on lptg.game_id = g.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "author_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "version_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "changelog", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "version_type", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "featured", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "requested_status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "loaders", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 14, - "name": "games", - "type_info": "VarcharArray" - }, - { - "ordinal": 15, - "name": "files", - "type_info": "Jsonb" - }, - { - "ordinal": 16, - "name": "hashes", - "type_info": "Jsonb" - }, - { - "ordinal": 17, - "name": "dependencies", - "type_info": "Jsonb" - }, - { - "ordinal": 18, - "name": "version_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 19, - "name": "loader_fields", - "type_info": "Jsonb" - }, - { - "ordinal": 20, - "name": "loader_field_enum_values", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "a377a9953cc222848dcad095cf4a6de53aeadacb33bd3134dae2d9c8f8a18651" -} diff --git a/.sqlx/query-f7aee6fbd3415c7819d9ae1a75a0ae5753aaa3373c3ac9bc04adb3087781b49f.json b/.sqlx/query-f7aee6fbd3415c7819d9ae1a75a0ae5753aaa3373c3ac9bc04adb3087781b49f.json new file mode 100644 index 00000000..d67b3518 --- /dev/null +++ b/.sqlx/query-f7aee6fbd3415c7819d9ae1a75a0ae5753aaa3373c3ac9bc04adb3087781b49f.json @@ -0,0 +1,148 @@ +{ + "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 ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.name) filter (where g.name is not null) games,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies,\n \n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'field_id', vf.field_id,\n 'int_value', vf.int_value,\n 'enum_value', vf.enum_value,\n 'string_value', vf.string_value\n )\n ) filter (where vf.field_id is not null) version_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'lf_id', lf.id,\n 'loader_name', l.loader,\n 'field', lf.field,\n 'field_type', lf.field_type,\n 'enum_type', lf.enum_type,\n 'min_val', lf.min_val,\n 'max_val', lf.max_val,\n 'optional', lf.optional\n )\n ) filter (where lf.id is not null) loader_fields,\n JSONB_AGG(\n DISTINCT jsonb_build_object(\n 'id', lfev.id,\n 'enum_id', lfev.enum_id,\n 'value', lfev.value,\n 'ordering', lfev.ordering,\n 'created', lfev.created,\n 'metadata', lfev.metadata\n ) \n ) filter (where lfev.id is not null) loader_field_enum_values\n \n FROM versions v\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN loaders_project_types lpt on l.id = lpt.joining_loader_id\n LEFT JOIN project_types pt on lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg on l.id = lptg.loader_id AND pt.id = lptg.project_type_id\n LEFT JOIN games g on lptg.game_id = g.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n LEFT OUTER JOIN version_fields vf on v.id = vf.version_id\n LEFT OUTER JOIN loader_fields lf on vf.field_id = lf.id\n LEFT OUTER JOIN loader_field_enums lfe on lf.enum_type = lfe.id\n LEFT OUTER JOIN loader_field_enum_values lfev on lfe.id = lfev.enum_id\n\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.ordering ASC NULLS LAST, v.date_published ASC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "changelog", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "version_type", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 13, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 14, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 15, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 16, + "name": "files", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "hashes", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "dependencies", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "version_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "loader_fields", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "loader_field_enum_values", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "f7aee6fbd3415c7819d9ae1a75a0ae5753aaa3373c3ac9bc04adb3087781b49f" +} diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 4de959a0..3dba4e03 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -228,7 +228,7 @@ pub enum VersionFieldValue { } #[derive(Clone, Serialize, Deserialize, Debug)] -pub struct QueryVersionField { +pub struct QueryVersionField { pub version_id: VersionId, pub field_id: LoaderFieldId, pub int_value: Option, diff --git a/src/lib.rs b/src/lib.rs index 61ff3807..0912e4e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,7 @@ use actix_web::web; use database::redis::RedisPool; use log::{info, warn}; use queue::{ - analytics::AnalyticsQueue, payouts::PayoutsQueue, session::AuthQueue, - socket::ActiveSockets, + analytics::AnalyticsQueue, payouts::PayoutsQueue, session::AuthQueue, socket::ActiveSockets, }; use scheduler::Scheduler; use sqlx::Postgres; diff --git a/src/queue/analytics.rs b/src/queue/analytics.rs index c3c8cfa3..e36e95e8 100644 --- a/src/queue/analytics.rs +++ b/src/queue/analytics.rs @@ -1,10 +1,10 @@ use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; use crate::models::analytics::{Download, PageView, Playtime}; use crate::routes::ApiError; use dashmap::{DashMap, DashSet}; use redis::cmd; use sqlx::PgPool; -use crate::database::redis::RedisPool; const DOWNLOADS_NAMESPACE: &str = "downloads"; @@ -151,8 +151,8 @@ impl AnalyticsQueue { WHERE id = ANY($1)", &version_ids ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; sqlx::query!( "UPDATE mods @@ -160,8 +160,8 @@ impl AnalyticsQueue { WHERE id = ANY($1)", &project_ids ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; transaction.commit().await?; downloads.end().await?; @@ -169,4 +169,4 @@ impl AnalyticsQueue { Ok(()) } -} \ No newline at end of file +} diff --git a/src/routes/v3/analytics_get.rs b/src/routes/v3/analytics_get.rs index 2eb468ec..dc31c69c 100644 --- a/src/routes/v3/analytics_get.rs +++ b/src/routes/v3/analytics_get.rs @@ -341,7 +341,7 @@ pub async fn revenue_get( // Round end_date up to nearest resolution let diff = end_date.timestamp() % (resolution_minutes as i64 * 60); let end_date = end_date + Duration::seconds((resolution_minutes as i64 * 60) - diff); - + // Convert String list to list of ProjectIds or VersionIds // - Filter out unauthorized projects/versions // - If no project_ids or version_ids are provided, we default to all projects the user has access to diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 264b36b5..524e0c45 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -79,7 +79,7 @@ pub struct InitialVersionData { pub uploaded_images: Vec, // The ordering relative to other versions pub ordering: Option, - + // Flattened loader fields // All other fields are loader-specific VersionFields // These are flattened during serialization diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index d3756cd5..b3504ee9 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -200,7 +200,7 @@ pub struct EditVersion { pub downloads: Option, pub status: Option, pub file_types: Option>, - + pub ordering: Option>, //TODO: How do you actually pass this in json? // Flattened loader fields diff --git a/tests/common/api_v2/version.rs b/tests/common/api_v2/version.rs index e2ac2743..eafef956 100644 --- a/tests/common/api_v2/version.rs +++ b/tests/common/api_v2/version.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use actix_http::{StatusCode, header::AUTHORIZATION}; +use actix_http::{header::AUTHORIZATION, StatusCode}; use actix_web::{ dev::ServiceResponse, test::{self, TestRequest}, @@ -327,7 +327,6 @@ impl ApiV2 { test::read_body_json(resp).await } - // TODO: remove redundancy in these functions pub async fn create_default_version( diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index 7263bf40..0c0d5464 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -8,10 +8,7 @@ pub fn assert_status(response: &actix_web::dev::ServiceResponse, status: actix_h assert_eq!(response.status(), status, "{:#?}", response.response()); } -pub fn assert_version_ids( - versions: &[LegacyVersion], - expected_ids: Vec, -) { +pub fn assert_version_ids(versions: &[LegacyVersion], expected_ids: Vec) { let version_ids = versions .iter() .map(|v| get_json_val_str(v.id)) From 9cde43afce5da353830de51867adb1b58014e14a Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 8 Nov 2023 19:55:33 -0800 Subject: [PATCH 29/31] fixed sorting bug --- src/database/models/version_item.rs | 2 +- src/routes/v3/version_file.rs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 9cff920b..efb0f17b 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -910,7 +910,7 @@ impl std::cmp::Ord for Version { }; match ordering_order { - Ordering::Equal => self.date_published.cmp(&other.date_published), + Ordering::Equal => other.date_published.cmp(&self.date_published), ordering => ordering, } } diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 47318d85..538704ff 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -160,10 +160,9 @@ pub async fn get_update_from_hash( } bool }) - .sorted() - .collect::>(); + .sorted(); - if let Some(first) = versions.pop() { + if let Some(first) = versions.next() { if !is_authorized_version(&first.inner, &user_option, &pool).await? { return Ok(HttpResponse::NotFound().body("")); } From 2b3264b7d8195e754f9fc810219872f1b642f184 Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 8 Nov 2023 20:12:28 -0800 Subject: [PATCH 30/31] reversed back- wrong order for consistency --- src/database/models/version_item.rs | 2 +- src/routes/v3/version_file.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index efb0f17b..9cff920b 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -910,7 +910,7 @@ impl std::cmp::Ord for Version { }; match ordering_order { - Ordering::Equal => other.date_published.cmp(&self.date_published), + Ordering::Equal => self.date_published.cmp(&other.date_published), ordering => ordering, } } diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index 538704ff..bc2b91a6 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -133,7 +133,7 @@ pub async fn get_update_from_hash( if let Some(project) = database::models::Project::get_id(file.project_id, &**pool, &redis).await? { - let mut versions = + let versions = database::models::Version::get_many(&project.versions, &**pool, &redis) .await? .into_iter() @@ -162,7 +162,7 @@ pub async fn get_update_from_hash( }) .sorted(); - if let Some(first) = versions.next() { + if let Some(first) = versions.last() { if !is_authorized_version(&first.inner, &user_option, &pool).await? { return Ok(HttpResponse::NotFound().body("")); } @@ -363,7 +363,7 @@ pub async fn update_files( bool }) .sorted() - .next(); + .last(); if let Some(version) = version { if is_authorized_version(&version.inner, &user_option, &pool).await? { @@ -477,7 +477,7 @@ pub async fn update_individual_files( bool }) .sorted() - .next(); + .last(); if let Some(version) = version { if is_authorized_version(&version.inner, &user_option, &pool).await? { From 6c513cc33a05bb38c87b7b7eb69cb3d22118bfc1 Mon Sep 17 00:00:00 2001 From: thesuzerain Date: Sat, 11 Nov 2023 16:02:57 -0800 Subject: [PATCH 31/31] fmt; clippy; prepare --- src/models/v3/pats.rs | 4 +-- src/routes/v2/project_creation.rs | 2 +- src/routes/v3/version_file.rs | 53 +++++++++++++++---------------- tests/common/api_v3/oauth.rs | 1 - 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/models/v3/pats.rs b/src/models/v3/pats.rs index 83f9b1c5..d4ef6e28 100644 --- a/src/models/v3/pats.rs +++ b/src/models/v3/pats.rs @@ -132,9 +132,7 @@ impl Scopes { } pub fn parse_from_oauth_scopes(scopes: &str) -> Result { - let scopes = scopes - .replace(['+', ' '], "|") - .replace("%20", "|"); + let scopes = scopes.replace(['+', ' '], "|").replace("%20", "|"); bitflags::parser::from_str(&scopes) } diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index a2eade1d..6e36de5c 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -165,7 +165,7 @@ pub async fn project_create( // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. // Setting of 'project_type' directly is removed, it's loader-based now. if project_type == "modpack" { - fields.insert("mrpack_loaders".to_string(), json!(v.loaders.clone())); + fields.insert("mrpack_loaders".to_string(), json!(v.loaders)); } let loaders = if project_type == "modpack" { diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index bc2b91a6..558cf5f9 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -133,34 +133,33 @@ pub async fn get_update_from_hash( if let Some(project) = database::models::Project::get_id(file.project_id, &**pool, &redis).await? { - let versions = - database::models::Version::get_many(&project.versions, &**pool, &redis) - .await? - .into_iter() - .filter(|x| { - let mut bool = true; - if let Some(version_types) = &update_data.version_types { - bool &= version_types - .iter() - .any(|y| y.as_str() == x.inner.version_type); - } - if let Some(loaders) = &update_data.loaders { - bool &= x.loaders.iter().any(|y| loaders.contains(y)); - } - if let Some(loader_fields) = &update_data.loader_fields { - for (key, values) in loader_fields { - bool &= if let Some(x_vf) = - x.version_fields.iter().find(|y| y.field_name == *key) - { - values.iter().any(|v| x_vf.value.contains_json_value(v)) - } else { - true - }; - } + let versions = database::models::Version::get_many(&project.versions, &**pool, &redis) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + if let Some(version_types) = &update_data.version_types { + bool &= version_types + .iter() + .any(|y| y.as_str() == x.inner.version_type); + } + if let Some(loaders) = &update_data.loaders { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + if let Some(loader_fields) = &update_data.loader_fields { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; } - bool - }) - .sorted(); + } + bool + }) + .sorted(); if let Some(first) = versions.last() { if !is_authorized_version(&first.inner, &user_option, &pool).await? { diff --git a/tests/common/api_v3/oauth.rs b/tests/common/api_v3/oauth.rs index ee78b5d9..1a6b35f4 100644 --- a/tests/common/api_v3/oauth.rs +++ b/tests/common/api_v3/oauth.rs @@ -114,7 +114,6 @@ pub fn generate_authorize_uri( optional_query_param("scope", scope), optional_query_param("state", state), ) - .to_string() } pub async fn get_authorize_accept_flow_id(response: ServiceResponse) -> String {