mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
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:
+188
-69
@@ -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 aren’t 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 it’s 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
|
||||
|
||||
Reference in New Issue
Block a user