diff --git a/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json b/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json new file mode 100644 index 0000000000..5ab18b5979 --- /dev/null +++ b/apps/labrinth/.sqlx/query-26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET organization_id = NULL, slug = COALESCE($1, slug)\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "26ca3d90c2e2289027ef434e370f4aa746272cd51dd50445057deb213db37049" +} diff --git a/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json b/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json new file mode 100644 index 0000000000..7985334938 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mod_follows\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5d7d7e33c2952199225d7b93f5a74f3436ba18aa24e6ef1840becbf236447fd6" +} diff --git a/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json b/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json new file mode 100644 index 0000000000..e2b1133eb2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6f23a7cd9f7d0ff70d63bb58228042cf53c2e26c050205a52a147c500fad6a97" +} diff --git a/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json b/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json new file mode 100644 index 0000000000..bcf9c634ce --- /dev/null +++ b/apps/labrinth/.sqlx/query-7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n m.id AS \"id!\",\n m.status AS \"status!\",\n m.slug,\n t.id AS \"thread_id!\"\n FROM mods m\n INNER JOIN team_members tm\n ON tm.team_id = m.team_id\n AND tm.user_id = $1\n AND tm.is_owner = TRUE\n INNER JOIN threads t ON t.mod_id = m.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "status!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "thread_id!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "7a3ab78f56ae29dd35aadc928e92da7a72fa8604e62c2168e6b98d90772acb16" +} diff --git a/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json b/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json new file mode 100644 index 0000000000..73142111d3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "82e46320390619773a259aef7431c35cfd16fec52dbf3b4a6ddb3108e50169f2" +} diff --git a/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json b/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json new file mode 100644 index 0000000000..29d412eb36 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE team_id = $1\n RETURNING user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f2726e5a70cf171654cebb3c909234fc041e6d9f9610b443a26edae44925b358" +} diff --git a/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json b/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json new file mode 100644 index 0000000000..7db72f3ff1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM mods\n WHERE\n (slug = LOWER($1) OR text_id_lower = LOWER($1))\n AND id != $2\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ff9139f6a7a23e2f25f960f81726085938a42d6506dac1cfdd141ab143d1d28f" +} diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index a5efc5e701..cb88f48a72 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -1,15 +1,19 @@ use super::ids::{DBProjectId, DBUserId}; use super::{DBCollectionId, DBReportId, DBThreadId}; use crate::database::models::charge_item::DBCharge; +use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::user_subscription_item::DBUserSubscription; use crate::database::models::{DBOrganizationId, DatabaseError}; use crate::database::{PgTransaction, models}; use crate::models::billing::ChargeStatus; +use crate::models::projects::ProjectStatus; +use crate::models::threads::MessageBody; use crate::models::users::Badges; use crate::util::error::Context; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{DateTime, Utc}; use dashmap::DashMap; +use futures::TryStreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::fmt::{Debug, Display}; @@ -593,12 +597,120 @@ impl DBUser { .wrap_err("failed to get user by ID")?; if let Some(delete_user) = user { + let username = delete_user.username.clone(); DBUser::clear_caches(&[(id, Some(delete_user.username))], redis) .await .wrap_err("failed to clear caches")?; let deleted_user: DBUserId = crate::models::users::DELETED_USER.into(); + let user_id_str = ariadne::ids::UserId::from(id).to_string(); + + let owned_projects = sqlx::query!( + r#" + SELECT + m.id AS "id!", + m.status AS "status!", + m.slug, + t.id AS "thread_id!" + FROM mods m + INNER JOIN team_members tm + ON tm.team_id = m.team_id + AND tm.user_id = $1 + AND tm.is_owner = TRUE + INNER JOIN threads t ON t.mod_id = m.id + "#, + id as DBUserId, + ) + .fetch(&mut *transaction) + .try_collect::>() + .await + .wrap_err("failed to fetch projects owned by deleted user")?; + + for project in &owned_projects { + let thread_id = DBThreadId(project.thread_id); + + ThreadMessageBuilder { + author_id: Some(deleted_user), + body: MessageBody::Text { + body: format!( + "Project transferred to Ghost when user account `{username}` (`{user_id_str}`) was deleted" + ), + private: true, + replying_to: None, + associated_images: Vec::new(), + }, + thread_id, + hide_identity: false, + } + .insert(&mut *transaction) + .await + .wrap_err( + "failed to insert project transfer thread message", + )?; + + if ProjectStatus::from_string(&project.status) + == ProjectStatus::Processing + { + ThreadMessageBuilder { + author_id: Some(deleted_user), + body: MessageBody::Text { + body: format!( + "Automatically rejected when user account `{username}` (`{user_id_str}`) was deleted" + ), + private: true, + replying_to: None, + associated_images: Vec::new(), + }, + thread_id, + hide_identity: false, + } + .insert(&mut *transaction) + .await + .wrap_err( + "failed to insert automatic rejection thread message", + )?; + + ThreadMessageBuilder { + author_id: Some(deleted_user), + body: MessageBody::StatusChange { + new_status: ProjectStatus::Rejected, + old_status: ProjectStatus::Processing, + }, + thread_id, + hide_identity: false, + } + .insert(&mut *transaction) + .await + .wrap_err( + "failed to insert automatic rejection status change", + )?; + + sqlx::query!( + r#" + UPDATE mods + SET status = $1 + WHERE id = $2 + "#, + ProjectStatus::Rejected.as_str(), + project.id, + ) + .execute(&mut *transaction) + .await + .wrap_err( + "failed to reject processing project owned by deleted user", + )?; + } + + models::DBProject::clear_cache( + DBProjectId(project.id), + project.slug.clone(), + None, + redis, + ) + .await + .wrap_err("failed to clear project cache")?; + } sqlx::query!( " @@ -626,7 +738,6 @@ impl DBUser { .await .wrap_err("failed to update versions author_id")?; - use futures::TryStreamExt; let notifications: Vec = sqlx::query!( " SELECT n.id FROM notifications n diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 4af79d0635..40346a7a60 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -22,8 +22,9 @@ use crate::models::pats::Scopes; use crate::models::projects::{ MonetizationStatus, Project, ProjectStatus, SideTypesMigrationReviewStatus, }; -use crate::models::teams::ProjectPermissions; +use crate::models::teams::{DEFAULT_ROLE, ProjectPermissions}; use crate::models::threads::MessageBody; +use crate::models::users::DELETED_USER; use crate::models::{self, exp}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -40,6 +41,7 @@ use chrono::Utc; use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use validator::Validate; use xredis::RedisPool; @@ -216,7 +218,7 @@ pub async fn projects_get( Ok(HttpResponse::Ok().json(projects)) } -/// Get a project. +/// Get a project. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = OK, body = Project)) @@ -351,7 +353,7 @@ pub struct EditProject { } #[allow(clippy::too_many_arguments)] -/// Update a project. +/// Update a project. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = NO_CONTENT)) @@ -1313,7 +1315,7 @@ pub async fn edit_project_categories( // pub total_hits: usize, // } -/// Search projects. +/// Search projects. #[utoipa::path( tag = "search", get, @@ -1360,7 +1362,7 @@ pub async fn project_search( } // for more complicated search queries -/// Search projects. +/// Search projects. #[utoipa::path( tag = "search", request_body = serde_json::Value, @@ -1380,7 +1382,7 @@ pub async fn project_search_post( } //checks the validity of a project id or slug -/// Check project availability. +/// Check project availability. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = OK, body = ProjectCheckResponse)) @@ -1420,7 +1422,7 @@ pub struct DependencyInfo { pub versions: Vec, } -/// List project dependencies. +/// List project dependencies. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = OK, body = DependencyInfo)) @@ -1944,7 +1946,7 @@ pub struct Extension { } #[allow(clippy::too_many_arguments)] -/// Update a project icon. +/// Update a project icon. #[utoipa::path( context_path = "/project", tag = "projects", @@ -2108,7 +2110,7 @@ pub async fn project_icon_edit_internal( Ok(HttpResponse::NoContent().body("")) } -/// Delete a project icon. +/// Delete a project icon. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = NO_CONTENT)) @@ -2249,7 +2251,7 @@ pub struct GalleryCreateQuery { } #[allow(clippy::too_many_arguments)] -/// Add a gallery item. +/// Add a gallery item. #[utoipa::path( context_path = "/project", tag = "projects", @@ -2472,7 +2474,7 @@ pub struct GalleryEditQuery { pub ordering: Option, } -/// Update a gallery item. +/// Update a gallery item. #[utoipa::path( context_path = "/project", tag = "projects", @@ -2694,7 +2696,7 @@ pub struct GalleryDeleteQuery { pub url: String, } -/// Delete a gallery item. +/// Delete a gallery item. #[utoipa::path( context_path = "/project", tag = "projects", @@ -2849,7 +2851,7 @@ pub async fn delete_gallery_item_internal( Ok(HttpResponse::NoContent().body("")) } -/// Delete a project. +/// Delete a project. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = NO_CONTENT)) @@ -2934,6 +2936,169 @@ pub async fn project_delete_internal( .begin() .await .wrap_internal_err("failed to start transaction")?; + + // rejected & withheld projects are transferred to ghost so moderation data is preserved + if matches!( + project.inner.status, + ProjectStatus::Rejected | ProjectStatus::Withheld + ) { + let deleted_user: db_ids::DBUserId = DELETED_USER.into(); + + let deleted_slug = if let Some(slug) = &project.inner.slug { + let candidate = format!( + "{slug}--deleted-{}", + ProjectId::from(project.inner.id) + ); + if candidate.len() <= 255 { + let taken = sqlx::query!( + r#" + SELECT EXISTS( + SELECT 1 FROM mods + WHERE + (slug = LOWER($1) OR text_id_lower = LOWER($1)) + AND id != $2 + ) AS "exists!" + "#, + candidate, + project.inner.id as db_ids::DBProjectId, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("checking deleted slug availability")? + .exists; + + if !taken { + Some(candidate.to_lowercase()) + } else { + None + } + } else { + None + } + } else { + None + }; + + sqlx::query!( + " + UPDATE mods + SET organization_id = NULL, slug = COALESCE($1, slug) + WHERE id = $2 + ", + deleted_slug, + project.inner.id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err( + "failed to detach project from organization and update slug", + )?; + + let affected_user_ids = sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 + RETURNING user_id + ", + project.inner.team_id as db_ids::DBTeamId, + ) + .fetch(&mut transaction) + .map_ok(|x| db_ids::DBUserId(x.user_id)) + .try_collect::>() + .await + .wrap_internal_err("failed to remove project team members")?; + + let new_member_id = db_ids::generate_team_member_id(&mut transaction) + .await + .wrap_internal_err("failed to generate team member ID")?; + DBTeamMember { + id: new_member_id, + team_id: project.inner.team_id, + user_id: deleted_user, + role: DEFAULT_ROLE.to_owned(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ZERO, + ordering: 0, + } + .insert(&mut transaction) + .await + .wrap_internal_err("failed to transfer project ownership to ghost")?; + + ThreadMessageBuilder { + author_id: Some(deleted_user), + body: MessageBody::Text { + body: format!( + "Project transferred to Ghost when user account `{}` (`{}`) deleted this project", + user.username, + user.id + ), + private: true, + replying_to: None, + associated_images: Vec::new(), + }, + thread_id: project.thread_id, + hide_identity: false, + } + .insert(&mut transaction) + .await + .wrap_internal_err( + "failed to insert project transfer thread message", + )?; + + sqlx::query!( + " + DELETE FROM collections_mods + WHERE mod_id = $1 + ", + project.inner.id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to delete project from collections_mods")?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE mod_id = $1 + ", + project.inner.id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to delete project followers")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit transaction")?; + + let mut cache_user_ids = affected_user_ids; + cache_user_ids.push(deleted_user); + db_models::DBUser::clear_project_cache(&cache_user_ids, &redis) + .await + .wrap_internal_err("failed to clear user project cache")?; + DBTeamMember::clear_cache(project.inner.team_id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; + db_models::DBProject::clear_cache( + project.inner.id, + project.inner.slug.clone(), + None, + &redis, + ) + .await + .wrap_internal_err("clearing cached data from Redis")?; + search_state + .queue + .push_project_removal(project.inner.id.into()) + .await; + + return Ok(()); + } + delphi::tech_review_sync::sync_deleted_project_tech_review_exit( project.inner.id, &mut transaction, @@ -3001,7 +3166,7 @@ pub async fn project_delete_internal( } } -/// Follow a project. +/// Follow a project. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = NO_CONTENT)) @@ -3108,7 +3273,7 @@ pub async fn project_follow_internal( } } -/// Unfollow a project. +/// Unfollow a project. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = NO_CONTENT)) @@ -3212,7 +3377,7 @@ pub async fn project_unfollow_internal( } } -/// Get a project's organization. +/// Get a project's organization. #[utoipa::path( context_path = "/project", tag = "projects", responses((status = OK, body = models::organizations::Organization))