diff --git a/.sqlx/query-959d6a60bbb47b24bc86963c0063bdd1268c75d03563eaf78eec41752cc1a491.json b/.sqlx/query-6b3265a168fb436018db26f164d24dc2fe6bb074a2766cbac68b07f2592f93d9.json similarity index 69% rename from .sqlx/query-959d6a60bbb47b24bc86963c0063bdd1268c75d03563eaf78eec41752cc1a491.json rename to .sqlx/query-6b3265a168fb436018db26f164d24dc2fe6bb074a2766cbac68b07f2592f93d9.json index 6cf71c0..83ca559 100644 --- a/.sqlx/query-959d6a60bbb47b24bc86963c0063bdd1268c75d03563eaf78eec41752cc1a491.json +++ b/.sqlx/query-6b3265a168fb436018db26f164d24dc2fe6bb074a2766cbac68b07f2592f93d9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n m.id, m.repository, m.about, m.changelog, m.featured,\n m.download_count, m.created_at, m.updated_at\n FROM mods m\n WHERE id = $1", + "query": "SELECT\n m.id, m.repository, m.about, m.changelog, m.featured,\n m.download_count, m.created_at, m.updated_at,\n m.status AS \"status: _\", m.status_info\n FROM mods m\n WHERE m.id = $1", "describe": { "columns": [ { @@ -90,6 +90,39 @@ "name": "updated_at" } } + }, + { + "ordinal": 8, + "name": "status: _", + "type_info": { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "origin": { + "Table": { + "table": "mods", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "status_info", + "type_info": "Text", + "origin": { + "Table": { + "table": "mods", + "name": "status_info" + } + } } ], "parameters": { @@ -105,8 +138,10 @@ false, false, false, - false + false, + false, + true ] }, - "hash": "959d6a60bbb47b24bc86963c0063bdd1268c75d03563eaf78eec41752cc1a491" + "hash": "6b3265a168fb436018db26f164d24dc2fe6bb074a2766cbac68b07f2592f93d9" } diff --git a/.sqlx/query-2894a3bf8b4f43496a3a1532ba1e630726cf78d354b586094b1ee8c16a6fe9ee.json b/.sqlx/query-95f89020fa1c0cf7c6efb65c7d09923efe082b9e162f406536f438d0aa8e1a50.json similarity index 85% rename from .sqlx/query-2894a3bf8b4f43496a3a1532ba1e630726cf78d354b586094b1ee8c16a6fe9ee.json rename to .sqlx/query-95f89020fa1c0cf7c6efb65c7d09923efe082b9e162f406536f438d0aa8e1a50.json index 91dd8c2..c8282f2 100644 --- a/.sqlx/query-2894a3bf8b4f43496a3a1532ba1e630726cf78d354b586094b1ee8c16a6fe9ee.json +++ b/.sqlx/query-95f89020fa1c0cf7c6efb65c7d09923efe082b9e162f406536f438d0aa8e1a50.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n m.id, m.repository, m.about, m.changelog, m.featured, m.download_count as mod_download_count, m.created_at, m.updated_at,\n mv.id as version_id, mv.name, mv.description, mv.version, mv.download_link, mv.managed_download_link,\n mv.download_count as mod_version_download_count, mv.created_at as mod_version_created_at, mv.updated_at as mod_version_updated_at, mv.hash,\n format_semver(mv.geode_major, mv.geode_minor, mv.geode_patch, mv.geode_meta) as \"geode!: _\",\n mv.early_load, mv.requires_patching, mv.api, mv.mod_id, mvs.status as \"status: _\", mvs.info\n FROM mods m\n INNER JOIN mod_versions mv ON m.id = mv.mod_id\n INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id\n WHERE m.id = $1\n AND ($2 = false OR mvs.status = 'accepted')\n ORDER BY mv.id DESC", + "query": "SELECT\n m.id, m.repository, m.about, m.changelog, m.featured, m.download_count as mod_download_count, m.created_at, m.updated_at,\n mv.id as version_id, mv.name, mv.description, mv.version, mv.download_link, mv.managed_download_link,\n mv.download_count as mod_version_download_count, mv.created_at as mod_version_created_at, mv.updated_at as mod_version_updated_at, mv.hash,\n format_semver(mv.geode_major, mv.geode_minor, mv.geode_patch, mv.geode_meta) as \"geode!: _\",\n mv.early_load, mv.requires_patching, mv.api, mv.mod_id, mvs.status as \"status: _\", mvs.info,\n m.status AS \"mod_status: _\", m.status_info AS mod_status_info\n FROM mods m\n INNER JOIN mod_versions mv ON m.id = mv.mod_id\n INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id\n WHERE m.id = $1\n AND ($2 = false OR mvs.status = 'accepted')\n ORDER BY mv.id DESC", "describe": { "columns": [ { @@ -284,6 +284,39 @@ "name": "info" } } + }, + { + "ordinal": 25, + "name": "mod_status: _", + "type_info": { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "origin": { + "Table": { + "table": "mods", + "name": "status" + } + } + }, + { + "ordinal": 26, + "name": "mod_status_info", + "type_info": "Text", + "origin": { + "Table": { + "table": "mods", + "name": "status_info" + } + } } ], "parameters": { @@ -317,8 +350,10 @@ false, false, false, + true, + false, true ] }, - "hash": "2894a3bf8b4f43496a3a1532ba1e630726cf78d354b586094b1ee8c16a6fe9ee" + "hash": "95f89020fa1c0cf7c6efb65c7d09923efe082b9e162f406536f438d0aa8e1a50" } diff --git a/.sqlx/query-f98fe5c88a7a234cc0b68fb39c156a741fbad1d2e4682e1e748037bb08962912.json b/.sqlx/query-ad037bc14883dee2aac7c31346fa6d78c9604eac5c8a95378edcc6ef43410b16.json similarity index 73% rename from .sqlx/query-f98fe5c88a7a234cc0b68fb39c156a741fbad1d2e4682e1e748037bb08962912.json rename to .sqlx/query-ad037bc14883dee2aac7c31346fa6d78c9604eac5c8a95378edcc6ef43410b16.json index df6a0a8..95c7229 100644 --- a/.sqlx/query-f98fe5c88a7a234cc0b68fb39c156a741fbad1d2e4682e1e748037bb08962912.json +++ b/.sqlx/query-ad037bc14883dee2aac7c31346fa6d78c9604eac5c8a95378edcc6ef43410b16.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO mods (\n id,\n repository,\n changelog,\n about,\n image\n ) VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id, repository, about,\n changelog, featured,\n download_count, created_at,\n updated_at", + "query": "INSERT INTO mods (\n id,\n repository,\n changelog,\n about,\n image\n ) VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id, repository, about,\n changelog, featured,\n download_count, created_at,\n updated_at, status as \"status: _\",\n status_info", "describe": { "columns": [ { @@ -90,6 +90,39 @@ "name": "updated_at" } } + }, + { + "ordinal": 8, + "name": "status: _", + "type_info": { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "origin": { + "Table": { + "table": "mods", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "status_info", + "type_info": "Text", + "origin": { + "Table": { + "table": "mods", + "name": "status_info" + } + } } ], "parameters": { @@ -109,8 +142,10 @@ false, false, false, - false + false, + false, + true ] }, - "hash": "f98fe5c88a7a234cc0b68fb39c156a741fbad1d2e4682e1e748037bb08962912" + "hash": "ad037bc14883dee2aac7c31346fa6d78c9604eac5c8a95378edcc6ef43410b16" } diff --git a/.sqlx/query-cf34dd72d792ebe6639b65b0cbd79e71ee627208e9b346fa5101f0b0fb96f302.json b/.sqlx/query-cf34dd72d792ebe6639b65b0cbd79e71ee627208e9b346fa5101f0b0fb96f302.json new file mode 100644 index 0000000..fb517b1 --- /dev/null +++ b/.sqlx/query-cf34dd72d792ebe6639b65b0cbd79e71ee627208e9b346fa5101f0b0fb96f302.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods m\n WHERE m.id = $1 AND m.status = $2\n ) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + } + ] + }, + "nullable": [ + null + ] + }, + "hash": "cf34dd72d792ebe6639b65b0cbd79e71ee627208e9b346fa5101f0b0fb96f302" +} diff --git a/.sqlx/query-d4441790dde78a00677bee572620183594185e5a763e3946f45fd512f9c86270.json b/.sqlx/query-d4441790dde78a00677bee572620183594185e5a763e3946f45fd512f9c86270.json new file mode 100644 index 0000000..5d3bcd3 --- /dev/null +++ b/.sqlx/query-d4441790dde78a00677bee572620183594185e5a763e3946f45fd512f9c86270.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mods\n SET status = $1,\n status_info = $2,\n status_locked = $3\n WHERE id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "Text", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d4441790dde78a00677bee572620183594185e5a763e3946f45fd512f9c86270" +} diff --git a/.sqlx/query-dbcd4443d116fb6c101efad6d0a4179c8282c04aee5e85c8a8c161b2ce8f55f2.json b/.sqlx/query-dbcd4443d116fb6c101efad6d0a4179c8282c04aee5e85c8a8c161b2ce8f55f2.json new file mode 100644 index 0000000..82fc720 --- /dev/null +++ b/.sqlx/query-dbcd4443d116fb6c101efad6d0a4179c8282c04aee5e85c8a8c161b2ce8f55f2.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_status_logs\n (status, info, locked, actor_id, mod_id)\n VALUES\n ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "Text", + "Bool", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dbcd4443d116fb6c101efad6d0a4179c8282c04aee5e85c8a8c161b2ce8f55f2" +} diff --git a/.sqlx/query-a9131f0ab03fa27cc7e6531397dc1d9343a95ff30a648d9c9b21120651a1caaa.json b/.sqlx/query-ea6a98e28767884cec10a13ce45a7710ceab8e8e1d1d03658d6a802aaed5df7f.json similarity index 70% rename from .sqlx/query-a9131f0ab03fa27cc7e6531397dc1d9343a95ff30a648d9c9b21120651a1caaa.json rename to .sqlx/query-ea6a98e28767884cec10a13ce45a7710ceab8e8e1d1d03658d6a802aaed5df7f.json index e7c2506..d402a91 100644 --- a/.sqlx/query-a9131f0ab03fa27cc7e6531397dc1d9343a95ff30a648d9c9b21120651a1caaa.json +++ b/.sqlx/query-ea6a98e28767884cec10a13ce45a7710ceab8e8e1d1d03658d6a802aaed5df7f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n m.id, m.repository, NULL as about, NULL as changelog, m.featured,\n m.download_count, m.created_at, m.updated_at\n FROM mods m\n WHERE id = $1", + "query": "SELECT\n m.id, m.repository, NULL as about, NULL as changelog, m.featured,\n m.download_count, m.created_at, m.updated_at,\n m.status AS \"status: _\", NULL AS status_info\n FROM mods m\n WHERE m.id = $1", "describe": { "columns": [ { @@ -80,6 +80,34 @@ "name": "updated_at" } } + }, + { + "ordinal": 8, + "name": "status: _", + "type_info": { + "Custom": { + "name": "mod_status", + "kind": { + "Enum": [ + "default", + "archived", + "unlisted" + ] + } + } + }, + "origin": { + "Table": { + "table": "mods", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "status_info", + "type_info": "Text", + "origin": "Expression" } ], "parameters": { @@ -95,8 +123,10 @@ false, false, false, - false + false, + false, + null ] }, - "hash": "a9131f0ab03fa27cc7e6531397dc1d9343a95ff30a648d9c9b21120651a1caaa" + "hash": "ea6a98e28767884cec10a13ce45a7710ceab8e8e1d1d03658d6a802aaed5df7f" } diff --git a/.sqlx/query-f879070b03c4617a9058d3396ee0f1f2576fc308d39b95bb12c9fd276d63d4e6.json b/.sqlx/query-f879070b03c4617a9058d3396ee0f1f2576fc308d39b95bb12c9fd276d63d4e6.json new file mode 100644 index 0000000..ae45ac6 --- /dev/null +++ b/.sqlx/query-f879070b03c4617a9058d3396ee0f1f2576fc308d39b95bb12c9fd276d63d4e6.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods m\n WHERE m.id = $1 AND m.status_locked = TRUE\n ) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f879070b03c4617a9058d3396ee0f1f2576fc308d39b95bb12c9fd276d63d4e6" +} diff --git a/migrations/20260730233906_add_mod_statuses.down.sql b/migrations/20260730233906_add_mod_statuses.down.sql new file mode 100644 index 0000000..ab702ce --- /dev/null +++ b/migrations/20260730233906_add_mod_statuses.down.sql @@ -0,0 +1,11 @@ +-- Add down migration script here + +drop index mods_status_idx; + +alter table mods + drop column status, + drop column status_info, + drop column status_locked; + +drop table mod_status_logs; +drop type mod_status; \ No newline at end of file diff --git a/migrations/20260730233906_add_mod_statuses.up.sql b/migrations/20260730233906_add_mod_statuses.up.sql new file mode 100644 index 0000000..b26b150 --- /dev/null +++ b/migrations/20260730233906_add_mod_statuses.up.sql @@ -0,0 +1,29 @@ +-- Add up migration script here + +create type mod_status as enum('default', 'archived', 'unlisted'); + +create table mod_status_logs( + id serial primary key, + mod_id TEXT not null, + performed_at timestamptz not null default now(), + actor_id integer, + status mod_status not null default 'default', + info text, + locked BOOLEAN, + foreign key (mod_id) + references mods(id) + on delete cascade, + foreign key (actor_id) + references developers(id) + on delete set null +); + +create index mod_statuses_actor_id_idx on mod_status_logs(actor_id); +create index mod_statuses_mod_id_idx on mod_status_logs(mod_id); + +alter table mods + add column status mod_status not null default 'default', + add column status_info text, + add column status_locked boolean not null default false; + +create index mods_status_idx on mods(status); \ No newline at end of file diff --git a/src/database/repository/mods.rs b/src/database/repository/mods.rs index 5191855..edead5e 100644 --- a/src/database/repository/mods.rs +++ b/src/database/repository/mods.rs @@ -1,6 +1,9 @@ use crate::{ database::DatabaseError, - types::{mod_json::ModJson, models::mod_entity::Mod}, + types::{ + mod_json::ModJson, + models::{mod_entity::Mod, mod_status::ModStatusEnum} + }, }; use chrono::{DateTime, Utc}; use sqlx::PgConnection; @@ -18,6 +21,9 @@ struct ModRecordGetOne { changelog: Option, created_at: DateTime, updated_at: DateTime, + status: ModStatusEnum, + #[sqlx(default)] + status_info: Option, } impl ModRecordGetOne { @@ -35,6 +41,8 @@ impl ModRecordGetOne { about: self.about.clone(), changelog: self.changelog.clone(), links: None, + status: self.status, + status_info: self.status_info, } } } @@ -51,11 +59,12 @@ pub async fn get_one( if include_md { sqlx::query_as!( ModRecordGetOne, - "SELECT + r#"SELECT m.id, m.repository, m.about, m.changelog, m.featured, - m.download_count, m.created_at, m.updated_at + m.download_count, m.created_at, m.updated_at, + m.status AS "status: _", m.status_info FROM mods m - WHERE id = $1", + WHERE m.id = $1"#, id ) .fetch_optional(conn) @@ -66,11 +75,12 @@ pub async fn get_one( } else { sqlx::query_as!( ModRecordGetOne, - "SELECT + r#"SELECT m.id, m.repository, NULL as about, NULL as changelog, m.featured, - m.download_count, m.created_at, m.updated_at + m.download_count, m.created_at, m.updated_at, + m.status AS "status: _", NULL AS status_info FROM mods m - WHERE id = $1", + WHERE m.id = $1"#, id ) .fetch_optional(conn) @@ -86,7 +96,7 @@ pub async fn get_one( pub async fn create(json: &ModJson, conn: &mut PgConnection) -> Result { sqlx::query_as!( ModRecordGetOne, - "INSERT INTO mods ( + r#"INSERT INTO mods ( id, repository, changelog, @@ -97,7 +107,8 @@ pub async fn create(json: &ModJson, conn: &mut PgConnection) -> Result Result<(), D Ok(()) } + +#[tracing::instrument(skip_all, fields(mod_id = %id))] +pub async fn has_status( + id: &str, + status: ModStatusEnum, + pool: &mut PgConnection, +) -> Result { + sqlx::query_scalar!(r#"SELECT EXISTS( + SELECT 1 FROM mods m + WHERE m.id = $1 AND m.status = $2 + ) AS "exists!""#, + id, status as ModStatusEnum + ) + .fetch_one(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(mod_id = %id))] +pub async fn is_status_locked( + id: &str, + pool: &mut PgConnection, +) -> Result { + sqlx::query_scalar!(r#"SELECT EXISTS( + SELECT 1 FROM mods m + WHERE m.id = $1 AND m.status_locked = TRUE + ) AS "exists!""#, + id + ) + .fetch_one(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} \ No newline at end of file diff --git a/src/endpoints/mod_version_submissions.rs b/src/endpoints/mod_version_submissions.rs index 9b1954e..5da9ce5 100644 --- a/src/endpoints/mod_version_submissions.rs +++ b/src/endpoints/mod_version_submissions.rs @@ -7,6 +7,7 @@ use crate::extractors::auth::Auth; use crate::types::api::{ApiResponse, PaginatedData}; use crate::types::models::audit_actions::{AuditAction, AuditActionRow}; use crate::types::models::developer::Developer; +use crate::types::models::mod_status::ModStatusEnum; use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::types::models::mod_version_submission::{ CreateCommentPayload, ModVersionSubmission, ModVersionSubmissionAttachment, @@ -80,6 +81,10 @@ async fn check_submission_lock( return Ok(true); } + if mods::has_status(mod_id, ModStatusEnum::Archived, &mut *conn).await? { + return Ok(false); + } + let access_to_mod = developers::has_access_to_mod(dev.id, mod_id, &mut *conn).await?; let active_developer = developers::has_active_mod(dev.id, &mut *conn).await?; diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 3d48b99..83f3d54 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -18,6 +18,7 @@ use crate::events::mod_created::{ use crate::mod_zip::{self, download_mod}; use crate::s3_worker::S3WorkerTask; use crate::types::models; +use crate::types::models::mod_status::ModStatusEnum; use crate::types::models::mod_version_submission::ModVersionSubmissionLock; use crate::webhook::discord::DiscordWebhook; use crate::{ @@ -323,6 +324,10 @@ pub async fn create_version( return Err(ApiError::Authorization); } + if the_mod.status == ModStatusEnum::Archived { + return Err(ApiError::BadRequest("Mod is archived".to_string())); + } + let versions = mod_versions::get_for_mod( &the_mod.id, Some(&[ @@ -462,20 +467,22 @@ pub async fn create_version( tx.commit().await?; if make_accepted { - let owner = developers::get_owner_for_mod(&version.mod_id, &mut pool) - .await? - .ok_or(ApiError::BadRequest("Mod doesn't have an owner".into()))?; + if the_mod.status != ModStatusEnum::Unlisted { + let owner = developers::get_owner_for_mod(&version.mod_id, &mut pool) + .await? + .ok_or(ApiError::BadRequest("Mod doesn't have an owner".into()))?; - NewModVersionAcceptedEvent { - id: version.mod_id.clone(), - name: version.name.clone(), - version: version.version.clone(), - owner, - verified: NewModVersionVerification::VerifiedDev, - base_url: data.app_url().to_string(), + NewModVersionAcceptedEvent { + id: version.mod_id.clone(), + name: version.name.clone(), + version: version.version.clone(), + owner, + verified: NewModVersionVerification::VerifiedDev, + base_url: data.app_url().to_string(), + } + .to_discord_webhook() + .send(data.http_client(), data.webhook_url()); } - .to_discord_webhook() - .send(data.http_client(), data.webhook_url()); data.send_s3_task(S3WorkerTask::UploadMod { data: bytes, @@ -571,6 +578,8 @@ pub async fn update_version( ) .await?; + let mod_status = the_mod.status; + if old_status == ModVersionStatusEnum::Pending && version.status == ModVersionStatusEnum::Accepted { @@ -638,7 +647,7 @@ pub async fn update_version( tx.commit().await?; - if payload.status == ModVersionStatusEnum::Accepted { + if payload.status == ModVersionStatusEnum::Accepted && mod_status != ModStatusEnum::Unlisted { let is_update = approved_count > 0; let owner = developers::get_owner_for_mod(&version.mod_id, &mut pool) diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index b6c622b..c24d411 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -23,6 +23,7 @@ use crate::types::models::deprecations::Deprecation; use crate::types::models::mod_entity::{Mod, ModUpdate}; use crate::types::models::mod_gd_version::{GDVersionEnum, VerPlatform}; use crate::types::models::mod_link::ModLinks; +use crate::types::models::mod_status::ModStatusEnum; use crate::types::models::mod_version_status::ModVersionStatusEnum; use crate::webhook::discord::DiscordWebhook; use actix_web::{HttpResponse, Responder, get, post, put, web}; @@ -428,7 +429,10 @@ pub async fn get_logo( #[derive(Deserialize, ToSchema)] struct UpdateModPayload { - featured: bool, + featured: Option, + status: Option, + info: Option, + status_locked: Option } /// Update a mod (admin only) @@ -443,7 +447,7 @@ struct UpdateModPayload { responses( (status = 204, description = "Mod updated successfully"), (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden - Admin only"), + (status = 403, description = "Forbidden - Fields being updated are admin only"), (status = 404, description = "Mod not found") ), security( @@ -459,40 +463,74 @@ pub async fn update_mod( auth: Auth, ) -> Result { let dev = auth.developer()?; - auth.check_admin()?; let mut pool = data.db().acquire().await?; - let mut tx = pool.begin().await?; let id = path.into_inner(); + let mut tx = pool.begin().await?; if !mods::exists(&id, &mut tx).await? { return Err(ApiError::NotFound("Mod not found".into())); } - let featured = mods::is_featured(&id, &mut tx).await?; + if let Some(payload_featured) = payload.featured { + auth.check_admin()?; - Mod::update_mod(&id, payload.featured, &mut tx).await?; + let featured = mods::is_featured(&id, &mut tx).await?; + + Mod::update_mod(&id, payload_featured, &mut tx).await?; + + if featured != payload_featured { + let item = Mod::get_one(&id, true, &mut tx).await?; + if let Some(item) = item + && let Some(owner) = developers::get_owner_for_mod(&id, &mut tx).await? + && let Some(ver) = item.versions.first() + { + ModFeaturedEvent { + id: item.id, + name: ver.name.clone(), + owner, + admin: dev.clone(), + base_url: data.app_url().to_string(), + featured: payload_featured, + } + .to_discord_webhook() + .send(data.http_client(), data.webhook_url()); + } + } + } - tx.commit().await?; + if let Some(payload_status) = payload.status { + let status_locked = mods::is_status_locked(&id, &mut tx).await?; + let updating_locked_fields = payload.status_locked.is_some() || payload.info.is_some(); - if featured != payload.featured { - let item = Mod::get_one(&id, true, &mut pool).await?; - if let Some(item) = item - && let Some(owner) = developers::get_owner_for_mod(&id, &mut pool).await? - && let Some(ver) = item.versions.first() - { - ModFeaturedEvent { - id: item.id, - name: ver.name.clone(), - owner, - admin: dev, - base_url: data.app_url().to_string(), - featured: payload.featured, - } - .to_discord_webhook() - .send(data.http_client(), data.webhook_url()); + if !dev.admin && (status_locked || updating_locked_fields) { + return Err(ApiError::Authorization); + } + + let dev_of_mod = developers::has_access_to_mod(dev.id, &id, &mut tx).await?; + if !dev.admin && !dev_of_mod { + return Err(ApiError::Authorization); } + + Mod::update_mod_status( + &id, + payload_status, + payload.info.as_deref(), + payload.status_locked.unwrap_or(status_locked), + &mut tx, + ).await?; + + Mod::create_mod_status_log( + &id, + payload_status, + payload.info.as_deref(), + payload.status_locked, + &dev, + &mut tx, + ).await?; } + tx.commit().await?; + Ok(HttpResponse::NoContent()) } diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index 1fa103e..b1d9810 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -8,6 +8,7 @@ pub mod github_login_attempt; pub mod incompatibility; pub mod loader_version; pub mod mod_entity; +pub mod mod_status; pub mod mod_gd_version; pub mod mod_link; pub mod mod_version; diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index 55421ed..bd45c35 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -18,7 +18,12 @@ use crate::{ }, types::{ api::PaginatedData, - models::{mod_version::ModVersion, mod_version_status::ModVersionStatusEnum}, + models::{ + mod_version::ModVersion, + mod_version_status::ModVersionStatusEnum, + developer::Developer, + mod_status::ModStatusEnum, + }, serde::chrono_dt_secs, }, }; @@ -48,6 +53,8 @@ pub struct Mod { #[serde(with = "chrono_dt_secs")] pub updated_at: DateTime, pub links: Option, + pub status: ModStatusEnum, + pub status_info: Option, } #[derive(Serialize, Debug, ToSchema)] @@ -74,6 +81,7 @@ struct ModRecord { changelog: Option, created_at: DateTime, updated_at: DateTime, + status: ModStatusEnum, } #[derive(sqlx::FromRow)] @@ -103,6 +111,8 @@ struct ModRecordGetOne { mod_version_created_at: Option>, mod_version_updated_at: Option>, info: Option, + mod_status: ModStatusEnum, + mod_status_info: Option, } pub struct ModStats { @@ -270,6 +280,13 @@ impl Mod { } builder.push(" AND mvs.status = ").push_bind(status); + builder.push(" AND m.status <> 'unlisted'"); + + let direct_search = search_str.as_ref().is_some_and(|x| !x.is_empty()) || developer.is_some(); + if !direct_search { + // hide archived mods from direct search + builder.push(" AND m.status <> 'archived'"); + } if let Some(rp) = requires_patching { builder.push(" AND mv.requires_patching = ").push_bind(rp); @@ -348,10 +365,10 @@ impl Mod { let mut records_builder = sqlx::QueryBuilder::new( "SELECT q.id, q.repository, q.about, q.changelog, - q.download_count, q.featured, q.created_at, q.updated_at + q.download_count, q.featured, q.created_at, q.updated_at, q.status FROM ( SELECT DISTINCT ON (m.id) m.id, mv.name, m.repository, m.about, m.changelog, - m.download_count, m.featured, m.created_at, m.updated_at ", + m.download_count, m.featured, m.created_at, m.updated_at, m.status ", ); core_query(&mut records_builder); @@ -434,6 +451,8 @@ impl Mod { about: None, changelog: None, links, + status: x.status, + status_info: None, }) }) .collect(); @@ -481,6 +500,8 @@ impl Mod { about: x.about, changelog: x.changelog, links, + status: x.status, + status_info: None, } }) .collect::>(); @@ -591,7 +612,8 @@ impl Mod { mv.id as version_id, mv.name, mv.description, mv.version, mv.download_link, mv.managed_download_link, mv.download_count as mod_version_download_count, mv.created_at as mod_version_created_at, mv.updated_at as mod_version_updated_at, mv.hash, format_semver(mv.geode_major, mv.geode_minor, mv.geode_patch, mv.geode_meta) as "geode!: _", - mv.early_load, mv.requires_patching, mv.api, mv.mod_id, mvs.status as "status: _", mvs.info + mv.early_load, mv.requires_patching, mv.api, mv.mod_id, mvs.status as "status: _", mvs.info, + m.status AS "mod_status: _", m.status_info AS mod_status_info FROM mods m INNER JOIN mod_versions mv ON m.id = mv.mod_id INNER JOIN mod_version_statuses mvs ON mvs.mod_version_id = mv.id @@ -671,6 +693,8 @@ impl Mod { about: records[0].about.clone(), changelog: records[0].changelog.clone(), links, + status: records[0].mod_status, + status_info: records[0].mod_status_info.clone(), }; Ok(Some(mod_entity)) } @@ -803,4 +827,48 @@ impl Mod { Ok(ret) } + + #[tracing::instrument(skip_all, fields(mod_id = %id, status = ?status))] + pub async fn update_mod_status( + id: &str, + status: ModStatusEnum, + info: Option<&str>, + locked: bool, + pool: &mut PgConnection, + ) -> Result<(), DatabaseError> { + sqlx::query!("UPDATE mods + SET status = $1, + status_info = $2, + status_locked = $3 + WHERE id = $4", + status as ModStatusEnum, info, locked, id + ) + .execute(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|_| ()) + } + + #[tracing::instrument(skip_all, fields(mod_id = %id, status = ?status))] + pub async fn create_mod_status_log( + id: &str, + status: ModStatusEnum, + info: Option<&str>, + locked: Option, + updated_by: &Developer, + pool: &mut PgConnection, + ) -> Result<(), DatabaseError> { + sqlx::query!("INSERT INTO mod_status_logs + (status, info, locked, actor_id, mod_id) + VALUES + ($1, $2, $3, $4, $5)", + status as ModStatusEnum, info, locked, updated_by.id, id + ) + .execute(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|_| ()) + } } diff --git a/src/types/models/mod_status.rs b/src/types/models/mod_status.rs new file mode 100644 index 0000000..f929e64 --- /dev/null +++ b/src/types/models/mod_status.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(sqlx::Type, Debug, Deserialize, Serialize, Clone, Copy, Hash, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "lowercase")] +#[sqlx(rename_all = "lowercase", type_name = "mod_status")] +pub enum ModStatusEnum { + Default, + Archived, + Unlisted, +}