Skip to content
Open
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
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ButtonStyled, CopyCode, NewModal, useDebugLogger } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'

export type UnsafeFile = {
Expand All @@ -16,6 +16,8 @@ const props = defineProps<{
unsafeFiles: UnsafeFile[]
}>()

const debug = useDebugLogger('MaliciousSummaryModal')

const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')

const versionDataCache = ref<
Expand All @@ -36,14 +38,19 @@ async function fetchVersionHashes(versionIds: string[]) {
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
try {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
const version = (await useBaseFetch(`version/${versionId}`, { apiVersion: 3 })) as {
files: Array<{
id?: string
filename: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
debug('Full version response:', version)
debug(
'Version files:',
version.files.map((f) => ({ id: f.id, filename: f.filename })),
)
for (const file of version.files) {
if (file.id) {
filesMap.set(file.id, file.hashes.sha512)
Expand All @@ -62,7 +69,9 @@ async function fetchVersionHashes(versionIds: string[]) {
}

function getFileHash(versionId: string, fileId: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileId)
const hash = versionDataCache.value.get(versionId)?.files.get(fileId)
debug('getFileHash:', { versionId, fileId, found: !!hash })
return hash
}

function isHashLoading(versionId: string): boolean {
Expand Down
244 changes: 185 additions & 59 deletions apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { Labrinth } from '@modrinth/api-client'
import {
BugIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ClipboardCopyIcon,
Expand All @@ -13,6 +14,7 @@ import {
LoaderCircleIcon,
ShieldCheckIcon,
TimerIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
import {
Expand Down Expand Up @@ -252,6 +254,16 @@ function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeve
}
}

function truncateMiddle(str: string, maxLength: number = 120): string {
if (str.length <= maxLength) return str
const separator = '...'
const sepLen = separator.length
const charsToShow = maxLength - sepLen
const frontChars = Math.ceil(charsToShow / 3)
const backChars = Math.floor((charsToShow * 2) / 3)
return str.slice(0, frontChars) + separator + str.slice(-backChars)
}

const severityColor = computed(() => {
switch (highestSeverity.value) {
case 'severe':
Expand Down Expand Up @@ -357,7 +369,72 @@ function getFileMarkedCount(file: FlattenedFileReport): number {
return count
}

const remainingUnmarkedCount = computed(() => {
if (!selectedFile.value) return 0
return getFileDetailCount(selectedFile.value) - getFileMarkedCount(selectedFile.value)
})

const isBatchUpdating = ref(false)

async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
if (!selectedFile.value || isBatchUpdating.value) return

const detailIds: string[] = []
for (const issue of selectedFile.value.issues) {
for (const detail of issue.details) {
const detailWithStatus = detail as typeof detail & {
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}
if (getDetailDecision(detailWithStatus.id, detailWithStatus.status) === 'pending') {
detailIds.push(detail.id)
}
}
}

if (detailIds.length === 0) return

isBatchUpdating.value = true
try {
await Promise.all(
detailIds.map((detailId) =>
client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }),
),
)

const decision = verdict === 'safe' ? 'safe' : 'malware'
for (const detailId of detailIds) {
detailDecisions.value.set(detailId, decision)
}

addNotification({
type: 'success',
title: `Marked ${detailIds.length} traces as ${verdict}`,
text: `All remaining traces have been marked as ${verdict === 'safe' ? 'false positives' : 'malicious'}.`,
})
} catch (error) {
console.error('Failed to batch update:', error)
addNotification({
type: 'error',
title: 'Batch update failed',
text: 'An error occurred while updating traces.',
})
} finally {
isBatchUpdating.value = false
}
}

async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') {
let priorDecision: 'safe' | 'malware' | 'pending' = 'pending'
outer: for (const report of props.item.reports) {
for (const issue of report.issues) {
const detail = issue.details.find((d) => d.id === detailId)
if (detail) {
priorDecision = getDetailDecision(detail.id, detail.status)
break outer
}
}
}

updatingDetails.value.add(detailId)

try {
Expand Down Expand Up @@ -395,11 +472,14 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
detailDecisions.value.set(detailId, decision)
}

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.value.delete(classGroup.filePath)
break
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
if (priorDecision === 'pending') {
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.value.delete(classGroup.filePath)
break
}
}
}

Expand Down Expand Up @@ -621,6 +701,7 @@ const reviewSummaryPreview = computed(() => {

const timestamp = dayjs().utc().format('MMMM D, YYYY [at] h:mm A [UTC]')
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
markdown += `<details>\n<summary>File Details (${totalSafe} safe, ${totalUnsafe} unsafe)</summary>\n\n`

for (const [, fileData] of fileDecisions) {
if (fileData.decisions.length === 0) continue
Expand All @@ -643,6 +724,7 @@ const reviewSummaryPreview = computed(() => {
markdown += `\n</details>\n\n`
}

markdown += `</details>\n\n`
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`

return markdown
Expand Down Expand Up @@ -919,11 +1001,12 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
>
<div class="flex items-center gap-3">
<span
v-tooltip="file.file_name"
class="font-medium text-contrast"
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
@click="getFileDetailCount(file) > 0 && viewFileFlags(file)"
>
{{ file.file_name }}
{{ truncateMiddle(file.file_name, 50) }}
</span>
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
<span class="text-sm font-medium text-secondary">{{
Expand Down Expand Up @@ -989,11 +1072,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</template>

<template v-else-if="currentTab === 'File' && selectedFile">
<div
v-if="remainingUnmarkedCount > 0"
class="flex gap-2 border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2 p-4"
>
<ButtonStyled color="brand" :disabled="isBatchUpdating">
<button @click="batchMarkRemaining('safe')">
<CheckCircleIcon class="size-5" />
Remaining safe ({{ remainingUnmarkedCount }})
</button>
</ButtonStyled>
<ButtonStyled color="red" :disabled="isBatchUpdating">
<button @click="batchMarkRemaining('unsafe')">
<TriangleAlertIcon class="size-5" />
Remaining malware ({{ remainingUnmarkedCount }})
</button>
</ButtonStyled>
</div>
<div
v-for="(classItem, idx) in groupedByClass"
:key="classItem.filePath"
class="border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2"
:class="{ 'rounded-bl-2xl rounded-br-2xl': idx === groupedByClass.length - 1 }"
:class="{
'rounded-bl-2xl rounded-br-2xl':
idx === groupedByClass.length - 1 && remainingUnmarkedCount === 0,
}"
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
Expand All @@ -1009,7 +1112,9 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</button>
</ButtonStyled>

<span class="font-mono font-semibold">{{ classItem.filePath }}</span>
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>

<div
class="rounded-full border-solid px-2.5 py-1"
Expand Down Expand Up @@ -1054,66 +1159,87 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="grid grid-cols-[1fr_auto_auto] items-center rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
>
<span
class="text-base font-semibold text-contrast"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>{{ flag.issueType.replace(/_/g, ' ') }}</span
>

<div
class="flex w-20 justify-center"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
</div>

<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
Pass
</button>
</ButtonStyled>

<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
>
Pass
</button>
</ButtonStyled>

<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
>
Fail
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
Fail
</button>
</ButtonStyled>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>

Expand Down
Loading
Loading