diff --git a/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json b/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json new file mode 100644 index 0000000000..9245b56bc2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1" +} diff --git a/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json b/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json new file mode 100644 index 0000000000..7cf8db01cb --- /dev/null +++ b/apps/labrinth/.sqlx/query-2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc" +} diff --git a/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json b/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json new file mode 100644 index 0000000000..212faa2dca --- /dev/null +++ b/apps/labrinth/.sqlx/query-4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed" +} diff --git a/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json b/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json new file mode 100644 index 0000000000..153b859c15 --- /dev/null +++ b/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator\n\t\t\tLIMIT 1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846" +} diff --git a/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json b/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json new file mode 100644 index 0000000000..5d5edc88ae --- /dev/null +++ b/apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2)\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05" +} diff --git a/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json b/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json deleted file mode 100644 index d627ef2dc6..0000000000 --- a/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e" -} diff --git a/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json b/apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json similarity index 50% rename from apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json rename to apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json index 3d9785e4dd..45fb189ae2 100644 --- a/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json +++ b/apps/labrinth/.sqlx/query-9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ", + "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b" + "hash": "9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d" } diff --git a/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json b/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json new file mode 100644 index 0000000000..e43bc36603 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tDELETE FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = $2\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13" +} diff --git a/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json b/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json deleted file mode 100644 index 73d8a8eec0..0000000000 --- a/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad" -} diff --git a/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json b/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json new file mode 100644 index 0000000000..f9227fe06e --- /dev/null +++ b/apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Jsonb", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4" +} diff --git a/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.json b/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.json new file mode 100644 index 0000000000..d813a19859 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd.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": "c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd" +} diff --git a/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json b/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json deleted file mode 100644 index dc7aa810bb..0000000000 --- a/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b" -} diff --git a/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json b/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json new file mode 100644 index 0000000000..79515fb7ca --- /dev/null +++ b/apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "disclosure_type!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_by", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "set_by_moderator", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10" +} diff --git a/apps/labrinth/migrations/20260730120000_project_disclosures.sql b/apps/labrinth/migrations/20260730120000_project_disclosures.sql new file mode 100644 index 0000000000..58af9af11e --- /dev/null +++ b/apps/labrinth/migrations/20260730120000_project_disclosures.sql @@ -0,0 +1,9 @@ +CREATE TABLE project_disclosures ( + project_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE, + type TEXT NOT NULL, + metadata JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + set_by_moderator BOOLEAN NOT NULL, + PRIMARY KEY (project_id, type) +); diff --git a/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql b/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql new file mode 100644 index 0000000000..de94c60023 --- /dev/null +++ b/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql @@ -0,0 +1,9 @@ +INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator) +SELECT id, 'archived', '{"note": null}'::jsonb, 0, FALSE +FROM mods +WHERE status = 'archived' +ON CONFLICT (project_id, type) DO NOTHING; + +UPDATE mods +SET status = 'approved' +WHERE status = 'archived'; diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index badfad0bdb..896791e7d3 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -30,6 +30,7 @@ pub mod payout_item; pub mod payouts_values_notifications; pub mod product_item; pub mod products_tax_identifier_item; +pub mod project_disclosure_item; pub mod project_item; pub mod report_item; pub mod session_item; @@ -53,6 +54,7 @@ pub use image_item::DBImage; pub use oauth_client_item::DBOAuthClient; pub use organization_item::DBOrganization; pub use passkey_item::DBPasskey; +pub use project_disclosure_item::DBProjectDisclosure; pub use project_item::DBProject; pub use team_item::DBTeam; pub use team_item::DBTeamMember; diff --git a/apps/labrinth/src/database/models/project_disclosure_item.rs b/apps/labrinth/src/database/models/project_disclosure_item.rs new file mode 100644 index 0000000000..ffb4f7825a --- /dev/null +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -0,0 +1,151 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + database::models::{DBProjectId, DBUserId, DatabaseError}, + models::v3::disclosures::{ProjectDisclosure, ProjectDisclosureType}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBProjectDisclosure { + pub project_id: DBProjectId, + pub disclosure: ProjectDisclosure, + pub updated_at: DateTime, + pub updated_by: DBUserId, + pub set_by_moderator: bool, +} + +impl DBProjectDisclosure { + pub async fn upsert( + &self, + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let (disclosure_type, metadata) = + self.disclosure.to_parts().map_err(|e| { + DatabaseError::Internal(eyre::Report::new(e).wrap_err( + "failed to serialize project disclosure metadata", + )) + })?; + + sqlx::query!( + r#" + INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (project_id, type) DO UPDATE SET + metadata = $3, + updated_at = now(), + updated_by = $4, + set_by_moderator = $5 + "#, + self.project_id as DBProjectId, + disclosure_type, + metadata, + self.updated_by as DBUserId, + self.set_by_moderator, + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn get_many_for_project( + project_id: DBProjectId, + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let rows = sqlx::query!( + r#" + SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator + FROM project_disclosures + WHERE project_id = $1 + ORDER BY updated_at DESC + "#, + project_id as DBProjectId, + ) + .fetch_all(exec) + .await?; + + rows.into_iter() + .map(|row| { + Ok(DBProjectDisclosure { + project_id: DBProjectId(row.project_id), + disclosure: ProjectDisclosure::from_parts( + &row.disclosure_type, + row.metadata, + ) + .map_err(|e| { + DatabaseError::Internal(eyre::Report::new(e).wrap_err( + "failed to deserialize project disclosure metadata", + )) + })?, + updated_at: row.updated_at, + updated_by: DBUserId(row.updated_by), + set_by_moderator: row.set_by_moderator, + }) + }) + .collect() + } + + /// Returns the subset of `project_ids` that carry a disclosure of the given type. + pub async fn projects_with_type( + disclosure_type: ProjectDisclosureType, + project_ids: &[DBProjectId], + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let ids = project_ids.iter().map(|id| id.0).collect::>(); + let rows = sqlx::query_scalar!( + r#" + SELECT project_id + FROM project_disclosures + WHERE type = $1 AND project_id = ANY($2) + "#, + <&'static str>::from(disclosure_type), + &ids, + ) + .fetch_all(exec) + .await?; + + Ok(rows.into_iter().map(DBProjectId).collect()) + } + + pub async fn any_set_by_moderator( + project_id: DBProjectId, + types: &[String], + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result { + let existing = sqlx::query_scalar!( + r#" + SELECT 1 FROM project_disclosures + WHERE project_id = $1 AND type = ANY($2) AND set_by_moderator + LIMIT 1 + "#, + project_id as DBProjectId, + types, + ) + .fetch_optional(exec) + .await?; + + Ok(existing.is_some()) + } + + pub async fn remove( + project_id: DBProjectId, + disclosure_type: ProjectDisclosureType, + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, + ) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM project_disclosures + WHERE project_id = $1 AND type = $2 + "#, + project_id as DBProjectId, + <&'static str>::from(disclosure_type), + ) + .execute(exec) + .await?; + + Ok(result.rows_affected() > 0) + } +} diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index c39de80557..e21616f12b 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -6,6 +6,7 @@ pub mod v3; pub use v3::analytics; pub use v3::billing; pub use v3::collections; +pub use v3::disclosures; pub use v3::ids; pub use v3::images; pub use v3::moderation_notes; diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs index 4afd9dc877..c4501b7a8e 100644 --- a/apps/labrinth/src/models/v2/projects.rs +++ b/apps/labrinth/src/models/v2/projects.rs @@ -3,7 +3,10 @@ use std::convert::TryFrom; use std::collections::HashMap; use super::super::ids::OrganizationId; -use crate::database::models::{DatabaseError, version_item}; +use crate::database::models::{ + DBProjectDisclosure, DBProjectId, DatabaseError, version_item, +}; +use crate::models::disclosures::ProjectDisclosureType; use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId}; use crate::models::projects::{ Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus, @@ -238,28 +241,42 @@ impl LegacyProject { } // Because from needs a version_item, this is a helper function to get many from one db query. - pub async fn from_many<'a, E>( + pub async fn from_many( data: Vec, - exec: E, + pool: &crate::database::PgPool, redis: &RedisPool, - ) -> Result, DatabaseError> - where - E: crate::database::Acquire<'a, Database = sqlx::Postgres>, - { + ) -> Result, DatabaseError> { let version_ids: Vec<_> = data .iter() .filter_map(|p| p.versions.first().map(|i| (*i).into())) .collect(); + let project_ids: Vec = + data.iter().map(|p| p.id.into()).collect(); + let example_versions = - version_item::DBVersion::get_many(&version_ids, exec, redis) + version_item::DBVersion::get_many(&version_ids, pool, redis) .await?; + let archived_disclosure_ids = DBProjectDisclosure::projects_with_type( + ProjectDisclosureType::Archived, + &project_ids, + pool, + ) + .await?; + let mut legacy_projects = Vec::new(); for project in data { let version_item = example_versions .iter() .find(|v| v.inner.project_id == project.id.into()) .cloned(); - let project = LegacyProject::from(project, version_item); + let has_archived_disclosure = + archived_disclosure_ids.contains(&project.id.into()); + let mut project = LegacyProject::from(project, version_item); + if has_archived_disclosure + && project.status == ProjectStatus::Approved + { + project.status = ProjectStatus::Archived; + } legacy_projects.push(project); } Ok(legacy_projects) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs new file mode 100644 index 0000000000..c9145b155f --- /dev/null +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -0,0 +1,142 @@ +use crate::database::models::DBProjectDisclosure; +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use strum::{EnumDiscriminants, EnumString, IntoStaticStr}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, EnumDiscriminants)] +#[serde(rename_all = "snake_case", tag = "type")] +#[strum_discriminants( + name(ProjectDisclosureType), + derive(IntoStaticStr, EnumString), + strum(serialize_all = "snake_case") +)] +pub enum ProjectDisclosure { + AiContent { + note: Option, + uses: BTreeSet, + }, + Advertisements { + note: Option, + }, + EpilepsyTriggers { + note: Option, + }, + SystemInteractions { + note: Option, + interactions: Vec, + }, + Telemetry { + consent: TelemetryConsent, + data_collected: Vec, + }, + DerivativeWork { + sources: Vec, + }, + PaidFeatures { + features: Vec, + }, + Archived { + note: Option, + }, +} + +impl ProjectDisclosure { + pub fn to_parts( + &self, + ) -> Result<(&'static str, serde_json::Value), serde_json::Error> { + let serde_json::Value::Object(mut object) = serde_json::to_value(self)? + else { + return Err(serde::ser::Error::custom( + "project disclosure must serialize to a JSON object", + )); + }; + object.remove("type"); + Ok(( + ProjectDisclosureType::from(self).into(), + serde_json::Value::Object(object), + )) + } + + pub fn from_parts( + kind: &str, + metadata: serde_json::Value, + ) -> Result { + let serde_json::Value::Object(mut object) = metadata else { + return Err(serde::de::Error::custom( + "project disclosure metadata must be a JSON object", + )); + }; + object.insert( + "type".to_owned(), + serde_json::Value::String(kind.to_owned()), + ); + serde_json::from_value(serde_json::Value::Object(object)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ProjectDisclosureData { + #[serde(flatten)] + pub disclosure: ProjectDisclosure, + pub set_by_moderator: bool, + pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, +} + +impl ProjectDisclosureData { + pub fn from_db( + value: DBProjectDisclosure, + viewer_is_moderator: bool, + ) -> Self { + let updated_by = (!value.set_by_moderator || viewer_is_moderator) + .then_some(value.updated_by.into()); + + Self { + disclosure: value.disclosure, + set_by_moderator: value.set_by_moderator, + updated_at: value.updated_at, + updated_by, + } + } +} + +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + ToSchema, + IntoStaticStr, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum AiUsages { + Code, + Assets, + Text, + Functionality, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum TelemetryConsent { + OptIn, + OptOut, + AlwaysActive, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DerivativeSource { + pub link: Option, + pub label: String, + pub note: Option, +} diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 8925b7caec..777de99443 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -3,6 +3,7 @@ pub mod analytics; pub mod analytics_event; pub mod billing; pub mod collections; +pub mod disclosures; pub mod ids; pub mod images; pub mod moderation_notes; diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index c2479a88fc..03e05e51b6 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -570,11 +570,13 @@ impl ProjectStatus { pub fn can_be_requested(&self) -> bool { match self { ProjectStatus::Approved => true, - ProjectStatus::Archived => true, ProjectStatus::Unlisted => true, ProjectStatus::Private => true, ProjectStatus::Draft => true, + // `archived` is represented by a disclosure, not a status, so it + // can no longer be requested or set as a status. + ProjectStatus::Archived => false, ProjectStatus::Rejected => false, ProjectStatus::Processing => false, ProjectStatus::Unknown => false, diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index 407d6359b2..1adf08f1c2 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -1,7 +1,9 @@ +use crate::database::models as db_models; use crate::database::models::categories::LinkPlatform; use crate::database::models::{project_item, version_item}; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::file_hosting::FileHost; +use crate::models::disclosures::ProjectDisclosureType; use crate::models::projects::{ Link, MonetizationStatus, Project, ProjectStatus, Version, }; @@ -206,7 +208,7 @@ pub async fn random_projects_get( match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis) + LegacyProject::from_many(project, &pool, &redis) .await .wrap_internal_err( "executing `LegacyProject::from_many`", @@ -252,7 +254,7 @@ pub async fn projects_get( match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis) + LegacyProject::from_many(project, &pool, &redis) .await .wrap_internal_err( "executing `LegacyProject::from_many`", @@ -315,7 +317,19 @@ pub async fn project_get( } None => None, }; - let project = LegacyProject::from(project, version_item); + let has_archived_disclosure = + db_models::DBProjectDisclosure::projects_with_type( + ProjectDisclosureType::Archived, + &[project.id.into()], + &**pool, + ) + .await + .wrap_internal_err("fetching archival disclosure status")? + .contains(&project.id.into()); + let mut project = LegacyProject::from(project, version_item); + if has_archived_disclosure && project.status == ProjectStatus::Approved { + project.status = ProjectStatus::Archived; + } Ok(HttpResponse::Ok().json(project)) } @@ -402,7 +416,7 @@ pub async fn dependency_list( Ok(dependency_info) => { let converted_projects = LegacyProject::from_many( dependency_info.projects, - &**pool, + &pool, &redis, ) .await @@ -671,6 +685,7 @@ pub async fn project_edit( redis.clone(), session_queue.clone(), search_state.clone(), + true, ) .await .or_else(v2_reroute::flatten_404_error) diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 3c5dae8e8e..860159d638 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -193,7 +193,7 @@ pub async fn projects_list( match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis) + LegacyProject::from_many(project, &pool, &redis) .await .wrap_internal_err( "executing `LegacyProject::from_many`", @@ -465,7 +465,7 @@ pub async fn user_follows( match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis) + LegacyProject::from_many(project, &pool, &redis) .await .wrap_internal_err( "executing `LegacyProject::from_many`", diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index f1db94c0bf..b9108eb1ef 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -364,7 +364,7 @@ pub async fn get_projects_from_hashes( .collect::>(); let legacy_projects = LegacyProject::from_many( projects_hashes.into_values().collect(), - &**pool, + &pool, &redis, ) .await diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs new file mode 100644 index 0000000000..8c1c894a23 --- /dev/null +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -0,0 +1,235 @@ +use actix_web::{HttpRequest, get, patch, web}; +use chrono::Utc; +use eyre::eyre; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use xredis::RedisPool; + +use crate::auth::checks::is_visible_project; +use crate::auth::get_user_from_headers; +use crate::database::{DBProject, models as db_models}; +use crate::database::{PgPool, ReadOnlyPgPool}; +use crate::models::disclosures::{ + ProjectDisclosure, ProjectDisclosureData, ProjectDisclosureType, +}; +use crate::models::pats::Scopes; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::search::SearchState; +use crate::util::error::Context; +use std::str::FromStr; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_project_disclosures) + .service(modify_project_disclosures); +} + +#[derive(Serialize, ToSchema)] +pub struct GetProjectDisclosures { + pub disclosures: Vec, +} + +#[utoipa::path( + context_path = "/project", + tag = "project_disclosures", + responses((status = OK, body = GetProjectDisclosures), (status = BAD_REQUEST, description = "Invalid request input")) +)] +#[get("/{project_id}/disclosures")] +pub async fn get_project_disclosures( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let (string,) = info.into_inner(); + + let project = db_models::DBProject::get(&string, &***ro_pool, &redis) + .await + .wrap_internal_err("failed to fetch project")? + .wrap_not_found_err("resource not found")?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await + .map(|(_, user)| user) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_internal_err("failed to check project visibility")? + { + return Err(ApiError::NotFound(eyre!("resource not found"))); + } + + let viewer_is_moderator = + user_option.is_some_and(|user| user.role.is_mod()); + + let disclosures = db_models::DBProjectDisclosure::get_many_for_project( + project.inner.id, + &***ro_pool, + ) + .await + .wrap_internal_err("failed to fetch project disclosures")?; + + Ok(web::Json(GetProjectDisclosures { + disclosures: disclosures + .into_iter() + .map(|disclosure| { + ProjectDisclosureData::from_db(disclosure, viewer_is_moderator) + }) + .collect(), + })) +} + +#[derive(Deserialize, ToSchema)] +pub struct ModifyProjectDisclosures { + pub set: Vec, + pub remove: Vec, +} + +#[utoipa::path( + context_path = "/project", + tag = "project_disclosures", + request_body = ModifyProjectDisclosures, + responses((status = NO_CONTENT)) +)] +#[patch("/{project_id}/disclosures")] +pub async fn modify_project_disclosures( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + search_state: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result<(), ApiError> { + let (string,) = info.into_inner(); + let body = body.into_inner(); + + if body.set.is_empty() && body.remove.is_empty() { + return Err(ApiError::Request(eyre!( + "must provide at least one disclosure to set or remove" + ))); + } + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")? + .1; + + let project = db_models::DBProject::get(&string, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch project")? + .wrap_not_found_err("resource not found")?; + + let (team_member, organization_team_member) = + db_models::DBTeamMember::get_for_project_permissions( + &project.inner, + user.id.into(), + &**pool, + ) + .await + .wrap_internal_err("failed to fetch project permissions")?; + + let can_edit_details = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .is_some_and(|perms| perms.contains(ProjectPermissions::EDIT_DETAILS)); + + if !can_edit_details { + return Err(ApiError::Auth(eyre!( + "you do not have permission to edit this project's disclosures" + ))); + } + + if !user.role.is_mod() { + let modified_types = body + .set + .iter() + .map(|disclosure| { + <&'static str>::from(ProjectDisclosureType::from(disclosure)) + .to_owned() + }) + .chain(body.remove.iter().cloned()) + .collect::>(); + + if db_models::DBProjectDisclosure::any_set_by_moderator( + project.inner.id, + &modified_types, + &**pool, + ) + .await + .wrap_internal_err("failed to check moderator disclosures")? + { + return Err(ApiError::Auth(eyre!( + "you cannot modify a disclosure set by a moderator" + ))); + } + } + + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + + for disclosure in body.set { + db_models::DBProjectDisclosure { + project_id: project.inner.id, + disclosure, + updated_at: Utc::now(), + updated_by: user.id.into(), + set_by_moderator: user.role.is_mod(), + } + .upsert(&mut transaction) + .await + .wrap_internal_err("failed to upsert project disclosure")?; + } + + for disclosure_type in &body.remove { + let disclosure_type = ProjectDisclosureType::from_str(disclosure_type) + .map_err(|_| { + ApiError::Request(eyre!( + "unknown disclosure type `{disclosure_type}`" + )) + })?; + db_models::DBProjectDisclosure::remove( + project.inner.id, + disclosure_type, + &mut transaction, + ) + .await + .wrap_internal_err("failed to remove project disclosure")?; + } + + transaction + .commit() + .await + .wrap_internal_err("failed to commit project disclosure changes")?; + + DBProject::clear_cache(project.inner.id, project.inner.slug, None, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; + + search_state + .queue + .push_project_change(project.inner.id.into()) + .await; + + Ok(()) +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 735daff22f..7a0d92a9c7 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -9,6 +9,7 @@ pub mod analytics_get; pub mod blocked_users; pub mod collections; pub mod content; +pub mod disclosures; pub mod friends; pub mod images; pub mod limits; @@ -44,7 +45,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("/v3/project") .wrap(default_cors()) .configure(projects::project_config) - .configure(project_creation::config), + .configure(project_creation::config) + .configure(disclosures::config), ); cfg.service( web::scope("/v3") @@ -111,6 +113,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { projects::project_unfollow, projects::project_get_organization, projects::dependency_list, + disclosures::get_project_disclosures, + disclosures::modify_project_disclosures, project_creation::project_create, project_creation::project_create_with_id, project_creation::new::create, diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 4af79d0635..27e7397527 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -15,6 +15,7 @@ use crate::database::{self, models as db_models}; use crate::database::{PgPool, PgTransaction, ReadOnlyPgPool}; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureType}; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; @@ -374,6 +375,7 @@ pub async fn project_edit( redis, session_queue, search_state, + false, ) .await } @@ -386,6 +388,7 @@ pub async fn project_edit_internal( redis: web::Data, session_queue: web::Data, search_state: web::Data, + sync_archival_disclosure: bool, ) -> Result { let user = get_user_from_headers( &req, @@ -486,105 +489,139 @@ pub async fn project_edit_internal( ))); } - if !(user.role.is_mod() - || !project_item.inner.status.is_approved() - && status == &ProjectStatus::Processing - || project_item.inner.status.is_approved() - && status.can_be_requested()) - { - return Err(ApiError::Auth(eyre::eyre!( - "You don't have permission to set this status!", - ))); - } - - // If a moderator (non-admin) is completing a review while another moderator holds an - // active checklist lock, block them from changing the project status. - if user.role.is_mod() - && !user.role.is_admin() - && project_item.inner.status == ProjectStatus::Processing - && status != &ProjectStatus::Processing - && let Some(lock) = - DBModerationLock::get_with_user(project_item.inner.id, &pool) - .await - .wrap_internal_err( - "fetching moderation lock from database", - )? - && lock.moderator_id != db_ids::DBUserId::from(user.id) - && !lock.expired - { - return Err(ApiError::Auth(eyre::eyre!(format!( - "This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", - lock.moderator_username - )))); - } + let archival_disclosure = + db_models::DBProjectDisclosure::get_many_for_project( + project_item.inner.id, + &mut transaction, + ) + .await + .wrap_internal_err("failed to fetch project disclosures")? + .into_iter() + .find(|disclosure| { + matches!( + disclosure.disclosure, + ProjectDisclosure::Archived { .. } + ) + }); + let has_archived_disclosure = archival_disclosure.is_some(); + + if status == &ProjectStatus::Archived { + if !has_archived_disclosure { + db_models::DBProjectDisclosure { + project_id: project_item.inner.id, + disclosure: ProjectDisclosure::Archived { note: None }, + updated_at: Utc::now(), + updated_by: user.id.into(), + set_by_moderator: user.role.is_mod(), + } + .upsert(&mut transaction) + .await + .wrap_internal_err("failed to upsert archival disclosure")?; + } + } else { + if !(user.role.is_mod() + || !project_item.inner.status.is_approved() + && status == &ProjectStatus::Processing + || project_item.inner.status.is_approved() + && status.can_be_requested()) + { + return Err(ApiError::Auth(eyre::eyre!( + "You don't have permission to set this status!", + ))); + } - if status == &ProjectStatus::Processing { - if project_item.versions.is_empty() { - return Err(ApiError::Request(eyre::eyre!(String::from( - "Project submitted for review with no initial versions", + // If a moderator (non-admin) is completing a review while another moderator holds an + // active checklist lock, block them from changing the project status. + if user.role.is_mod() + && !user.role.is_admin() + && project_item.inner.status == ProjectStatus::Processing + && status != &ProjectStatus::Processing + && let Some(lock) = DBModerationLock::get_with_user( + project_item.inner.id, + &pool, + ) + .await + .wrap_internal_err("fetching moderation lock from database")? + && lock.moderator_id != db_ids::DBUserId::from(user.id) + && !lock.expired + { + return Err(ApiError::Auth(eyre::eyre!(format!( + "This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", + lock.moderator_username )))); } - sqlx::query!( - " - UPDATE mods - SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() - WHERE (id = $1) - ", - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await.wrap_internal_err("querying database for `project_edit_internal`")?; - } + if status == &ProjectStatus::Processing { + if project_item.versions.is_empty() { + return Err(ApiError::Request(eyre::eyre!(String::from( + "Project submitted for review with no initial versions", + )))); + } - if status.is_approved() && !project_item.inner.status.is_approved() { - sqlx::query!( - " - UPDATE mods - SET approved = NOW() - WHERE id = $1 AND approved IS NULL - ", - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await - .wrap_internal_err( - "querying database for `project_edit_internal`", - )?; - } + sqlx::query!( + " + UPDATE mods + SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() + WHERE (id = $1) + ", + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; + } - if status.is_searchable() - && !project_item.inner.webhook_sent - && !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty() - && project_item.inner.components.minecraft_server.is_none() - { - crate::util::webhook::send_discord_webhook( - project_item.inner.id.into(), - &pool, - &redis, - &ENV.PUBLIC_DISCORD_WEBHOOK, - None, - ) - .await - .ok(); + if status.is_approved() && !project_item.inner.status.is_approved() + { + sqlx::query!( + " + UPDATE mods + SET approved = NOW() + WHERE id = $1 AND approved IS NULL + ", + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; + } - sqlx::query!( - " + if status.is_searchable() + && !project_item.inner.webhook_sent + && !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty() + && project_item.inner.components.minecraft_server.is_none() + { + crate::util::webhook::send_discord_webhook( + project_item.inner.id.into(), + &pool, + &redis, + &ENV.PUBLIC_DISCORD_WEBHOOK, + None, + ) + .await + .ok(); + + sqlx::query!( + " UPDATE mods SET webhook_sent = TRUE WHERE id = $1 ", - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await - .wrap_internal_err( - "querying database for `project_edit_internal`", - )?; - } + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; + } - if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() { - crate::util::webhook::send_slack_project_webhook( + if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() { + crate::util::webhook::send_slack_project_webhook( project_item.inner.id.into(), &pool, &redis, @@ -603,83 +640,100 @@ pub async fn project_edit_internal( ) .await .ok(); - } + } - if team_member.is_none_or(|x| !x.accepted) { - let notified_members = sqlx::query!( - " - SELECT tm.user_id id - FROM team_members tm - WHERE tm.team_id = $1 AND tm.accepted - ", - project_item.inner.team_id as db_ids::DBTeamId - ) - .fetch(&mut transaction) - .map_ok(|c| db_models::DBUserId(c.id)) - .try_collect::>() - .await - .wrap_internal_err("fetching notified members from database")?; + if team_member.is_none_or(|x| !x.accepted) { + let notified_members = sqlx::query!( + " + SELECT tm.user_id id + FROM team_members tm + WHERE tm.team_id = $1 AND tm.accepted + ", + project_item.inner.team_id as db_ids::DBTeamId + ) + .fetch(&mut transaction) + .map_ok(|c| db_models::DBUserId(c.id)) + .try_collect::>() + .await + .wrap_internal_err("fetching notified members from database")?; - NotificationBuilder { - body: NotificationBody::StatusChange { - project_id: project_item.inner.id.into(), - old_status: project_item.inner.status, + NotificationBuilder { + body: NotificationBody::StatusChange { + project_id: project_item.inner.id.into(), + old_status: project_item.inner.status, + new_status: *status, + }, + } + .insert_many(notified_members.clone(), &mut transaction, &redis) + .await + .wrap_internal_err( + "inserting database records for `project_edit_internal`", + )?; + + NotificationBuilder { + body: if status.is_approved() { + NotificationBody::ProjectStatusApproved { + project_id: project_item.inner.id.into(), + } + } else { + NotificationBody::ProjectStatusNeutral { + project_id: project_item.inner.id.into(), + old_status: project_item.inner.status, + new_status: *status, + } + }, + } + .insert_many(notified_members, &mut transaction, &redis) + .await + .wrap_internal_err( + "inserting database records for `project_edit_internal`", + )?; + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: MessageBody::StatusChange { new_status: *status, + old_status: project_item.inner.status, }, + thread_id: project_item.thread_id, + hide_identity: user.role.is_mod(), } - .insert_many(notified_members.clone(), &mut transaction, &redis) + .insert(&mut transaction) .await .wrap_internal_err( "inserting database records for `project_edit_internal`", )?; - NotificationBuilder { - body: if status.is_approved() { - NotificationBody::ProjectStatusApproved { - project_id: project_item.inner.id.into(), - } - } else { - NotificationBody::ProjectStatusNeutral { - project_id: project_item.inner.id.into(), - old_status: project_item.inner.status, - new_status: *status, - } - }, - } - .insert_many(notified_members, &mut transaction, &redis) + sqlx::query!( + " + UPDATE mods + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) .await .wrap_internal_err( - "inserting database records for `project_edit_internal`", + "querying database for `project_edit_internal`", )?; - } - ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: MessageBody::StatusChange { - new_status: *status, - old_status: project_item.inner.status, - }, - thread_id: project_item.thread_id, - hide_identity: user.role.is_mod(), + if sync_archival_disclosure + && archival_disclosure.is_some_and(|disclosure| { + user.role.is_mod() || !disclosure.set_by_moderator + }) + { + db_models::DBProjectDisclosure::remove( + project_item.inner.id, + ProjectDisclosureType::Archived, + &mut transaction, + ) + .await + .wrap_internal_err("failed to remove archival disclosure")?; + } } - .insert(&mut transaction) - .await - .wrap_internal_err( - "inserting database records for `project_edit_internal`", - )?; - - sqlx::query!( - " - UPDATE mods - SET status = $1 - WHERE (id = $2) - ", - status.as_str(), - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await - .wrap_internal_err("querying database for `project_edit_internal`")?; } if let Some(requested_status) = &new_project.requested_status { diff --git a/apps/labrinth/src/search/backend/elasticsearch/mod.rs b/apps/labrinth/src/search/backend/elasticsearch/mod.rs index b6474bd61d..8f5d5b52ba 100644 --- a/apps/labrinth/src/search/backend/elasticsearch/mod.rs +++ b/apps/labrinth/src/search/backend/elasticsearch/mod.rs @@ -688,6 +688,7 @@ impl Elasticsearch { "compatible_dependency_project_ids": { "type": "keyword" }, + "disclosure_types": {"type": "keyword"}, "project_loader_fields": { "type": "object", "enabled": false diff --git a/apps/labrinth/src/search/backend/typesense/mod.rs b/apps/labrinth/src/search/backend/typesense/mod.rs index 6c7e407730..a3555e6a61 100644 --- a/apps/labrinth/src/search/backend/typesense/mod.rs +++ b/apps/labrinth/src/search/backend/typesense/mod.rs @@ -715,6 +715,14 @@ impl SearchField { optional: true, token_separators: &["-"], }, + SearchField::DisclosureTypes => TypesenseFieldSpec { + path: "disclosure_types", + ty: "string[]", + facet: true, + sort: false, + optional: true, + token_separators: &["_"], + }, } } } diff --git a/apps/labrinth/src/search/indexing.rs b/apps/labrinth/src/search/indexing.rs index 26ca2ba53b..d6a52a8997 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -18,6 +18,7 @@ use crate::database::models::{ DBOrganizationId, DBProjectId, DBUserId, DBVersionId, LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId, }; +use crate::models::disclosures::ProjectDisclosure; use crate::models::exp; use crate::models::ids::{ProjectId, VersionId}; use crate::models::projects::{DependencyType, from_duplicate_version_fields}; @@ -316,6 +317,32 @@ async fn build_search_documents( ) .await?; + info!("Indexing local disclosures!"); + + let project_disclosures: DashMap< + DBProjectId, + Vec<(String, serde_json::Value)>, + > = sqlx::query!( + " + SELECT project_id, type, metadata + FROM project_disclosures + WHERE project_id = ANY($1) + ", + &*project_ids, + ) + .fetch(pool) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + acc.entry(DBProjectId(m.project_id)) + .or_default() + .push((m.r#type, m.metadata)); + async move { Ok(acc) } + }, + ) + .await + .wrap_err("failed to fetch project disclosures")?; + info!("Indexing local versions!"); let mut versions = load_project_versions(pool, project_ids.clone()).await?; @@ -497,6 +524,45 @@ async fn build_search_documents( let mut project_categories = categories; project_categories.sort(); project_categories.dedup(); + + let disclosure_types = project_disclosures + .remove(&project.id) + .map(|(_, disclosures)| disclosures) + .unwrap_or_default() + .into_iter() + .flat_map(|(kind, metadata)| { + let disclosure = + match ProjectDisclosure::from_parts(&kind, metadata) { + Ok(disclosure) => disclosure, + Err(e) => { + warn!( + project_id = project.id.0, + disclosure_type = kind, + "indexing project disclosure without its metadata: {e}" + ); + return vec![kind]; + } + }; + + let mut disclosure_type_tokens = vec![kind.clone()]; + + match &disclosure { + ProjectDisclosure::AiContent { uses, .. } => { + disclosure_type_tokens.extend(uses.iter().map(|usage| { + format!("{kind}_{}", <&'static str>::from(usage)) + })); + } + ProjectDisclosure::Telemetry { consent, .. } => { + disclosure_type_tokens.push(format!( + "{kind}_{}", + <&'static str>::from(consent) + )); + } + _ => {} + } + disclosure_type_tokens + }) + .collect::>(); let dependencies = dependencies .get(&project.id) .map(|x| x.clone()) @@ -732,6 +798,7 @@ async fn build_search_documents( dependency_project_ids, compatible_dependency_project_ids, dependencies, + disclosure_types, project_loader_fields, loader_fields, loaders: project_loaders, diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 9df4220e71..2d1106b2db 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -218,6 +218,7 @@ pub enum SearchField { MinecraftJavaServerPingData, DependencyProjectIds, CompatibleDependencyProjectIds, + DisclosureTypes, } #[derive(Debug, Error)] @@ -290,6 +291,8 @@ pub struct UploadSearchProject { pub compatible_dependency_project_ids: Vec, #[serde(default)] pub dependencies: Vec, + #[serde(default)] + pub disclosure_types: Vec, // Hidden fields to get the Project model out of the search results. pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model. @@ -388,6 +391,8 @@ pub struct ResultSearchProject { pub compatible_dependency_project_ids: Vec, #[serde(default)] pub dependencies: Vec, + #[serde(default)] + pub disclosure_types: Vec, // Hidden fields to get the Project model out of the search results. pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model. @@ -430,6 +435,7 @@ impl From for ResultSearchProject { compatible_dependency_project_ids: source .compatible_dependency_project_ids, dependencies: source.dependencies, + disclosure_types: source.disclosure_types, loaders: source.loaders, project_loader_fields: source.project_loader_fields, components: source.components,