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
@@ -73,6 +73,18 @@ export class LabrinthVersionsV2Module extends AbstractModule {
})
}
public async getVersionFromFileHash(
hash: string,
algorithm: keyof Labrinth.Versions.v2.VersionFileHash,
): Promise<Labrinth.Versions.v2.Version> {
return this.client.request<Labrinth.Versions.v2.Version>(`/version_file/${hash}`, {
api: 'labrinth',
version: 2,
method: 'GET',
params: { algorithm },
})
}
/**
* Get multiple versions by IDs (v2)
*
+1 -1
View File
@@ -26,7 +26,7 @@ pub use self::paths::{get_full_path, get_mod_full_path};
pub use self::projects::{
InstallProjectWithDependenciesRequest, add_project_from_path,
add_project_from_version, install_project_with_dependencies,
remove_project, repair_managed_modrinth,
is_file_on_modrinth, remove_project, repair_managed_modrinth,
switch_project_version_with_dependencies, toggle_disable_project,
update_all_projects, update_managed_modrinth_version, update_project,
};
+16 -1
View File
@@ -1,7 +1,7 @@
use crate::event::emit::{emit_instance, emit_loading, init_loading};
use crate::event::{InstancePayloadType, LoadingBarType};
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{ProjectType, State};
use crate::state::{CacheBehaviour, CachedEntry, ProjectType, State};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
@@ -213,6 +213,21 @@ pub async fn add_project_from_path(
.await
}
#[tracing::instrument]
pub async fn is_file_on_modrinth(path: &Path) -> crate::Result<bool> {
let state = State::get().await?;
let (_, hash) = fetch::sha1_file_async(path).await?;
let files = CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(!files.is_empty())
}
#[tracing::instrument]
pub async fn toggle_disable_project(
instance_id: &str,
+10 -2
View File
@@ -113,7 +113,8 @@ pub struct CreatePackInstance {
pub icon: Option<PathBuf>, // the icon for the instance
pub icon_url: Option<String>, // the URL icon for an instance during import
pub link: Option<InstanceLink>,
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub unknown_file: bool, // true when the mrpack archive isn't found on Modrinth via hash lookup
pub external_files_in_modpack: Vec<String>,
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
@@ -130,6 +131,7 @@ impl Default for CreatePackInstance {
icon_url: None,
link: None,
unknown_file: false,
external_files_in_modpack: Vec::new(),
skip_install_profile: Some(true),
no_watch: Some(false),
}
@@ -149,7 +151,6 @@ pub struct CreatePack {
pub description: CreatePackDescription,
}
// The hash lookup only gates the unknown-pack warning, so avoid a long blocking scan for huge local packs.
const MAX_LOCAL_FILE_HASH_LOOKUP_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug)]
@@ -214,9 +215,16 @@ pub async fn get_instance_from_pack(
false
};
let external_files_in_modpack =
super::install_mrpack::get_external_files_from_mrpack(
&CreatePackFile::Path(path),
)
.await?;
Ok(CreatePackInstance {
name: file_name,
unknown_file: !is_known_file,
external_files_in_modpack,
..Default::default()
})
}
@@ -23,7 +23,7 @@ use async_zip::base::read::{WithEntry, ZipEntryReader};
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
use futures::StreamExt;
use path_util::SafeRelativeUtf8UnixPathBuf;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::{
@@ -237,6 +237,96 @@ where
Ok((size, hasher.digest().to_string()))
}
pub(crate) async fn get_external_files_from_mrpack(
file: &CreatePackFile,
) -> crate::Result<Vec<String>> {
let mut zip_reader = MrpackZipReader::new(file).await?;
let Some(manifest_idx) =
zip_reader.file().entries().iter().position(|entry| {
matches!(entry.filename().as_str(), Ok("modrinth.index.json"))
})
else {
return Err(crate::Error::from(crate::ErrorKind::InputError(
"No pack manifest found in mrpack".to_string(),
)));
};
let manifest = zip_reader.read_entry_to_string(manifest_idx).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
let mut candidates = pack
.files
.into_iter()
.filter_map(|file| {
let path = file.path.as_str();
let hash = file.hashes.get(&PackFileHash::Sha1)?.clone();
let file_name = path.rsplit('/').next()?.to_string();
Some((file_name, hash))
})
.collect::<Vec<_>>();
let override_entries = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let path = entry.filename().as_str().ok()?;
let relative_path = path
.strip_prefix("overrides/")
.or_else(|| path.strip_prefix("client-overrides/"))?;
if path.ends_with('/')
|| ProjectType::get_from_parent_folder(relative_path).is_none()
{
return None;
}
let file_name = relative_path.rsplit('/').next()?.to_string();
Some((index, file_name))
})
.collect::<Vec<_>>();
for (index, file_name) in override_entries {
let (_, hash) = zip_reader.hash_entry(index).await?;
candidates.push((file_name, hash));
}
if candidates.is_empty() {
return Ok(Vec::new());
}
let state = State::get().await?;
let hashes = candidates
.iter()
.map(|(_, hash)| hash.as_str())
.collect::<Vec<_>>();
let recognized_hashes = match CachedEntry::get_file_many(
&hashes,
None,
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => files
.into_iter()
.map(|file| file.hash)
.collect::<HashSet<_>>(),
Err(err) => {
tracing::warn!("Failed to look up files in imported mrpack: {err}");
HashSet::new()
}
};
let mut external_files = candidates
.into_iter()
.filter_map(|(file_name, hash)| {
(!recognized_hashes.contains(&hash)).then_some(file_name)
})
.collect::<Vec<_>>();
external_files.sort();
external_files.dedup();
Ok(external_files)
}
async fn extract_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
path: &Path,
+7 -7
View File
@@ -1,5 +1,5 @@
use {
crate::{Blockchain, FiatAmount, UsdSymbol, WalletDetails},
crate::{Blockchain, FiatAmount, UsdSymbol, WalletDetails},
chrono::{DateTime, Utc},
derive_more::{Deref, Display},
rust_decimal::Decimal,
@@ -133,17 +133,17 @@ pub struct AccountDetails {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Balance {
#[serde(rename_all = "camelCase")]
Blockchain {
token_symbol: String,
exponent: u32,
Blockchain {
token_symbol: String,
exponent: u32,
#[serde(with = "rust_decimal::serde::str")]
value: Decimal,
blockchain: Blockchain,
},
#[serde(rename_all = "camelCase")]
Fiat {
currency_symbol: UsdSymbol,
exponent: u32,
Fiat {
currency_symbol: UsdSymbol,
exponent: u32,
#[serde(with = "rust_decimal::serde::str")]
value: Decimal,
},
@@ -170,6 +170,7 @@ const props = withDefaults(
header?: string
hideHeader?: boolean
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
mergeHeader?: boolean
scrollable?: boolean
@@ -196,6 +197,7 @@ const props = withDefaults(
header: undefined,
hideHeader: false,
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
@@ -289,6 +291,7 @@ function hide() {
previousFocusEl = null
setTimeout(() => {
open.value = false
nextTick(() => props.onAfterHide?.())
}, 300)
}
@@ -0,0 +1,255 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:on-hide="handleHide"
:on-after-hide="handleAfterHide"
max-width="544px"
width="544px"
>
<div class="flex flex-col items-end gap-6">
<Admonition
type="warning"
:header="
formatMessage(
mode === 'modpack' ? messages.modpackWarningTitle : messages.modWarningTitle,
)
"
class="w-full"
>
<span class="font-medium text-contrast">{{ fileName }}</span>
{{
formatMessage(mode === 'modpack' ? messages.modpackWarningBody : messages.modWarningBody)
}}
</Admonition>
<p class="m-0 w-full leading-6 text-primary">
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="mode === 'modpack'" class="relative w-full">
<div
ref="externalFileTableBody"
class="max-h-[242px] overflow-y-auto rounded-2xl"
@scroll="checkTableScrollState"
>
<Table
:columns="externalFileColumns"
:data="externalFileRows"
row-key="id"
virtualized
:virtual-row-height="48"
class="shadow-sm"
>
<template #cell-name="{ value }">
<span class="block truncate" :title="String(value)">{{ value }}</span>
</template>
</Table>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-2"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-2"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTableBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<p class="m-0 w-full font-medium leading-6 text-orange">
{{ formatMessage(messages.malwareWarning) }}
</p>
<Checkbox
v-if="mode === 'mod'"
v-model="dontShowAgain"
class="w-full"
:label="formatMessage(messages.dontShowAgain)"
/>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent" color="orange">
<button type="button" @click="continueInstallation">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" @click="cancelInstallation">
<BanIcon aria-hidden="true" />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { BanIcon } from '@modrinth/assets'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import Table, { type TableColumn } from '../base/Table.vue'
import NewModal from './NewModal.vue'
const props = withDefaults(
defineProps<{
mode: 'modpack' | 'mod'
fileName: string
externalFilesInModpack?: string[]
}>(),
{
externalFilesInModpack: () => [],
},
)
const emit = defineEmits<{
cancel: []
continue: [dontShowAgain: boolean]
}>()
const { formatMessage } = useVIntl()
const modal = useTemplateRef('modal')
const externalFileTableBody = ref<HTMLElement | null>(null)
const dontShowAgain = ref(false)
let pendingAction: { type: 'cancel' } | { type: 'continue'; dontShowAgain: boolean } | null = null
const {
showTopFade: showTableTopFade,
showBottomFade: showTableBottomFade,
checkScrollState: checkTableScrollState,
forceCheck: forceCheckTableScroll,
} = useScrollIndicator(externalFileTableBody)
const messages = defineMessages({
header: {
id: 'unknown-file-warning-modal.header',
defaultMessage: 'Confirm installation',
},
modpackWarningTitle: {
id: 'unknown-file-warning-modal.modpack-warning-title',
defaultMessage: 'Unknown files warning',
},
modWarningTitle: {
id: 'unknown-file-warning-modal.mod-warning-title',
defaultMessage: 'Unknown file warning',
},
modpackWarningBody: {
id: 'unknown-file-warning-modal.modpack-warning-body',
defaultMessage:
' contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
modWarningBody: {
id: 'unknown-file-warning-modal.mod-warning-body',
defaultMessage:
' isnt published on Modrinth. We strongly recommend only installing files from sources you trust.',
},
reviewedFiles: {
id: 'unknown-file-warning-modal.reviewed-files',
defaultMessage:
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
},
unrecognizedFiles: {
id: 'unknown-file-warning-modal.unrecognized-files',
defaultMessage: 'Unrecognized files',
},
malwareWarning: {
id: 'unknown-file-warning-modal.malware-warning',
defaultMessage:
'Malware is often distributed through mod files by sharing them on platforms like Discord.',
},
dontShowAgain: {
id: 'unknown-file-warning-modal.dont-show-again',
defaultMessage: 'Dont show this warning again',
},
installAnyway: {
id: 'unknown-file-warning-modal.install-anyway',
defaultMessage: 'Install anyway',
},
dontInstall: {
id: 'unknown-file-warning-modal.dont-install',
defaultMessage: 'Dont install',
},
})
type ExternalFileColumn = 'name'
type ExternalFileRow = {
id: string
name: string
}
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
{
key: 'name',
label: formatMessage(messages.unrecognizedFiles),
cellClass: '!h-12',
},
])
const externalFileRows = computed<ExternalFileRow[]>(() =>
props.externalFilesInModpack.map((name, index) => ({
id: `${index}-${name}`,
name,
})),
)
async function show() {
dontShowAgain.value = false
pendingAction = null
modal.value?.show()
await nextTick()
forceCheckTableScroll()
}
function hide() {
modal.value?.hide()
}
function handleHide() {
pendingAction ??= { type: 'cancel' }
}
function handleAfterHide() {
const action = pendingAction
pendingAction = null
dontShowAgain.value = false
if (action?.type === 'continue') {
emit('continue', action.dontShowAgain)
} else {
emit('cancel')
}
}
function cancelInstallation() {
pendingAction = { type: 'cancel' }
modal.value?.hide()
}
function continueInstallation() {
pendingAction = { type: 'continue', dontShowAgain: dontShowAgain.value }
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
@@ -8,4 +8,5 @@ export { default as OpenInAppModal } from './OpenInAppModal.vue'
export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'
export { default as UnknownFileWarningModal } from './UnknownFileWarningModal.vue'
export { default as UploadProgressModal } from './UploadProgressModal.vue'
@@ -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
+60
View File
@@ -455,12 +455,39 @@
"content.diff-modal.diff-type.updated": {
"defaultMessage": "Updated"
},
"content.diff-modal.dont-install": {
"defaultMessage": "Don't install"
},
"content.diff-modal.external-diff-type.added": {
"defaultMessage": "Added"
},
"content.diff-modal.external-diff-type.removed": {
"defaultMessage": "Removed"
},
"content.diff-modal.external-diff-type.updated": {
"defaultMessage": "Updated"
},
"content.diff-modal.install-anyway": {
"defaultMessage": "Install anyway"
},
"content.diff-modal.removed-count": {
"defaultMessage": "{count} removed"
},
"content.diff-modal.reviewed-files": {
"defaultMessage": "A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack)."
},
"content.diff-modal.unknown-content-body": {
"defaultMessage": "Some content on your server could not be analyzed and may be affected by this change."
},
"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."
},
"content.diff-modal.unknown-files-warning": {
"defaultMessage": "Unknown files warning"
},
"content.diff-modal.unknown-project": {
"defaultMessage": "Unknown"
},
"content.diff-modal.updated-count": {
"defaultMessage": "{count} updated"
},
@@ -5555,6 +5582,39 @@
"ui.stacked-admonitions.dismiss-all": {
"defaultMessage": "Dismiss all"
},
"unknown-file-warning-modal.dont-install": {
"defaultMessage": "Dont install"
},
"unknown-file-warning-modal.dont-show-again": {
"defaultMessage": "Dont show this warning again"
},
"unknown-file-warning-modal.header": {
"defaultMessage": "Confirm installation"
},
"unknown-file-warning-modal.install-anyway": {
"defaultMessage": "Install anyway"
},
"unknown-file-warning-modal.malware-warning": {
"defaultMessage": "Malware is often distributed through mod files by sharing them on platforms like Discord."
},
"unknown-file-warning-modal.mod-warning-body": {
"defaultMessage": " isnt published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"unknown-file-warning-modal.mod-warning-title": {
"defaultMessage": "Unknown file warning"
},
"unknown-file-warning-modal.modpack-warning-body": {
"defaultMessage": " contains files that arent published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"unknown-file-warning-modal.modpack-warning-title": {
"defaultMessage": "Unknown files warning"
},
"unknown-file-warning-modal.reviewed-files": {
"defaultMessage": "A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack)."
},
"unknown-file-warning-modal.unrecognized-files": {
"defaultMessage": "Unrecognized files"
},
"user.profile.badge.alpha.about.1": {
"defaultMessage": "This user has been around since Modrinth Alpha, which ended in November 2020."
},
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import UnknownFileWarningModal from '../../components/modal/UnknownFileWarningModal.vue'
const meta = {
title: 'Modal/UnknownFileWarningModal',
component: UnknownFileWarningModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof UnknownFileWarningModal>
export default meta
type Story = StoryObj<typeof meta>
export const Modpack: Story = {
render: () => ({
components: { ButtonStyled, UnknownFileWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof UnknownFileWarningModal> | null>(null)
return { modalRef }
},
template: /* html */ `
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open modpack warning</button>
</ButtonStyled>
<UnknownFileWarningModal
ref="modalRef"
mode="modpack"
file-name="cozy-cottage-1.4.0.mrpack"
:external-files-in-modpack="[
'voicechat-fabric-1.20.1-2.5.26.jar',
'xaeros-minimap-24.6.1_Fabric_1.20.jar',
'InventoryProfilesNext-forge-1.20.1-1.10.12.jar',
'MouseTweaks-forge-mc1.20.1-2.25.jar',
'Terralith_1.20.x_v2.5.4.jar',
'YungsApi-1.20-Forge-4.0.5.jar',
]"
/>
`,
}),
}
export const Mod: Story = {
render: () => ({
components: { ButtonStyled, UnknownFileWarningModal },
setup() {
const modalRef = ref<InstanceType<typeof UnknownFileWarningModal> | null>(null)
return { modalRef }
},
template: /* html */ `
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open file warning</button>
</ButtonStyled>
<UnknownFileWarningModal
ref="modalRef"
mode="mod"
file-name="voicechat-fabric-1.20.1-2.5.26.jar"
/>
`,
}),
}
@@ -0,0 +1,91 @@
import { DownloadIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentDiffModal from '../../layouts/shared/installation-settings/components/ContentDiffModal.vue'
import type { ContentDiffItem } from '../../layouts/shared/installation-settings/types'
const meta = {
title: 'Modal/UpdateToPlayModal',
component: ContentDiffModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ContentDiffModal>
export default meta
type Story = StoryObj<typeof meta>
const diffs: ContentDiffItem[] = [
{
type: 'added',
external: true,
fileName: 'voicechat-fabric-1.20.1-2.5.26.jar',
},
{
type: 'added',
external: true,
fileName: 'xaeros-minimap-24.6.1_Fabric_1.20.jar',
},
{
type: 'updated',
projectName: 'Cloth Config API',
currentVersionName: '18.0.145+neoforge',
newVersionName: '20.0.149+neoforge',
},
{
type: 'added',
projectName: 'Sodium',
newVersionName: '1.21.10-0.7.3-neoforge',
},
{
type: 'updated',
projectName: 'Iris Shaders',
currentVersionName: '1.8.8+1.21.8-neoforge',
newVersionName: '1.9.6+1.21.10-neoforge',
},
{
type: 'updated',
projectName: 'Entity Culling',
currentVersionName: '1.8.1',
newVersionName: '1.9.3',
},
{
type: 'updated',
projectName: 'FerriteCore',
currentVersionName: '7.0.2',
newVersionName: '8.0.0',
},
{
type: 'removed',
projectName: 'Lithium',
currentVersionName: '0.15.0+mc1.21.8',
},
]
export const ExternalFiles: Story = {
render: () => ({
components: { ButtonStyled, ContentDiffModal },
setup() {
const modalRef = ref<InstanceType<typeof ContentDiffModal> | null>(null)
return { diffs, DownloadIcon, modalRef }
},
template: /* html */ `
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open update warning</button>
</ButtonStyled>
<ContentDiffModal
ref="modalRef"
header="Update to play"
description="An update is required to play Epic Modrinth Pack. Please update to the latest version to launch the game."
:diffs="diffs"
version-date="November 25, 2025"
show-external-warnings
confirm-label="Update"
:confirm-icon="DownloadIcon"
removed-label="Removed"
/>
`,
}),
}