From d0d3927132ef02b7392e89f4e102c8734cf23a75 Mon Sep 17 00:00:00 2001 From: Fleeym <61891787+Fleeym@users.noreply.github.com> Date: Tue, 4 Feb 2025 22:45:16 +0200 Subject: [PATCH] feat(mods): rewrite mod updates --- src/database/repository/dependencies.rs | 74 ++++ src/database/repository/incompatibilities.rs | 77 ++++ src/database/repository/mod.rs | 4 + src/database/repository/mod_gd_versions.rs | 52 +++ src/database/repository/mod_tags.rs | 118 ++++++ .../repository/mod_version_statuses.rs | 25 ++ src/database/repository/mod_versions.rs | 359 +++++++++++++++++- src/database/repository/mods.rs | 115 +++++- src/endpoints/mod_versions.rs | 224 ++++++----- src/endpoints/mods.rs | 72 ++-- src/main.rs | 1 + src/mod_zip.rs | 141 +++++++ src/types/mod_json.rs | 118 +----- src/types/models/dependency.rs | 44 ++- src/types/models/incompatibility.rs | 40 +- src/types/models/mod_entity.rs | 186 +-------- src/types/models/mod_version.rs | 218 +---------- 17 files changed, 1226 insertions(+), 642 deletions(-) create mode 100644 src/database/repository/dependencies.rs create mode 100644 src/database/repository/incompatibilities.rs create mode 100644 src/database/repository/mod_gd_versions.rs create mode 100644 src/database/repository/mod_version_statuses.rs create mode 100644 src/mod_zip.rs diff --git a/src/database/repository/dependencies.rs b/src/database/repository/dependencies.rs new file mode 100644 index 0000000..3bd6813 --- /dev/null +++ b/src/database/repository/dependencies.rs @@ -0,0 +1,74 @@ +use sqlx::PgConnection; + +use crate::types::{ + api::ApiError, + mod_json::ModJson, + models::dependency::{DependencyImportance, FetchedDependency, ModVersionCompare}, +}; + +pub async fn create( + mod_version_id: i32, + json: &ModJson, + conn: &mut PgConnection, +) -> Result, ApiError> { + let dependencies = json.prepare_dependencies_for_create()?; + if dependencies.is_empty() { + return Ok(vec![]); + } + + let len = dependencies.len(); + let dependent_id = vec![mod_version_id; len]; + let mut dependency_id: Vec = Vec::with_capacity(len); + let mut version: Vec = Vec::with_capacity(len); + let mut compare: Vec = Vec::with_capacity(len); + let mut importance: Vec = Vec::with_capacity(len); + + for i in dependencies { + dependency_id.push(i.dependency_id); + version.push(i.version); + compare.push(i.compare); + importance.push(i.importance); + } + + sqlx::query_as!( + FetchedDependency, + r#"INSERT INTO dependencies + (dependent_id, dependency_id, version, compare, importance) + SELECT * FROM UNNEST( + $1::int4[], + $2::text[], + $3::text[], + $4::version_compare[], + $5::dependency_importance[] + ) + RETURNING + dependent_id as mod_version_id, + dependency_id, + version, + compare as "compare: _", + importance as "importance: _""#, + &dependent_id, + &dependency_id, + &version, + &compare as &[ModVersionCompare], + &importance as &[DependencyImportance] + ) + .fetch_all(conn) + .await + .inspect_err(|e| log::error!("Failed to insert dependencies: {}", e)) + .or(Err(ApiError::DbError)) +} + +pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), ApiError> { + sqlx::query!( + "DELETE FROM dependencies + WHERE dependent_id = $1", + id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to clear deps: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/database/repository/incompatibilities.rs b/src/database/repository/incompatibilities.rs new file mode 100644 index 0000000..4844f68 --- /dev/null +++ b/src/database/repository/incompatibilities.rs @@ -0,0 +1,77 @@ +use sqlx::PgConnection; + +use crate::types::{ + api::ApiError, + mod_json::ModJson, + models::{ + dependency::ModVersionCompare, + incompatibility::{FetchedIncompatibility, IncompatibilityImportance}, + }, +}; + +pub async fn create( + mod_version_id: i32, + json: &ModJson, + conn: &mut PgConnection, +) -> Result, ApiError> { + let incompats = json.prepare_incompatibilities_for_create()?; + if incompats.is_empty() { + return Ok(vec![]); + } + + let len = incompats.len(); + let mod_id = vec![mod_version_id; len]; + let mut incompatibility_id: Vec = Vec::with_capacity(len); + let mut version: Vec = Vec::with_capacity(len); + let mut compare: Vec = Vec::with_capacity(len); + let mut importance: Vec = Vec::with_capacity(len); + + for i in incompats { + incompatibility_id.push(i.incompatibility_id); + version.push(i.version); + compare.push(i.compare); + importance.push(i.importance); + } + + sqlx::query_as!( + FetchedIncompatibility, + r#"INSERT INTO incompatibilities + (mod_id, incompatibility_id, version, compare, importance) + SELECT * FROM UNNEST( + $1::int4[], + $2::text[], + $3::text[], + $4::version_compare[], + $5::incompatibility_importance[] + ) + RETURNING + mod_id, + incompatibility_id, + version, + compare as "compare: _", + importance as "importance: _""#, + &mod_id, + &incompatibility_id, + &version, + &compare as &[ModVersionCompare], + &importance as &[IncompatibilityImportance] + ) + .fetch_all(conn) + .await + .inspect_err(|e| log::error!("Failed to insert dependencies: {}", e)) + .or(Err(ApiError::DbError)) +} + +pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), ApiError> { + sqlx::query!( + "DELETE FROM incompatibilities + WHERE mod_id = $1", + id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to clear incompats: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index b1fe63f..4ca801c 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -1,9 +1,13 @@ pub mod auth_tokens; +pub mod dependencies; pub mod developers; pub mod github_login_attempts; pub mod github_web_logins; +pub mod incompatibilities; pub mod mod_downloads; +pub mod mod_gd_versions; pub mod mod_tags; +pub mod mod_version_statuses; pub mod mod_versions; pub mod mods; pub mod refresh_tokens; diff --git a/src/database/repository/mod_gd_versions.rs b/src/database/repository/mod_gd_versions.rs new file mode 100644 index 0000000..cc174dd --- /dev/null +++ b/src/database/repository/mod_gd_versions.rs @@ -0,0 +1,52 @@ +use sqlx::PgConnection; + +use crate::types::{ + api::ApiError, + mod_json::ModJson, + models::mod_gd_version::{DetailedGDVersion, GDVersionEnum, VerPlatform}, +}; + +pub async fn create( + mod_version_id: i32, + json: &ModJson, + conn: &mut PgConnection, +) -> Result { + let create = json.gd.to_create_payload(json); + + let gd: Vec = create.iter().map(|x| x.gd).collect(); + let platform: Vec = create.iter().map(|x| x.platform).collect(); + let mod_id = vec![mod_version_id; create.len()]; + + sqlx::query!( + "INSERT INTO mod_gd_versions + (gd, platform, mod_id) + SELECT * FROM UNNEST( + $1::gd_version[], + $2::gd_ver_platform[], + $3::int4[] + )", + &gd as &[GDVersionEnum], + &platform as &[VerPlatform], + &mod_id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to insert mod_gd_versions: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(json.gd.clone()) +} + +pub async fn clear(mod_version_id: i32, conn: &mut PgConnection) -> Result<(), ApiError> { + sqlx::query!( + "DELETE FROM mod_gd_versions mgv + WHERE mgv.mod_id = $1", + mod_version_id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to remove GD versions: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/database/repository/mod_tags.rs b/src/database/repository/mod_tags.rs index e61c133..82fe1f9 100644 --- a/src/database/repository/mod_tags.rs +++ b/src/database/repository/mod_tags.rs @@ -1,4 +1,5 @@ use crate::types::api::ApiError; +use crate::types::mod_json::ModJson; use crate::types::models::tag::Tag; use sqlx::PgConnection; @@ -28,3 +29,120 @@ pub async fn get_all(conn: &mut PgConnection) -> Result, ApiError> { Ok(tags) } + +pub async fn get_for_mod(id: &str, conn: &mut PgConnection) -> Result, ApiError> { + sqlx::query!( + "SELECT + id, + name, + display_name, + is_readonly + FROM mod_tags mt + INNER JOIN mods_mod_tags mmt ON mmt.tag_id = mt.id + WHERE mmt.mod_id = $1", + id + ) + .fetch_all(&mut *conn) + .await + .map_err(|e| { + log::error!("mod_tags::get_tags failed: {}", e); + ApiError::DbError + }) + .map(|vec| { + vec.into_iter() + .map(|i| Tag { + id: i.id, + display_name: i.display_name.unwrap_or(i.name.clone()), + name: i.name, + is_readonly: i.is_readonly, + }) + .collect() + }) +} + +pub async fn parse_tag_list( + tags: &[String], + conn: &mut PgConnection, +) -> Result, ApiError> { + if tags.is_empty() { + return Ok(vec![]); + } + + let db_tags = get_all(conn).await?; + + let mut ret = Vec::new(); + for tag in tags { + if let Some(t) = db_tags.iter().find(|t| t.name == *tag) { + ret.push(t.clone()); + } else { + return Err(ApiError::BadRequest(format!( + "Tag '{}' isn't allowed. Only the following are allowed: '{}'", + tag, + db_tags + .into_iter() + .map(|t| t.name) + .collect::>() + .join(", ") + ))); + } + } + + Ok(ret) +} + +pub async fn update_for_mod( + id: &str, + tags: &[Tag], + conn: &mut PgConnection, +) -> Result<(), ApiError> { + let existing = get_for_mod(id, &mut *conn).await?; + + let insertable = tags + .iter() + .filter(|t| !existing.iter().any(|e| e.id == t.id)) + .map(|x| x.id) + .collect::>(); + + let deletable = existing + .iter() + .filter(|e| !tags.iter().any(|t| e.id == t.id)) + .map(|x| x.id) + .collect::>(); + + if !deletable.is_empty() { + sqlx::query!( + "DELETE FROM mods_mod_tags + WHERE mod_id = $1 + AND tag_id = ANY($2)", + id, + &deletable + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to remove tags: {}", e)) + .or(Err(ApiError::DbError))?; + } + + if insertable.is_empty() { + return Ok(()); + } + + let mod_id = vec![id.into(); insertable.len()]; + + sqlx::query!( + "INSERT INTO mods_mod_tags + (mod_id, tag_id) + SELECT * FROM UNNEST( + $1::text[], + $2::int4[] + )", + &mod_id, + &insertable + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to insert tags: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/database/repository/mod_version_statuses.rs b/src/database/repository/mod_version_statuses.rs new file mode 100644 index 0000000..6777305 --- /dev/null +++ b/src/database/repository/mod_version_statuses.rs @@ -0,0 +1,25 @@ +use sqlx::PgConnection; + +use crate::types::{api::ApiError, models::mod_version_status::ModVersionStatusEnum}; + +pub async fn create( + mod_version_id: i32, + status: ModVersionStatusEnum, + info: Option, + conn: &mut PgConnection, +) -> Result { + sqlx::query!( + "INSERT INTO mod_version_statuses + (mod_version_id, status, info, admin_id) + VALUES ($1, $2, $3, NULL) + RETURNING id", + mod_version_id, + status as ModVersionStatusEnum, + info + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("Failed to create status: {}", e)) + .or(Err(ApiError::DbError)) + .map(|i| i.id) +} diff --git a/src/database/repository/mod_versions.rs b/src/database/repository/mod_versions.rs index 1d3be63..7ab80a8 100644 --- a/src/database/repository/mod_versions.rs +++ b/src/database/repository/mod_versions.rs @@ -1,6 +1,121 @@ -use crate::types::api::ApiError; +use crate::types::{ + api::ApiError, + mod_json::ModJson, + models::{ + developer::Developer, mod_version::ModVersion, mod_version_status::ModVersionStatusEnum, + }, +}; +use chrono::{DateTime, SecondsFormat, Utc}; use sqlx::PgConnection; +use super::mod_version_statuses; + +#[derive(sqlx::FromRow)] +struct ModVersionRow { + id: i32, + name: String, + description: Option, + version: String, + download_link: String, + download_count: i32, + hash: String, + geode: String, + early_load: bool, + api: bool, + mod_id: String, + status: ModVersionStatusEnum, + created_at: Option>, + updated_at: Option>, + #[sqlx(default)] + info: Option, +} + +impl ModVersionRow { + pub fn into_mod_version(self) -> ModVersion { + ModVersion { + id: self.id, + name: self.name, + description: self.description, + version: self.version, + download_link: self.download_link, + hash: self.hash, + geode: self.geode, + early_load: self.early_load, + download_count: self.download_count, + api: self.api, + mod_id: self.mod_id, + status: self.status, + gd: Default::default(), + developers: None, + tags: None, + dependencies: None, + incompatibilities: None, + info: self.info, + direct_download_link: None, + created_at: self + .created_at + .map(|x| x.to_rfc3339_opts(SecondsFormat::Secs, true)), + updated_at: self + .updated_at + .map(|x| x.to_rfc3339_opts(SecondsFormat::Secs, true)), + } + } +} + +pub async fn get_by_version_str( + mod_id: &str, + version: &str, + conn: &mut PgConnection, +) -> Result, ApiError> { + sqlx::query_as!( + ModVersionRow, + r#"SELECT + mv.id, mv.name, mv.description, mv.version, + mv.download_link, mv.download_count, mv.hash, + mv.geode, mv.early_load, mv.api, mv.mod_id, + mv.created_at, mv.updated_at, + mvs.status as "status: _", mvs.info + FROM mod_versions mv + INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id + WHERE mv.mod_id = $1 + AND mv.version = $2"#, + mod_id, + version + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("{}", e)) + .or(Err(ApiError::DbError)) + .map(|opt| opt.map(|x| x.into_mod_version())) +} + +pub async fn get_for_mod( + mod_id: &str, + statuses: Option<&[ModVersionStatusEnum]>, + conn: &mut PgConnection, +) -> Result, ApiError> { + sqlx::query_as( + r#"SELECT + mv.id, mv.name, mv.description, mv.version, + mv.download_link, mv.download_count, mv.hash, + mv.geode, mv.early_load, mv.api, mv.mod_id, + mv.created_at, mv.updated_at, + mvs.status as "status: _", mvs.info + FROM mod_versions mv + INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id + WHERE mv.mod_id = $1 + AND ($2 IS NULL OR mvs.status = ANY($2)) + ORDER BY mv.id DESC"#, + ) + .bind(mod_id) + .bind(statuses) + .fetch_all(conn) + .await + .inspect_err(|e| log::error!("{}", e)) + .or(Err(ApiError::DbError)) + .map(|opt: Vec| opt.into_iter().map(|x| x.into_mod_version()).collect()) +} + pub async fn increment_downloads(id: i32, conn: &mut PgConnection) -> Result<(), ApiError> { sqlx::query!( "UPDATE mod_versions @@ -21,3 +136,245 @@ pub async fn increment_downloads(id: i32, conn: &mut PgConnection) -> Result<(), Ok(()) } + +pub async fn create_from_json( + json: &ModJson, + make_accepted: bool, + conn: &mut PgConnection, +) -> Result { + sqlx::query!("SET CONSTRAINTS mod_versions_status_id_fkey DEFERRED") + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to update constraint: {}", e)) + .or(Err(ApiError::DbError))?; + + let row = sqlx::query!( + "INSERT INTO mod_versions + (name, version, description, download_link, + hash, geode, early_load, api, mod_id, status_id, + created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, + NOW(), NOW()) + RETURNING + id, name, version, description, + download_link, hash, geode, + early_load, api, mod_id, + created_at, updated_at", + json.name, + json.version, + json.description, + json.download_url, + json.hash, + json.geode, + json.early_load, + json.api.is_some(), + json.id + ) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to insert mod_version: {}", e)) + .or(Err(ApiError::DbError))?; + + let id = row.id; + + let status = match make_accepted { + true => ModVersionStatusEnum::Accepted, + false => ModVersionStatusEnum::Pending, + }; + + let status_id = mod_version_statuses::create(id, status, None, conn).await?; + sqlx::query!( + "UPDATE mod_versions SET status_id = $1 WHERE id = $2", + status_id, + id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to set status: {}", e)) + .or(Err(ApiError::DbError))?; + + sqlx::query!("SET CONSTRAINTS mod_versions_status_id_fkey IMMEDIATE") + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to update constraint: {}", e)) + .or(Err(ApiError::DbError))?; + + Ok(ModVersion { + id, + name: row.name, + description: row.description, + version: row.version, + download_link: row.download_link, + hash: row.hash, + geode: row.geode, + download_count: 0, + early_load: row.early_load, + api: row.api, + mod_id: row.mod_id, + gd: Default::default(), + status, + dependencies: Default::default(), + incompatibilities: Default::default(), + developers: Default::default(), + tags: Default::default(), + created_at: row + .created_at + .map(|i| i.to_rfc3339_opts(SecondsFormat::Secs, true)), + updated_at: row + .updated_at + .map(|i| i.to_rfc3339_opts(SecondsFormat::Secs, true)), + info: None, + direct_download_link: None, + }) +} + +pub async fn update_pending_version( + version_id: i32, + json: &ModJson, + make_accepted: bool, + conn: &mut PgConnection, +) -> Result { + let row = sqlx::query!( + "UPDATE mod_versions mv + SET name = $1, + version = $2, + download_link = $3, + hash = $4, + geode = $5, + early_load = $6, + api = $7, + description = $8, + updated_at = NOW() + FROM mod_version_statuses mvs + WHERE mv.status_id = mvs.id + AND mvs.status = 'pending' + AND mv.id = $9 + RETURNING mv.id, + name, + version, + download_link, + download_count, + hash, + geode, + early_load, + api, + status_id, + description, + mod_id, + mv.created_at, + mv.updated_at", + &json.name, + &json.version, + &json.download_url, + &json.hash, + &json.geode, + &json.early_load, + &json.api.is_some(), + json.description.clone().unwrap_or_default(), + version_id + ) + .fetch_one(&mut *conn) + .await + .map_err(|err| { + log::error!( + "Failed to update pending version {}-{}: {}", + json.id, + json.version, + err + ); + ApiError::DbError + })?; + + if make_accepted { + sqlx::query!( + "UPDATE mod_version_statuses + SET status = 'accepted' + WHERE id = $1", + row.status_id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to update tag for mod: {}", e)) + .or(Err(ApiError::DbError))?; + } + + Ok(ModVersion { + id: version_id, + name: row.name, + description: row.description, + version: row.version, + download_link: row.download_link, + hash: row.hash, + geode: row.geode, + download_count: row.download_count, + early_load: row.early_load, + api: row.api, + mod_id: row.mod_id, + gd: Default::default(), + status: match make_accepted { + true => ModVersionStatusEnum::Accepted, + false => ModVersionStatusEnum::Pending, + }, + dependencies: Default::default(), + incompatibilities: Default::default(), + developers: Default::default(), + tags: Default::default(), + created_at: row + .created_at + .map(|i| i.to_rfc3339_opts(SecondsFormat::Secs, true)), + updated_at: row + .updated_at + .map(|i| i.to_rfc3339_opts(SecondsFormat::Secs, true)), + info: None, + direct_download_link: None, + }) +} + +pub async fn update_version_status( + mut version: ModVersion, + status: ModVersionStatusEnum, + info: Option<&str>, + updated_by: &Developer, + conn: &mut PgConnection, +) -> Result { + if version.status == status { + return Ok(version); + } + + sqlx::query!( + "UPDATE mod_version_statuses + SET status = $1, + admin_id = $2, + info = $3, + updated_at = NOW() + WHERE mod_version_id = $4", + status as ModVersionStatusEnum, + updated_by.id, + info, + version.id + ) + .execute(&mut *conn) + .await + .inspect_err(|e| log::error!("{}", e)) + .or(Err(ApiError::DbError))?; + + version.status = status; + touch_updated_at(version.id, &mut *conn).await?; + + Ok(version) +} + +pub async fn touch_updated_at(id: i32, conn: &mut PgConnection) -> Result<(), ApiError> { + sqlx::query!( + "UPDATE mod_versions + SET updated_at = NOW() + WHERE id = $1", + id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to touch updated_at for mod version {}: {}", id, e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/database/repository/mods.rs b/src/database/repository/mods.rs index d165442..16233c7 100644 --- a/src/database/repository/mods.rs +++ b/src/database/repository/mods.rs @@ -1,6 +1,75 @@ -use crate::types::api::ApiError; +use crate::types::{api::ApiError, mod_json::ModJson, models::mod_entity::Mod}; +use chrono::{DateTime, SecondsFormat, Utc}; use sqlx::PgConnection; +#[derive(sqlx::FromRow)] +struct ModRecordGetOne { + id: String, + repository: Option, + featured: bool, + download_count: i32, + #[sqlx(default)] + about: Option, + #[sqlx(default)] + changelog: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl ModRecordGetOne { + pub fn into_mod(self) -> Mod { + Mod { + id: self.id, + repository: self.repository, + featured: self.featured, + download_count: self.download_count, + versions: Default::default(), + tags: Default::default(), + developers: Default::default(), + created_at: self.created_at.to_rfc3339_opts(SecondsFormat::Secs, true), + updated_at: self.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true), + about: self.about.clone(), + changelog: self.changelog.clone(), + links: None, + } + } +} + +/// Doesn't fetch about.md or changelog.md since those could be big files +pub async fn get_one(id: &str, conn: &mut PgConnection) -> Result, ApiError> { + sqlx::query_as!( + ModRecordGetOne, + "SELECT + m.id, m.repository, NULL as about, NULL as changelog, m.featured, + m.download_count, m.created_at, m.updated_at + FROM mods m + WHERE id = $1", + id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("Failed to fetch mod {}: {}", id, e)) + .or(Err(ApiError::DbError)) + .map(|x| x.map(|x| x.into_mod())) +} + +pub async fn get_one_with_md(id: &str, conn: &mut PgConnection) -> Result, ApiError> { + sqlx::query_as!( + ModRecordGetOne, + "SELECT + m.id, m.repository, m.about, m.changelog, m.featured, + m.download_count, m.created_at, m.updated_at + FROM mods m + WHERE id = $1", + id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("Failed to fetch mod {}: {}", id, e)) + .or(Err(ApiError::DbError)) + .map(|x| x.map(|x| x.into_mod())) +} + pub async fn is_featured(id: &str, conn: &mut PgConnection) -> Result { Ok(sqlx::query!("SELECT featured FROM mods WHERE id = $1", id) .fetch_optional(&mut *conn) @@ -71,3 +140,47 @@ pub async fn increment_downloads(id: &str, conn: &mut PgConnection) -> Result<() Ok(()) } + +pub async fn update_with_json( + mut the_mod: Mod, + json: ModJson, + conn: &mut PgConnection, +) -> Result { + sqlx::query!( + "UPDATE mods + SET repository = $1, + about = $2, + changelog = $3, + image = $4, + updated_at = NOW()", + json.repository, + json.about, + json.changelog, + json.logo + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to update mod: {}", e)) + .or(Err(ApiError::DbError))?; + + the_mod.repository = json.repository; + the_mod.about = json.about; + the_mod.changelog = json.changelog; + + Ok(the_mod) +} + +pub async fn touch_updated_at(id: &str, conn: &mut PgConnection) -> Result<(), ApiError> { + sqlx::query!( + "UPDATE mods + SET updated_at = NOW() + WHERE id = $1", + id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("Failed to touch updated_at for mod {}: {}", id, e)) + .or(Err(ApiError::DbError))?; + + Ok(()) +} diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 558c0f8..10c9fe2 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -5,10 +5,14 @@ use serde::Deserialize; use sqlx::{types::ipnetwork::IpNetwork, Acquire}; use crate::config::AppData; -use crate::database::repository::{developers, mod_downloads, mod_versions, mods}; +use crate::database::repository::{ + dependencies, developers, incompatibilities, mod_downloads, mod_gd_versions, mod_tags, + mod_versions, mods, +}; use crate::events::mod_created::{ NewModAcceptedEvent, NewModVersionAcceptedEvent, NewModVersionVerification, }; +use crate::mod_zip::{self, download_mod}; use crate::webhook::discord::DiscordWebhook; use crate::{ extractors::auth::Auth, @@ -16,7 +20,6 @@ use crate::{ api::{ApiError, ApiResponse}, mod_json::{split_version_and_compare, ModJson}, models::{ - mod_entity::{download_geode_file, Mod}, mod_gd_version::{GDVersionEnum, VerPlatform}, mod_version::{self, ModVersion}, mod_version_status::ModVersionStatusEnum, @@ -46,11 +49,6 @@ struct UpdatePayload { info: Option, } -#[derive(Deserialize)] -pub struct CreateVersionPath { - id: String, -} - #[derive(Deserialize)] struct UpdateVersionPath { id: String, @@ -240,7 +238,7 @@ pub async fn download_version( #[post("v1/mods/{id}/versions")] pub async fn create_version( - path: web::Path, + path: web::Path, data: web::Data, payload: web::Json, auth: Auth, @@ -252,16 +250,39 @@ pub async fn create_version( .await .or(Err(ApiError::DbAcquireError))?; - let fetched_mod = Mod::get_one(&path.id, false, &mut pool).await?; + let id = path.into_inner(); - if fetched_mod.is_none() { - return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); - } + let the_mod = mods::get_one(&id, &mut pool) + .await? + .ok_or(ApiError::NotFound(format!("Mod {} not found", &id)))?; - if !(developers::has_access_to_mod(dev.id, &path.id, &mut pool).await?) { + if !(developers::has_access_to_mod(dev.id, &the_mod.id, &mut pool).await?) { return Err(ApiError::Forbidden); } + let versions = mod_versions::get_for_mod( + &the_mod.id, + Some(&[ + ModVersionStatusEnum::Accepted, + ModVersionStatusEnum::Pending, + ModVersionStatusEnum::Unlisted, + ]), + &mut pool, + ) + .await?; + + let accepted_versions = versions + .iter() + .filter(|i| { + i.status == ModVersionStatusEnum::Accepted || i.status == ModVersionStatusEnum::Unlisted + }) + .count(); + + let verified = match accepted_versions { + 0 => false, + _ => dev.verified, + }; + // remove invalid characters from link - they break the location header on download let download_link: String = payload .download_link @@ -269,47 +290,75 @@ pub async fn create_version( .filter(|c| c.is_ascii() && *c != '\0') .collect(); - let mut file_path = download_geode_file(&download_link, data.max_download_mb()).await?; - let json = ModJson::from_zip( - &mut file_path, - &download_link, - dev.verified, - data.max_download_mb(), - ) - .map_err(|err| { + let bytes = download_mod(&download_link, data.max_download_mb()).await?; + let json = ModJson::from_zip(bytes, &download_link, verified).map_err(|err| { log::error!("Failed to parse mod.json: {}", err); ApiError::FilesystemError })?; - if json.id != path.id { + if json.id != the_mod.id { return Err(ApiError::BadRequest(format!( "Request id {} does not match mod.json id {}", - path.id, json.id + the_mod.id, json.id ))); } json.validate()?; - let mut transaction = pool.begin().await.or(Err(ApiError::TransactionError))?; - if let Err(e) = Mod::new_version(&json, &dev, &mut transaction).await { - transaction - .rollback() - .await - .or(Err(ApiError::TransactionError))?; - return Err(e); + + let mut tx = pool.begin().await.or(Err(ApiError::TransactionError))?; + + let mut version: ModVersion = if versions.is_empty() { + mod_versions::create_from_json(&json, verified, &mut tx).await? + } else { + let latest = versions.first().unwrap(); + let latest_version = semver::Version::parse(&latest.version) + .inspect_err(|e| log::error!("Failed to parse locally stored version: {}", e)) + .or(Err(ApiError::InternalError))?; + let new_version = semver::Version::parse(json.version.trim_start_matches('v')).or(Err( + ApiError::BadRequest(format!("Invalid mod.json version: {}", json.version)), + ))?; + + if new_version <= latest_version { + return Err(ApiError::BadRequest(format!( + "mod.json version {} is smaller / equal to latest mod version {}", + json.version, latest_version + ))); + } + + if latest.status == ModVersionStatusEnum::Pending { + // clear everything and update the version + dependencies::clear(latest.id, &mut tx).await?; + incompatibilities::clear(latest.id, &mut tx).await?; + mod_gd_versions::clear(latest.id, &mut tx).await?; + mod_versions::update_pending_version(latest.id, &json, verified, &mut tx).await? + } else { + mod_versions::create_from_json(&json, verified, &mut tx).await? + } + }; + + version.gd = mod_gd_versions::create(version.id, &json, &mut tx).await?; + dependencies::create(version.id, &json, &mut tx).await?; + incompatibilities::create(version.id, &json, &mut tx).await?; + + if verified || accepted_versions == 0 { + if let Some(tags) = &json.tags { + if !tags.is_empty() { + let tags = mod_tags::parse_tag_list(tags, &mut tx).await?; + mod_tags::update_for_mod(&the_mod.id, &tags, &mut tx).await?; + } + } + + mods::update_with_json(the_mod, json, &mut tx).await?; } - transaction - .commit() - .await - .or(Err(ApiError::TransactionError))?; - let approved_count = ModVersion::get_accepted_count(&json.id, &mut pool).await?; + tx.commit().await.or(Err(ApiError::TransactionError))?; - if dev.verified && approved_count != 0 { - let owner = developers::get_owner_for_mod(&json.id, &mut pool).await?; + if verified { + let owner = developers::get_owner_for_mod(&version.mod_id, &mut pool).await?; NewModVersionAcceptedEvent { - id: json.id.clone(), - name: json.name.clone(), - version: json.version.clone(), + id: version.mod_id.clone(), + name: version.name.clone(), + version: version.version.clone(), owner, verified: NewModVersionVerification::VerifiedDev, base_url: data.app_url().to_string(), @@ -328,62 +377,67 @@ pub async fn update_version( auth: Auth, ) -> Result { let dev = auth.developer()?; - if !dev.admin { - return Err(ApiError::Forbidden); - } + let mut pool = data .db() .acquire() .await .or(Err(ApiError::DbAcquireError))?; - let version = ModVersion::get_one( - path.id.as_str(), - path.version.as_str(), - false, - false, - &mut pool, - ) - .await?; + + let the_mod = mods::get_one(&path.id, &mut pool) + .await? + .ok_or(ApiError::NotFound(format!("Mod {} not found", path.id)))?; + + if !dev.admin && !developers::has_access_to_mod(dev.id, &the_mod.id, &mut pool).await? { + return Err(ApiError::Forbidden); + } + + let version = mod_versions::get_by_version_str(&the_mod.id, &path.version, &mut pool) + .await? + .ok_or(ApiError::NotFound(format!( + "Version {} not found", + path.version + )))?; + + if version.status == payload.status { + return Ok(HttpResponse::NoContent()); + } + + if payload.status == ModVersionStatusEnum::Pending { + return Err(ApiError::BadRequest( + "Cannot change version status to pending".into(), + )); + } + let approved_count = ModVersion::get_accepted_count(version.mod_id.as_str(), &mut pool).await?; - let mut transaction = pool.begin().await.or(Err(ApiError::TransactionError))?; - let id = match sqlx::query!( - "select id from mod_versions where mod_id = $1 and version = $2", - &path.id, - path.version.trim_start_matches('v') - ) - .fetch_optional(&mut *transaction) - .await - { - Ok(Some(id)) => id.id, - Ok(None) => { - return Err(ApiError::NotFound(String::from("Not Found"))); - } - Err(e) => { - log::error!("{}", e); - return Err(ApiError::DbError); - } - }; + let mut tx = pool.begin().await.or(Err(ApiError::TransactionError))?; - if let Err(e) = ModVersion::update_version( - id, + let old_status = version.status; + let version = mod_versions::update_version_status( + version, payload.status, - payload.info.clone(), - dev.id, - data.max_download_mb(), - &mut transaction, + payload.info.as_deref(), + &dev, + &mut tx, ) - .await + .await?; + + if old_status == ModVersionStatusEnum::Pending + && version.status == ModVersionStatusEnum::Accepted { - transaction - .rollback() - .await - .or(Err(ApiError::TransactionError))?; - return Err(e); + let bytes = mod_zip::download_mod_hash_comp( + &version.download_link, + &version.hash, + data.max_download_mb(), + ) + .await?; + + let json = ModJson::from_zip(bytes, &version.download_link, true)?; + + mods::update_with_json(the_mod, json, &mut tx).await?; } - transaction - .commit() - .await - .or(Err(ApiError::TransactionError))?; + + tx.commit().await.or(Err(ApiError::TransactionError))?; if payload.status == ModVersionStatusEnum::Accepted { let is_update = approved_count > 0; diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 4bd2d20..8d74957 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -1,12 +1,16 @@ +use std::thread::ThreadId; + use crate::config::AppData; use crate::database::repository::developers; +use crate::database::repository::mod_tags; use crate::database::repository::mods; use crate::events::mod_feature::ModFeaturedEvent; use crate::extractors::auth::Auth; +use crate::mod_zip; use crate::types::api::{create_download_link, ApiError, ApiResponse}; use crate::types::mod_json::ModJson; use crate::types::models::incompatibility::Incompatibility; -use crate::types::models::mod_entity::{download_geode_file, Mod, ModUpdate}; +use crate::types::models::mod_entity::{Mod, ModUpdate}; use crate::types::models::mod_gd_version::{GDVersionEnum, VerPlatform}; use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::webhook::discord::DiscordWebhook; @@ -98,19 +102,34 @@ pub async fn get( _ => false, }; - let found = Mod::get_one(&id, false, &mut pool).await?; - match found { - Some(mut m) => { - for i in &mut m.versions { - i.modify_metadata(data.app_url(), has_extended_permissions); - } - Ok(web::Json(ApiResponse { - error: "".into(), - payload: m, - })) - } - None => Err(ApiError::NotFound(format!("Mod '{id}' not found"))), - } + let mut the_mod = mods::get_one_with_md(&id, &mut pool) + .await? + .ok_or(ApiError::NotFound(format!("Mod '{id}' not found")))?; + + the_mod.tags = mod_tags::get_for_mod(&the_mod.id, &mut pool) + .await? + .into_iter() + .map(|t| t.name) + .collect(); + the_mod.developers = developers::get_all_for_mod(&the_mod.id, &mut pool).await?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: the_mod, + })) + // let found = Mod::get_one(&id, false, &mut pool).await?; + // match found { + // Some(mut m) => { + // for i in &mut m.versions { + // i.modify_metadata(data.app_url(), has_extended_permissions); + // } + // Ok(web::Json(ApiResponse { + // error: "".into(), + // payload: m, + // })) + // } + // None => Err(), + // } } #[post("/v1/mods")] @@ -125,27 +144,12 @@ pub async fn create( .acquire() .await .or(Err(ApiError::DbAcquireError))?; - let mut file_path = download_geode_file(&payload.download_link, data.max_download_mb()).await?; - let json = ModJson::from_zip( - &mut file_path, - &payload.download_link, - false, - data.max_download_mb(), - )?; + let bytes = mod_zip::download_mod(&payload.download_link, data.max_download_mb()).await?; + let json = ModJson::from_zip(bytes, &payload.download_link, false)?; json.validate()?; - let mut transaction = pool.begin().await.or(Err(ApiError::TransactionError))?; - let result = Mod::from_json(&json, dev.clone(), &mut transaction).await; - if result.is_err() { - transaction - .rollback() - .await - .or(Err(ApiError::TransactionError))?; - return Err(result.err().unwrap()); - } - transaction - .commit() - .await - .or(Err(ApiError::TransactionError))?; + let mut tx = pool.begin().await.or(Err(ApiError::TransactionError))?; + Mod::from_json(&json, dev.clone(), &mut tx).await?; + tx.commit().await.or(Err(ApiError::TransactionError))?; Ok(HttpResponse::NoContent()) } diff --git a/src/main.rs b/src/main.rs index 4bcc464..c3fd22c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod endpoints; mod events; mod extractors; mod jobs; +mod mod_zip; mod types; mod webhook; diff --git a/src/mod_zip.rs b/src/mod_zip.rs new file mode 100644 index 0000000..d5ab2af --- /dev/null +++ b/src/mod_zip.rs @@ -0,0 +1,141 @@ +use std::io::Seek; +use std::io::{BufReader, Cursor, Read}; + +use actix_web::web::Bytes; +use image::codecs::png::PngDecoder; +use image::codecs::png::PngEncoder; +use image::ImageEncoder; +use image::{DynamicImage, GenericImageView}; +use zip::read::ZipFile; +use zip::ZipArchive; + +use crate::types::api::ApiError; + +pub fn extract_mod_logo(file: &mut ZipFile) -> Result, ApiError> { + let mut logo: Vec = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut logo) + .inspect_err(|e| log::error!("logo.png read fail: {}", e)) + .or(Err(ApiError::BadRequest("Couldn't read logo.png".into())))?; + + let mut reader = BufReader::new(Cursor::new(logo)); + + let mut img = PngDecoder::new(&mut reader) + .and_then(DynamicImage::from_decoder) + .inspect_err(|e| log::error!("Failed to create PngDecoder: {}", e)) + .or(Err(ApiError::BadRequest("Invalid logo.png".into())))?; + + let dimensions = img.dimensions(); + + if dimensions.0 != dimensions.1 { + return Err(ApiError::BadRequest(format!( + "Mod logo must have 1:1 aspect ratio. Current size is {}x{}", + dimensions.0, dimensions.1 + ))); + } + + if (dimensions.0 > 336) || (dimensions.1 > 336) { + img = img.resize(336, 336, image::imageops::FilterType::Lanczos3); + } + + let mut cursor: Cursor> = Cursor::new(vec![]); + + let encoder = PngEncoder::new_with_quality( + &mut cursor, + image::codecs::png::CompressionType::Best, + image::codecs::png::FilterType::NoFilter, + ); + + let (width, height) = img.dimensions(); + + encoder + .write_image(img.as_bytes(), width, height, img.color().into()) + .inspect_err(|e| log::error!("Failed to downscale image to 336x336: {}", e)) + .or(Err(ApiError::BadRequest("Invalid mod.json".into())))?; + + cursor.seek(std::io::SeekFrom::Start(0)).unwrap(); + + let mut bytes: Vec = vec![]; + cursor.read_to_end(&mut bytes).unwrap(); + + Ok(bytes) +} + +pub fn validate_mod_logo(file: &mut ZipFile) -> Result<(), ApiError> { + let mut logo: Vec = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut logo) + .inspect_err(|e| log::error!("logo.png read fail: {}", e)) + .or(Err(ApiError::BadRequest("Couldn't read logo.png".into())))?; + + let mut reader = BufReader::new(Cursor::new(logo)); + + let img = PngDecoder::new(&mut reader) + .and_then(DynamicImage::from_decoder) + .inspect_err(|e| log::error!("Failed to create PngDecoder: {}", e)) + .or(Err(ApiError::BadRequest("Invalid logo.png".into())))?; + + let dimensions = img.dimensions(); + + if dimensions.0 != dimensions.1 { + Err(ApiError::BadRequest(format!( + "Mod logo must have 1:1 aspect ratio. Current size is {}x{}", + dimensions.0, dimensions.1 + ))) + } else { + Ok(()) + } +} + +pub async fn download_mod(url: &str, limit_mb: u32) -> Result { + download(url, limit_mb).await +} + +pub async fn download_mod_hash_comp( + url: &str, + hash: &str, + limit_mb: u32, +) -> Result { + let bytes = download(url, limit_mb).await?; + + let slice: &[u8] = &bytes; + + let new_hash = sha256::digest(slice); + if new_hash != hash { + return Err(ApiError::BadRequest(format!( + ".geode hash mismatch: old {}, new {}", + hash, new_hash, + ))); + } + + Ok(bytes) +} + +pub fn bytes_to_ziparchive(bytes: Bytes) -> Result>, ApiError> { + ZipArchive::new(Cursor::new(bytes)) + .inspect_err(|e| log::error!("Failed to create ZipArchive: {}", e)) + .or(Err(ApiError::InternalError)) +} + +async fn download(url: &str, limit_mb: u32) -> Result { + let limit_bytes = limit_mb * 1_000_000; + let response = reqwest::get(url).await.map_err(|e| { + log::error!("Failed to fetch .geode: {}", e); + ApiError::BadRequest("Couldn't download .geode file".into()) + })?; + + let len = response.content_length().ok_or(ApiError::BadRequest( + "Couldn't determine ,geode file size".into(), + ))?; + + if len > limit_bytes as u64 { + return Err(ApiError::BadRequest(format!( + "File size is too large, max {}MB", + limit_mb + ))); + } + + response + .bytes() + .await + .inspect_err(|e| log::error!("Failed to get bytes from .geode: {}", e)) + .or(Err(ApiError::InternalError)) +} diff --git a/src/types/mod_json.rs b/src/types/mod_json.rs index 94225df..e3ef350 100644 --- a/src/types/mod_json.rs +++ b/src/types/mod_json.rs @@ -1,18 +1,15 @@ use std::collections::HashMap; -use std::io::{Cursor, Read, Seek}; +use std::io::Read; use actix_web::web::Bytes; -use image::{ - codecs::png::{PngDecoder, PngEncoder}, - DynamicImage, GenericImageView, ImageEncoder, -}; use regex::Regex; use reqwest::Url; use semver::Version; use serde::Deserialize; -use std::io::BufReader; use zip::read::ZipFile; +use crate::mod_zip; + use super::{ api::ApiError, models::{ @@ -129,39 +126,22 @@ pub struct OldModJsonIncompatibility { impl ModJson { pub fn from_zip( - file: &mut Cursor, + file: Bytes, download_url: &str, store_image: bool, - max_size_mb: u32, ) -> Result { - let max_size_bytes = max_size_mb * 1_000_000; - let mut bytes: Vec = vec![]; - let mut take = file.take(max_size_bytes as u64); - match take.read_to_end(&mut bytes) { - Err(e) => { - log::error!("Failed to read bytes from {}: {}", download_url, e); - return Err(ApiError::FilesystemError); - } - Ok(b) => b, - }; - let hash = sha256::digest(bytes); - let reader = BufReader::new(file); - let mut archive = zip::ZipArchive::new(reader).map_err(|e| { - log::error!("Failed to create ZipArchive of mod: {}", e); - ApiError::BadRequest("Failed to unzip .geode file".into()) - })?; + let slice: &[u8] = &file; + let hash = sha256::digest(slice); + let mut archive = mod_zip::bytes_to_ziparchive(file)?; + let json_file = archive .by_name("mod.json") - .or(Err(ApiError::BadRequest(String::from( - "mod.json not found", - ))))?; - let mut json = match serde_json::from_reader::(json_file) { - Ok(j) => j, - Err(e) => { - log::error!("{}", e); - return Err(ApiError::BadRequest("Invalid mod.json".to_string())); - } - }; + .or(Err(ApiError::BadRequest("mod.json not found".into())))?; + + let mut json = serde_json::from_reader::(json_file) + .inspect_err(|e| log::error!("Failed to parse mod.json: {}", e)) + .or(Err(ApiError::BadRequest("Invalid mod.json".into())))?; + json.version = json.version.trim_start_matches('v').to_string(); json.hash = hash; json.download_url = parse_download_url(download_url); @@ -191,8 +171,11 @@ impl ModJson { ApiError::InternalError })?); } else if file.name() == "logo.png" { - let bytes = validate_mod_logo(&mut file, store_image)?; - json.logo = bytes; + if store_image { + json.logo = mod_zip::extract_mod_logo(&mut file)?; + } else { + mod_zip::validate_mod_logo(&mut file)?; + } } } } @@ -414,69 +397,6 @@ impl ModJson { } } -pub fn validate_mod_logo(file: &mut ZipFile, return_bytes: bool) -> Result, ApiError> { - let mut logo: Vec = vec![]; - if let Err(e) = file.read_to_end(&mut logo) { - log::error!("{}", e); - return Err(ApiError::BadRequest("Couldn't read logo.png".to_string())); - } - - let mut reader = BufReader::new(Cursor::new(logo)); - - let decoder = match PngDecoder::new(&mut reader) { - Ok(d) => d, - Err(e) => { - log::error!("{}", e); - return Err(ApiError::BadRequest("Invalid logo.png".to_string())); - } - }; - let mut img = match DynamicImage::from_decoder(decoder) { - Ok(i) => i, - Err(e) => { - log::error!("{}", e); - return Err(ApiError::BadRequest("Invalid logo.png".to_string())); - } - }; - - let dimensions = img.dimensions(); - - if dimensions.0 != dimensions.1 { - return Err(ApiError::BadRequest(format!( - "Mod logo must have 1:1 aspect ratio. Current size is {}x{}", - dimensions.0, dimensions.1 - ))); - } - - if (dimensions.0 > 336) || (dimensions.1 > 336) { - img = img.resize(336, 336, image::imageops::FilterType::Lanczos3); - } - - if !return_bytes { - return Ok(vec![]); - } - - let mut cursor: Cursor> = Cursor::new(vec![]); - - let encoder = PngEncoder::new_with_quality( - &mut cursor, - image::codecs::png::CompressionType::Best, - image::codecs::png::FilterType::NoFilter, - ); - - let (width, height) = img.dimensions(); - - if let Err(e) = encoder.write_image(img.as_bytes(), width, height, img.color().into()) { - log::error!("{}", e); - return Err(ApiError::BadRequest("Invalid logo.png".to_string())); - } - cursor.seek(std::io::SeekFrom::Start(0)).unwrap(); - - let mut bytes: Vec = vec![]; - cursor.read_to_end(&mut bytes).unwrap(); - - Ok(bytes) -} - fn parse_zip_entry_to_str(file: &mut ZipFile) -> Result { let mut string: String = String::from(""); match file.read_to_string(&mut string) { diff --git a/src/types/models/dependency.rs b/src/types/models/dependency.rs index cc7beac..95f52fb 100644 --- a/src/types/models/dependency.rs +++ b/src/types/models/dependency.rs @@ -1,7 +1,10 @@ use std::{collections::HashMap, fmt::Display}; use serde::{Deserialize, Serialize}; -use sqlx::{PgConnection, Postgres, QueryBuilder}; +use sqlx::{ + postgres::{PgHasArrayType, PgTypeInfo}, + PgConnection, Postgres, QueryBuilder, +}; use crate::types::api::ApiError; @@ -39,6 +42,19 @@ pub struct FetchedDependency { } impl FetchedDependency { + pub fn into_response(self) -> ResponseDependency { + ResponseDependency { + mod_id: self.dependency_id, + version: { + if self.version == "*" { + "*".to_string() + } else { + format!("{}{}", self.compare, self.version) + } + }, + importance: self.importance, + } + } pub fn to_response(&self) -> ResponseDependency { ResponseDependency { mod_id: self.dependency_id.clone(), @@ -128,25 +144,23 @@ impl Dependency { Ok(()) } - pub async fn clear_for_mod_version( - id: i32, - pool: &mut PgConnection - ) -> Result<(), ApiError> { + pub async fn clear_for_mod_version(id: i32, pool: &mut PgConnection) -> Result<(), ApiError> { sqlx::query!( "DELETE FROM dependencies WHERE dependent_id = $1", id ) - .execute(&mut *pool) - .await - .map(|_| ()) - .map_err(|err| { - log::error!( - "Failed to remove dependencies for mod version {}: {}", - id, err - ); - ApiError::DbError - }) + .execute(&mut *pool) + .await + .map(|_| ()) + .map_err(|err| { + log::error!( + "Failed to remove dependencies for mod version {}: {}", + id, + err + ); + ApiError::DbError + }) } pub async fn get_for_mod_versions( diff --git a/src/types/models/incompatibility.rs b/src/types/models/incompatibility.rs index d2afb54..789122b 100644 --- a/src/types/models/incompatibility.rs +++ b/src/types/models/incompatibility.rs @@ -63,6 +63,20 @@ pub struct ResponseIncompatibility { } impl FetchedIncompatibility { + pub fn into_response(self) -> ResponseIncompatibility { + ResponseIncompatibility { + mod_id: self.incompatibility_id, + version: { + if self.version == "*" { + "*".to_string() + } else { + format!("{}{}", self.compare, self.version) + } + }, + importance: self.importance, + } + } + pub fn to_response(&self) -> ResponseIncompatibility { ResponseIncompatibility { mod_id: self.incompatibility_id.clone(), @@ -110,25 +124,23 @@ impl Incompatibility { Ok(()) } - pub async fn clear_for_mod_version( - id: i32, - pool: &mut PgConnection - ) -> Result<(), ApiError> { + pub async fn clear_for_mod_version(id: i32, pool: &mut PgConnection) -> Result<(), ApiError> { sqlx::query!( "DELETE FROM incompatibilities WHERE mod_id = $1", id ) - .execute(&mut *pool) - .await - .map(|_| ()) - .map_err(|err| { - log::error!( - "Failed to remove incompatibilities for mod version {}: {}", - id, err - ); - ApiError::DbError - }) + .execute(&mut *pool) + .await + .map(|_| ()) + .map_err(|err| { + log::error!( + "Failed to remove incompatibilities for mod version {}: {}", + id, + err + ); + ApiError::DbError + }) } pub async fn get_for_mod_version( diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index 541d7c9..5378df7 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -14,24 +14,18 @@ use crate::{ }, types::{ api::{ApiError, PaginatedData}, - mod_json::{self, ModJson, ModJsonLinks}, + mod_json::{ModJson, ModJsonLinks}, models::{mod_version::ModVersion, mod_version_status::ModVersionStatusEnum}, }, }; -use actix_web::web::Bytes; use chrono::SecondsFormat; -use reqwest::Client; use semver::Version; use serde::Serialize; use sqlx::{ types::chrono::{DateTime, Utc}, PgConnection, Postgres, QueryBuilder, }; -use std::{ - collections::HashMap, - io::{Cursor, Read}, - str::FromStr, -}; +use std::{collections::HashMap, str::FromStr}; #[derive(Serialize, Debug, sqlx::FromRow)] pub struct Mod { @@ -736,89 +730,6 @@ impl Mod { Ok(()) } - pub async fn new_version( - json: &ModJson, - developer: &Developer, - pool: &mut PgConnection, - ) -> Result<(), ApiError> { - let result = sqlx::query!( - "SELECT DISTINCT m.id FROM mods m - INNER JOIN mod_versions mv ON mv.mod_id = m.id - INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id - WHERE m.id = $1", - json.id - ) - .fetch_optional(&mut *pool) - .await - .or(Err(ApiError::DbError))?; - if result.is_none() { - return Err(ApiError::NotFound(format!( - "Mod {} doesn't exist", - &json.id - ))); - } - - struct ModVersionItem { - version: String, - id: i32, - status: ModVersionStatusEnum, - } - - let latest = match sqlx::query_as!( - ModVersionItem, - r#"SELECT mv.version, mv.id, mvs.status as "status!: ModVersionStatusEnum" FROM mod_versions mv - INNER JOIN mod_version_statuses mvs ON mv.status_id = mvs.id - WHERE mv.mod_id = $1 - AND (mvs.status = 'pending' OR mvs.status = 'accepted' OR mvs.status = 'rejected') - ORDER BY mv.id DESC - LIMIT 1"#, - &json.id - ) - .fetch_one(&mut *pool) - .await - { - Ok(r) => r, - Err(e) => { - log::info!("Failed to fetch latest version for mod. Error: {}", e); - return Err(ApiError::DbError); - } - }; - - let version = Version::parse(&latest.version).map_err(|_| { - log::error!( - "Invalid semver for locally stored version: id {}, version {}", - latest.id, - latest.version - ); - ApiError::InternalError - })?; - let new_version = Version::parse(json.version.trim_start_matches('v')) - .map_err(|_| ApiError::BadRequest(format!("Invalid semver {}", json.version)))?; - if new_version <= version { - return Err(ApiError::BadRequest(format!( - "mod.json version {} is smaller / equal to latest mod version {}", - json.version, version - ))); - } - - let accepted_versions = ModVersion::get_accepted_count(&json.id, &mut *pool).await?; - - let verified = match accepted_versions { - 0 => false, - _ => developer.verified, - }; - - if latest.status == ModVersionStatusEnum::Pending { - ModVersion::update_pending_version(latest.id, json, verified, pool).await?; - } else { - ModVersion::create_from_json(json, verified, pool).await?; - } - - Mod::update_existing_with_json(json, verified, pool).await?; - - Ok(()) - } - /// At the moment this is only used to set the mod to featured. /// Checks if the mod exists. pub async fn update_mod( @@ -1358,97 +1269,4 @@ impl Mod { Ok(ret) } - - pub async fn update_mod_image( - id: &str, - hash: &str, - download_link: &str, - limit_mb: u32, - pool: &mut PgConnection, - ) -> Result<(), ApiError> { - let mut cursor = download_geode_file(download_link, limit_mb).await?; - let mut bytes: Vec = vec![]; - cursor.read_to_end(&mut bytes).map_err(|e| { - log::error!("Failed to fetch .geode for updating mod image: {}", e); - ApiError::InternalError - })?; - - let new_hash = sha256::digest(bytes); - if new_hash != hash { - return Err(ApiError::BadRequest(format!( - "Different hash detected: old: {}, new: {}", - hash, new_hash - ))); - } - - let mut archive = zip::ZipArchive::new(cursor).map_err(|e| { - log::error!("Failed to create ZipArchive for .geode: {}", e); - ApiError::BadRequest("Couldn't unzip .geode file".to_string()) - })?; - - let image_file = archive.by_name("logo.png").ok(); - if image_file.is_none() { - return Ok(()); - } - let mut image_file = image_file.unwrap(); - - let image = mod_json::validate_mod_logo(&mut image_file, true)?; - - sqlx::query!( - "UPDATE mods SET image = $1 - WHERE id = $2", - image, - id - ) - .execute(&mut *pool) - .await - .map_err(|e| { - log::error!("{}", e); - ApiError::DbError - })?; - - Ok(()) - } -} - -pub async fn download_geode_file(url: &str, limit_mb: u32) -> Result, ApiError> { - let limit_bytes = limit_mb * 1_000_000; - let size = get_download_size(url).await?; - if size > limit_bytes as u64 { - return Err(ApiError::BadRequest(format!( - "File size is too large, max {}MB", - limit_mb - ))); - } - Ok(Cursor::new( - reqwest::get(url) - .await - .map_err(|e| { - log::error!("Failed to fetch .geode: {}", e); - ApiError::BadRequest("Couldn't download .geode file".into()) - })? - .bytes() - .await - .map_err(|e| { - log::error!("Failed to get bytes from .geode: {}", e); - ApiError::InternalError - })?, - )) -} - -async fn get_download_size(url: &str) -> Result { - let res = Client::new().head(url).send().await.map_err(|err| { - log::error!("Failed to send HEAD request for .geode filesize: {:?}", err); - ApiError::BadRequest("Failed to query filesize for given URL".into()) - })?; - - res.headers() - .get("content-length") - .ok_or(ApiError::BadRequest( - "Couldn't extract download size from URL".into(), - ))? - .to_str() - .map_err(|_| ApiError::BadRequest("Invalid Content-Length for .geode".into()))? - .parse::() - .map_err(|_| ApiError::BadRequest("Invalid Content-Length for .geode".into())) } diff --git a/src/types/models/mod_version.rs b/src/types/models/mod_version.rs index 793ad33..f251a04 100644 --- a/src/types/models/mod_version.rs +++ b/src/types/models/mod_version.rs @@ -1,5 +1,10 @@ use std::collections::HashMap; +use crate::database::repository::developers; +use crate::types::{ + api::{create_download_link, ApiError, PaginatedData}, + mod_json::ModJson, +}; use chrono::SecondsFormat; use semver::Version; use serde::Serialize; @@ -7,12 +12,6 @@ use sqlx::{ types::chrono::{DateTime, Utc}, PgConnection, Postgres, QueryBuilder, Row, }; -use crate::database::repository::developers; -use crate::types::{ - api::{create_download_link, ApiError, PaginatedData}, - mod_json::ModJson, - models::mod_entity::Mod, -}; use super::{ dependency::{Dependency, ModVersionCompare, ResponseDependency}, @@ -39,9 +38,13 @@ pub struct ModVersion { pub mod_id: String, pub gd: DetailedGDVersion, pub status: ModVersionStatusEnum, + #[serde(skip_serializing_if = "Option::is_none")] pub dependencies: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub incompatibilities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub developers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, pub created_at: Option, @@ -626,82 +629,6 @@ impl ModVersion { Ok(()) } - pub async fn update_pending_version( - version_id: i32, - json: &ModJson, - make_accepted: bool, - pool: &mut PgConnection, - ) -> Result<(), ApiError> { - sqlx::query!( - "UPDATE mod_versions mv - SET name = $1, - version = $2, - download_link = $3, - hash = $4, - geode = $5, - early_load = $6, - api = $7, - description = $8, - updated_at = NOW() - FROM mod_version_statuses mvs - WHERE mv.status_id = mvs.id - AND mvs.status = 'pending' - AND mv.id = $9", - &json.name, - &json.version, - &json.download_url, - &json.hash, - &json.geode, - &json.early_load, - &json.api.is_some(), - json.description.clone().unwrap_or_default(), - version_id - ) - .execute(&mut *pool) - .await - .map_err(|err| { - log::error!( - "Failed to update pending version {}-{}: {}", - json.id, - json.version, - err - ); - ApiError::DbError - })?; - - let json_tags = json.tags.clone().unwrap_or_default(); - let tags = Tag::get_tag_ids(json_tags, pool).await?; - Tag::update_mod_tags(&json.id, tags.into_iter().map(|x| x.id).collect(), pool).await?; - ModGDVersion::clear_for_mod_version(version_id, pool) - .await - .map_err(|err| { - log::error!("{}", err); - ApiError::DbError - })?; - ModGDVersion::create_from_json(json.gd.to_create_payload(json), version_id, pool).await?; - Dependency::clear_for_mod_version(version_id, pool).await?; - Incompatibility::clear_for_mod_version(version_id, pool).await?; - - let dependencies = json.prepare_dependencies_for_create()?; - if !dependencies.is_empty() { - Dependency::create_for_mod_version(version_id, dependencies, pool).await?; - } - - let incompat = json.prepare_incompatibilities_for_create()?; - if !incompat.is_empty() { - Incompatibility::create_for_mod_version(version_id, incompat, pool).await?; - } - - let status = if make_accepted { - ModVersionStatusEnum::Accepted - } else { - ModVersionStatusEnum::Pending - }; - - ModVersionStatus::update_for_mod_version(version_id, status, None, None, pool).await?; - Ok(()) - } - pub async fn get_one( id: &str, version: &str, @@ -760,133 +687,6 @@ impl ModVersion { Ok(version) } - pub async fn update_version( - id: i32, - new_status: ModVersionStatusEnum, - info: Option, - admin_id: i32, - limit_geode_mb: u32, - pool: &mut PgConnection, - ) -> Result<(), ApiError> { - struct CurrentStatusRes { - status: ModVersionStatusEnum, - } - let current_status = match sqlx::query_as!( - CurrentStatusRes, - r#"select status as "status: _" from mod_version_statuses - where mod_version_id = $1"#, - id - ) - .fetch_one(&mut *pool) - .await - { - Err(e) => { - log::error!("{}", e); - return Err(ApiError::DbError); - } - Ok(s) => s, - }; - - if current_status.status == new_status { - return Ok(()); - } - - if current_status.status == ModVersionStatusEnum::Accepted - && new_status == ModVersionStatusEnum::Pending - { - return Err(ApiError::BadRequest( - "Cannot turn an accepted mod back into pending".to_string(), - )); - } - - let mut query_builder: QueryBuilder = - QueryBuilder::new("UPDATE mod_version_statuses SET "); - - query_builder.push("status = "); - query_builder.push_bind(new_status); - query_builder.push(", admin_id = "); - query_builder.push_bind(admin_id); - if let Some(i) = info { - query_builder.push(", info = "); - query_builder.push_bind(i); - } - - query_builder.push(" WHERE mod_version_id = "); - query_builder.push_bind(id); - - if let Err(e) = query_builder.build().execute(&mut *pool).await { - log::error!("{}", e); - return Err(ApiError::DbError); - } - - if current_status.status == ModVersionStatusEnum::Pending - && new_status == ModVersionStatusEnum::Accepted - { - // Time to download that image - let info = match sqlx::query!( - "SELECT download_link, hash, mod_id FROM mod_versions WHERE id = $1", - id - ) - .fetch_one(&mut *pool) - .await - { - Err(e) => { - log::error!("{}", e); - return Err(ApiError::DbError); - } - Ok(r) => r, - }; - Mod::update_mod_image( - &info.mod_id, - &info.hash, - &info.download_link, - limit_geode_mb, - pool, - ) - .await?; - } - - if new_status == ModVersionStatusEnum::Accepted { - match sqlx::query!( - "UPDATE mods m - SET updated_at = $1 - WHERE m.id = (select mv.mod_id from mod_versions mv where mv.id = $2)", - Utc::now(), - id - ) - .execute(&mut *pool) - .await - { - Err(e) => { - log::error!("{}", e); - return Err(ApiError::DbError); - } - Ok(r) => { - if r.rows_affected() == 0 { - log::error!("No mods affected by updated_at update."); - } - } - }; - } - - match sqlx::query!( - "UPDATE mod_versions SET updated_at=$1 WHERE id=$2", - Utc::now(), - id - ) - .execute(&mut *pool) - .await - { - Err(e) => { - log::error!("{}", e); - return Err(ApiError::DbError); - } - Ok(r) => r, - }; - - Ok(()) - } - pub async fn get_accepted_count( mod_id: &str, pool: &mut PgConnection,