use super::ApiError; use crate::auth::get_user_from_headers; use crate::database; use crate::database::PgPool; use crate::database::models::DBModerationLock; use crate::database::models::moderation_external_item; use crate::models::ids::{OrganizationId, ProjectId}; use crate::models::projects::{ProjectStatus, VersionStatus}; use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; use crate::queue::session::AuthQueue; use crate::util::error::Context; use crate::{ auth::{check_is_moderator_from_headers, get_user_from_bearer_token}, models::pats::Scopes, }; use actix_web::{HttpRequest, delete, get, post, web}; use ariadne::ids::{UserId, random_base62}; use chrono::{DateTime, Utc}; use eyre::eyre; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use xredis::RedisPool; pub mod external_license; mod ownership; pub mod tech_review; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(get_projects) .service(get_project_ids) .service(get_project_meta) .service(set_project_meta) .service(acquire_lock) .service(override_lock) .service(get_lock_status) .service(release_lock) .service(release_lock_beacon) .service(delete_all_locks) .service(web::scope("/tech-review").configure(tech_review::config)) .service( web::scope("/external-license").configure(external_license::config), ); } #[derive(Deserialize, utoipa::ToSchema)] pub struct ProjectsRequestOptions { /// How many projects to fetch. #[serde(default = "default_count")] pub count: u16, /// How many projects to skip. #[serde(default)] pub offset: u32, /// Whether to filter by modpacks that have external dependencies. #[serde(default)] pub has_external_dependencies: Option, /// Whether to exclude projects with pending technical review issues. #[serde(default)] pub exclude_technical_review: bool, /// Text query to search against project and owner fields. #[serde(default)] pub query: Option, /// Project type to filter by. Use `none` for projects without a type. #[serde(default)] pub project_type: Option, /// Sort order for the moderation queue. #[serde(default)] pub sort: Option, } fn default_count() -> u16 { 100 } const MAX_PROJECTS_PER_PAGE: u16 = 200; #[derive( Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema, Default, )] #[serde(rename_all = "snake_case")] pub enum ModerationProjectsSort { #[default] Oldest, Newest, MostExternalDeps, LeastExternalDeps, } impl ModerationProjectsSort { fn as_str(self) -> &'static str { match self { Self::Oldest => "oldest", Self::Newest => "newest", Self::MostExternalDeps => "most_external_deps", Self::LeastExternalDeps => "least_external_deps", } } } #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ModerationProjectsResponse { pub total: i64, pub projects: Vec, } #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ModerationProjectIdsResponse { pub ids: Vec, } /// Lightweight project information for the moderation queue index. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ModerationQueueProject { pub id: ProjectId, pub slug: Option, pub name: String, pub summary: String, pub icon_url: Option, pub status: ProjectStatus, pub requested_status: Option, pub queued: Option>, pub published: DateTime, pub updated: DateTime, pub project_types: Vec, /// Who owns the project. pub ownership: Ownership, /// How many external file dependencies the project has. pub external_dependencies_count: i64, } /// Fetched information on who owns a project. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema, Clone)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Ownership { /// Project is owned by a team, and this is the team owner. User { /// ID of the team owner. id: UserId, /// Name of the team owner. name: String, /// URL of the team owner's icon. icon_url: Option, }, /// Project is owned by an organization. Organization { /// ID of the organization. id: OrganizationId, /// Name of the organization. name: String, /// URL of the organization's icon. icon_url: Option, }, } /// Response for lock status check #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct LockStatusResponse { /// Whether the project is currently locked pub locked: bool, /// Whether the requesting user holds the lock pub is_own_lock: bool, /// Information about who holds the lock (if locked) #[serde(skip_serializing_if = "Option::is_none")] pub locked_by: Option, /// When the lock was acquired #[serde(skip_serializing_if = "Option::is_none")] pub locked_at: Option>, /// When the lock expires #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, /// Whether the lock has expired #[serde(skip_serializing_if = "Option::is_none")] pub expired: Option, } /// Information about the moderator holding the lock #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct LockedByUser { /// User ID (base62 encoded) pub id: String, /// Username pub username: String, /// Avatar URL pub avatar_url: Option, } /// Response for successful lock acquisition #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct LockAcquireResponse { /// Whether lock was successfully acquired pub success: bool, /// Whether the requesting user holds the lock (true when success is true) pub is_own_lock: bool, /// If blocked, info about who holds the lock #[serde(skip_serializing_if = "Option::is_none")] pub locked_by: Option, #[serde(skip_serializing_if = "Option::is_none")] pub locked_at: Option>, /// When the lock expires (present whether acquired or blocked) #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub expired: Option, } /// Response for lock release #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct LockReleaseResponse { pub success: bool, } /// Response for deleting all locks #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeleteAllLocksResponse { pub deleted_count: u64, } /// List projects in the moderation queue. #[utoipa::path( context_path = "/moderation", tag = "moderation", params( ("count" = Option, Query), ("offset" = Option, Query), ("has_external_dependencies" = Option, Query), ("exclude_technical_review" = bool, Query), ("query" = Option, Query), ("project_type" = Option, Query), ("sort" = Option, Query) ), responses((status = OK, body = ModerationProjectsResponse)) )] #[get("/projects")] pub async fn get_projects( req: HttpRequest, pool: web::Data, redis: web::Data, request_opts: web::Query, session_queue: web::Data, ) -> Result, ApiError> { get_projects_internal(req, pool, redis, request_opts, session_queue).await } pub async fn get_projects_internal( req: HttpRequest, pool: web::Data, redis: web::Data, request_opts: web::Query, session_queue: web::Data, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let request_opts = request_opts.into_inner(); let query = normalize_optional_string(request_opts.query.as_deref()); let project_type = normalize_optional_string(request_opts.project_type.as_deref()); let sort = request_opts.sort.unwrap_or_default().as_str(); let listed_version_statuses = listed_version_statuses(); let count = request_opts.count.min(MAX_PROJECTS_PER_PAGE) as i64; let offset = request_opts.offset as i64; let exclude_technical_review = request_opts.exclude_technical_review; let needs_filtered_queue = query.is_some() || project_type.is_some() || request_opts.has_external_dependencies.is_some() || matches!(sort, "most_external_deps" | "least_external_deps"); let (total, projects) = if needs_filtered_queue { let project_rows = sqlx::query!( r#" WITH moderation_projects AS ( SELECT m.id, m.slug, m.name, m.summary, m.description, m.queued, m.published, m.organization_id, m.team_id, m.components FROM mods m WHERE m.status = $1 AND ( $9::boolean = false OR NOT EXISTS ( SELECT 1 FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' ) ) ), external_dependencies AS ( SELECT v.mod_id, COUNT(*) AS external_dependencies_count FROM versions v INNER JOIN moderation_projects mp ON mp.id = v.mod_id INNER JOIN dependencies d ON d.dependent_id = v.id WHERE d.dependency_file_name IS NOT NULL GROUP BY v.mod_id ), version_project_types AS ( SELECT v.mod_id, ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types FROM versions v INNER JOIN moderation_projects mp ON mp.id = v.mod_id INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id WHERE v.status = ANY($2) GROUP BY v.mod_id ), queue_projects AS ( SELECT mp.id, mp.slug, mp.name, mp.summary, mp.description, mp.queued, mp.published, search_organization.name AS organization_name, search_owner.username AS owner_name, CASE WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(vpt.project_types::text[], ARRAY[]::text[]), 'modpack' ), 'minecraft_java_server' ) ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[]) END AS project_types, COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count FROM moderation_projects mp LEFT JOIN organizations search_organization ON search_organization.id = mp.organization_id AND $3::text IS NOT NULL LEFT JOIN LATERAL ( SELECT u.username FROM team_members tm INNER JOIN users u ON u.id = tm.user_id WHERE tm.team_id = mp.team_id AND tm.is_owner ORDER BY tm.ordering ASC LIMIT 1 ) search_owner ON $3::text IS NOT NULL AND mp.organization_id IS NULL LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id ), filtered_projects AS ( SELECT id, queued, published, project_types, external_dependencies_count FROM queue_projects WHERE ( $3::text IS NULL OR name ILIKE '%' || $3 || '%' OR slug ILIKE '%' || $3 || '%' OR summary ILIKE '%' || $3 || '%' OR description ILIKE '%' || $3 || '%' OR owner_name ILIKE '%' || $3 || '%' OR organization_name ILIKE '%' || $3 || '%' OR EXISTS ( SELECT 1 FROM UNNEST(project_types) AS searched_project_type(project_type) WHERE searched_project_type.project_type ILIKE '%' || $3 || '%' ) ) AND ( $4::text IS NULL OR ($4 = 'none' AND CARDINALITY(project_types) = 0) OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[]) OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4 ) ) AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5) ), total AS ( SELECT COUNT(*) AS total_count FROM filtered_projects ), page_ids AS ( SELECT id, queued, published, project_types, external_dependencies_count FROM filtered_projects ORDER BY CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC, CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC, CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST, id ASC OFFSET $7 LIMIT $6 ), page_projects AS ( SELECT m.id, m.slug, m.name, m.summary, m.icon_url, m.status, m.requested_status, m.queued, m.published, m.updated, m.organization_id, m.team_id, o.name AS organization_name, o.icon_url AS organization_icon_url, owner.user_id AS owner_id, owner.username AS owner_name, owner.avatar_url AS owner_icon_url, page_ids.project_types, page_ids.external_dependencies_count FROM page_ids INNER JOIN mods m ON m.id = page_ids.id LEFT JOIN organizations o ON o.id = m.organization_id LEFT JOIN LATERAL ( SELECT tm.user_id, u.username, u.avatar_url FROM team_members tm INNER JOIN users u ON u.id = tm.user_id WHERE tm.team_id = m.team_id AND tm.is_owner ORDER BY tm.ordering ASC LIMIT 1 ) owner ON m.organization_id IS NULL ) SELECT total.total_count AS "total_count!", page_projects.id AS "id?", page_projects.slug AS "slug?", page_projects.name AS "name?", page_projects.summary AS "summary?", page_projects.icon_url AS "icon_url?", page_projects.status AS "status?", page_projects.requested_status AS "requested_status?", page_projects.queued AS "queued?", page_projects.published AS "published?", page_projects.updated AS "updated?", page_projects.organization_id AS "organization_id?", page_projects.organization_name AS "organization_name?", page_projects.organization_icon_url AS "organization_icon_url?", page_projects.owner_id AS "owner_id?", page_projects.owner_name AS "owner_name?", page_projects.owner_icon_url AS "owner_icon_url?", page_projects.project_types AS "project_types?: Vec", page_projects.external_dependencies_count AS "external_dependencies_count?" FROM total LEFT JOIN page_projects ON true ORDER BY CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC, CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC, CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST, CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST, page_projects.id ASC "#, ProjectStatus::Processing.as_str(), &listed_version_statuses, query, project_type, request_opts.has_external_dependencies, count, offset, sort, exclude_technical_review, ) .fetch_all(&**pool) .await .wrap_internal_err("failed to fetch filtered projects awaiting review")?; let total = project_rows.first().map(|row| row.total_count).unwrap_or(0); let mut projects = Vec::new(); for row in project_rows { if let Some(project) = row_to_queue_project( row.id, row.slug, row.name, row.summary, row.icon_url, row.status, row.requested_status, row.queued, row.published, row.updated, row.organization_id, row.organization_name, row.organization_icon_url, row.owner_id, row.owner_name, row.owner_icon_url, row.project_types, row.external_dependencies_count, )? { projects.push(project); } } (total, projects) } else { let project_rows = sqlx::query!( r#" WITH filtered_projects AS ( SELECT m.id, m.queued, m.published FROM mods m WHERE m.status = $1 AND ( $6::boolean = false OR NOT EXISTS ( SELECT 1 FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' ) ) ), total AS ( SELECT COUNT(*) AS total_count FROM filtered_projects ), page_ids AS ( SELECT id, queued, published FROM filtered_projects ORDER BY CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST, id ASC OFFSET $4 LIMIT $3 ), page_project_types AS ( SELECT v.mod_id, ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types FROM versions v INNER JOIN page_ids page ON page.id = v.mod_id INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id WHERE v.status = ANY($2) GROUP BY v.mod_id ), page_external_dependencies AS ( SELECT v.mod_id, COUNT(*) AS external_dependencies_count FROM versions v INNER JOIN page_ids page ON page.id = v.mod_id INNER JOIN dependencies d ON d.dependent_id = v.id WHERE d.dependency_file_name IS NOT NULL GROUP BY v.mod_id ), page_projects AS ( SELECT m.id, m.slug, m.name, m.summary, m.icon_url, m.status, m.requested_status, m.queued, m.published, m.updated, m.organization_id, o.name AS organization_name, o.icon_url AS organization_icon_url, owner.user_id AS owner_id, owner.username AS owner_name, owner.avatar_url AS owner_icon_url, CASE WHEN jsonb_typeof(m.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(ppt.project_types::text[], ARRAY[]::text[]), 'modpack' ), 'minecraft_java_server' ) ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[]) END AS project_types, COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count FROM page_ids page INNER JOIN mods m ON m.id = page.id LEFT JOIN organizations o ON o.id = m.organization_id LEFT JOIN LATERAL ( SELECT tm.user_id, u.username, u.avatar_url FROM team_members tm INNER JOIN users u ON u.id = tm.user_id WHERE tm.team_id = m.team_id AND tm.is_owner ORDER BY tm.ordering ASC LIMIT 1 ) owner ON m.organization_id IS NULL LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id ) SELECT total.total_count AS "total_count!", page_projects.id AS "id?", page_projects.slug AS "slug?", page_projects.name AS "name?", page_projects.summary AS "summary?", page_projects.icon_url AS "icon_url?", page_projects.status AS "status?", page_projects.requested_status AS "requested_status?", page_projects.queued AS "queued?", page_projects.published AS "published?", page_projects.updated AS "updated?", page_projects.organization_id AS "organization_id?", page_projects.organization_name AS "organization_name?", page_projects.organization_icon_url AS "organization_icon_url?", page_projects.owner_id AS "owner_id?", page_projects.owner_name AS "owner_name?", page_projects.owner_icon_url AS "owner_icon_url?", page_projects.project_types AS "project_types?: Vec", page_projects.external_dependencies_count AS "external_dependencies_count?" FROM total LEFT JOIN page_projects ON true ORDER BY CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST, CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST, page_projects.id ASC "#, ProjectStatus::Processing.as_str(), &listed_version_statuses, count, offset, sort, exclude_technical_review, ) .fetch_all(&**pool) .await .wrap_internal_err("failed to fetch projects awaiting review")?; let total = project_rows.first().map(|row| row.total_count).unwrap_or(0); let mut projects = Vec::new(); for row in project_rows { if let Some(project) = row_to_queue_project( row.id, row.slug, row.name, row.summary, row.icon_url, row.status, row.requested_status, row.queued, row.published, row.updated, row.organization_id, row.organization_name, row.organization_icon_url, row.owner_id, row.owner_name, row.owner_icon_url, row.project_types, row.external_dependencies_count, )? { projects.push(project); } } (total, projects) }; Ok(web::Json(ModerationProjectsResponse { total, projects })) } #[utoipa::path( context_path = "/moderation", tag = "moderation", params( ("has_external_dependencies" = Option, Query), ("exclude_technical_review" = bool, Query), ("query" = Option, Query), ("project_type" = Option, Query), ("sort" = Option, Query) ), responses((status = OK, body = ModerationProjectIdsResponse)) )] #[get("/projects/ids")] pub async fn get_project_ids( req: HttpRequest, pool: web::Data, redis: web::Data, request_opts: web::Query, session_queue: web::Data, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let request_opts = request_opts.into_inner(); let query = normalize_optional_string(request_opts.query.as_deref()); let project_type = normalize_optional_string(request_opts.project_type.as_deref()); let sort = request_opts.sort.unwrap_or_default().as_str(); let listed_version_statuses = listed_version_statuses(); let exclude_technical_review = request_opts.exclude_technical_review; let needs_filtered_queue = query.is_some() || project_type.is_some() || request_opts.has_external_dependencies.is_some() || matches!(sort, "most_external_deps" | "least_external_deps"); let ids = if needs_filtered_queue { let project_rows = sqlx::query!( r#" WITH moderation_projects AS ( SELECT m.id, m.slug, m.name, m.summary, m.description, m.queued, m.published, m.organization_id, m.team_id, m.components FROM mods m WHERE m.status = $1 AND ( $7::boolean = false OR NOT EXISTS ( SELECT 1 FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' ) ) ), external_dependencies AS ( SELECT v.mod_id, COUNT(*) AS external_dependencies_count FROM versions v INNER JOIN moderation_projects mp ON mp.id = v.mod_id INNER JOIN dependencies d ON d.dependent_id = v.id WHERE d.dependency_file_name IS NOT NULL GROUP BY v.mod_id ), version_project_types AS ( SELECT v.mod_id, ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types FROM versions v INNER JOIN moderation_projects mp ON mp.id = v.mod_id INNER JOIN loaders_versions lv ON v.id = lv.version_id INNER JOIN loaders l ON lv.loader_id = l.id INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id WHERE v.status = ANY($2) GROUP BY v.mod_id ), queue_projects AS ( SELECT mp.id, mp.slug, mp.name, mp.summary, mp.description, mp.queued, mp.published, search_organization.name AS organization_name, search_owner.username AS owner_name, CASE WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(vpt.project_types::text[], ARRAY[]::text[]), 'modpack' ), 'minecraft_java_server' ) ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[]) END AS project_types, COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count FROM moderation_projects mp LEFT JOIN organizations search_organization ON search_organization.id = mp.organization_id AND $3::text IS NOT NULL LEFT JOIN LATERAL ( SELECT u.username FROM team_members tm INNER JOIN users u ON u.id = tm.user_id WHERE tm.team_id = mp.team_id AND tm.is_owner ORDER BY tm.ordering ASC LIMIT 1 ) search_owner ON $3::text IS NOT NULL AND mp.organization_id IS NULL LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id ) SELECT id FROM queue_projects WHERE ( $3::text IS NULL OR name ILIKE '%' || $3 || '%' OR slug ILIKE '%' || $3 || '%' OR summary ILIKE '%' || $3 || '%' OR description ILIKE '%' || $3 || '%' OR owner_name ILIKE '%' || $3 || '%' OR organization_name ILIKE '%' || $3 || '%' OR EXISTS ( SELECT 1 FROM UNNEST(project_types) AS searched_project_type(project_type) WHERE searched_project_type.project_type ILIKE '%' || $3 || '%' ) ) AND ( $4::text IS NULL OR ($4 = 'none' AND CARDINALITY(project_types) = 0) OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[]) OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4) ) AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5) ORDER BY CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC, CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC, CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST, id ASC "#, ProjectStatus::Processing.as_str(), &listed_version_statuses, query, project_type, request_opts.has_external_dependencies, sort, exclude_technical_review, ) .fetch_all(&**pool) .await .wrap_internal_err("failed to fetch filtered project ids awaiting review")?; project_rows .into_iter() .map(|row| ProjectId::from(database::models::DBProjectId(row.id))) .collect() } else { let project_rows = sqlx::query!( r#" SELECT id FROM mods WHERE status = $1 AND ( $3::boolean = false OR NOT EXISTS ( SELECT 1 FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = mods.id AND didws.status = 'pending' ) ) ORDER BY CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST, CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST, id ASC "#, ProjectStatus::Processing.as_str(), sort, exclude_technical_review, ) .fetch_all(&**pool) .await .wrap_internal_err("failed to fetch project ids awaiting review")?; project_rows .into_iter() .map(|row| ProjectId::from(database::models::DBProjectId(row.id))) .collect() }; Ok(web::Json(ModerationProjectIdsResponse { ids })) } fn row_to_queue_project( id: Option, slug: Option, name: Option, summary: Option, icon_url: Option, status: Option, requested_status: Option, queued: Option>, published: Option>, updated: Option>, organization_id: Option, organization_name: Option, organization_icon_url: Option, owner_id: Option, owner_name: Option, owner_icon_url: Option, project_types: Option>, external_dependencies_count: Option, ) -> Result, ApiError> { let Some(id) = id else { return Ok(None); }; let project_id = ProjectId::from(database::models::DBProjectId(id)); let name = name.wrap_internal_err_with(|| { eyre!("project {project_id} is missing `name` in moderation queue row") })?; let summary = summary.wrap_internal_err_with(|| { eyre!( "project {project_id} is missing `summary` in moderation queue row" ) })?; let status = status.wrap_internal_err_with(|| { eyre!( "project {project_id} is missing `status` in moderation queue row" ) })?; let published = published.wrap_internal_err_with(|| { eyre!( "project {project_id} is missing `published` in moderation queue row" ) })?; let updated = updated.wrap_internal_err_with(|| { eyre!( "project {project_id} is missing `updated` in moderation queue row" ) })?; let ownership = row_to_ownership( project_id, organization_id, organization_name, organization_icon_url, owner_id, owner_name, owner_icon_url, )?; Ok(Some(ModerationQueueProject { id: project_id, slug, name, summary, icon_url, status: ProjectStatus::from_string(&status), requested_status: requested_status .as_deref() .map(ProjectStatus::from_string), queued, published, updated, project_types: project_types.unwrap_or_default(), ownership, external_dependencies_count: external_dependencies_count.unwrap_or(0), })) } fn normalize_optional_string(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } fn listed_version_statuses() -> Vec { VersionStatus::iterator() .filter(|status| status.is_listed()) .map(|status| status.as_str().to_string()) .collect() } fn row_to_ownership( project_id: ProjectId, organization_id: Option, organization_name: Option, organization_icon_url: Option, owner_id: Option, owner_name: Option, owner_icon_url: Option, ) -> Result { if let Some(organization_id) = organization_id { let organization_name = organization_name.wrap_internal_err_with(|| { eyre!( "project {project_id} is owned by organization {} without a valid name", OrganizationId::from(database::models::DBOrganizationId( organization_id )) ) })?; return Ok(Ownership::Organization { id: OrganizationId::from(database::models::DBOrganizationId( organization_id, )), name: organization_name, icon_url: organization_icon_url, }); } let owner_id = owner_id.wrap_internal_err_with(|| { eyre!("project {project_id} is owned by a team without a valid owner") })?; let owner_name = owner_name.wrap_internal_err_with(|| { eyre!( "project {project_id} is owned by a team owner without a valid name" ) })?; Ok(Ownership::User { id: UserId::from(database::models::DBUserId(owner_id)), name: owner_name, icon_url: owner_icon_url, }) } /// Get project moderation metadata. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses((status = OK, body = MissingMetadata)) )] #[get("/project/{id}")] pub async fn get_project_meta( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, info: web::Path<(String,)>, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let project_id = info.into_inner().0; let project = database::models::DBProject::get(&project_id, &**pool, &redis).await?; if let Some(project) = project { let rows = sqlx::query!( " SELECT f.metadata, v.id version_id FROM versions v INNER JOIN files f ON f.version_id = v.id WHERE v.mod_id = $1 ", project.inner.id.0 ) .fetch_all(&**pool) .await?; let mut merged = MissingMetadata { identified: HashMap::new(), flame_files: HashMap::new(), unknown_files: HashMap::new(), }; let mut check_hashes = Vec::new(); let mut check_flames = Vec::new(); for row in rows { if let Some(metadata) = row .metadata .and_then(|x| serde_json::from_value::(x).ok()) { merged.identified.extend(metadata.identified); merged.flame_files.extend(metadata.flame_files); merged.unknown_files.extend(metadata.unknown_files); check_hashes.extend(merged.flame_files.keys().cloned()); check_hashes.extend(merged.unknown_files.keys().cloned()); check_flames .extend(merged.flame_files.values().map(|x| x.id as i32)); } } let rows = sqlx::query!( " SELECT encode(mef.sha1, 'escape') sha1, mel.status status FROM moderation_external_files mef INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id WHERE mef.sha1 = ANY($1) ", &check_hashes .iter() .map(|x| x.as_bytes().to_vec()) .collect::>() ) .fetch_all(&**pool) .await?; for row in rows { if let Some(sha1) = row.sha1 { if let Some(val) = merged.flame_files.remove(&sha1) { merged.identified.insert( sha1, IdentifiedFile { file_name: val.file_name, status: ApprovalType::from_string(&row.status) .unwrap_or(ApprovalType::Unidentified), }, ); } else if let Some(val) = merged.unknown_files.remove(&sha1) { merged.identified.insert( sha1, IdentifiedFile { file_name: val, status: ApprovalType::from_string(&row.status) .unwrap_or(ApprovalType::Unidentified), }, ); } } } let rows = sqlx::query!( " SELECT mel.id, mel.flame_project_id, mel.status status FROM moderation_external_licenses mel WHERE mel.flame_project_id = ANY($1) ", &check_flames, ) .fetch_all(&**pool) .await?; for row in rows { if let Some(sha1) = merged .flame_files .iter() .find(|x| Some(x.1.id as i32) == row.flame_project_id) .map(|x| x.0.clone()) && let Some(val) = merged.flame_files.remove(&sha1) { merged.identified.insert( sha1, IdentifiedFile { file_name: val.file_name.clone(), status: ApprovalType::from_string(&row.status) .unwrap_or(ApprovalType::Unidentified), }, ); } } Ok(web::Json(merged)) } else { Err(ApiError::NotFound) } } #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Judgement { Flame { id: i32, status: ApprovalType, link: String, title: String, }, Unknown { status: ApprovalType, proof: Option, link: Option, title: Option, }, } /// Update project moderation judgements. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses((status = NO_CONTENT)) )] #[post("/project")] pub async fn set_project_meta( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, judgements: web::Json>, ) -> Result<(), ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let mut transaction = pool.begin().await?; let mut licenses = Vec::new(); let mut file_hashes = Vec::new(); let mut file_filenames = Vec::new(); let mut file_license_ids = Vec::new(); for (hash, judgement) in judgements.0 { let id = random_base62(8); let (title, status, link, proof, flame_id) = match judgement { Judgement::Flame { id, status, link, title, } => ( Some(title), status, Some(link), Some("See Flame page/license for permission".to_string()), Some(id), ), Judgement::Unknown { status, proof, link, title, } => (title, status, link, proof, None), }; licenses.push(moderation_external_item::ExternalLicense { id: id as i64, title, status: status.as_str().to_string(), link, proof, flame_project_id: flame_id, }); file_hashes.push(hash); file_filenames.push(None); file_license_ids.push(id as i64); } let user_id = database::models::ids::DBUserId::from(user.id); moderation_external_item::ExternalLicense::insert_many( &mut transaction, &licenses, user_id, ) .await?; moderation_external_item::ExternalLicense::insert_files( &mut transaction, &file_hashes .iter() .map(|x| x.as_bytes().to_vec()) .collect::>(), &file_filenames, &file_license_ids, user_id, ) .await?; transaction.commit().await?; Ok(()) } /// Acquire a moderation lock. /// Returns success if acquired, or info about who holds the lock if blocked. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses( (status = OK, body = LockAcquireResponse), (status = NOT_FOUND, description = "Project not found") ) )] #[post("/lock/{project_id}")] pub async fn acquire_lock( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(String,)>, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? { Ok(()) => Ok(web::Json(LockAcquireResponse { success: true, is_own_lock: true, locked_by: None, locked_at: None, expires_at: None, expired: None, })), Err(lock) => Ok(web::Json(LockAcquireResponse { success: false, is_own_lock: false, locked_by: Some(LockedByUser { id: UserId::from(lock.moderator_id).to_string(), username: lock.moderator_username, avatar_url: lock.moderator_avatar_url, }), locked_at: Some(lock.locked_at), expires_at: Some(lock.expires_at), expired: Some(lock.expired), })), } } /// Override a moderation lock. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses( (status = OK, body = LockAcquireResponse), (status = NOT_FOUND, description = "Project not found") ) )] #[post("/lock/{project_id}/override")] pub async fn override_lock( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(String,)>, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); DBModerationLock::force_acquire(db_project_id, db_user_id, &pool).await?; Ok(web::Json(LockAcquireResponse { success: true, is_own_lock: true, locked_by: None, locked_at: None, expires_at: None, expired: None, })) } /// Get moderation lock status. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses( (status = OK, body = LockStatusResponse), (status = NOT_FOUND, description = "Project not found") ) )] #[get("/lock/{project_id}")] pub async fn get_lock_status( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(String,)>, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); match DBModerationLock::get_with_user(db_project_id, &pool).await? { Some(lock) => { let is_own_lock = lock.moderator_id == db_user_id; Ok(web::Json(LockStatusResponse { locked: true, is_own_lock, locked_by: Some(LockedByUser { id: UserId::from(lock.moderator_id).to_string(), username: lock.moderator_username, avatar_url: lock.moderator_avatar_url, }), locked_at: Some(lock.locked_at), expires_at: Some(lock.expires_at), expired: Some(lock.expired), })) } None => Ok(web::Json(LockStatusResponse { locked: false, is_own_lock: false, locked_by: None, locked_at: None, expires_at: None, expired: None, })), } } /// Release a moderation lock. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses( (status = OK, body = LockReleaseResponse), (status = NOT_FOUND, description = "Project not found") ) )] #[delete("/lock/{project_id}")] pub async fn release_lock( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(String,)>, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); let released = DBModerationLock::release(db_project_id, db_user_id, &pool).await?; let _ = DBModerationLock::cleanup_expired(&pool).await; Ok(web::Json(LockReleaseResponse { success: released })) } /// Release a moderation lock by beacon. /// /// For use with `navigator.sendBeacon`, which cannot set `Authorization` or send `DELETE`. /// The body must be `text/plain` containing the same token value as the `Authorization` header /// (optional `Bearer ` prefix). This avoids a CORS preflight compared to `application/json`. #[utoipa::path( context_path = "/moderation", tag = "moderation", request_body( content = String, description = "Token value (same as Authorization header)", content_type = "text/plain" ), responses( (status = OK, body = LockReleaseResponse), (status = NOT_FOUND, description = "Project not found") ) )] #[post("/lock/{project_id}/release")] pub async fn release_lock_beacon( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(String,)>, body: String, ) -> Result, ApiError> { let token = body.trim(); if token.is_empty() { return Err(ApiError::InvalidInput( "missing token in request body".to_string(), )); } let token = token.strip_prefix("Bearer ").unwrap_or(token).trim(); let (scopes, user) = get_user_from_bearer_token( &req, Some(token), &**pool, &redis, &session_queue, false, ) .await?; if !scopes.contains(Scopes::PROJECT_WRITE) { return Err(ApiError::CustomAuthentication( "token is missing required scopes".to_string(), )); } if !user.role.is_mod() { return Err(ApiError::CustomAuthentication( "only moderators may release moderation locks".to_string(), )); } let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) .await? .ok_or(ApiError::NotFound)?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); let released = DBModerationLock::release(db_project_id, db_user_id, &pool).await?; let _ = DBModerationLock::cleanup_expired(&pool).await; Ok(web::Json(LockReleaseResponse { success: released })) } /// Delete all moderation locks. #[utoipa::path( context_path = "/moderation", tag = "moderation", responses( (status = OK, body = DeleteAllLocksResponse), (status = UNAUTHORIZED, description = "Not an admin") ) )] #[delete("/locks")] pub async fn delete_all_locks( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, ) -> Result, ApiError> { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await? .1; if !user.role.is_admin() { return Err(ApiError::CustomAuthentication( "You must be an admin to delete all locks".to_string(), )); } let deleted_count = DBModerationLock::delete_all(&pool).await?; Ok(web::Json(DeleteAllLocksResponse { deleted_count })) }