Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ interface FileDecisions {
severity: string
decision: 'safe' | 'malware'
}>
unresolved: Array<{
filePath: string
issueType: string
severity: string
}>
maxSeverity: string
}

Expand Down Expand Up @@ -391,6 +396,15 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
}
}

// Auto-jump back to Files tab when all flags for current file are complete
if (selectedFile.value) {
const fileComplete =
getFileMarkedCount(selectedFile.value) === getFileDetailCount(selectedFile.value)
if (fileComplete) {
backToFileList()
}
}

if (verdict === 'safe') {
addNotification({
type: 'success',
Expand Down Expand Up @@ -541,13 +555,15 @@ const reviewSummaryPreview = computed(() => {
const fileDecisions = new Map<string, FileDecisions>()
let totalSafe = 0
let totalUnsafe = 0
let totalUnresolved = 0

for (const report of props.item.reports) {
if (!fileDecisions.has(report.id)) {
fileDecisions.set(report.id, {
fileName: report.file_name,
fileSize: report.file_size,
decisions: [],
unresolved: [],
maxSeverity: 'low',
})
}
Expand All @@ -560,7 +576,20 @@ const reviewSummaryPreview = computed(() => {
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}
const decision = getDetailDecision(detailWithStatus.id, detailWithStatus.status)
if (decision === 'pending') continue

if ((severityOrder[detail.severity] ?? 0) > (severityOrder[fileData.maxSeverity] ?? 0)) {
fileData.maxSeverity = detail.severity
}

if (decision === 'pending') {
fileData.unresolved.push({
filePath: detail.file_path,
issueType: issue.issue_type.replace(/_/g, ' '),
severity: detail.severity,
})
totalUnresolved++
continue
}

fileData.decisions.push({
filePath: detail.file_path,
Expand All @@ -569,44 +598,60 @@ const reviewSummaryPreview = computed(() => {
decision,
})

if ((severityOrder[detail.severity] ?? 0) > (severityOrder[fileData.maxSeverity] ?? 0)) {
fileData.maxSeverity = detail.severity
}

if (decision === 'safe') totalSafe++
else totalUnsafe++
}
}
}

const totalDecisions = totalSafe + totalUnsafe
if (totalDecisions === 0) return ''
if (totalDecisions === 0 && totalUnresolved === 0) return ''

const timestamp = dayjs().utc().format('MMMM D, YYYY [at] h:mm A [UTC]')
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`

for (const [, fileData] of fileDecisions) {
if (fileData.decisions.length === 0) continue
if (fileData.decisions.length === 0 && fileData.unresolved.length === 0) continue

const fileSafe = fileData.decisions.filter((d) => d.decision === 'safe').length
const fileUnsafe = fileData.decisions.filter((d) => d.decision === 'malware').length
const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe'

markdown += `### ${fileData.fileName}\n`
markdown += `> ${formatFileSize(fileData.fileSize)} • ${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
markdown += `| Class | Issue Type | Severity | Decision |\n`
markdown += `|-------|------------|----------|----------|\n`

for (const d of fileData.decisions) {
const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe'
markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n`
markdown += `> ${formatFileSize(fileData.fileSize)} • ${fileData.decisions.length + fileData.unresolved.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`

// Show unresolved issues at the top
if (fileData.unresolved.length > 0) {
markdown += `<details>\n<summary>⏳ Unresolved (${fileData.unresolved.length} pending)</summary>\n\n`
markdown += `| Class | Issue Type | Severity |\n`
markdown += `|-------|------------|----------|\n`

for (const u of fileData.unresolved) {
markdown += `| \`${u.filePath}\` | ${u.issueType} | ${capitalizeString(u.severity)} |\n`
}

markdown += `\n</details>\n\n`
}

markdown += `\n</details>\n\n`
if (fileData.decisions.length > 0) {
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
markdown += `| Class | Issue Type | Severity | Decision |\n`
markdown += `|-------|------------|----------|----------|\n`

for (const d of fileData.decisions) {
const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe'
markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n`
}

markdown += `\n</details>\n\n`
}
}

markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)`
if (totalUnresolved > 0) {
markdown += ` • **Unresolved:** ${totalUnresolved} pending`
}
markdown += `\n\n`

return markdown
})
Expand Down
183 changes: 183 additions & 0 deletions apps/frontend/src/pages/moderation/technical-review/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { ref } from 'vue'

import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import MaliciousSummaryModal from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'

const client = injectModrinthClient()
const { params } = useRoute()
const router = useRouter()
const projectId = params.id as string

const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'

type CachedSource = {
source: string
timestamp: number
}

function getCachedSource(detailId: string): string | null {
try {
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
if (!cached) return null

const data: CachedSource = JSON.parse(cached)
const now = Date.now()

if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
return null
}

return data.source
} catch {
return null
}
}

function setCachedSource(detailId: string, source: string): void {
try {
const data: CachedSource = {
source,
timestamp: Date.now(),
}
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
} catch (error) {
console.error('Failed to cache source:', error)
}
}

const loadingIssues = ref<Set<string>>(new Set())
const decompiledSources = ref<Map<string, string>>(new Map())

type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
version_id: string
}

const { data: reviewItem, refresh: refetch } = await useAsyncData(
`tech-review-${projectId}`,
async () => {
try {
const response = await client.labrinth.tech_review_internal.getProject(projectId)
const projectReport = response.project_reports[0]
if (!projectReport) {
throw createError({ statusCode: 404, statusMessage: 'Tech review not found' })
}

const project = response.projects[projectReport.project_id]
const thread = project?.thread_id ? response.threads[project.thread_id] : null
if (!thread) {
throw createError({ statusCode: 404, statusMessage: 'Tech review not found' })
}

const reports: FlattenedFileReport[] = projectReport.versions.flatMap((version) =>
version.files.map((file) => ({
...file,
id: file.report_id,
version_id: version.version_id,
})),
)

return {
project,
project_owner: response.ownership[projectReport.project_id],
thread,
reports,
}
} catch (error) {
console.error('Error fetching tech review:', error)
throw createError({
statusCode: 404,
statusMessage: 'Tech review not found',
})
}
},
)

async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.value.has(issueId)) return

loadingIssues.value.add(issueId)

try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)

for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.value.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.value.delete(issueId)
}
}

function tryLoadCachedSourcesForFile(reportId: string): void {
if (!reviewItem.value) return
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (!decompiledSources.value.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.value.set(detail.id, cached)
}
}
}
}
}
}

function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)

if (!reviewItem.value) return
const report = reviewItem.value.reports.find((r) => r.id === reportId)
if (report) {
for (const issue of report.issues) {
const hasUncached = issue.details.some((d) => !decompiledSources.value.has(d.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
}

function handleMarkComplete(_projectId: string) {
router.push('/moderation/technical-review')
}

const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()
const currentUnsafeFiles = ref<UnsafeFile[]>([])

function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
currentUnsafeFiles.value = unsafeFiles
maliciousSummaryModalRef.value?.show()
}
</script>

<template>
<div class="flex flex-col gap-3">
<ModerationTechRevCard
v-if="reviewItem"
:item="reviewItem"
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
@refetch="refetch"
@load-file-sources="handleLoadFileSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>

<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
</div>
</template>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading