From 8c4556074b1776ee901963c2109d1ac17c2af27e Mon Sep 17 00:00:00 2001 From: IMB11 Date: Sat, 13 Sep 2025 18:34:11 +0100 Subject: [PATCH 1/7] feat: batch scan alert --- .../delphi/BatchScanProgressAlert.vue | 39 ++ .../moderation/technical-review-mockup.vue | 387 ------------------ .../src/pages/moderation/technical-review.vue | 19 +- 3 files changed, 57 insertions(+), 388 deletions(-) create mode 100644 apps/frontend/src/components/ui/moderation/delphi/BatchScanProgressAlert.vue delete mode 100644 apps/frontend/src/pages/moderation/technical-review-mockup.vue diff --git a/apps/frontend/src/components/ui/moderation/delphi/BatchScanProgressAlert.vue b/apps/frontend/src/components/ui/moderation/delphi/BatchScanProgressAlert.vue new file mode 100644 index 0000000000..42c8b80de0 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/delphi/BatchScanProgressAlert.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/frontend/src/pages/moderation/technical-review-mockup.vue b/apps/frontend/src/pages/moderation/technical-review-mockup.vue deleted file mode 100644 index 95e4c1fbb3..0000000000 --- a/apps/frontend/src/pages/moderation/technical-review-mockup.vue +++ /dev/null @@ -1,387 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/moderation/technical-review.vue b/apps/frontend/src/pages/moderation/technical-review.vue index 3a5ae57552..27360f2437 100644 --- a/apps/frontend/src/pages/moderation/technical-review.vue +++ b/apps/frontend/src/pages/moderation/technical-review.vue @@ -1,3 +1,20 @@ + + From 518c7d16dd2b614570c01d9dbc4cbb2dfa8f3167 Mon Sep 17 00:00:00 2001 From: IMB11 Date: Tue, 16 Sep 2025 18:55:00 +0100 Subject: [PATCH 2/7] feat: layout --- .../frontend/src/helpers/tech-review.dummy.ts | 105 +++++++ apps/frontend/src/helpers/tech-review.ts | 47 +++ .../src/pages/moderation/technical-review.vue | 279 +++++++++++++++++- 3 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/helpers/tech-review.dummy.ts create mode 100644 apps/frontend/src/helpers/tech-review.ts diff --git a/apps/frontend/src/helpers/tech-review.dummy.ts b/apps/frontend/src/helpers/tech-review.dummy.ts new file mode 100644 index 0000000000..852e1f719b --- /dev/null +++ b/apps/frontend/src/helpers/tech-review.dummy.ts @@ -0,0 +1,105 @@ +// Dummy data for the technical review queue, used when backend is unavailable + +export type DelphiReportSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'SEVERE' +export type DelphiReportIssueStatus = 'pending' | 'approved' | 'rejected' + +export interface DelphiIssueJavaClass { + id: number + issue_id: number + internal_class_name: string + decompiled_source?: string | null +} + +export interface DelphiReportSummary { + id: number + file_id?: number | null + delphi_version: number + artifact_url: string + created: string // ISO date + severity: DelphiReportSeverity +} + +export interface DelphiIssueSummary { + id: number + report_id: number + issue_type: string + status: DelphiReportIssueStatus +} + +export interface DelphiIssueResult { + issue: DelphiIssueSummary + report: DelphiReportSummary + java_classes: DelphiIssueJavaClass[] + project_id?: number | null + project_published?: string | null +} + +export const DUMMY_ISSUE_TYPES: string[] = [ + 'reflection_indirection', + 'xor_obfuscation', + 'included_libraries', + 'suspicious_binaries', + 'corrupt_classes', + 'suspicious_classes', + 'url_usage', + 'classloader_usage', + 'processbuilder_usage', + 'runtime_exec_usage', + 'jni_usage', + 'main_method', + 'native_loading', + 'malformed_jar', + 'nested_jar_too_deep', + 'failed_decompilation', + 'analysis_failure', + 'malware_easyforme', + 'malware_simplyloader', +] + +export const DUMMY_ISSUES: DelphiIssueResult[] = [ + { + issue: { + id: 1001, + report_id: 501, + issue_type: 'suspicious_classes', + status: 'pending', + }, + report: { + id: 501, + file_id: 90001, + delphi_version: 47, + artifact_url: 'https://cdn.modrinth.com/data/abc/versions/1.0.0.jar', + created: new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString(), + severity: 'SEVERE', + }, + java_classes: [ + { + id: 7001, + issue_id: 1001, + internal_class_name: 'com/example/Suspect', + decompiled_source: 'public class Suspect { /* ... */ }', + }, + ], + project_id: 123456, + project_published: new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(), + }, + { + issue: { + id: 1002, + report_id: 502, + issue_type: 'url_usage', + status: 'pending', + }, + report: { + id: 502, + file_id: 90002, + delphi_version: 47, + artifact_url: 'https://cdn.modrinth.com/data/def/versions/2.3.4.jar', + created: new Date(Date.now() - 1 * 24 * 3600 * 1000).toISOString(), + severity: 'HIGH', + }, + java_classes: [], + project_id: 789012, + project_published: new Date(Date.now() - 45 * 24 * 3600 * 1000).toISOString(), + }, +] diff --git a/apps/frontend/src/helpers/tech-review.ts b/apps/frontend/src/helpers/tech-review.ts new file mode 100644 index 0000000000..5fe651f322 --- /dev/null +++ b/apps/frontend/src/helpers/tech-review.ts @@ -0,0 +1,47 @@ +import { DUMMY_ISSUE_TYPES, DUMMY_ISSUES, type DelphiIssueResult } from './tech-review.dummy' + +// TODO: @modrinth/api-client package + +export type OrderBy = + | 'created_asc' + | 'created_desc' + | 'pending_status_first' + | 'severity_asc' + | 'severity_desc' + +export interface FetchIssuesParams { + type?: string | null + status?: 'pending' | 'approved' | 'rejected' | null + order_by?: OrderBy | null + count?: number + offset?: number +} + +export async function fetchIssueTypeSchema(): Promise { + try { + const schema = await useBaseFetch('internal/delphi/issue_type/schema', { internal: true }) + // Expecting a JSON object map of type -> metadata; return its keys + if (schema && typeof schema === 'object') { + return Object.keys(schema as Record) + } + return DUMMY_ISSUE_TYPES + } catch { + return DUMMY_ISSUE_TYPES + } +} + +export async function fetchDelphiIssues(params: FetchIssuesParams): Promise { + const query = new URLSearchParams() + if (params.type) query.set('type', params.type) + if (params.status) query.set('status', params.status) + if (params.order_by) query.set('order_by', params.order_by) + if (params.count != null) query.set('count', String(params.count)) + if (params.offset != null) query.set('offset', String(params.offset)) + + try { + const res = await useBaseFetch(`internal/delphi/issues?${query.toString()}`, { internal: true }) + return (res as any[]) || [] + } catch { + return DUMMY_ISSUES + } +} diff --git a/apps/frontend/src/pages/moderation/technical-review.vue b/apps/frontend/src/pages/moderation/technical-review.vue index 27360f2437..cb3b4f4574 100644 --- a/apps/frontend/src/pages/moderation/technical-review.vue +++ b/apps/frontend/src/pages/moderation/technical-review.vue @@ -2,6 +2,200 @@ import BatchScanProgressAlert, { type BatchScanProgress, } from '@/components/ui/moderation/delphi/BatchScanProgressAlert.vue' +import { FilterIcon, SearchIcon, SortAscIcon, SortDescIcon, XIcon } from '@modrinth/assets' +import { Button, DropdownSelect, Pagination } from '@modrinth/ui' +import { defineMessages, useVIntl } from '@vintl/vintl' +import Fuse from 'fuse.js' +import { fetchDelphiIssues, fetchIssueTypeSchema, type OrderBy } from '~/helpers/tech-review' + +// Data from backend helper (with dummy fallback) +type TechReviewItem = Awaited>[number] +const reviewItems = ref([]) + +// Basic pagination state (mirrors moderation pages) +const currentPage = ref(1) +const itemsPerPage = 15 +// Search/filter/sort UI state +const { formatMessage } = useVIntl() +const route = useRoute() +const router = useRouter() + +const messages = defineMessages({ + searchPlaceholder: { + id: 'moderation.search.placeholder', + defaultMessage: 'Search...', + }, + filterBy: { + id: 'moderation.filter.by', + defaultMessage: 'Filter by', + }, + sortBy: { + id: 'moderation.sort.by', + defaultMessage: 'Sort by', + }, +}) + +const query = ref(route.query.q?.toString() || '') + +watch( + query, + (newQuery) => { + const currentQuery = { ...route.query } + if (newQuery) { + currentQuery.q = newQuery + } else { + delete currentQuery.q + } + + router.replace({ + path: route.path, + query: currentQuery, + }) + goToPage(1) + }, + { immediate: false }, +) + +watch( + () => route.query.q, + (newQueryParam) => { + const newValue = newQueryParam?.toString() || '' + if (query.value !== newValue) { + query.value = newValue + } + }, +) + +const currentFilterType = ref('All issues') +const rawIssueTypes = ref(null) +const filterTypes = computed(() => { + const base: string[] = ['All issues'] + if (rawIssueTypes.value && rawIssueTypes.value.length) base.push(...rawIssueTypes.value) + return base +}) + +const currentSortType = ref('Oldest') +const sortTypes: readonly string[] = readonly([ + 'Oldest', + 'Newest', + 'Pending first', + 'Severity ↑', + 'Severity ↓', +]) + +const fuse = computed(() => { + if (!reviewItems.value || reviewItems.value.length === 0) return null + return new Fuse(reviewItems.value, { + keys: [ + { name: 'issue.issue_type', weight: 3 }, + { name: 'report.artifact_url', weight: 2 }, + { name: 'java_classes.internal_class_name', weight: 2 }, + ], + includeScore: true, + threshold: 0.4, + }) +}) + +const searchResults = computed(() => { + if (!query.value || !fuse.value) return null + return fuse.value.search(query.value).map((result) => result.item as TechReviewItem) +}) + +const baseFiltered = computed(() => { + if (!reviewItems.value) return [] + return query.value && searchResults.value ? searchResults.value : [...reviewItems.value] +}) + +const typeFiltered = computed(() => { + if (currentFilterType.value === 'All issues') return baseFiltered.value + const type = currentFilterType.value + return baseFiltered.value.filter((it) => it.issue.issue_type === type) +}) + +const filteredItems = computed(() => { + const filtered = [...typeFiltered.value] + + switch (currentSortType.value) { + case 'Oldest': + filtered.sort( + (a, b) => new Date(a.report.created).getTime() - new Date(b.report.created).getTime(), + ) + break + case 'Newest': + filtered.sort( + (a, b) => new Date(b.report.created).getTime() - new Date(a.report.created).getTime(), + ) + break + case 'Pending first': { + const p = (s: string) => (s === 'pending' ? 0 : 1) + filtered.sort((a, b) => p(a.issue.status) - p(b.issue.status)) + break + } + case 'Severity ↑': { + const order = { LOW: 0, MEDIUM: 1, HIGH: 2, SEVERE: 3 } as Record + filtered.sort((a, b) => (order[a.report.severity] ?? 0) - (order[b.report.severity] ?? 0)) + break + } + case 'Severity ↓': { + const order = { LOW: 0, MEDIUM: 1, HIGH: 2, SEVERE: 3 } as Record + filtered.sort((a, b) => (order[b.report.severity] ?? 0) - (order[a.report.severity] ?? 0)) + break + } + } + + return filtered +}) + +const totalPages = computed(() => Math.ceil((filteredItems.value?.length || 0) / itemsPerPage)) +const paginatedItems = computed(() => { + if (!filteredItems.value) return [] + const start = (currentPage.value - 1) * itemsPerPage + const end = start + itemsPerPage + return filteredItems.value.slice(start, end) +}) +function goToPage(page: number) { + currentPage.value = page +} + +// Map sort label to backend order_by param +function toOrderBy(label: string): OrderBy | null { + switch (label) { + case 'Oldest': + return 'created_asc' + case 'Newest': + return 'created_desc' + case 'Pending first': + return 'pending_status_first' + case 'Severity ↑': + return 'severity_asc' + case 'Severity ↓': + return 'severity_desc' + default: + return null + } +} + +// Initial fetch and reactive refetch on filter/sort changes +onMounted(async () => { + rawIssueTypes.value = await fetchIssueTypeSchema() + const order_by = toOrderBy(currentSortType.value) + reviewItems.value = await fetchDelphiIssues({ count: 350, offset: 0, order_by }) +}) + +watch(currentFilterType, async (val) => { + const type = val === 'All issues' ? null : val + const order_by = toOrderBy(currentSortType.value) + reviewItems.value = await fetchDelphiIssues({ type, count: 350, offset: 0, order_by }) + goToPage(1) +}) + +watch(currentSortType, async (val) => { + const type = currentFilterType.value === 'All issues' ? null : currentFilterType.value + const order_by = toOrderBy(val) + // If you prefer server-side sorting only, keep this; otherwise client-side above already reorders + reviewItems.value = await fetchDelphiIssues({ type, count: 350, offset: 0, order_by }) + goToPage(1) +}) // TODO: Live way to update this via the backend, polling? const batchScanProgressInformation = computed(() => { @@ -13,8 +207,85 @@ const batchScanProgressInformation = computed(() From 8cd9e2e15081f579dc06b988c71b62b8026bccee Mon Sep 17 00:00:00 2001 From: IMB11 Date: Thu, 18 Sep 2025 12:56:09 +0100 Subject: [PATCH 3/7] feat: introduce surface variables --- .../ui/moderation/ModerationReportCard.vue | 241 ++++++++---------- .../src/components/ui/thread/ReportThread.vue | 71 ++++-- apps/frontend/tailwind.config.ts | 11 +- packages/assets/styles/variables.scss | 32 ++- 4 files changed, 195 insertions(+), 160 deletions(-) diff --git a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue index 895099a780..b813f87385 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue @@ -1,126 +1,123 @@