use std::{collections::HashMap, fmt}; use crate::database::PgPool; use actix_web::{HttpRequest, get, patch, post, put, web}; use chrono::{DateTime, Utc}; use itertools::Itertools; use serde::{Deserialize, Serialize}; use super::ownership::get_projects_ownership; use crate::{ auth::check_is_moderator_from_headers, database::{ DBProject, models::{ DBFileId, DBProjectId, DBThread, DBThreadId, DBUser, DBVersion, DBVersionId, DelphiReportId, DelphiReportIssueDetailsId, DelphiReportIssueId, delphi_report_item::{ DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict, ReportIssueDetail, }, thread_item::ThreadMessageBuilder, version_item::VersionQueryResult, }, redis::RedisPool, }, models::{ ids::{FileId, ProjectId, ThreadId, VersionId}, pats::Scopes, projects::{Project, ProjectStatus}, threads::{MessageBody, Thread}, }, queue::session::AuthQueue, routes::{ApiError, internal::moderation::Ownership}, util::error::Context, }; use eyre::eyre; pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { cfg.service(search_projects) .service(get_project_report) .service(get_report) .service(get_issue) .service(submit_report) .service(update_issue_details) .service(add_report); } /// Arguments for searching project technical reviews. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SearchProjects { #[serde(default = "default_limit")] #[schema(default = 20)] pub limit: u64, #[serde(default)] #[schema(default = 0)] pub page: u64, #[serde(default)] pub filter: SearchProjectsFilter, #[serde(default = "default_sort_by")] pub sort_by: SearchProjectsSort, } fn default_limit() -> u64 { 20 } fn default_sort_by() -> SearchProjectsSort { SearchProjectsSort::CreatedAsc } #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct SearchProjectsFilter { #[serde(default)] pub project_type: Vec, #[serde(default)] pub replied_to: Option, #[serde(default)] pub project_status: Vec, #[serde(default)] pub issue_type: Vec, } /// Filter by whether a moderator has replied to the last message in the /// project's moderation thread. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema, )] #[serde(rename_all = "snake_case")] pub enum RepliedTo { /// Last message in the thread is from a moderator, indicating a moderator /// has replied to it. Replied, /// Last message in the thread is not from a moderator. Unreplied, } #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema, )] #[serde(rename_all = "snake_case")] pub enum SearchProjectsSort { CreatedAsc, CreatedDesc, SeverityAsc, SeverityDesc, } impl fmt::Display for SearchProjectsSort { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = serde_json::to_value(*self).unwrap(); let s = s.as_str().unwrap(); write!(f, "{s}") } } #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct FileReport { /// ID of this report. pub report_id: DelphiReportId, /// ID of the file that was scanned. pub file_id: FileId, /// When the report for this file was created. pub created: DateTime, /// Why this project was flagged. pub flag_reason: FlagReason, /// According to this report, how likely is the project malicious? pub severity: DelphiSeverity, /// Name of the flagged file. pub file_name: String, /// Size of the flagged file, in bytes. pub file_size: i32, /// URL to download the flagged file. pub download_url: String, /// What issues appeared in the file. #[serde(default)] pub issues: Vec, } /// Issue raised by Delphi in a flagged file. /// /// The issue is scoped to the JAR, not any specific class, but issues can be /// raised because they appeared in a class - see [`FileIssueDetails`]. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct FileIssue { /// ID of the issue. pub id: DelphiReportIssueId, /// ID of the report this issue is a part of. pub report_id: DelphiReportId, /// Delphi-determined kind of issue that this is, e.g. `OBFUSCATED_NAMES`. /// /// Labrinth does not know the full set of kinds of issues, so this is kept /// as a string. pub issue_type: String, /// Details of why this issue might have been raised, such as what file it /// was found in. #[serde(default)] pub details: Vec, } /// Why a project was flagged for technical review. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema, )] #[serde(rename_all = "snake_case")] pub enum FlagReason { /// Delphi anti-malware scanner flagged a file in the project. Delphi, } /// Get info on an issue in a Delphi report. #[utoipa::path( security(("bearer_auth" = [])), responses((status = OK, body = inline(FileIssue))) )] #[get("/issue/{issue_id}")] async fn get_issue( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(DelphiReportIssueId,)>, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let (issue_id,) = path.into_inner(); let row = sqlx::query!( r#" SELECT to_jsonb(dri) || jsonb_build_object( -- TODO: replace with `json_array` in Postgres 16 'details', ( SELECT coalesce(jsonb_agg( jsonb_build_object( 'id', didws.id, 'issue_id', didws.issue_id, 'key', didws.key, 'file_path', didws.file_path, 'decompiled_source', didws.decompiled_source, 'data', didws.data, 'severity', didws.severity, 'status', didws.status ) ), '[]'::jsonb) FROM delphi_issue_details_with_statuses didws WHERE didws.issue_id = dri.id ) ) AS "data!: sqlx::types::Json" FROM delphi_report_issues dri WHERE dri.id = $1 "#, issue_id as DelphiReportIssueId, ) .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch issue from database")? .ok_or(ApiError::NotFound)?; Ok(web::Json(row.data.0)) } /// Get info on a specific report for a project. #[utoipa::path( security(("bearer_auth" = [])), responses((status = OK, body = inline(FileReport))) )] #[get("/report/{id}")] async fn get_report( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(DelphiReportId,)>, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let (report_id,) = path.into_inner(); let row = sqlx::query!( r#" SELECT DISTINCT ON (dr.id) to_jsonb(dr) || jsonb_build_object( 'report_id', dr.id, 'file_id', to_base62(f.id), 'version_id', to_base62(v.id), 'project_id', to_base62(v.mod_id), 'file_name', f.filename, 'file_size', f.size, 'flag_reason', 'delphi', 'download_url', f.url, -- TODO: replace with `json_array` in Postgres 16 'issues', ( SELECT json_agg( to_jsonb(dri) || jsonb_build_object( -- TODO: replace with `json_array` in Postgres 16 'details', ( SELECT coalesce(jsonb_agg( jsonb_build_object( 'id', didws.id, 'issue_id', didws.issue_id, 'key', didws.key, 'file_path', didws.file_path, 'decompiled_source', didws.decompiled_source, 'data', didws.data, 'severity', didws.severity, 'status', didws.status ) ), '[]'::jsonb) FROM delphi_issue_details_with_statuses didws WHERE didws.issue_id = dri.id ) ) ) FROM delphi_report_issues dri WHERE dri.report_id = dr.id -- see delphi.rs todo comment AND dri.issue_type != '__dummy' ) ) AS "data!: sqlx::types::Json" FROM delphi_reports dr INNER JOIN files f ON f.id = dr.file_id INNER JOIN versions v ON v.id = f.version_id WHERE dr.id = $1 "#, report_id as DelphiReportId, ) .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch report from database")? .ok_or(ApiError::NotFound)?; Ok(web::Json(row.data.0)) } /// See [`search_projects`]. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct SearchResponse { /// List of reported projects returned, and their report data. pub project_reports: Vec, /// Fetched project information for projects in the returned reports. pub projects: HashMap, /// Fetched moderation threads for projects in the returned reports. pub threads: HashMap, /// Fetched owner information for projects. pub ownership: HashMap, } /// Response for a single project's technical review report. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectReportResponse { /// The project's technical review report. pub project_report: Option, /// The moderation thread for this project. pub thread: Thread, } /// Single project's reports from a search response. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectReport { /// ID of the project this report is for. pub project_id: ProjectId, /// Highest severity of any report of any file of any version under this /// project. pub max_severity: Option, /// Reports for this project's versions. #[serde(default)] pub versions: Vec, } /// Single project version's reports from a search response. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct VersionReport { /// ID of the project version this report is for. pub version_id: VersionId, /// Reports for this version's files. #[serde(default)] pub files: Vec, } /// Limited set of project information returned by [`search_projects`]. #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectModerationInfo { /// Project ID. pub id: ProjectId, /// Project moderation thread ID. pub thread_id: ThreadId, /// Project name. pub name: String, /// Current project status. pub status: ProjectStatus, /// The aggregated project typos of the versions of this project #[serde(default)] pub project_types: Vec, /// The URL of the icon of the project pub icon_url: Option, } async fn fetch_project_reports( project_ids: &[DBProjectId], pool: &PgPool, redis: &RedisPool, ) -> Result, ApiError> { struct FileRow { file_id: DBFileId, version_id: DBVersionId, url: String, filename: String, size: i32, } struct DelphiReportRow { report_id: DelphiReportId, file_id: DBFileId, created: DateTime, severity: DelphiSeverity, } struct DelphiReportIssueRow { id: DelphiReportIssueId, report_id: DelphiReportId, issue_type: String, } let version_id_rows = sqlx::query!( "SELECT id FROM versions WHERE mod_id = ANY($1::bigint[])", &project_ids.iter().map(|id| id.0).collect::>() ) .fetch_all(pool) .await .wrap_internal_err("failed to fetch version ids")?; let version_ids: Vec = version_id_rows .into_iter() .map(|r| DBVersionId(r.id)) .collect(); let versions = DBVersion::get_many(&version_ids, pool, redis) .await .wrap_internal_err("failed to fetch versions")?; let file_rows = sqlx::query!( r#" SELECT id AS "file_id: DBFileId", version_id AS "version_id: DBVersionId", url, filename, size FROM files WHERE version_id = ANY($1::bigint[]) "#, &version_ids.iter().map(|id| id.0).collect::>() ) .fetch_all(pool) .await .wrap_internal_err("failed to fetch files")?; let report_rows = sqlx::query!( r#" SELECT id AS "report_id!: DelphiReportId", file_id AS "file_id!: DBFileId", created, severity AS "severity!: DelphiSeverity" FROM delphi_reports WHERE file_id = ANY($1::bigint[]) "#, &file_rows.iter().map(|f| f.file_id.0).collect::>() ) .fetch_all(pool) .await .wrap_internal_err("failed to fetch delphi reports")?; let issue_rows = sqlx::query!( r#" SELECT id AS "id: DelphiReportIssueId", report_id AS "report_id: DelphiReportId", issue_type FROM delphi_report_issues WHERE report_id = ANY($1::bigint[]) "#, &report_rows .iter() .map(|r| r.report_id.0) .collect::>() ) .fetch_all(pool) .await .wrap_internal_err("failed to fetch delphi report issues")?; let issue_ids: Vec = issue_rows.iter().map(|i| i.id).collect(); let detail_rows = sqlx::query!( r#" SELECT drid.id AS "id!: DelphiReportIssueDetailsId", drid.issue_id AS "issue_id!: DelphiReportIssueId", drid.key AS "key!: String", drid.jar AS "jar?: String", drid.file_path AS "file_path!: String", drid.data AS "data!: sqlx::types::Json>", drid.severity AS "severity!: DelphiSeverity", COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS "status!: DelphiStatus" FROM delphi_report_issue_details drid INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id INNER JOIN delphi_reports dr ON dr.id = dri.report_id INNER JOIN files f ON f.id = dr.file_id INNER JOIN versions v ON v.id = f.version_id INNER JOIN mods m ON m.id = v.mod_id LEFT JOIN delphi_issue_detail_verdicts didv ON m.id = didv.project_id AND drid.key = didv.detail_key WHERE drid.issue_id = ANY($1::bigint[]) "#, &issue_ids.iter().map(|i| i.0).collect::>() ) .fetch_all(pool) .await .wrap_internal_err("failed to fetch delphi issue details")?; let versions_by_project: HashMap> = versions .into_iter() .into_group_map_by(|v| v.inner.project_id); let files_by_version: HashMap> = file_rows .into_iter() .map(|r| FileRow { file_id: r.file_id, version_id: r.version_id, url: r.url, filename: r.filename, size: r.size, }) .into_group_map_by(|f| f.version_id); let reports_by_file: HashMap> = report_rows .into_iter() .map(|r| DelphiReportRow { report_id: r.report_id, file_id: r.file_id, created: r.created, severity: r.severity, }) .into_group_map_by(|r| r.file_id); let issues_by_report: HashMap> = issue_rows .into_iter() .map(|i| DelphiReportIssueRow { id: i.id, report_id: i.report_id, issue_type: i.issue_type, }) .into_group_map_by(|i| i.report_id); let details_by_issue: HashMap> = detail_rows .into_iter() .map(|d| ReportIssueDetail { id: d.id, issue_id: d.issue_id, key: d.key, jar: d.jar, file_path: d.file_path, decompiled_source: None, data: d.data.0, severity: d.severity, status: d.status, }) .into_group_map_by(|d| d.issue_id); let empty_versions: Vec = vec![]; let empty_files: Vec = vec![]; let empty_reports: Vec = vec![]; let empty_issues: Vec = vec![]; let empty_details: Vec = vec![]; let mut project_reports = Vec::::new(); for project_id in project_ids { let project_versions = versions_by_project .get(project_id) .unwrap_or(&empty_versions); let mut version_reports = Vec::new(); for version_query in project_versions { let version_files = files_by_version .get(&version_query.inner.id) .unwrap_or(&empty_files); let mut file_reports = Vec::new(); for file_row in version_files { let report_list = reports_by_file .get(&file_row.file_id) .unwrap_or(&empty_reports); for report_row in report_list { let report_issues = issues_by_report .get(&report_row.report_id) .unwrap_or(&empty_issues); let mut file_issues = Vec::new(); for issue_row in report_issues { if issue_row.issue_type == "__dummy" { continue; } let issue_details = details_by_issue .get(&issue_row.id) .unwrap_or(&empty_details); file_issues.push(FileIssue { id: issue_row.id, report_id: issue_row.report_id, issue_type: issue_row.issue_type.clone(), details: issue_details.clone(), }); } file_reports.push(FileReport { report_id: report_row.report_id, file_id: FileId::from(file_row.file_id), created: report_row.created, flag_reason: FlagReason::Delphi, severity: report_row.severity, file_name: file_row.filename.clone(), file_size: file_row.size, download_url: file_row.url.clone(), issues: file_issues, }); } } version_reports.push(VersionReport { version_id: VersionId::from(version_query.inner.id), files: file_reports, }); } let max_severity = version_reports .iter() .flat_map(|vr| vr.files.iter()) .map(|fr| fr.severity) .max(); let project_report = ProjectReport { project_id: ProjectId::from(*project_id), max_severity, versions: version_reports, }; project_reports.push(project_report); } Ok(project_reports) } /// Searches all projects which are awaiting technical review. #[utoipa::path( security(("bearer_auth" = [])), responses((status = OK, body = inline(Vec))) )] #[post("/search")] async fn search_projects( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, search_req: web::Json, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let sort_by = search_req.sort_by.to_string(); let limit = search_req.limit.max(50); let offset = limit.saturating_mul(search_req.page); let limit = i64::try_from(limit).wrap_request_err("limit cannot fit into `i64`")?; let offset = i64::try_from(offset) .wrap_request_err("offset cannot fit into `i64`")?; let replied_to_filter = search_req.filter.replied_to.map(|r| match r { RepliedTo::Replied => "replied", RepliedTo::Unreplied => "unreplied", }); let mut project_ids = Vec::::new(); let mut thread_ids = Vec::::new(); let rows = sqlx::query!( r#" SELECT m.id AS "project_id: DBProjectId", MIN(t.id) AS "thread_id!: DBThreadId" FROM mods m INNER JOIN threads t ON t.mod_id = m.id INNER JOIN versions v ON v.mod_id = m.id INNER JOIN files f ON f.version_id = v.id INNER JOIN delphi_reports dr ON dr.file_id = f.id INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id INNER JOIN delphi_report_issue_details drid ON drid.issue_id = dri.id LEFT JOIN delphi_issue_detail_verdicts didv ON m.id = didv.project_id AND drid.key = didv.detail_key LEFT JOIN threads_messages tm_last ON tm_last.thread_id = t.id AND tm_last.id = ( SELECT id FROM threads_messages WHERE thread_id = t.id ORDER BY created DESC LIMIT 1 ) LEFT JOIN users u_last ON u_last.id = tm_last.author_id WHERE ( cardinality($4::text[]) = 0 OR ( 'minecraft_java_server' = ANY($4::text[]) AND ( m.components ? 'minecraft_server' OR m.components ? 'minecraft_java_server' ) ) OR EXISTS ( SELECT 1 FROM versions type_v INNER JOIN loaders_versions type_lv ON type_lv.version_id = type_v.id INNER JOIN loaders_project_types type_lpt ON type_lpt.joining_loader_id = type_lv.loader_id INNER JOIN project_types type_pt ON type_pt.id = type_lpt.joining_project_type_id WHERE type_v.mod_id = m.id AND type_pt.name = ANY($4::text[]) AND ( type_pt.name != 'modpack' OR NOT ( m.components ? 'minecraft_server' OR m.components ? 'minecraft_java_server' ) ) ) ) AND m.status NOT IN ('draft', 'rejected', 'withheld') AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[])) AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[])) AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status) AND ( $5::text IS NULL OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin'))) OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin')) ) GROUP BY m.id ORDER BY CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC, CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC, CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC, CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC, -- tie-breaker: oldest reports MIN(dr.created) ASC LIMIT $1 OFFSET $2 "#, limit, offset, &sort_by, &search_req.filter.project_type, replied_to_filter.as_deref(), &search_req .filter .project_status .iter() .map(|status| status.to_string()) .collect::>(), &search_req .filter .issue_type ) .fetch_all(&**pool) .await .wrap_internal_err("failed to fetch projects")?; for row in rows { project_ids.push(row.project_id); thread_ids.push(row.thread_id); } let project_reports = fetch_project_reports(&project_ids, &pool, &redis).await?; let projects = DBProject::get_many_ids(&project_ids, &**pool, &redis) .await .wrap_internal_err("failed to fetch projects")? .into_iter() .map(|project| { (ProjectId::from(project.inner.id), Project::from(project)) }) .collect::>(); let db_threads = DBThread::get_many(&thread_ids, &**pool) .await .wrap_internal_err("failed to fetch threads")?; let thread_author_ids = db_threads .iter() .flat_map(|thread| { thread .messages .iter() .filter_map(|message| message.author_id) }) .collect::>(); let thread_authors = DBUser::get_many_ids(&thread_author_ids, &**pool, &redis) .await .wrap_internal_err("failed to fetch thread authors")? .into_iter() .map(From::from) .collect::>(); let threads = db_threads .into_iter() .map(|thread| { let thread = Thread::from(thread, thread_authors.clone(), &user); (thread.id, thread) }) .collect::>(); let project_list: Vec = projects.values().cloned().collect(); let ownerships = get_projects_ownership(&project_list, &pool, &redis) .await .wrap_internal_err("failed to fetch project ownerships")?; let ownership = projects .keys() .copied() .zip(ownerships) .collect::>(); Ok(web::Json(SearchResponse { project_reports, projects: projects .into_iter() .map(|(id, project)| { ( id, ProjectModerationInfo { id, thread_id: project.thread_id, name: project.name, status: project.status, project_types: project.project_types, icon_url: project.icon_url, }, ) }) .collect(), threads, ownership, })) } /// Gets the technical review report for a specific project. #[utoipa::path( security(("bearer_auth" = [])), responses((status = OK, body = inline(ProjectReportResponse))) )] #[get("/project/{id}")] async fn get_project_report( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, path: web::Path<(ProjectId,)>, ) -> Result, ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_READ, ) .await?; let (project_id,) = path.into_inner(); let db_project_id = DBProjectId::from(project_id); let row = sqlx::query!( r#" SELECT t.id AS "thread_id: DBThreadId" FROM threads t WHERE t.mod_id = $1 "#, db_project_id as _, ) .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch thread")? .ok_or(ApiError::NotFound)?; let project_reports = fetch_project_reports(&[db_project_id], &pool, &redis).await?; let project_report = project_reports.into_iter().next(); let db_threads = DBThread::get_many(&[row.thread_id], &**pool) .await .wrap_internal_err("failed to fetch thread")?; let thread_author_ids = db_threads .iter() .flat_map(|thread| { thread .messages .iter() .filter_map(|message| message.author_id) }) .collect::>(); let thread_authors = DBUser::get_many_ids(&thread_author_ids, &**pool, &redis) .await .wrap_internal_err("failed to fetch thread authors")? .into_iter() .map(From::from) .collect::>(); let threads = db_threads .into_iter() .map(|thread| { let thread = Thread::from(thread, thread_authors.clone(), &user); (thread.id, thread) }) .collect::>(); let thread = threads .get(&row.thread_id.into()) .cloned() .ok_or(ApiError::NotFound)?; Ok(web::Json(ProjectReportResponse { project_report, thread, })) } /// See [`submit_report`]. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SubmitReport { /// Does the moderator think this report shows that the project is safe or /// unsafe? pub verdict: DelphiVerdict, /// Moderator message to send to the thread when rejecting the project. pub message: Option, } /// Submits a verdict for a project based on its technical reports. /// /// Before this is called, all issues for this project's reports must have been /// marked as either safe or unsafe. Otherwise, this will error with /// [`ApiError::TechReviewIssuesWithNoVerdict`], providing the issue IDs which /// are still unmarked. #[utoipa::path( security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] #[post("/submit/{project_id}")] async fn submit_report( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, web::Json(submit_report): web::Json, path: web::Path<(ProjectId,)>, ) -> Result<(), ApiError> { let user = check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let (project_id,) = path.into_inner(); let project_id = DBProjectId::from(project_id); let mut txn = pool .begin() .await .wrap_internal_err("failed to begin transaction")?; let pending_issue_details = sqlx::query!( r#" SELECT didws.id AS "issue_detail_id!" FROM mods m INNER JOIN versions v ON v.mod_id = m.id INNER JOIN files f ON f.version_id = v.id INNER JOIN delphi_reports dr ON dr.file_id = f.id INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id INNER JOIN delphi_issue_details_with_statuses didws ON didws.issue_id = dri.id WHERE m.id = $1 AND didws.status = 'pending' -- see delphi.rs todo comment AND dri.issue_type != '__dummy' "#, project_id as _, ) .fetch_all(&mut txn) .await .wrap_internal_err("failed to fetch pending issues")?; if !pending_issue_details.is_empty() { return Err(ApiError::TechReviewDetailsWithNoVerdict { details: pending_issue_details .into_iter() .map(|record| { DelphiReportIssueDetailsId(record.issue_detail_id) }) .collect(), }); } sqlx::query!( " DELETE FROM delphi_report_issue_details drid WHERE issue_id IN ( SELECT dri.id FROM mods m INNER JOIN versions v ON v.mod_id = m.id INNER JOIN files f ON f.version_id = v.id INNER JOIN delphi_reports dr ON dr.file_id = f.id INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id WHERE m.id = $1 AND dri.issue_type = '__dummy' ) ", project_id as _, ) .execute(&mut txn) .await .wrap_internal_err("failed to delete dummy issue")?; let record = sqlx::query!( r#" SELECT t.id AS "thread_id: DBThreadId" FROM mods m INNER JOIN threads t ON t.mod_id = m.id WHERE m.id = $1 "#, project_id as _, ) .fetch_one(&mut txn) .await .wrap_internal_err("failed to update reports")?; if let Some(body) = submit_report.message { ThreadMessageBuilder { author_id: Some(user.id.into()), body: MessageBody::Text { body, private: true, replying_to: None, associated_images: Vec::new(), }, thread_id: record.thread_id, hide_identity: user.role.is_mod(), } .insert(&mut txn) .await .wrap_internal_err("failed to add moderator message")?; } let verdict = submit_report.verdict; ThreadMessageBuilder { author_id: Some(user.id.into()), body: MessageBody::TechReview { verdict }, thread_id: record.thread_id, hide_identity: user.role.is_mod(), } .insert(&mut txn) .await .wrap_internal_err("failed to add tech review message")?; if verdict == DelphiVerdict::Unsafe { let record = sqlx::query!( r#" WITH old AS ( SELECT id, status FROM mods WHERE id = $2 ), updated AS ( UPDATE mods SET status = $1 FROM threads t WHERE mods.id = $2 AND t.mod_id = mods.id RETURNING mods.id, t.id AS thread_id ) SELECT updated.thread_id AS "thread_id: DBThreadId", old.status AS "old_status!" FROM updated INNER JOIN old ON old.id = updated.id "#, ProjectStatus::Rejected.as_str(), project_id as _, ) .fetch_one(&mut txn) .await .wrap_internal_err("failed to mark project as rejected")?; ThreadMessageBuilder { author_id: Some(user.id.into()), body: MessageBody::StatusChange { new_status: ProjectStatus::Rejected, old_status: ProjectStatus::from_string(&record.old_status), }, thread_id: record.thread_id, hide_identity: user.role.is_mod(), } .insert(&mut txn) .await .wrap_internal_err("failed to add tech review message")?; DBProject::clear_cache(project_id, None, None, &redis) .await .wrap_internal_err("failed to clear project cache")?; } txn.commit() .await .wrap_internal_err("failed to commit transaction")?; Ok(()) } /// See [`update_issue`]. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UpdateIssue { /// ID of the issue detail to update. pub detail_id: DelphiReportIssueDetailsId, /// What the moderator has decided the outcome of this issue is. pub verdict: DelphiVerdict, } /// Updates the state of a technical review issue detail. /// /// This will not automatically reject the project for malware, but just flag /// this issue with a verdict. #[utoipa::path( security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] #[patch("/issue-detail")] async fn update_issue_details( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, update_reqs: web::Json>, ) -> Result<(), ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let mut txn = pool .begin() .await .wrap_internal_err("failed to start transaction")?; let updates = update_reqs.into_inner(); let detail_ids = updates.iter().map(|u| u.detail_id.0).collect::>(); let verdicts = updates .iter() .map(|u| match u.verdict { DelphiVerdict::Safe => "safe".to_string(), DelphiVerdict::Unsafe => "unsafe".to_string(), }) .collect::>(); let record = sqlx::query!( r#" WITH incoming AS ( SELECT * FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY AS u(detail_id, verdict, ord) ), resolved AS ( SELECT i.ord, didws.project_id, didws.key AS detail_key, i.verdict::delphi_report_issue_status AS verdict FROM incoming i INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id WHERE -- see delphi.rs todo comment dri.issue_type != '__dummy' ), validated AS ( SELECT (SELECT COUNT(*) FROM incoming) AS incoming_count, (SELECT COUNT(*) FROM resolved) AS resolved_count ), upserted AS ( INSERT INTO delphi_issue_detail_verdicts ( project_id, detail_key, verdict ) SELECT DISTINCT ON (project_id, detail_key) project_id, detail_key, verdict FROM resolved ORDER BY project_id, detail_key, ord DESC ON CONFLICT (project_id, detail_key) DO UPDATE SET verdict = EXCLUDED.verdict RETURNING 1 ) SELECT (v.incoming_count = v.resolved_count) AS "all_found!", (SELECT COUNT(*) FROM upserted) AS "upserted_count!" FROM validated v "#, &detail_ids, &verdicts, ) .fetch_one(&mut txn) .await .wrap_internal_err("failed to update issue details")?; if !record.all_found { return Err(ApiError::Request(eyre!("issue detail does not exist"))); } txn.commit() .await .wrap_internal_err("failed to commit transaction")?; Ok(()) } /// See [`add_report`]. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct AddReport { pub file_id: FileId, } /// Adds a file to the technical review queue by adding an empty report, if one /// does not already exist for it. #[utoipa::path] #[put("/report")] async fn add_report( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, web::Json(add_report): web::Json, ) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PROJECT_WRITE, ) .await?; let file_id = add_report.file_id; let mut txn = pool .begin() .await .wrap_internal_err("failed to begin transaction")?; let record = sqlx::query!( r#" SELECT f.url, COUNT(dr.id) AS "report_count!" FROM files f LEFT JOIN delphi_reports dr ON dr.file_id = f.id WHERE f.id = $1 GROUP BY f.url "#, DBFileId::from(file_id) as _, ) .fetch_one(&mut txn) .await .wrap_internal_err("failed to fetch file")?; if record.report_count > 0 { return Err(ApiError::Request(eyre!("file already has reports"))); } let report_id = DBDelphiReport { id: DelphiReportId(0), file_id: Some(file_id.into()), delphi_version: -1, // TODO artifact_url: record.url, created: Utc::now(), severity: DelphiSeverity::Low, // TODO } .upsert(&mut txn) .await .wrap_internal_err("failed to insert report")?; txn.commit() .await .wrap_internal_err("failed to commit transaction")?; Ok(web::Json(report_id)) }