From 95f97a44ecc00144cfb405b3345b385861227440 Mon Sep 17 00:00:00 2001 From: Sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:25 -0400 Subject: [PATCH] feat(labrinth): shared instances moderation (#6784) * feat(labrinth): shared instance report type * style(labrinth): cargo fmt * feat(labrinth): check that instance exists when reporting * style(labrinth): cleanup unused import * feat(labrinth): store version id for shared instance reports * style(labrinth): satisfy clippy * fix(labrinth): use string id instead of int --- apps/labrinth/.env.docker-compose | 3 + apps/labrinth/.env.local | 3 + ...0b819aa1ee54f121d2c47903d44fc871e45e.json} | 6 +- ...1e50b852afea1c6e751664cae4fd9e789aab.json} | 24 +++++-- ...20260717120000_reports-shared-instance.sql | 3 + apps/labrinth/src/database/models/ids.rs | 1 + .../src/database/models/report_item.rs | 14 +++- apps/labrinth/src/env.rs | 3 + apps/labrinth/src/models/v2/reports.rs | 1 + apps/labrinth/src/models/v3/reports.rs | 11 +++ apps/labrinth/src/routes/v3/reports.rs | 72 ++++++++++++++++++- 11 files changed, 128 insertions(+), 13 deletions(-) rename apps/labrinth/.sqlx/{query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json => query-d03688d1840db14dc10cc942a8aa0b819aa1ee54f121d2c47903d44fc871e45e.json} (55%) rename apps/labrinth/.sqlx/{query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json => query-de085413113d832b534db925b8bf1e50b852afea1c6e751664cae4fd9e789aab.json} (65%) create mode 100644 apps/labrinth/migrations/20260717120000_reports-shared-instance.sql diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index e89b51b3af..70e6a660bf 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -169,6 +169,9 @@ GOTENBERG_URL=http://labrinth-gotenberg:13000 GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg GOTENBERG_TIMEOUT=30000 +SHARED_INSTANCES_URL=none +SHARED_INSTANCES_KEY=none + ARCHON_URL=none MURALPAY_API_URL=https://api.muralpay.com diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 2ba735b315..f3f117da1d 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -195,6 +195,9 @@ GOTENBERG_URL=http://localhost:13000 GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg GOTENBERG_TIMEOUT=30000 +SHARED_INSTANCES_URL=none +SHARED_INSTANCES_KEY=none + ARCHON_URL=none MURALPAY_API_URL=https://api-staging.muralpay.com diff --git a/apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json b/apps/labrinth/.sqlx/query-d03688d1840db14dc10cc942a8aa0b819aa1ee54f121d2c47903d44fc871e45e.json similarity index 55% rename from apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json rename to apps/labrinth/.sqlx/query-d03688d1840db14dc10cc942a8aa0b819aa1ee54f121d2c47903d44fc871e45e.json index f666afe230..faf079fbac 100644 --- a/apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json +++ b/apps/labrinth/.sqlx/query-d03688d1840db14dc10cc942a8aa0b819aa1ee54f121d2c47903d44fc871e45e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO reports (\n id, report_type_id, mod_id, version_id, user_id,\n body, reporter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7\n )\n ", + "query": "\n INSERT INTO reports (\n id, report_type_id, mod_id, version_id, user_id,\n shared_instance_id, shared_instance_version_id, body, reporter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9\n )\n ", "describe": { "columns": [], "parameters": { @@ -10,11 +10,13 @@ "Int8", "Int8", "Int8", + "Int8", + "Int4", "Varchar", "Int8" ] }, "nullable": [] }, - "hash": "c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c" + "hash": "d03688d1840db14dc10cc942a8aa0b819aa1ee54f121d2c47903d44fc871e45e" } diff --git a/apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json b/apps/labrinth/.sqlx/query-de085413113d832b534db925b8bf1e50b852afea1c6e751664cae4fd9e789aab.json similarity index 65% rename from apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json rename to apps/labrinth/.sqlx/query-de085413113d832b534db925b8bf1e50b852afea1c6e751664cae4fd9e789aab.json index 30c43125df..733676afd6 100644 --- a/apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json +++ b/apps/labrinth/.sqlx/query-de085413113d832b534db925b8bf1e50b852afea1c6e751664cae4fd9e789aab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, t.id thread_id, r.closed\n FROM reports r\n INNER JOIN report_types rt ON rt.id = r.report_type_id\n INNER JOIN threads t ON t.report_id = r.id\n WHERE r.id = ANY($1)\n ORDER BY r.created DESC\n ", + "query": "\n SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.shared_instance_id, r.shared_instance_version_id, r.body, r.reporter, r.created, t.id thread_id, r.closed\n FROM reports r\n INNER JOIN report_types rt ON rt.id = r.report_type_id\n INNER JOIN threads t ON t.report_id = r.id\n WHERE r.id = ANY($1)\n ORDER BY r.created DESC\n ", "describe": { "columns": [ { @@ -30,26 +30,36 @@ }, { "ordinal": 5, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "shared_instance_version_id", + "type_info": "Int4" + }, + { + "ordinal": 7, "name": "body", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 8, "name": "reporter", "type_info": "Int8" }, { - "ordinal": 7, + "ordinal": 9, "name": "created", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 10, "name": "thread_id", "type_info": "Int8" }, { - "ordinal": 9, + "ordinal": 11, "name": "closed", "type_info": "Bool" } @@ -65,6 +75,8 @@ true, true, true, + true, + true, false, false, false, @@ -72,5 +84,5 @@ false ] }, - "hash": "6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f" + "hash": "de085413113d832b534db925b8bf1e50b852afea1c6e751664cae4fd9e789aab" } diff --git a/apps/labrinth/migrations/20260717120000_reports-shared-instance.sql b/apps/labrinth/migrations/20260717120000_reports-shared-instance.sql new file mode 100644 index 0000000000..d835290ecf --- /dev/null +++ b/apps/labrinth/migrations/20260717120000_reports-shared-instance.sql @@ -0,0 +1,3 @@ +ALTER TABLE reports + ADD COLUMN shared_instance_id bigint, + ADD COLUMN shared_instance_version_id integer; diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 2a638326c6..5f92ece21d 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -292,3 +292,4 @@ id_type!(StatusId as i32); id_type!(DelphiReportId as i64); id_type!(DelphiReportIssueId as i64); id_type!(DelphiReportIssueDetailsId as i64); +id_type!(SharedInstanceId as i64); diff --git a/apps/labrinth/src/database/models/report_item.rs b/apps/labrinth/src/database/models/report_item.rs index 506510b058..cf7edea740 100644 --- a/apps/labrinth/src/database/models/report_item.rs +++ b/apps/labrinth/src/database/models/report_item.rs @@ -9,6 +9,8 @@ pub struct DBReport { pub project_id: Option, pub version_id: Option, pub user_id: Option, + pub shared_instance_id: Option, + pub shared_instance_version_id: Option, pub body: String, pub reporter: DBUserId, pub created: DateTime, @@ -21,6 +23,8 @@ pub struct ReportQueryResult { pub project_id: Option, pub version_id: Option, pub user_id: Option, + pub shared_instance_id: Option, + pub shared_instance_version_id: Option, pub body: String, pub reporter: DBUserId, pub created: DateTime, @@ -37,11 +41,11 @@ impl DBReport { " INSERT INTO reports ( id, report_type_id, mod_id, version_id, user_id, - body, reporter + shared_instance_id, shared_instance_version_id, body, reporter ) VALUES ( $1, $2, $3, $4, $5, - $6, $7 + $6, $7, $8, $9 ) ", self.id as DBReportId, @@ -49,6 +53,8 @@ impl DBReport { self.project_id.map(|x| x.0 as i64), self.version_id.map(|x| x.0 as i64), self.user_id.map(|x| x.0 as i64), + self.shared_instance_id.map(|x| x.0 as i64), + self.shared_instance_version_id, self.body, self.reporter as DBUserId ) @@ -83,7 +89,7 @@ impl DBReport { report_ids.iter().map(|x| x.0).collect(); let reports = sqlx::query!( " - SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, t.id thread_id, r.closed + SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.shared_instance_id, r.shared_instance_version_id, r.body, r.reporter, r.created, t.id thread_id, r.closed FROM reports r INNER JOIN report_types rt ON rt.id = r.report_type_id INNER JOIN threads t ON t.report_id = r.id @@ -99,6 +105,8 @@ impl DBReport { project_id: x.mod_id.map(DBProjectId), version_id: x.version_id.map(DBVersionId), user_id: x.user_id.map(DBUserId), + shared_instance_id: x.shared_instance_id.map(SharedInstanceId), + shared_instance_version_id: x.shared_instance_version_id, body: x.body, reporter: DBUserId(x.reporter), created: x.created, diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 431fb65390..2ad219b1de 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -312,6 +312,9 @@ vars! { DELPHI_URL: String = ""; + SHARED_INSTANCES_URL: String = ""; + SHARED_INSTANCES_KEY: String = ""; + AVALARA_1099_API_URL: String = "https://www.track1099.com/api"; AVALARA_1099_API_KEY: String = "none"; AVALARA_1099_API_TEAM_ID: String = "none"; diff --git a/apps/labrinth/src/models/v2/reports.rs b/apps/labrinth/src/models/v2/reports.rs index 7fedbb59af..0f65679d90 100644 --- a/apps/labrinth/src/models/v2/reports.rs +++ b/apps/labrinth/src/models/v2/reports.rs @@ -31,6 +31,7 @@ impl From for LegacyItemType { ItemType::Project => LegacyItemType::Project, ItemType::Version => LegacyItemType::Version, ItemType::User => LegacyItemType::User, + ItemType::SharedInstance => LegacyItemType::Unknown, ItemType::Unknown => LegacyItemType::Unknown, } } diff --git a/apps/labrinth/src/models/v3/reports.rs b/apps/labrinth/src/models/v3/reports.rs index 5f50184ffa..34beeb3f96 100644 --- a/apps/labrinth/src/models/v3/reports.rs +++ b/apps/labrinth/src/models/v3/reports.rs @@ -1,6 +1,7 @@ use crate::database::models::report_item::ReportQueryResult as DBReport; use crate::models::ids::{ProjectId, ReportId, ThreadId, VersionId}; use ariadne::ids::UserId; +use ariadne::ids::base62_impl::to_base62; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -10,6 +11,8 @@ pub struct Report { pub report_type: String, pub item_id: String, pub item_type: ItemType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_instance_version_id: Option, pub reporter: UserId, pub body: String, pub created: DateTime, @@ -23,6 +26,7 @@ pub enum ItemType { Project, Version, User, + SharedInstance, Unknown, } @@ -32,6 +36,7 @@ impl ItemType { ItemType::Project => "project", ItemType::Version => "version", ItemType::User => "user", + ItemType::SharedInstance => "shared-instance", ItemType::Unknown => "unknown", } } @@ -41,6 +46,7 @@ impl From for Report { fn from(x: DBReport) -> Self { let mut item_id = "".to_string(); let mut item_type = ItemType::Unknown; + let mut shared_instance_version_id = None; if let Some(project_id) = x.project_id { item_id = ProjectId::from(project_id).to_string(); @@ -51,6 +57,10 @@ impl From for Report { } else if let Some(user_id) = x.user_id { item_id = UserId::from(user_id).to_string(); item_type = ItemType::User; + } else if let Some(shared_instance_id) = x.shared_instance_id { + item_id = to_base62(shared_instance_id.0 as u64); + item_type = ItemType::SharedInstance; + shared_instance_version_id = x.shared_instance_version_id; } Report { @@ -58,6 +68,7 @@ impl From for Report { report_type: x.report_type, item_id, item_type, + shared_instance_version_id, reporter: x.reporter.into(), body: x.body, created: x.created, diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index 85613a0bfd..c5defa4ddf 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -1,11 +1,13 @@ use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::database; use crate::database::PgPool; +use crate::database::models::SharedInstanceId; use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::{ ThreadBuilder, ThreadMessageBuilder, }; +use crate::env::ENV; use crate::models::ids::ImageId; use crate::models::ids::{ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; @@ -15,11 +17,13 @@ use crate::models::reports::{ItemType, Report}; use crate::models::threads::{MessageBody, ThreadType}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::Context; +use crate::util::http::HTTP_CLIENT; use crate::util::img; use crate::util::routes::read_typed_from_payload; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; -use ariadne::ids::base62_impl::parse_base62; +use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::Utc; use serde::Deserialize; use validator::Validate; @@ -103,6 +107,8 @@ pub async fn report_create( project_id: None, version_id: None, user_id: None, + shared_instance_id: None, + shared_instance_version_id: None, body: new_report.body.clone(), reporter: current_user.id.into(), created: Utc::now(), @@ -169,6 +175,64 @@ pub async fn report_create( report.user_id = Some(user_id.into()) } + ItemType::SharedInstance => { + // parsing + let (instance_part, version_part) = new_report + .item_id + .split_once('/') + .ok_or_else(|| { + ApiError::InvalidInput( + "Shared instance reports must format the item ID as `instance_id/version_id`" + .to_string(), + ) + })?; + + let shared_instance_id = + SharedInstanceId(parse_base62(instance_part)? as i64); + let shared_instance_version_id: i32 = + version_part.parse().map_err(|_| { + ApiError::InvalidInput(format!( + "Shared instance version is not a number: {version_part}" + )) + })?; + + // validation + let url = format!( + "{}/v1/instances/{}", + ENV.SHARED_INSTANCES_URL, + to_base62(shared_instance_id.0 as u64) + ); + let instance_response = HTTP_CLIENT + .get(&url) + .bearer_auth(&ENV.SHARED_INSTANCES_KEY) + .send() + .await + .wrap_internal_err( + "failed to reach the shared instance service (instance lookup)", + )?; + + if !instance_response.status().is_success() { + return Err(ApiError::InvalidInput(format!( + "Shared instance could not be found: {instance_part}" + ))); + } + + let version_response = HTTP_CLIENT + .get(format!("{url}/versions/{version_part}")) + .bearer_auth(&ENV.SHARED_INSTANCES_KEY) + .send() + .await + .wrap_internal_err("failed to reach the shared instance service (version lookup)")?; + + if !version_response.status().is_success() { + return Err(ApiError::InvalidInput(format!( + "Shared instance version could not be found: {instance_part}/{version_part}" + ))); + } + + report.shared_instance_id = Some(shared_instance_id); + report.shared_instance_version_id = Some(shared_instance_version_id) + } ItemType::Unknown => { return Err(ApiError::InvalidInput(format!( "Invalid report item type: {}", @@ -236,8 +300,12 @@ pub async fn report_create( Ok(HttpResponse::Ok().json(Report { id: id.into(), report_type: new_report.report_type.clone(), - item_id: new_report.item_id.clone(), + item_id: match report.shared_instance_id { + Some(shared_instance_id) => to_base62(shared_instance_id.0 as u64), + None => new_report.item_id.clone(), + }, item_type: new_report.item_type.clone(), + shared_instance_version_id: report.shared_instance_version_id, reporter: current_user.id, body: new_report.body.clone(), created: Utc::now(),