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-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json b/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json new file mode 100644 index 0000000000..153b859c15 --- /dev/null +++ b/apps/labrinth/.sqlx/query-763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator\n\t\t\tLIMIT 1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846" +} diff --git a/apps/labrinth/.sqlx/query-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..8b3114452d --- /dev/null +++ b/apps/labrinth/src/database/models/project_disclosure_item.rs @@ -0,0 +1,127 @@ +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 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, + 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/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..8a0161e5ca --- /dev/null +++ b/apps/labrinth/src/models/v3/disclosures.rs @@ -0,0 +1,110 @@ +use crate::database::models::DBProjectDisclosure; +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +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, + code: bool, + assets: bool, + text: bool, + }, + Advertisements { + note: Option, + }, + EpilepsyTriggers { + note: Option, + }, + SystemInteractions { + note: Option, + }, + Telemetry { + consent: TelemetryConsent, + data_collected: Vec, + }, + DerivativeWork { + sources: Vec, + }, + PaidFeatures { + features: Vec, + }, +} + +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, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, +} + +impl ProjectDisclosureData { + pub fn from_db( + value: DBProjectDisclosure, + viewer_is_moderator: bool, + ) -> Self { + let updated_by = (!value.set_by_moderator || viewer_is_moderator) + .then_some(value.updated_by.into()); + + Self { + disclosure: value.disclosure, + set_by_moderator: value.set_by_moderator, + updated_at: value.updated_at, + updated_by, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TelemetryConsent { + OptIn, + OptOut, + AlwaysActive, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct DerivativeSource { + pub link: Option, + pub label: String, + pub note: Option, +} diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 8925b7caec..777de99443 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -3,6 +3,7 @@ pub mod analytics; pub mod analytics_event; pub mod billing; pub mod collections; +pub mod disclosures; pub mod ids; pub mod images; pub mod moderation_notes; diff --git a/apps/labrinth/src/routes/v3/disclosures.rs b/apps/labrinth/src/routes/v3/disclosures.rs new file mode 100644 index 0000000000..4ddf53dc3d --- /dev/null +++ b/apps/labrinth/src/routes/v3/disclosures.rs @@ -0,0 +1,202 @@ +use actix_web::{HttpRequest, get, patch, web}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use xredis::RedisPool; + +use crate::auth::checks::is_visible_project; +use crate::auth::get_user_from_headers; +use crate::database::models as db_models; +use crate::database::{PgPool, ReadOnlyPgPool}; +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) + .service(modify_project_disclosures); +} + +#[derive(Serialize, ToSchema)] +pub struct GetProjectDisclosures { + pub disclosures: Vec, +} + +#[utoipa::path( + context_path = "/project", + tag = "project_disclosures", + responses((status = OK, body = GetProjectDisclosures)) +)] +#[get("/{project_id}/disclosures")] +pub async fn get_project_disclosures( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let (string,) = info.into_inner(); + + let project = db_models::DBProject::get(&string, &***ro_pool, &redis) + .await + .wrap_internal_err("failed to fetch project")? + .ok_or(ApiError::NotFound)?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await + .map(|(_, user)| user) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_internal_err("failed to check project visibility")? + { + return Err(ApiError::NotFound); + } + + let viewer_is_moderator = + user_option.is_some_and(|user| user.role.is_mod()); + + let disclosures = db_models::DBProjectDisclosure::get_many_for_project( + project.inner.id, + &***ro_pool, + ) + .await + .wrap_internal_err("failed to fetch project disclosures")?; + + Ok(web::Json(GetProjectDisclosures { + disclosures: disclosures + .into_iter() + .map(|disclosure| { + ProjectDisclosureData::from_db(disclosure, viewer_is_moderator) + }) + .collect(), + })) +} + +#[derive(Deserialize, ToSchema)] +pub struct ModifyProjectDisclosures { + pub set: Vec, + pub remove: Vec, +} + +#[utoipa::path( + context_path = "/project", + tag = "project_disclosures", + request_body = ModifyProjectDisclosures, + responses((status = NO_CONTENT)) +)] +#[patch("/{project_id}/disclosures")] +pub async fn modify_project_disclosures( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + 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 735daff22f..7a0d92a9c7 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -9,6 +9,7 @@ pub mod analytics_get; pub mod blocked_users; pub mod collections; pub mod content; +pub mod disclosures; pub mod friends; pub mod images; pub mod limits; @@ -44,7 +45,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("/v3/project") .wrap(default_cors()) .configure(projects::project_config) - .configure(project_creation::config), + .configure(project_creation::config) + .configure(disclosures::config), ); cfg.service( web::scope("/v3") @@ -111,6 +113,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { projects::project_unfollow, projects::project_get_organization, projects::dependency_list, + disclosures::get_project_disclosures, + disclosures::modify_project_disclosures, project_creation::project_create, project_creation::project_create_with_id, project_creation::new::create,