diff --git a/migrations/20260208100437_new_deprecations.down.sql b/migrations/20260208100437_new_deprecations.down.sql new file mode 100644 index 0000000..2da6467 --- /dev/null +++ b/migrations/20260208100437_new_deprecations.down.sql @@ -0,0 +1,4 @@ +-- Add down migration script here + +DROP TABLE deprecated_by; +DROP TABLE deprecations; diff --git a/migrations/20260208100437_new_deprecations.up.sql b/migrations/20260208100437_new_deprecations.up.sql new file mode 100644 index 0000000..a90db98 --- /dev/null +++ b/migrations/20260208100437_new_deprecations.up.sql @@ -0,0 +1,15 @@ +-- Add up migration script here + +CREATE TABLE deprecations ( + id SERIAL PRIMARY KEY NOT NULL, + mod_id TEXT NOT NULL, + reason TEXT NOT NULL, + FOREIGN KEY (mod_id) REFERENCES mods(id) ON DELETE CASCADE +); +CREATE TABLE deprecated_by ( + deprecation_id INTEGER NOT NULL, + by_mod_id TEXT NOT NULL, + -- If we want to add mod-specific reasons, add them here + FOREIGN KEY (deprecation_id) REFERENCES deprecations(id) ON DELETE CASCADE, + FOREIGN KEY (by_mod_id) REFERENCES mods(id) +); diff --git a/src/endpoints/deprecations.rs b/src/endpoints/deprecations.rs new file mode 100644 index 0000000..7d86c6a --- /dev/null +++ b/src/endpoints/deprecations.rs @@ -0,0 +1,78 @@ + +use actix_web::{HttpResponse, Responder, get, post, web}; +use serde::Deserialize; +use crate::{ + config::AppData, + database::repository::{developers, mods}, + endpoints::ApiError, + extractors::auth::Auth, + types::{api::ApiResponse, models::deprecations::Deprecation} +}; + +#[derive(Deserialize)] +struct DeprecationPath { + id: String, +} + +#[derive(Deserialize)] +struct UpdateDeprecationParams { + by: Vec, + reason: String, +} + +/// Update the deprecation status of a mod +#[get("v1/mods/{id}/deprecate")] +pub async fn check_mod_deprecation( + data: web::Data, + path: web::Path, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + // Allow admins to deprecate any mod + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { + return Err(ApiError::Authorization); + } + + let deps = Deprecation::get_deprecations_for(&mut pool, std::slice::from_ref(&path.id)).await?; + Ok(web::Json(ApiResponse { + error: "".into(), + payload: deps.into_iter().next() + })) +} + +/// Update the deprecation status of a mod +#[post("v1/mods/{id}/deprecate")] +pub async fn deprecate_mod( + data: web::Data, + path: web::Path, + json: web::Json>, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + // Allow admins to deprecate any mod + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { + return Err(ApiError::Authorization); + } + + // Delete old deprecation data (if it exists) + // If we're updating deprecations, just replace the data + Deprecation::delete_deprecation(&mut pool, &path.id).await?; + + // Add new deprecation data if provided + if let Some(json) = json.0 { + Deprecation::create_deprecation(&mut pool, &path.id, &json.by, &json.reason).await?; + } + + Ok(HttpResponse::NoContent()) +} + diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index c903706..b7a00e1 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -12,6 +12,7 @@ pub mod mod_versions; pub mod mods; pub mod stats; pub mod tags; +pub mod deprecations; #[derive(thiserror::Error, Debug)] pub enum ApiError { diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index aefb999..801989d 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -14,6 +14,7 @@ use crate::mod_zip; use crate::types::api::{create_download_link, ApiResponse}; use crate::types::mod_json::ModJson; use crate::types::models; +use crate::types::models::deprecations::Deprecation; use crate::types::models::incompatibility::Incompatibility; use crate::types::models::mod_entity::{Mod, ModUpdate}; use crate::types::models::mod_gd_version::{GDVersionEnum, VerPlatform}; @@ -22,6 +23,7 @@ use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::webhook::discord::DiscordWebhook; use actix_web::{get, post, put, web, HttpResponse, Responder}; use serde::Deserialize; +use serde::Serialize; use sqlx::Acquire; #[derive(Deserialize, Default)] @@ -234,6 +236,16 @@ struct UpdateQueryParams { platform: VerPlatform, geode: String, } +#[derive(Serialize)] +#[serde(untagged)] +enum UpdateQueryResponse { + V4(Vec), + V5 { + updates: Vec, + deprecations: Vec, + } +} + #[get("/v1/mods/updates")] pub async fn get_mod_updates( data: web::Data, @@ -258,40 +270,24 @@ pub async fn get_mod_updates( )) })?; - let mut result: Vec = + let result: Vec = Mod::get_updates(&ids, query.platform, &geode, query.gd, &mut pool).await?; - let mut replacements = - Incompatibility::get_supersedes_for(&ids, query.platform, query.gd, &geode, &mut pool) - .await?; - - for i in &mut result { - if let Some(replacement) = replacements.get(&i.id) { - let mut clone = replacement.clone(); - clone.download_link = create_download_link(data.app_url(), &clone.id, &clone.version); - i.replacement = Some(clone); - replacements.remove_entry(&i.id); - } - i.download_link = create_download_link(data.app_url(), &i.id, &i.version); - } - - for i in replacements { - let mut replacement = i.1.clone(); - replacement.download_link = - create_download_link(data.app_url(), &replacement.id, &replacement.version); - result.push(ModUpdate { - id: i.0.clone(), - version: "1.0.0".to_string(), - mod_version_id: 0, - download_link: replacement.download_link.clone(), - replacement: Some(replacement), - dependencies: vec![], - incompatibilities: vec![], - }); + + // On v5, we return deprecations as a separate array + if geode.major >= 5 { + let deprecations = Deprecation::get_deprecations_for(&mut pool, &ids).await?; + return Ok(web::Json(ApiResponse { + error: "".into(), + payload: UpdateQueryResponse::V5 { + updates: result, + deprecations, + } + })); } Ok(web::Json(ApiResponse { error: "".into(), - payload: result, + payload: UpdateQueryResponse::V4(result), })) } diff --git a/src/main.rs b/src/main.rs index 4359cef..ea43f07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,8 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mod_versions::download_version) .service(endpoints::mod_versions::create_version) .service(endpoints::mod_versions::update_version) + .service(endpoints::deprecations::check_mod_deprecation) + .service(endpoints::deprecations::deprecate_mod) .service(endpoints::auth::github::start_github_web_login) .service(endpoints::auth::refresh_token) .service(endpoints::auth::github::github_web_callback) diff --git a/src/types/models/deprecations.rs b/src/types/models/deprecations.rs new file mode 100644 index 0000000..aee22d4 --- /dev/null +++ b/src/types/models/deprecations.rs @@ -0,0 +1,83 @@ +use serde::Serialize; +use sqlx::PgConnection; + +use crate::endpoints::ApiError; + +#[derive(sqlx::FromRow, Serialize)] +pub struct Deprecation { + pub mod_id: String, + pub by: Vec, + pub reason: String, +} + +impl Deprecation { + pub async fn get_deprecations_for(pool: &mut PgConnection, ids: &[String]) + -> Result, ApiError> + { + let deps = sqlx::query!( + r#" + SELECT d.id, d.mod_id, d.reason + FROM deprecations d + WHERE d.mod_id = ANY($1) + "#, + ids + ).fetch_all(&mut *pool).await?; + + let mut bys: Vec<_> = sqlx::query!( + r#" + SELECT dby.deprecation_id, dby.by_mod_id + FROM deprecated_by dby + WHERE dby.deprecation_id = ANY($1) + "#, + &deps.iter().map(|d| d.id).collect::>() + ).fetch_all(&mut *pool).await?; + + Ok(deps.into_iter().map(|dep| Deprecation { + mod_id: dep.mod_id, + by: bys + .extract_if(.., |by| by.deprecation_id == dep.id) + .map(|by| by.by_mod_id) + .collect(), + reason: dep.reason, + }).collect()) + } + + pub async fn create_deprecation(pool: &mut PgConnection, mod_id: &str, by: &[String], reason: &str) + -> Result<(), ApiError> + { + let id = sqlx::query!( + r#" + INSERT INTO deprecations(mod_id, reason) + VALUES ($1, $2) + RETURNING id + "#, + mod_id, reason + ).fetch_one(&mut *pool).await?.id; + + // I'm not sure how you'd insert multiple values at once with sqlx but + // this endpoint shouldn't be called very often so this oughta be fine + for by_id in by { + sqlx::query!( + r#" + INSERT INTO deprecated_by(deprecation_id, by_mod_id) + VALUES ($1, $2) + "#, + id, by_id + ).execute(&mut *pool).await?; + } + + Ok(()) + } + pub async fn delete_deprecation(pool: &mut PgConnection, mod_id: &str) + -> Result<(), ApiError> + { + sqlx::query!( + r#" + DELETE FROM deprecations + WHERE mod_id = $1 + "#, + mod_id + ).execute(&mut *pool).await?; + Ok(()) + } +} diff --git a/src/types/models/incompatibility.rs b/src/types/models/incompatibility.rs index e9dec24..b20cf20 100644 --- a/src/types/models/incompatibility.rs +++ b/src/types/models/incompatibility.rs @@ -172,123 +172,4 @@ impl Incompatibility { Ok(ret) } - - pub async fn get_supersedes_for( - ids: &Vec, - platform: VerPlatform, - gd: GDVersionEnum, - geode: &semver::Version, - pool: &mut PgConnection, - ) -> Result, DatabaseError> { - let mut ret: HashMap = HashMap::new(); - let pre = if geode.pre.is_empty() { - None - } else { - Some(geode.pre.to_string()) - }; - let r = sqlx::query!( - r#" - SELECT - q.replaced, - q.replacement, - q.replacement_version, - q.replacement_id - FROM ( - SELECT - replaced.incompatibility_id AS replaced, - replacement.mod_id AS replacement, - replacement.version AS replacement_version, - replacement.id AS replacement_id, - ROW_NUMBER() OVER( - partition by replacement.mod_id - order by replacement.version desc - ) rn - FROM incompatibilities replaced - INNER JOIN mod_versions replacement ON replacement.id = replaced.mod_id - INNER JOIN mod_gd_versions replacement_mgv ON replacement.id = replacement_mgv.mod_id - INNER JOIN mod_version_statuses replacement_status - ON replacement.status_id = replacement_status.id - WHERE replaced.importance = 'superseded' - AND replacement_status.status = 'accepted' - AND replaced.incompatibility_id = ANY($1) - AND (replacement_mgv.gd = $2 OR replacement_mgv.gd = '*') - AND replacement_mgv.platform = $3 - AND ($4 = replacement.geode_major) - AND ($5 >= replacement.geode_minor) - AND ( - ($7::text IS NULL AND replacement.geode_meta NOT ILIKE 'alpha%') - OR ( - $7 ILIKE 'alpha%' - AND $5 = replacement.geode_minor - AND $6 = replacement.geode_patch - AND $7 = replacement.geode_meta - ) - OR ( - replacement.geode_meta IS NULL - OR $5 > replacement.geode_minor - OR $6 > replacement.geode_patch - OR (replacement.geode_meta NOT ILIKE 'alpha%' AND $7 >= replacement.geode_meta) - ) - ) - ORDER BY replacement.id DESC, replacement.version DESC - ) q - WHERE q.rn = 1 - "#, - ids, - gd as GDVersionEnum, - platform as VerPlatform, - i32::try_from(geode.major).unwrap_or_default(), - i32::try_from(geode.minor).unwrap_or_default(), - i32::try_from(geode.patch).unwrap_or_default(), - pre - ) - .fetch_all(&mut *pool) - .await - .inspect_err(|e| log::error!("Failed to fetch supersedes: {e}"))?; - - // Client doesn't actually use those, we might as well not return them yet - // TODO: enable back when client supports then - // let ids: Vec = r.iter().map(|x| x.replacement_id).collect(); - // let deps = - // Dependency::get_for_mod_versions(&ids, Some(platform), Some(gd), Some(geode), pool) - // .await?; - // let incompat = Incompatibility::get_for_mod_versions( - // &ids, - // Some(platform), - // Some(gd), - // Some(geode), - // pool, - // ) - // .await?; - - for i in r.iter() { - ret.entry(i.replaced.clone()).or_insert(Replacement { - id: i.replacement.clone(), - version: i.replacement_version.clone(), - replacement_id: i.replacement_id, - // Should be completed later - download_link: "".to_string(), - dependencies: vec![], - incompatibilities: vec![], // dependencies: deps - // .get(&i.replacement_id) - // .cloned() - // .unwrap_or_default() - // .into_iter() - // .map(|x| x.to_response()) - // .collect(), - // incompatibilities: incompat - // .get(&i.replacement_id) - // .cloned() - // .unwrap_or_default() - // .into_iter() - // .filter(|x| { - // x.importance != IncompatibilityImportance::Superseded - // && x.incompatibility_id != i.replacement - // }) - // .map(|x| x.to_response()) - // .collect(), - }); - } - Ok(ret) - } } diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index 8de7178..f9719dc 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -11,3 +11,4 @@ pub mod stats; pub mod tag; pub mod loader_version; pub mod gd_version_alias; +pub mod deprecations;