diff --git a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue index 33688274b..e2e4fc4b0 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue @@ -5,6 +5,7 @@ import { CheckCircleIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, ClipboardCopyIcon, CodeIcon, CopyIcon, @@ -159,6 +160,15 @@ watch(selectedFile, (newFile) => { const client = injectModrinthClient() +async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) { + await client.request('/moderation/tech-review/issue-detail', { + api: 'labrinth', + version: 'internal', + method: 'PATCH', + body: data, + }) +} + const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record const detailDecisions = reactive>(new Map()) @@ -393,11 +403,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') { isBatchUpdating.value = true try { - await Promise.all( - detailIds.map((detailId) => - client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }), - ), - ) + await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict }))) const decision = verdict === 'safe' ? 'safe' : 'malware' for (const detailId of detailIds) { @@ -445,7 +451,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') updatingDetails.add(detailId) try { - await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }) + await updateIssueDetails([{ detail_id: detailId, verdict }]) const decision = verdict === 'safe' ? 'safe' : 'malware' @@ -484,7 +490,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') for (const classGroup of groupedByClass.value) { const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId) if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) { - expandedClasses.delete(classGroup.filePath) + expandedClasses.delete(classGroup.key) break } } @@ -533,6 +539,8 @@ const expandedClasses = reactive>(new Set()) const showCopyFeedback = reactive>(new Map()) interface ClassGroup { + key: string + jar: string | null filePath: string flags: Array<{ issueId: string @@ -543,6 +551,26 @@ interface ClassGroup { }> } +interface JarGroup { + key: string + jar: string | null + segments: string[] + classes: ClassGroup[] +} + +function splitJarSegments(jar: string | null, currentFileName: string | null): string[] { + if (!jar) return [] + const segments = jar + .split('#') + .map((s) => decodeURIComponent(s.trim())) + .filter((s) => s.length > 0) + // Skip the first segment if it matches the current file tab (it's already shown in the file list) + if (segments.length > 0 && currentFileName && segments[0] === currentFileName) { + return segments.slice(1) + } + return segments +} + const groupedByClass = computed(() => { if (!selectedFile.value) return [] @@ -550,14 +578,20 @@ const groupedByClass = computed(() => { for (const issue of selectedFile.value.issues) { for (const detail of issue.details) { - if (!classMap.has(detail.file_path)) { - classMap.set(detail.file_path, { filePath: detail.file_path, flags: [] }) + const classKey = `${detail.jar ?? ''}::${detail.file_path}` + if (!classMap.has(classKey)) { + classMap.set(classKey, { + key: classKey, + jar: detail.jar ?? null, + filePath: detail.file_path, + flags: [], + }) } // Cast detail to include status (backend will provide this field) const detailWithStatus = detail as Labrinth.TechReview.Internal.ReportIssueDetail & { status: Labrinth.TechReview.Internal.DelphiReportIssueStatus } - classMap.get(detail.file_path)!.flags.push({ + classMap.get(classKey)!.flags.push({ issueId: issue.id, issueType: issue.issue_type, detail: detailWithStatus, @@ -585,12 +619,35 @@ const groupedByClass = computed(() => { }) }) +const groupedByJar = computed(() => { + const jarMap = new Map() + + for (const classItem of groupedByClass.value) { + const jarKey = classItem.jar ?? '' + if (!jarMap.has(jarKey)) { + jarMap.set(jarKey, { + key: jarKey, + jar: classItem.jar, + segments: splitJarSegments(classItem.jar, selectedFile.value?.file_name ?? null), + classes: [], + }) + } + jarMap.get(jarKey)!.classes.push(classItem) + } + + return Array.from(jarMap.values()).sort((a, b) => { + const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags)) + const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags)) + return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0) + }) +}) + // Auto-expand if there's only one class in the file watch( groupedByClass, (classes) => { if (classes.length === 1) { - expandedClasses.add(classes[0].filePath) + expandedClasses.add(classes[0].key) } }, { immediate: true }, @@ -608,11 +665,11 @@ function getHighestSeverityInClass( ) } -function toggleClass(filePath: string) { - if (expandedClasses.has(filePath)) { - expandedClasses.delete(filePath) +function toggleClass(classKey: string) { + if (expandedClasses.has(classKey)) { + expandedClasses.delete(classKey) } else { - expandedClasses.add(filePath) + expandedClasses.add(classKey) } } @@ -1097,204 +1154,234 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
-
- -
- -
-
-
-
- {{ - flag.issueType.replace(/_/g, ' ') - }} -
- {{ - capitalizeString(flag.detail.severity) - }} -
-
- -
- - - - - - - -
-
-
-
- {{ key }}: - - {{ value }} - - {{ value }} -
-
-
- -
- +
+
+
+ -
+ {{ + truncateMiddle(classItem.filePath) + }} + +
+ {{ + capitalizeString(getHighestSeverityInClass(classItem.flags)) + }} +
+ +
+ + {{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags +
+ +
+ + + Loading source... + +
+
+
+
+ + +
+
+
+
+ {{ + flag.issueType.replace(/_/g, ' ') + }} +
+ {{ + capitalizeString(flag.detail.severity) + }} +
+
+ +
+ + + + + + + +
+
+
- {{ n + 1 }} -
-
-

+											{{ key }}:
+											
+												{{ value }}
+											
+											{{ value }}
 										
+ +
+ + + + +
+
+
+ {{ n + 1 }} +
+
+

+											
+
+
+
+
+

+ Source code not available or failed to decompile for this file. +

+
-
-

- Source code not available or failed to decompile for this file. -

-
-
- + +
diff --git a/apps/labrinth/.sqlx/query-80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0.json b/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json similarity index 62% rename from apps/labrinth/.sqlx/query-80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0.json rename to apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json index 602c4e02a..23c13cd16 100644 --- a/apps/labrinth/.sqlx/query-80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0.json +++ b/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ", + "query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.jar AS \"jar?: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ", "describe": { "columns": [ { @@ -20,16 +20,21 @@ }, { "ordinal": 3, - "name": "file_path!: String", + "name": "jar?: String", "type_info": "Text" }, { "ordinal": 4, + "name": "file_path!: String", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "data!: sqlx::types::Json>", "type_info": "Jsonb" }, { - "ordinal": 5, + "ordinal": 6, "name": "severity!: DelphiSeverity", "type_info": { "Custom": { @@ -46,7 +51,7 @@ } }, { - "ordinal": 6, + "ordinal": 7, "name": "status!: DelphiStatus", "type_info": { "Custom": { @@ -71,11 +76,12 @@ false, false, false, + true, false, false, false, null ] }, - "hash": "80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0" + "hash": "263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a" } diff --git a/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json b/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json new file mode 100644 index 000000000..85dbe5f87 --- /dev/null +++ b/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n 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'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id: DBProjectId", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id!: DBThreadId", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Int4Array", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004" +} diff --git a/apps/labrinth/.sqlx/query-555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256.json b/apps/labrinth/.sqlx/query-555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256.json new file mode 100644 index 000000000..2e5491c6d --- /dev/null +++ b/apps/labrinth/.sqlx/query-555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "thread_id: DBThreadId", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256" +} diff --git a/apps/labrinth/.sqlx/query-8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d.json b/apps/labrinth/.sqlx/query-8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d.json new file mode 100644 index 000000000..1ab82223c --- /dev/null +++ b/apps/labrinth/.sqlx/query-8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_in_tech_review!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d" +} diff --git a/apps/labrinth/.sqlx/query-b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4.json b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json similarity index 72% rename from apps/labrinth/.sqlx/query-b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4.json rename to apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json index a0ea4442e..8ca4b6949 100644 --- a/apps/labrinth/.sqlx/query-b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4.json +++ b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", + "query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", "describe": { "columns": [ { @@ -15,6 +15,7 @@ "Text", "Text", "Text", + "Text", "Jsonb", { "Custom": { @@ -35,5 +36,5 @@ false ] }, - "hash": "b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4" + "hash": "9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2" } diff --git a/apps/labrinth/.sqlx/query-997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195.json b/apps/labrinth/.sqlx/query-997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195.json new file mode 100644 index 000000000..ccb1ff7d6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_unflagged_issue_details!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195" +} diff --git a/apps/labrinth/.sqlx/query-b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706.json b/apps/labrinth/.sqlx/query-b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706.json deleted file mode 100644 index 016b67f65..000000000 --- a/apps/labrinth/.sqlx/query-b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n didws.project_id,\n didws.key,\n $1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.id = $2\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - { - "Custom": { - "name": "delphi_report_issue_status", - "kind": { - "Enum": [ - "pending", - "safe", - "unsafe" - ] - } - } - }, - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706" -} diff --git a/apps/labrinth/.sqlx/query-ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f.json b/apps/labrinth/.sqlx/query-ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f.json new file mode 100644 index 000000000..b676807d6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict::delphi_report_issue_status AS verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "all_found!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "upserted_count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f" +} diff --git a/apps/labrinth/.sqlx/query-f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac.json b/apps/labrinth/.sqlx/query-f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac.json deleted file mode 100644 index 89941e97b..000000000 --- a/apps/labrinth/.sqlx/query-f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT ON (m.id)\n m.id AS \"project_id: DBProjectId\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n 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'))\n )\n GROUP BY m.id, t.id\n ORDER BY m.id,\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MAX(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC\n LIMIT $1 OFFSET $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "project_id: DBProjectId", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "thread_id: DBThreadId", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text", - "Int4Array", - "Text", - "TextArray", - "TextArray" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac" -} diff --git a/apps/labrinth/migrations/20260219205041_delphi_issue_detail_jars.sql b/apps/labrinth/migrations/20260219205041_delphi_issue_detail_jars.sql new file mode 100644 index 000000000..3aa1f3673 --- /dev/null +++ b/apps/labrinth/migrations/20260219205041_delphi_issue_detail_jars.sql @@ -0,0 +1,2 @@ +ALTER TABLE delphi_report_issue_details +ADD COLUMN jar TEXT; diff --git a/apps/labrinth/src/database/models/delphi_report_item.rs b/apps/labrinth/src/database/models/delphi_report_item.rs index f594b9bde..a88439d67 100644 --- a/apps/labrinth/src/database/models/delphi_report_item.rs +++ b/apps/labrinth/src/database/models/delphi_report_item.rs @@ -221,6 +221,9 @@ pub struct ReportIssueDetail { /// This acts as a stable identifier for an issue detail, even across /// different versions of the same file. pub key: String, + /// If this detail was found inside a JAR embedded inside the scanned JAR, + /// this will point to the path of that JAR inside the outer JAR. + pub jar: Option, /// Name of the Java class path in which this issue was found. pub file_path: String, /// Decompiled, pretty-printed source of the Java class. @@ -241,12 +244,13 @@ impl ReportIssueDetail { ) -> Result { Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!( " - INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id ", self.issue_id as DelphiReportIssueId, self.key, + self.jar, self.file_path, self.decompiled_source, sqlx::types::Json(&self.data) as Json<&HashMap>, diff --git a/apps/labrinth/src/routes/internal/delphi.rs b/apps/labrinth/src/routes/internal/delphi.rs index 0f0815567..20c051ca6 100644 --- a/apps/labrinth/src/routes/internal/delphi.rs +++ b/apps/labrinth/src/routes/internal/delphi.rs @@ -59,15 +59,42 @@ static DELPHI_CLIENT: LazyLock = LazyLock::new(|| { .unwrap() }); -#[derive(Deserialize)] +/// Type of [`DelphiReportIssueDetails::key`]. +/// +/// Delphi may provide `null` for the key, but we require a key for storing +/// issue details in the database, since detail verdicts are keyed by +/// (project id, issue detail key). Keys are opaque strings generated by Delphi +/// which refer to some "unique location" in a JAR file, such that subsequent +/// Delphi scans of different JARs with the same issue detail will result in +/// having the same key. +/// +/// If Delphi doesn't provide us with a key, we generate a random one. +#[derive(Debug, Clone)] +pub struct IssueDetailKey(pub String); + +impl<'de> Deserialize<'de> for IssueDetailKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + let value = value.unwrap_or_else(|| { + format!("", rand::random::()) + }); + Ok(Self(value)) + } +} + +#[derive(Debug, Deserialize)] struct DelphiReportIssueDetails { pub file: String, - pub key: String, + pub key: IssueDetailKey, + pub jar: Option, pub data: HashMap, pub severity: DelphiSeverity, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] struct DelphiReport { pub url: String, pub project_id: crate::models::ids::ProjectId, @@ -205,11 +232,34 @@ async fn ingest_report_deserialized( .await .wrap_internal_err("failed to check if pending issue details exist")?; - if record.pending_issue_details_exist { - info!( - "File's project already has pending issue details, is not entering tech review queue" - ); - } else { + let issue_detail_keys = report + .issues + .values() + .flatten() + .map(|issue_detail| issue_detail.key.0.clone()) + .collect::>(); + + let has_unflagged_issue_details = sqlx::query!( + r#" + SELECT EXISTS( + SELECT 1 + FROM unnest($2::text[]) AS incoming(detail_key) + LEFT JOIN delphi_issue_detail_verdicts didv + ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key + WHERE didv.project_id IS NULL + ) AS "has_unflagged_issue_details!" + "#, + DBProjectId::from(report.project_id) as _, + &issue_detail_keys + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to check if report has unflagged issue details")?; + + let should_enter_tech_review = !record.pending_issue_details_exist + && has_unflagged_issue_details.has_unflagged_issue_details; + + if should_enter_tech_review { info!("File's project is entering tech review queue"); ThreadMessageBuilder { @@ -221,6 +271,10 @@ async fn ingest_report_deserialized( .insert(&mut transaction) .await .wrap_internal_err("failed to add entering tech review message")?; + } else { + info!( + "File's project is not entering tech review queue (already pending or no new unflagged issue details)" + ); } // TODO: Currently, the way we determine if an issue is in tech review or not @@ -232,7 +286,7 @@ async fn ingest_report_deserialized( // This is undesirable, but we can't rework the database schema to fix it // right now. As a hack, we add a dummy report issue which blocks the // project from exiting the tech review queue. - { + if should_enter_tech_review { let dummy_issue_id = DBDelphiReportIssue { id: DelphiReportIssueId(0), // This will be set by the database report_id, @@ -245,6 +299,7 @@ async fn ingest_report_deserialized( id: DelphiReportIssueDetailsId(0), // This will be set by the database issue_id: dummy_issue_id, key: "".into(), + jar: None, file_path: "".into(), decompiled_source: None, data: HashMap::new(), @@ -275,7 +330,8 @@ async fn ingest_report_deserialized( ReportIssueDetail { id: DelphiReportIssueDetailsId(0), // This will be set by the database issue_id, - key: issue_detail.key, + key: issue_detail.key.0, + jar: issue_detail.jar, file_path: issue_detail.file, decompiled_source: decompiled_source.cloned().flatten(), data: issue_detail.data, @@ -333,6 +389,83 @@ pub async fn run( Ok(HttpResponse::NoContent().finish()) } +pub async fn is_project_in_tech_review( + project_id: DBProjectId, + exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>, +) -> Result { + let row = sqlx::query!( + r#" + SELECT EXISTS( + SELECT 1 + FROM delphi_issue_details_with_statuses didws + INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id + WHERE + didws.project_id = $1 + AND didws.status = 'pending' + -- see delphi.rs todo comment + AND dri.issue_type != '__dummy' + ) AS "is_in_tech_review!" + "#, + project_id as _, + ) + .fetch_one(exec) + .await + .wrap_internal_err("failed to fetch project tech review state")?; + + Ok(row.is_in_tech_review) +} + +pub async fn send_tech_review_exit_file_deleted_message( + project_id: DBProjectId, + txn: &mut crate::database::PgTransaction<'_>, +) -> Result<(), ApiError> { + let thread = sqlx::query!( + r#" + SELECT id AS "thread_id: DBThreadId" + FROM threads + WHERE mod_id = $1 + LIMIT 1 + "#, + project_id as _, + ) + .fetch_optional(&mut *txn) + .await + .wrap_internal_err("failed to fetch thread for tech review exit message")?; + + if let Some(thread) = thread { + ThreadMessageBuilder { + author_id: None, + body: MessageBody::TechReviewExitFileDeleted, + thread_id: thread.thread_id, + hide_identity: false, + } + .insert(txn) + .await + .wrap_internal_err("failed to add tech review exit message")?; + } + + Ok(()) +} + +pub async fn send_tech_review_exit_file_deleted_message_if_exited( + project_id: DBProjectId, + was_in_tech_review: bool, + txn: &mut crate::database::PgTransaction<'_>, +) -> Result<(), ApiError> { + if !was_in_tech_review { + return Ok(()); + } + + let is_still_in_tech_review = + is_project_in_tech_review(project_id, &mut *txn).await?; + + if !is_still_in_tech_review { + send_tech_review_exit_file_deleted_message(project_id, txn).await?; + } + + Ok(()) +} + #[post("run")] async fn _run( req: HttpRequest, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index be0b960bb..e91de0e4b 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -42,7 +42,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service(get_report) .service(get_issue) .service(submit_report) - .service(update_issue_detail) + .service(update_issue_details) .service(add_report); } @@ -388,6 +388,8 @@ pub struct ProjectModerationInfo { 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, @@ -498,6 +500,7 @@ async fn fetch_project_reports( 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", @@ -561,6 +564,7 @@ async fn fetch_project_reports( 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, @@ -698,9 +702,9 @@ async fn search_projects( let rows = sqlx::query!( r#" - SELECT DISTINCT ON (m.id) + SELECT m.id AS "project_id: DBProjectId", - t.id AS "thread_id: DBThreadId" + 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 @@ -734,12 +738,14 @@ async fn search_projects( 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, t.id - ORDER BY m.id, - CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC, - CASE WHEN $3 = 'created_desc' THEN MAX(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 + 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, @@ -831,6 +837,7 @@ async fn search_projects( id, thread_id: project.thread_id, name: project.name, + status: project.status, project_types: project.project_types, icon_url: project.icon_url, }, @@ -1119,6 +1126,8 @@ async fn submit_report( /// 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, } @@ -1131,14 +1140,13 @@ pub struct UpdateIssue { security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] -#[patch("/issue-detail/{id}")] -async fn update_issue_detail( +#[patch("/issue-detail")] +async fn update_issue_details( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, - update_req: web::Json, - path: web::Path<(DelphiReportIssueDetailsId,)>, + update_reqs: web::Json>, ) -> Result<(), ApiError> { check_is_moderator_from_headers( &req, @@ -1148,44 +1156,76 @@ async fn update_issue_detail( Scopes::PROJECT_WRITE, ) .await?; - let (issue_detail_id,) = path.into_inner(); let mut txn = pool .begin() .await .wrap_internal_err("failed to start transaction")?; - let status = match update_req.verdict { - DelphiVerdict::Safe => DelphiStatus::Safe, - DelphiVerdict::Unsafe => DelphiStatus::Unsafe, - }; - let results = sqlx::query!( + 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#" - INSERT INTO delphi_issue_detail_verdicts ( - project_id, - detail_key, - verdict + 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 - didws.project_id, - didws.key, - $1 - FROM delphi_issue_details_with_statuses didws - INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id - WHERE - didws.id = $2 - -- see delphi.rs todo comment - AND dri.issue_type != '__dummy' - ON CONFLICT (project_id, detail_key) - DO UPDATE SET verdict = EXCLUDED.verdict + (v.incoming_count = v.resolved_count) AS "all_found!", + (SELECT COUNT(*) FROM upserted) AS "upserted_count!" + FROM validated v "#, - status as _, - issue_detail_id as _, + &detail_ids, + &verdicts, ) - .execute(&mut txn) + .fetch_one(&mut txn) .await - .wrap_internal_err("failed to update issue detail")?; - if results.rows_affected() == 0 { + .wrap_internal_err("failed to update issue details")?; + + if !record.all_found { return Err(ApiError::Request(eyre!("issue detail does not exist"))); } diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 7ad4b7058..f7f4da3f2 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -28,6 +28,7 @@ use crate::models::threads::MessageBody; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::routes::internal::delphi; use crate::search::indexing::remove_documents; use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::error::Context; @@ -2218,6 +2219,18 @@ pub async fn project_delete( .begin() .await .wrap_internal_err("failed to start transaction")?; + let was_in_tech_review = + delphi::is_project_in_tech_review(project.inner.id, &mut transaction) + .await?; + + if was_in_tech_review { + delphi::send_tech_review_exit_file_deleted_message( + project.inner.id, + &mut transaction, + ) + .await?; + } + let context = ImageContext::Project { project_id: Some(project.inner.id.into()), }; diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index b1865509a..f0ce7b2a4 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -9,6 +9,7 @@ use crate::models::pats::Scopes; use crate::models::projects::VersionType; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::internal::delphi; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, web}; use dashmap::DashMap; @@ -688,6 +689,9 @@ pub async fn delete_file( } let mut transaction = pool.begin().await?; + let was_in_tech_review = + delphi::is_project_in_tech_review(row.project_id, &mut transaction) + .await?; sqlx::query!( " @@ -709,6 +713,13 @@ pub async fn delete_file( .execute(&mut transaction) .await?; + delphi::send_tech_review_exit_file_deleted_message_if_exited( + row.project_id, + was_in_tech_review, + &mut transaction, + ) + .await?; + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 91a5a7809..5912ca430 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -25,6 +25,7 @@ use crate::models::projects::{ use crate::models::projects::{Loader, skip_nulls}; use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; +use crate::routes::internal::delphi; use crate::search::SearchConfig; use crate::search::indexing::remove_documents; use crate::util::error::Context; @@ -959,6 +960,12 @@ pub async fn version_delete( } let mut transaction = pool.begin().await?; + let was_in_tech_review = delphi::is_project_in_tech_review( + version.inner.project_id, + &mut transaction, + ) + .await?; + let context = ImageContext::Version { version_id: Some(version.inner.id.into()), }; @@ -977,6 +984,14 @@ pub async fn version_delete( &mut transaction, ) .await?; + + delphi::send_tech_review_exit_file_deleted_message_if_exited( + version.inner.project_id, + was_in_tech_review, + &mut transaction, + ) + .await?; + transaction.commit().await?; database::models::DBProject::clear_cache( diff --git a/docker-compose.yml b/docker-compose.yml index 10820d252..2d0b6958c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,7 +122,7 @@ services: delphi: profiles: - with-delphi - image: ghcr.io/modrinth/delphi:feature-schema-rework + image: ghcr.io/modrinth/delphi:main container_name: labrinth-delphi ports: - '127.0.0.1:59999:59999'