feat: malware warning modal changes (#6721)

* feat: improved warning modals

* fix: qa

* feat: install to play and update to play changes

* fix: dont warn for server projects as already reviewed

* fix: lint
This commit is contained in:
Calum H.
2026-07-14 20:47:10 +00:00
committed by GitHub
parent 8cca911775
commit 905204cc5f
58 changed files with 1244 additions and 1351 deletions
@@ -1,86 +1,134 @@
<template>
<NewModal ref="modal" :header="header" :closable="true" :disable-close="disableClose" no-padding>
<div class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
<div class="flex flex-col gap-2">
<span>{{ description }}</span>
<span v-if="hasUnknownContent">{{ formatMessage(messages.unknownContentBody) }}</span>
</div>
<NewModal
ref="modal"
:header="header"
:closable="true"
:disable-close="disableClose"
max-width="544px"
width="544px"
no-padding
>
<div class="flex flex-col gap-4" :class="hasExternalDiffs ? 'px-6 py-4' : 'p-4'">
<template v-if="hasExternalDiffs">
<p v-if="description" class="m-0 text-primary">{{ description }}</p>
<Admonition
v-if="hasExternalDiffs"
type="warning"
:header="formatMessage(messages.unknownFilesWarning)"
>
{{ formatMessage(messages.unknownFilesDescription) }}
</Admonition>
</template>
<Admonition v-else :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
<div class="flex flex-col gap-2">
<span>{{ description }}</span>
<span v-if="hasUnknownContent">{{ formatMessage(messages.unknownContentBody) }}</span>
</div>
</Admonition>
<div v-if="diffs.length" class="flex gap-2">
<div v-if="removedCount" class="flex gap-1 items-center">
<MinusIcon />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="addedCount" class="flex gap-1 items-center">
<PlusIcon />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="updatedCount" class="flex gap-1 items-center">
<RefreshCwIcon />
<div v-if="diffs.length" class="flex flex-col gap-1">
<span v-if="versionDate" class="font-semibold text-contrast">{{ versionDate }}</span>
<div class="flex flex-wrap items-center gap-2 text-primary">
<div v-if="updatedCount" class="flex items-center gap-1">
<RefreshCwIcon class="size-4" />
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="(diff, index) in sortedDiffs"
:key="diff.projectName || diff.fileName || index"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.projectName ? 'grid-cols-[auto_auto_1fr]' : 'grid-cols-[auto_auto_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" class="text-red" />
<RefreshCwIcon v-else />
<div
:class="index === sortedDiffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
<div v-if="addedCount" class="flex items-center gap-1">
<PlusIcon class="size-4" />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="removedCount" class="flex items-center gap-1">
<MinusIcon class="size-4" />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<span class="text-sm shrink-0 whitespace-nowrap">{{
diff.type === 'removed' && props.removedLabel
? props.removedLabel
: formatMessage(diffTypeMessages[diff.type])
}}</span>
<span
v-if="diff.projectName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ diff.projectName }}
</span>
<span
v-else-if="diff.fileName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex max-h-[272px] flex-col overflow-y-auto border-0 border-y border-solid border-surface-5 bg-surface-2 px-3 py-4"
>
<div
v-if="showBackupCreator"
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
v-for="(diff, index) in sortedDiffs"
:key="diff.projectName || diff.fileName || index"
class="flex h-10 min-h-10 items-center gap-2"
:class="showExternalWarning(diff) ? '-mx-3 px-5' : 'px-2'"
:style="
showExternalWarning(diff)
? {
backgroundColor: 'color-mix(in srgb, var(--color-orange) 10%, transparent)',
}
: undefined
"
>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before version change"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
<div class="relative flex w-4 shrink-0 self-stretch items-center justify-center">
<div
v-if="index > 0"
class="absolute left-1/2 top-0 h-3 w-px -translate-x-1/2 bg-surface-5"
/>
<PlusIcon v-if="diff.type === 'added'" class="relative z-[1] size-4" />
<MinusIcon v-else-if="diff.type === 'removed'" class="relative z-[1] size-4 text-red" />
<RefreshCwIcon v-else class="relative z-[1] size-4" />
<div
v-if="index < sortedDiffs.length - 1"
class="absolute bottom-0 left-1/2 top-7 w-px -translate-x-1/2 bg-surface-5"
/>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1 text-sm">
<span class="shrink-0 whitespace-nowrap text-primary">{{ getDiffTypeLabel(diff) }}</span>
<template v-if="showExternalWarning(diff)">
<CircleAlertIcon class="size-4 shrink-0 text-orange" />
<span class="truncate font-medium text-orange">
{{ formatMessage(messages.unknownProject) }}
</span>
</template>
<span v-else class="truncate font-medium text-contrast">
{{ diff.projectName || (diff.fileName ? decodeURIComponent(diff.fileName) : '') }}
</span>
</div>
<span
v-if="getVersionLabel(diff)"
class="ml-2 max-w-[60%] min-w-0 shrink truncate text-right text-xs"
:class="showExternalWarning(diff) ? 'text-orange' : 'text-primary'"
:title="getVersionLabel(diff)"
>
{{ getVersionLabel(diff) }}
</span>
</div>
</div>
<div
v-if="showBackupCreator"
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before version change"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex justify-between gap-2 pt-4">
<div v-if="hasExternalDiffs" class="flex flex-col gap-6 p-2">
<p class="m-0 text-primary">{{ formatMessage(messages.reviewedFiles) }}</p>
<div class="flex justify-end gap-2">
<ButtonStyled type="transparent" color="orange">
<button :disabled="buttonsDisabled" @click="handleConfirm">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</div>
</div>
<div v-else class="flex justify-between gap-2 pt-4">
<div>
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
<button @click="emit('report')">
@@ -109,7 +157,15 @@
</template>
<script setup lang="ts">
import { MinusIcon, PlusIcon, RefreshCwIcon, ReportIcon, XIcon } from '@modrinth/assets'
import {
BanIcon,
CircleAlertIcon,
MinusIcon,
PlusIcon,
RefreshCwIcon,
ReportIcon,
XIcon,
} from '@modrinth/assets'
import { type Component, computed, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
@@ -133,6 +189,8 @@ const props = defineProps<{
showBackupCreator?: boolean
removedLabel?: string
disableClose?: boolean
showExternalWarnings?: boolean
versionDate?: string
}>()
const emit = defineEmits<{
@@ -150,14 +208,34 @@ const buttonsDisabled = ref(false)
const removedCount = computed(() => props.diffs.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => props.diffs.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => props.diffs.filter((d) => d.type === 'updated').length)
const hasExternalDiffs = computed(() => props.diffs.some(showExternalWarning))
const sortedDiffs = computed(() =>
[...props.diffs].sort((a, b) => {
const aExternal = showExternalWarning(a)
const bExternal = showExternalWarning(b)
if (aExternal !== bExternal) return aExternal ? -1 : 1
const typeOrder = { added: 0, updated: 1, removed: 2 }
return typeOrder[a.type] - typeOrder[b.type]
}),
)
function getDiffTypeLabel(diff: ContentDiffItem) {
if (showExternalWarning(diff)) return formatMessage(externalDiffTypeMessages[diff.type])
if (diff.type === 'removed' && props.removedLabel) return props.removedLabel
return formatMessage(diffTypeMessages[diff.type])
}
function getVersionLabel(diff: ContentDiffItem) {
if (showExternalWarning(diff) && diff.fileName) return decodeURIComponent(diff.fileName)
return diff.type === 'removed' ? diff.currentVersionName : diff.newVersionName
}
function showExternalWarning(diff: ContentDiffItem) {
return Boolean(props.showExternalWarnings && diff.external && diff.type !== 'removed')
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
@@ -194,6 +272,32 @@ const messages = defineMessages({
defaultMessage:
'Some content on your server could not be analyzed and may be affected by this change.',
},
unknownFilesWarning: {
id: 'content.diff-modal.unknown-files-warning',
defaultMessage: 'Unknown files warning',
},
unknownFilesDescription: {
id: 'content.diff-modal.unknown-files-description',
defaultMessage:
'This update contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
unknownProject: {
id: 'content.diff-modal.unknown-project',
defaultMessage: 'Unknown',
},
reviewedFiles: {
id: 'content.diff-modal.reviewed-files',
defaultMessage:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
installAnyway: {
id: 'content.diff-modal.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'content.diff-modal.dont-install',
defaultMessage: "Don't install",
},
})
const diffTypeMessages = defineMessages({
@@ -211,5 +315,20 @@ const diffTypeMessages = defineMessages({
},
})
const externalDiffTypeMessages = defineMessages({
added: {
id: 'content.diff-modal.external-diff-type.added',
defaultMessage: 'Added',
},
removed: {
id: 'content.diff-modal.external-diff-type.removed',
defaultMessage: 'Removed',
},
updated: {
id: 'content.diff-modal.external-diff-type.updated',
defaultMessage: 'Updated',
},
})
defineExpose({ show, hide })
</script>
@@ -37,6 +37,7 @@ export interface LoaderVersionEntry {
export interface ContentDiffItem {
type: 'added' | 'removed' | 'updated'
external?: boolean
projectName?: string
fileName?: string
currentVersionName?: string
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client'
import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerPermissions } from '#ui/composables/server-permissions'
@@ -124,6 +125,10 @@ const contentUploadSession = useUploadSessionUpload({
uploadState,
cancelUpload,
})
const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
const skipUnknownFileWarningKey = 'hosting-skip-unknown-file-warning'
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const { canSetup, permissionDeniedMessage } = useServerPermissions()
@@ -932,8 +937,18 @@ function handleUploadFiles() {
if (!wid) return
try {
const fileRecognition = await Promise.all(files.map(isFileOnModrinth))
const unrecognizedFileSet = new Set(files.filter((_, index) => !fileRecognition[index]))
const confirmedFiles: File[] = []
for (const file of files) {
if (!unrecognizedFileSet.has(file) || (await confirmUnknownFileInstallation(file.name))) {
confirmedFiles.push(file)
}
}
if (confirmedFiles.length === 0) return
const result = await contentUploadSession.uploadFiles(
files.map((file) => ({ file, filename: file.name })),
confirmedFiles.map((file) => ({ file, filename: file.name })),
)
if (result === 'completed') await contentQuery.refetch()
} catch (err) {
@@ -947,6 +962,45 @@ function handleUploadFiles() {
input.click()
}
async function isFileOnModrinth(file: File) {
const buffer = await file.arrayBuffer()
const digest = await crypto.subtle.digest('SHA-1', buffer)
const hash = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
try {
await client.labrinth.versions_v2.getVersionFromFileHash(hash, 'sha1')
return true
} catch (error) {
return !(error instanceof ModrinthApiError && error.statusCode === 404)
}
}
function confirmUnknownFileInstallation(fileName: string) {
if (localStorage.getItem(skipUnknownFileWarningKey) === 'true') {
return Promise.resolve(true)
}
unknownFileName.value = fileName
return new Promise<boolean>((resolve) => {
resolveUnknownFileConfirmation = resolve
void nextTick(() => unknownFileWarningModal.value?.show())
})
}
function resolveUnknownFileWarning(confirmed: boolean) {
const resolve = resolveUnknownFileConfirmation
resolveUnknownFileConfirmation = null
unknownFileName.value = ''
resolve?.(confirmed)
}
function handleUnknownFileContinue(dontShowAgain: boolean) {
if (dontShowAgain) localStorage.setItem(skipUnknownFileWarningKey, 'true')
resolveUnknownFileWarning(true)
}
function addonToContentItem(addon: AddonWithUiState): ContentItem {
return {
project: {
@@ -1380,6 +1434,13 @@ provideContentManager({
<ReadyTransition :pending="contentReadyPending">
<ContentPageLayout :bottom-padding="false">
<template #modals>
<UnknownFileWarningModal
ref="unknownFileWarningModal"
mode="mod"
:file-name="unknownFileName"
@cancel="resolveUnknownFileWarning(false)"
@continue="handleUnknownFileContinue"
/>
<ConfirmUnlinkModal
ref="modpackUnlinkModal"
server