From ad17a6ecadb44ca311e49df9680ea47efc08b984 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:38:49 -0400 Subject: [PATCH 01/18] feat(labrinth): project disclosures model --- apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v3/disclosures.rs | 46 ++++++++++++++++++++++ apps/labrinth/src/models/v3/mod.rs | 1 + 3 files changed, 48 insertions(+) create mode 100644 apps/labrinth/src/models/v3/disclosures.rs 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/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs new file mode 100644 index 0000000000..c644b4bee6 --- /dev/null +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use strum::IntoStaticStr; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] +#[serde(rename_all = "snake_case", tag = "type")] +#[strum(serialize_all = "snake_case")] +pub enum ProjectDisclosure { + AiContent { + note: Option, + }, + Advertisements { + note: Option, + }, + EpilepsyTriggers { + note: Option, + }, + SystemInteractions { + note: Option, + }, + Telemetry { + consent: TelementryConsent, + data_collected: Vec, + }, + DerivativeWork { + sources: Vec, + }, + PaidFeatures { + features: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TelementryConsent { + 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; From ab08f8ef99ffd4326f51e7ebd35f3fe44727d458 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:18:36 -0400 Subject: [PATCH 02/18] feat(labrinth): project disclosures database model --- ...b690a420f7529a6b319893bd219760a258926.json | 18 +++ ...9d5e5ec33d41b9ae3be81330facedd50c7564.json | 18 +++ ...7da1a33a059b0a1d00b51e27b4a5399492b13.json | 15 +++ ...d0d2e5a708e59b897961f00164a28ab6294a4.json | 18 +++ ...12813c1374a86daf5737f14fe49944de5d73f.json | 52 +++++++++ ...16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json | 52 +++++++++ ...4e4499ecb9ec4ee80308bfe7fa3082be08f0c.json | 52 +++++++++ .../20260730120000_project_disclosures.sql | 9 ++ apps/labrinth/src/database/models/mod.rs | 2 + .../models/project_disclosure_item.rs | 107 ++++++++++++++++++ apps/labrinth/src/models/v3/disclosures.rs | 54 +++++++++ 11 files changed, 397 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json create mode 100644 apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json create mode 100644 apps/labrinth/.sqlx/query-9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13.json create mode 100644 apps/labrinth/.sqlx/query-b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4.json create mode 100644 apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json create mode 100644 apps/labrinth/.sqlx/query-ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10.json create mode 100644 apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json create mode 100644 apps/labrinth/migrations/20260730120000_project_disclosures.sql create mode 100644 apps/labrinth/src/database/models/project_disclosure_item.rs diff --git a/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json b/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json new file mode 100644 index 0000000000..812dc633de --- /dev/null +++ b/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, support)\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\tsupport = $5\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Jsonb", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926" +} diff --git a/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json b/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json new file mode 100644 index 0000000000..9dbe4c09c4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_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\tmoderator = $5\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Jsonb", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564" +} 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-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json b/apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json new file mode 100644 index 0000000000..630a0b7001 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_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": "moderator", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f" +} 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/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json b/apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json new file mode 100644 index 0000000000..8c64af2f48 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, support\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": "support", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c" +} 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/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..14b13ae727 --- /dev/null +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ + database::models::{DBProjectId, DBUserId, DatabaseError}, + models::v3::disclosures::ProjectDisclosure, +}; + +#[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() + } + + pub async fn remove( + project_id: DBProjectId, + disclosure_type: &str, + 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, + disclosure_type, + ) + .execute(exec) + .await?; + + Ok(result.rows_affected() > 0) + } +} diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index c644b4bee6..90c6673204 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -1,6 +1,9 @@ +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum::IntoStaticStr; use utoipa::ToSchema; +use crate::database::models::DBProjectDisclosure; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] #[serde(rename_all = "snake_case", tag = "type")] @@ -30,6 +33,57 @@ pub enum ProjectDisclosure { }, } +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((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::ser::Error::custom( + "project disclosure metadata must be a JSON object, this should never be reachable", + )); + }; + 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, + pub updated_by: UserId, +} + +impl From for ProjectDisclosureData { + fn from(db: DBProjectDisclosure) -> Self { + Self { + disclosure: db.disclosure, + set_by_moderator: db.set_by_moderator, + updated_at: db.updated_at, + updated_by: db.updated_by.into() + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] pub enum TelementryConsent { From 96f3c3380407d496bebd50e0bfdfdd38a73dd316 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:31:40 -0400 Subject: [PATCH 03/18] feat(labrinth): project disclosures get endpoint --- apps/labrinth/src/models/v3/disclosures.rs | 2 +- apps/labrinth/src/routes/v3/disclosures.rs | 70 ++++++++++++++++++++++ apps/labrinth/src/routes/v3/mod.rs | 5 +- 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 apps/labrinth/src/routes/v3/disclosures.rs diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 90c6673204..fff7794bb6 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -1,8 +1,8 @@ -use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum::IntoStaticStr; use utoipa::ToSchema; +use ariadne::ids::UserId; use crate::database::models::DBProjectDisclosure; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs new file mode 100644 index 0000000000..1e7be1bc66 --- /dev/null +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -0,0 +1,70 @@ +use actix_web::{HttpRequest, get, web}; +use xredis::RedisPool; + +use crate::auth::checks::is_visible_project; +use crate::auth::get_user_from_headers; +use crate::database::models as db_models; +use crate::database::{PgPool, ReadOnlyPgPool}; +use crate::models::disclosures::ProjectDisclosureData; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::error::Context; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_project_disclosures); +} + +#[utoipa::path( + context_path = "/project", + tag = "project_disclosures", + responses((status = OK, body = [ProjectDisclosureData])) +)] +#[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 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( + disclosures + .into_iter() + .map(ProjectDisclosureData::from) + .collect(), + )) +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 735daff22f..a2e0fa3c4d 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,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { projects::project_unfollow, projects::project_get_organization, projects::dependency_list, + disclosures::get_project_disclosures, project_creation::project_create, project_creation::project_create_with_id, project_creation::new::create, From ceb6c73e39ac1b6dad7d8dfebe35728a53342c53 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:47:24 -0400 Subject: [PATCH 04/18] feat(labrinth): censor user ids if set by moderator --- apps/labrinth/src/models/v3/disclosures.rs | 21 ++++++++++++++------- apps/labrinth/src/routes/v3/disclosures.rs | 7 ++++++- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index fff7794bb6..9cf2f376b6 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -70,16 +70,23 @@ pub struct ProjectDisclosureData { pub disclosure: ProjectDisclosure, pub set_by_moderator: bool, pub updated_at: DateTime, - pub updated_by: UserId, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, } -impl From for ProjectDisclosureData { - fn from(db: DBProjectDisclosure) -> Self { +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: db.disclosure, - set_by_moderator: db.set_by_moderator, - updated_at: db.updated_at, - updated_by: db.updated_by.into() + disclosure: value.disclosure, + set_by_moderator: value.set_by_moderator, + updated_at: value.updated_at, + updated_by, } } } diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 1e7be1bc66..ef774ec419 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -54,6 +54,9 @@ pub async fn get_project_disclosures( 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, @@ -64,7 +67,9 @@ pub async fn get_project_disclosures( Ok(web::Json( disclosures .into_iter() - .map(ProjectDisclosureData::from) + .map(|disclosure| { + ProjectDisclosureData::from_db(disclosure, viewer_is_moderator) + }) .collect(), )) } From b6c1fb9be67be27d4156db437e62b88324e9a142 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:50:34 -0400 Subject: [PATCH 05/18] feat(labrinth): wrap disclosures in struct --- apps/labrinth/src/routes/v3/disclosures.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index ef774ec419..081aef70d6 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -1,4 +1,6 @@ use actix_web::{HttpRequest, get, web}; +use serde::Serialize; +use utoipa::ToSchema; use xredis::RedisPool; use crate::auth::checks::is_visible_project; @@ -15,10 +17,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get_project_disclosures); } +#[derive(Serialize, ToSchema)] +pub struct GetProjectDisclosures { + pub disclosures: Vec, +} + #[utoipa::path( context_path = "/project", tag = "project_disclosures", - responses((status = OK, body = [ProjectDisclosureData])) + responses((status = OK, body = GetProjectDisclosures)) )] #[get("/{project_id}/disclosures")] pub async fn get_project_disclosures( @@ -28,7 +35,7 @@ pub async fn get_project_disclosures( ro_pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result>, ApiError> { +) -> Result, ApiError> { let (string,) = info.into_inner(); let project = db_models::DBProject::get(&string, &***ro_pool, &redis) @@ -64,12 +71,12 @@ pub async fn get_project_disclosures( .await .wrap_internal_err("failed to fetch project disclosures")?; - Ok(web::Json( - disclosures + Ok(web::Json(GetProjectDisclosures { + disclosures: disclosures .into_iter() .map(|disclosure| { ProjectDisclosureData::from_db(disclosure, viewer_is_moderator) }) - .collect(), - )) + .collect() + })) } From 31b3381405b7cd4207e24822a63a4a940e9b3309 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:35:48 -0400 Subject: [PATCH 06/18] feat(labrinth): edit project disclosures endpoint --- ...61a8dff02ed316fc0b98754605a6e16e8e846.json | 23 ++++ .../models/project_disclosure_item.rs | 20 +++ apps/labrinth/src/routes/v3/disclosures.rs | 129 +++++++++++++++++- apps/labrinth/src/routes/v3/mod.rs | 1 + 4 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json 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/src/database/models/project_disclosure_item.rs b/apps/labrinth/src/database/models/project_disclosure_item.rs index 14b13ae727..8b3114452d 100644 --- a/apps/labrinth/src/database/models/project_disclosure_item.rs +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -86,6 +86,26 @@ impl DBProjectDisclosure { .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: &str, diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 081aef70d6..059c7da5fb 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -1,5 +1,6 @@ -use actix_web::{HttpRequest, get, web}; -use serde::Serialize; +use actix_web::{HttpRequest, get, patch, web}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use xredis::RedisPool; @@ -7,14 +8,16 @@ use crate::auth::checks::is_visible_project; use crate::auth::get_user_from_headers; use crate::database::models as db_models; use crate::database::{PgPool, ReadOnlyPgPool}; -use crate::models::disclosures::ProjectDisclosureData; +use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureData}; use crate::models::pats::Scopes; +use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; pub fn config(cfg: &mut web::ServiceConfig) { - cfg.service(get_project_disclosures); + cfg.service(get_project_disclosures) + .service(modify_project_disclosures); } #[derive(Serialize, ToSchema)] @@ -77,6 +80,122 @@ pub async fn get_project_disclosures( .map(|disclosure| { ProjectDisclosureData::from_db(disclosure, viewer_is_moderator) }) - .collect() + .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, + session_queue: web::Data, + body: web::Json, +) -> Result<(), ApiError> { + let (string,) = info.into_inner(); + let body = body.into_inner(); + + 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(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 { + 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")?; + + Ok(()) +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index a2e0fa3c4d..7a0d92a9c7 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -114,6 +114,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { 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, From 01d59d83d878b903fd18d24cc59646ff67ed40fa Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:08:14 -0400 Subject: [PATCH 07/18] style(labrinth): cargo fmt --- apps/labrinth/src/models/v3/disclosures.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 9cf2f376b6..4b3558a1f8 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -1,9 +1,9 @@ +use crate::database::models::DBProjectDisclosure; +use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum::IntoStaticStr; use utoipa::ToSchema; -use ariadne::ids::UserId; -use crate::database::models::DBProjectDisclosure; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] #[serde(rename_all = "snake_case", tag = "type")] From 3f8c1b6d79c5b22cedd29de0184e124ead8639ce Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:11:52 -0400 Subject: [PATCH 08/18] style(labrinth): fix typo --- apps/labrinth/src/models/v3/disclosures.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 4b3558a1f8..b400f2b7e5 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -22,7 +22,7 @@ pub enum ProjectDisclosure { note: Option, }, Telemetry { - consent: TelementryConsent, + consent: TelemetryConsent, data_collected: Vec, }, DerivativeWork { @@ -93,7 +93,7 @@ impl ProjectDisclosureData { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "snake_case")] -pub enum TelementryConsent { +pub enum TelemetryConsent { OptIn, OptOut, AlwaysActive, From 920441865db5b0793b186df8cde34786035e5ec2 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:42:42 -0400 Subject: [PATCH 09/18] feat(labrinth): add fields for ai content disclosure --- apps/labrinth/src/models/v3/disclosures.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index b400f2b7e5..04a06e882a 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -11,6 +11,9 @@ use utoipa::ToSchema; pub enum ProjectDisclosure { AiContent { note: Option, + code: bool, + assets: bool, + test: bool, }, Advertisements { note: Option, From 33a77c317eb67f13548cdfa9862e15c720e36369 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:02:27 -0400 Subject: [PATCH 10/18] fix(labrinth): field typo --- apps/labrinth/src/models/v3/disclosures.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 04a06e882a..8a0161e5ca 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -13,7 +13,7 @@ pub enum ProjectDisclosure { note: Option, code: bool, assets: bool, - test: bool, + text: bool, }, Advertisements { note: Option, From 46d3163662bf767686d18e72688f8f36d137f056 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:14:06 -0400 Subject: [PATCH 11/18] chore(labrinth): update query cache --- ...b690a420f7529a6b319893bd219760a258926.json | 18 ------- ...9d5e5ec33d41b9ae3be81330facedd50c7564.json | 18 ------- ...12813c1374a86daf5737f14fe49944de5d73f.json | 52 ------------------- ...4e4499ecb9ec4ee80308bfe7fa3082be08f0c.json | 52 ------------------- 4 files changed, 140 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json delete mode 100644 apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json delete mode 100644 apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json delete mode 100644 apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json diff --git a/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json b/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json deleted file mode 100644 index 812dc633de..0000000000 --- a/apps/labrinth/.sqlx/query-02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, support)\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\tsupport = $5\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Jsonb", - "Int8", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "02ef27605b8d19c35cebe27d618b690a420f7529a6b319893bd219760a258926" -} diff --git a/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json b/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json deleted file mode 100644 index 9dbe4c09c4..0000000000 --- a/apps/labrinth/.sqlx/query-67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_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\tmoderator = $5\n\t\t\t", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Jsonb", - "Int8", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "67443fdd9d8daaecc26e198cb439d5e5ec33d41b9ae3be81330facedd50c7564" -} diff --git a/apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json b/apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json deleted file mode 100644 index 630a0b7001..0000000000 --- a/apps/labrinth/.sqlx/query-e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_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": "moderator", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "e6db9cff0b164399dba86a54ac512813c1374a86daf5737f14fe49944de5d73f" -} diff --git a/apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json b/apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json deleted file mode 100644 index 8c64af2f48..0000000000 --- a/apps/labrinth/.sqlx/query-fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, support\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": "support", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "fd137835f703a2e4041554b36c94e4499ecb9ec4ee80308bfe7fa3082be08f0c" -} From 5978bd45528726b77423704aa0f14320d857ca97 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:09:54 -0400 Subject: [PATCH 12/18] feat(labrinth): index disclosures in search --- ...f132a7f229b4d63cc3ebcd89dbc1dcebfd01e.json | 28 ++++++++++++++++++ .../src/search/backend/typesense/mod.rs | 8 +++++ apps/labrinth/src/search/indexing.rs | 29 +++++++++++++++++++ apps/labrinth/src/search/mod.rs | 6 ++++ 4 files changed, 71 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-74bf305377aa69ee03a5ef5f494f132a7f229b4d63cc3ebcd89dbc1dcebfd01e.json 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/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, From 23b2b196a3ac1d3f78daa75f1f514fbbb0541796 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:36:25 -0400 Subject: [PATCH 13/18] refactor(labrinth): use enum instead of bools --- apps/labrinth/src/models/v3/disclosures.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 8a0161e5ca..8274eaf87e 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -11,9 +11,7 @@ use utoipa::ToSchema; pub enum ProjectDisclosure { AiContent { note: Option, - code: bool, - assets: bool, - text: bool, + uses: Vec, }, Advertisements { note: Option, @@ -94,6 +92,15 @@ impl ProjectDisclosureData { } } +#[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 { From db479bb71e4045ebf020a7121adca335774ed092 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:56:56 -0400 Subject: [PATCH 14/18] feat(labrinth): trigger incremental index on disclosure change --- apps/labrinth/src/routes/v3/disclosures.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs index 059c7da5fb..e3126e5864 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -1,5 +1,6 @@ use actix_web::{HttpRequest, get, patch, web}; use chrono::Utc; +use eyre::eyre; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use xredis::RedisPool; @@ -13,6 +14,7 @@ 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; pub fn config(cfg: &mut web::ServiceConfig) { @@ -28,7 +30,7 @@ pub struct GetProjectDisclosures { #[utoipa::path( context_path = "/project", tag = "project_disclosures", - responses((status = OK, body = GetProjectDisclosures)) + responses((status = OK, body = GetProjectDisclosures), (status = BAD_REQUEST, description = "Invalid request input")) )] #[get("/{project_id}/disclosures")] pub async fn get_project_disclosures( @@ -102,12 +104,19 @@ pub async fn modify_project_disclosures( 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, @@ -197,5 +206,10 @@ pub async fn modify_project_disclosures( .await .wrap_internal_err("failed to commit project disclosure changes")?; + search_state + .queue + .push_project_change(project.inner.id.into()) + .await; + Ok(()) } From c8b7ac993b453b71e6e7f7b00157848e87728f44 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:15:15 -0400 Subject: [PATCH 15/18] feat(labrinth): change archived status to disclosure --- .../20260803120000_archived-status-to-disclosure.sql | 9 +++++++++ apps/labrinth/src/models/v3/disclosures.rs | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 apps/labrinth/migrations/20260803120000_archived-status-to-disclosure.sql diff --git a/apps/labrinth/migrations/20260803120000_archived-status-to-disclosure.sql b/apps/labrinth/migrations/20260803120000_archived-status-to-disclosure.sql new file mode 100644 index 0000000000..de94c60023 --- /dev/null +++ b/apps/labrinth/migrations/20260803120000_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/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index 8274eaf87e..d6a1a27ed1 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -32,6 +32,9 @@ pub enum ProjectDisclosure { PaidFeatures { features: Vec, }, + Archived { + note: Option, + }, } impl ProjectDisclosure { From 06ef059094516c54348291e52d8d4fedbf731852 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:10:32 -0400 Subject: [PATCH 16/18] feat(labrinth): use disclosure for archival status --- ...5705f8efc09ac21e0cf9a15c4cce019aaeb05.json | 23 ++ .../models/project_disclosure_item.rs | 30 +- apps/labrinth/src/models/v2/projects.rs | 35 ++- apps/labrinth/src/models/v3/disclosures.rs | 15 +- apps/labrinth/src/models/v3/projects.rs | 4 +- apps/labrinth/src/routes/v2/projects.rs | 21 +- apps/labrinth/src/routes/v2/users.rs | 4 +- apps/labrinth/src/routes/v2/version_file.rs | 2 +- apps/labrinth/src/routes/v3/disclosures.rs | 21 +- apps/labrinth/src/routes/v3/projects.rs | 264 ++++++++++-------- 10 files changed, 278 insertions(+), 141 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05.json 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/src/database/models/project_disclosure_item.rs b/apps/labrinth/src/database/models/project_disclosure_item.rs index 8b3114452d..ffb4f7825a 100644 --- a/apps/labrinth/src/database/models/project_disclosure_item.rs +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -1,9 +1,11 @@ +use std::collections::HashSet; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ database::models::{DBProjectId, DBUserId, DatabaseError}, - models::v3::disclosures::ProjectDisclosure, + models::v3::disclosures::{ProjectDisclosure, ProjectDisclosureType}, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -86,6 +88,28 @@ impl DBProjectDisclosure { .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], @@ -108,7 +132,7 @@ impl DBProjectDisclosure { pub async fn remove( project_id: DBProjectId, - disclosure_type: &str, + disclosure_type: ProjectDisclosureType, exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, ) -> Result { let result = sqlx::query!( @@ -117,7 +141,7 @@ impl DBProjectDisclosure { WHERE project_id = $1 AND type = $2 "#, project_id as DBProjectId, - disclosure_type, + <&'static str>::from(disclosure_type), ) .execute(exec) .await?; 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 index d6a1a27ed1..fa1999232f 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -2,12 +2,16 @@ use crate::database::models::DBProjectDisclosure; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use strum::IntoStaticStr; +use strum::{EnumDiscriminants, EnumString, IntoStaticStr}; use utoipa::ToSchema; -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, EnumDiscriminants)] #[serde(rename_all = "snake_case", tag = "type")] -#[strum(serialize_all = "snake_case")] +#[strum_discriminants( + name(ProjectDisclosureType), + derive(IntoStaticStr, EnumString), + strum(serialize_all = "snake_case") +)] pub enum ProjectDisclosure { AiContent { note: Option, @@ -48,7 +52,10 @@ impl ProjectDisclosure { )); }; object.remove("type"); - Ok((self.into(), serde_json::Value::Object(object))) + Ok(( + ProjectDisclosureType::from(self).into(), + serde_json::Value::Object(object), + )) } pub fn from_parts( 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 index e3126e5864..2ee656fac2 100644 --- a/apps/labrinth/src/routes/v3/disclosures.rs +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -7,15 +7,18 @@ use xredis::RedisPool; use crate::auth::checks::is_visible_project; use crate::auth::get_user_from_headers; -use crate::database::models as db_models; +use crate::database::{DBProject, models as db_models}; use crate::database::{PgPool, ReadOnlyPgPool}; -use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureData}; +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) @@ -158,7 +161,10 @@ pub async fn modify_project_disclosures( let modified_types = body .set .iter() - .map(|disclosure| <&'static str>::from(disclosure).to_owned()) + .map(|disclosure| { + <&'static str>::from(ProjectDisclosureType::from(disclosure)) + .to_owned() + }) .chain(body.remove.iter().cloned()) .collect::>(); @@ -192,6 +198,12 @@ pub async fn modify_project_disclosures( } 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, @@ -206,6 +218,9 @@ pub async fn modify_project_disclosures( .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()) 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 { From c84cd4a8a598d08f8b4b1fde5774967fc11dcb98 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:48:34 -0400 Subject: [PATCH 17/18] fix(labrinth): error type for deserialization --- apps/labrinth/src/models/v3/disclosures.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/models/v3/disclosures.rs b/apps/labrinth/src/models/v3/disclosures.rs index fa1999232f..4edd475b43 100644 --- a/apps/labrinth/src/models/v3/disclosures.rs +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -63,8 +63,8 @@ impl ProjectDisclosure { metadata: serde_json::Value, ) -> Result { let serde_json::Value::Object(mut object) = metadata else { - return Err(serde::ser::Error::custom( - "project disclosure metadata must be a JSON object, this should never be reachable", + return Err(serde::de::Error::custom( + "project disclosure metadata must be a JSON object", )); }; object.insert( From 2396ea3c897e112c1104f96bf6a8572f6777f006 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:38:53 -0400 Subject: [PATCH 18/18] fix(labrinth): migration timestamp --- ...osure.sql => 20260803130000_archived-status-to-disclosure.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/labrinth/migrations/{20260803120000_archived-status-to-disclosure.sql => 20260803130000_archived-status-to-disclosure.sql} (100%) diff --git a/apps/labrinth/migrations/20260803120000_archived-status-to-disclosure.sql b/apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql similarity index 100% rename from apps/labrinth/migrations/20260803120000_archived-status-to-disclosure.sql rename to apps/labrinth/migrations/20260803130000_archived-status-to-disclosure.sql