From a2ca0dda5a774af6c5bf230099fdcb4a9dadc52b 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/10] 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 f3b79b3cfc91047aafe0e14aa0f7a3cd798bc5b7 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/10] 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 9e6d64a25911420998c42d963c70c56dc993df81 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/10] 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 62eb98a9979c7e45d87959022d6a725b4111e15c 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/10] 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 b9c123031b513f10cf356ccb2e0228275ac570f7 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/10] 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 6e5e33b1fda5378e50bf4d0097e311e70aa1d04b 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/10] feat(labrinth): edit project disclosures endpoint --- ...61a8dff02ed316fc0b98754605a6e16e8e846.json | 23 ++++ .../models/project_disclosure_item.rs | 20 +++ apps/labrinth/src/routes/v3/disclosures.rs | 130 +++++++++++++++++- apps/labrinth/src/routes/v3/mod.rs | 1 + 4 files changed, 169 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..4ddf53dc3d 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,123 @@ 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::CustomAuthentication( + "you do not have permission to edit this project's disclosures" + .to_string(), + )); + } + + 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::CustomAuthentication( + "you cannot modify a disclosure set by a moderator".to_string(), + )); + } + } + + 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 600a9806f14e230666d597b25daca015d3a650c9 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/10] 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 c80de3003499185a08c4fcd44765367c16f872e3 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/10] 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 4e250fe144c49afc424b70206198c3939c8493d7 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/10] 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 7fe48e079253cff5c1384df3853a13068f56218c 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/10] 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,