diff --git a/apps/labrinth/.sqlx/query-74bf305377aa69ee03a5ef5f494f132a7f229b4d63cc3ebcd89dbc1dcebfd01e.json b/apps/labrinth/.sqlx/query-74bf305377aa69ee03a5ef5f494f132a7f229b4d63cc3ebcd89dbc1dcebfd01e.json new file mode 100644 index 0000000000..bbeb5fc338 --- /dev/null +++ b/apps/labrinth/.sqlx/query-74bf305377aa69ee03a5ef5f494f132a7f229b4d63cc3ebcd89dbc1dcebfd01e.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT project_id, type\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" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "74bf305377aa69ee03a5ef5f494f132a7f229b4d63cc3ebcd89dbc1dcebfd01e" +} 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-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-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-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 09eddcaed7..9d7117911d 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 a5edfd216b..ca69cdbc79 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, @@ -225,28 +228,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..4edd475b43 --- /dev/null +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -0,0 +1,127 @@ +use crate::database::models::DBProjectDisclosure; +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +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: Vec, + }, + Advertisements { + note: Option, + }, + EpilepsyTriggers { + note: Option, + }, + SystemInteractions { + note: Option, + }, + 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)] +#[serde(rename_all = "snake_case")] +pub enum AiUsages { + Code, + Assets, + Text, + Functionality, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_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 ac50b33228..f844061e84 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -567,11 +567,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 d36bae318f..b29154452d 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, }; @@ -199,7 +201,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).await?; + LegacyProject::from_many(project, &pool, &redis).await?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -240,7 +242,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).await?; + LegacyProject::from_many(project, &pool, &redis).await?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -295,7 +297,18 @@ 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? + .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)) } @@ -381,7 +394,7 @@ pub async fn dependency_list( Ok(dependency_info) => { let converted_projects = LegacyProject::from_many( dependency_info.projects, - &**pool, + &pool, &redis, ) .await?; diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 5879315336..25fe5c0b61 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -187,7 +187,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).await?; + LegacyProject::from_many(project, &pool, &redis).await?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -454,7 +454,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).await?; + LegacyProject::from_many(project, &pool, &redis).await?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index a4c529b8c1..05e2141e72 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -358,7 +358,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..2ee656fac2 --- /dev/null +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -0,0 +1,230 @@ +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")? + .ok_or(ApiError::NotFound)?; + + 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); + } + + 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? + .1; + + let project = db_models::DBProject::get(&string, &**pool, &redis) + .await + .wrap_internal_err("failed to fetch project")? + .ok_or(ApiError::NotFound)?; + + 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?; + + 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?; + + 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 8c7074a4ba..12bfac658e 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -14,6 +14,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; @@ -474,43 +475,67 @@ 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::CustomAuthentication( - "You don't have permission to set this status!".to_string(), - )); - } - - // 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? - && lock.moderator_id != db_ids::DBUserId::from(user.id) - && !lock.expired - { - return Err(ApiError::CustomAuthentication(format!( - "This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", - lock.moderator_username - ))); - } + let has_archived_disclosure = + db_models::DBProjectDisclosure::projects_with_type( + ProjectDisclosureType::Archived, + &[id], + &mut transaction, + ) + .await? + .contains(&id); + + 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?; + } + } 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::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } - if status == &ProjectStatus::Processing { - if project_item.versions.is_empty() { - return Err(ApiError::InvalidInput(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? + && lock.moderator_id != db_ids::DBUserId::from(user.id) + && !lock.expired + { + return Err(ApiError::CustomAuthentication(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!( + if status == &ProjectStatus::Processing { + if project_item.versions.is_empty() { + return Err(ApiError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } + + sqlx::query!( " UPDATE mods SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() @@ -520,50 +545,51 @@ pub async fn project_edit_internal( ) .execute(&mut transaction) .await?; - } + } - if status.is_approved() && !project_item.inner.status.is_approved() { - sqlx::query!( - " + 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?; - } + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await?; + } - 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_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!( - " + sqlx::query!( + " UPDATE mods SET webhook_sent = TRUE WHERE id = $1 ", - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await?; - } + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await?; + } - 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, @@ -582,72 +608,82 @@ pub async fn project_edit_internal( ) .await .ok(); - } + } - if team_member.is_none_or(|x| !x.accepted) { - let notified_members = sqlx::query!( - " + 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?; - - 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?; + project_item.inner.team_id as db_ids::DBTeamId + ) + .fetch(&mut transaction) + .map_ok(|c| db_models::DBUserId(c.id)) + .try_collect::>() + .await?; - NotificationBuilder { - body: if status.is_approved() { - NotificationBody::ProjectStatusApproved { - project_id: project_item.inner.id.into(), - } - } else { - NotificationBody::ProjectStatusNeutral { + 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?; + + 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?; + } + + 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, &mut transaction, &redis) + .insert(&mut transaction) .await?; - } - 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(&mut transaction) - .await?; - - sqlx::query!( - " + sqlx::query!( + " UPDATE mods SET status = $1 WHERE (id = $2) ", - status.as_str(), - id as db_ids::DBProjectId, - ) - .execute(&mut transaction) - .await?; + status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut transaction) + .await?; + + if has_archived_disclosure { + db_models::DBProjectDisclosure::remove( + project_item.inner.id, + ProjectDisclosureType::Archived, + &mut transaction, + ) + .await?; + } + } } if let Some(requested_status) = &new_project.requested_status { diff --git a/apps/labrinth/src/search/backend/typesense/mod.rs b/apps/labrinth/src/search/backend/typesense/mod.rs index e65232106c..cfc54f97d4 100644 --- a/apps/labrinth/src/search/backend/typesense/mod.rs +++ b/apps/labrinth/src/search/backend/typesense/mod.rs @@ -714,6 +714,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 03e6c34061..ade88c26b3 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -316,6 +316,29 @@ async fn build_search_documents( ) .await?; + info!("Indexing local disclosures!"); + + let disclosure_types: DashMap> = sqlx::query!( + " + SELECT project_id, type + 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); + 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?; @@ -494,6 +517,11 @@ async fn build_search_documents( let mut project_categories = categories; project_categories.sort(); project_categories.dedup(); + + let disclosure_types = disclosure_types + .remove(&project.id) + .map(|(_, types)| types) + .unwrap_or_default(); let dependencies = dependencies .get(&project.id) .map(|x| x.clone()) @@ -729,6 +757,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 690efe149a..901aa8c5aa 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -213,6 +213,7 @@ pub enum SearchField { MinecraftJavaServerPingData, DependencyProjectIds, CompatibleDependencyProjectIds, + DisclosureTypes, } #[derive(Debug, Error)] @@ -285,6 +286,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. @@ -383,6 +386,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. @@ -425,6 +430,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,