From 2d6703191b9f5f106f5ca33b502bc9cfc1abca86 Mon Sep 17 00:00:00 2001 From: IDCs Date: Thu, 6 Nov 2025 08:53:53 +0000 Subject: [PATCH 1/2] Added C# script allow map - currently only contains MCM for FNV This commit is a bit more hefty than I'd like, but this was a long time coming. - IDownload now has proper typings (no more scouring through the code for the correct state branch for modId/fileId/gameId) - Fixed incorrect typings being used by mixpanel wherever the nexusIds selector was used. (highlighted by the fact that we now actually have proper damn types) - Added ability to generate a IModReference object reliably from an IDownload object. - Added selector to retrieve all finished downloads for a specific game closes nexus-mods/vortex#18739 --- .../download_management/DownloadObserver.ts | 4 +- .../download_management/selectors.ts | 12 ++++ .../download_management/types/IDownload.ts | 57 ++++++++++++++++++- .../mod_management/InstallContext.ts | 8 +-- .../mod_management/InstallManager.ts | 44 +++++++------- .../util/cSharpScriptAllowList.ts | 26 +++++++++ .../mod_management/util/testModReference.ts | 31 ++++++++++ src/extensions/nexus_integration/selectors.ts | 6 +- 8 files changed, 157 insertions(+), 31 deletions(-) create mode 100644 src/extensions/mod_management/util/cSharpScriptAllowList.ts diff --git a/src/extensions/download_management/DownloadObserver.ts b/src/extensions/download_management/DownloadObserver.ts index a7edbf8c5..7c31a4e23 100644 --- a/src/extensions/download_management/DownloadObserver.ts +++ b/src/extensions/download_management/DownloadObserver.ts @@ -173,7 +173,7 @@ export class DownloadObserver { // Only track analytics if we have valid Nexus metadata if (nexusIds) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); const isCollection = nexusIds.collectionSlug !== undefined && nexusIds.revisionId !== undefined; if ((err instanceof ProcessCanceled) || (err instanceof UserCanceled)) { @@ -450,7 +450,7 @@ export class DownloadObserver { this.mApi.events.emit('analytics-track-mixpanel-event', new CollectionsDownloadCompletedEvent(nexusIds.collectionId, nexusIds.revisionId, nexusIds.numericGameId, download.size, duration_ms)); } else if (nexusIds?.modId !== undefined && nexusIds?.fileId !== undefined) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); this.mApi.events.emit('analytics-track-mixpanel-event', new ModsDownloadCompletedEvent(nexusIds.modId, nexusIds.fileId, nexusIds.numericGameId, modUID, fileUID, download.size, duration_ms)); } else { diff --git a/src/extensions/download_management/selectors.ts b/src/extensions/download_management/selectors.ts index 9184b9a9e..92f7ad645 100644 --- a/src/extensions/download_management/selectors.ts +++ b/src/extensions/download_management/selectors.ts @@ -26,6 +26,18 @@ export function downloadPathForGame(state: IState, gameId?: string) { } const downloadFiles = (state: IState) => state.persistent.downloads.files; +export const downloadsForGame = (state: IState, gameId: string) => { + return Object.keys(downloadFiles(state)).reduce((prev, id) => { + const download = downloadFiles(state)[id]; + if (download.game.includes(gameId) && ['finished'].includes(download.state)) { + prev[id] = download; + } + return prev; + }, {} as { [dlId: string]: IDownload }); +}; + +export const downloadsForActiveGame = (state: IState) => createSelector( + activeGameId, (inGameId: string) => downloadsForGame(state, inGameId)); const ACTIVE_STATES: DownloadState[] = ['finalizing', 'started']; diff --git a/src/extensions/download_management/types/IDownload.ts b/src/extensions/download_management/types/IDownload.ts index 26e29b0ae..5c1574be3 100644 --- a/src/extensions/download_management/types/IDownload.ts +++ b/src/extensions/download_management/types/IDownload.ts @@ -19,6 +19,59 @@ export interface IDownloadOptions { fileName?: string; } +/** + * Metadata for Nexus Mods downloads + */ +export interface INexusModMeta { + archived?: boolean; + details?: { + author?: string; + category?: string; + description?: string; + fileId?: string; + homepage?: string; + modId?: string; + }; + domainName?: string; + expires?: number; + fileMD5?: string; + fileName?: string; + fileSizeBytes?: number; + fileVersion?: string; + gameId?: string; + logicalFileName?: string; + source?: string; + sourceURI?: string; + status?: string; +} + +/** + * Extended mod info structure with common properties + */ +export interface IModInfo { + collectionSlug?: string; + game?: string; + meta?: INexusModMeta; + name?: string; + nexus?: { + ids?: { + collectionSlug?: string; + collectionId?: number; + fileId?: number; + gameId?: string; + modId?: number; + revisionId?: number; + revisionNumber?: number; + }; + [key: string]: any; + }; + referenceTag?: string; + revisionNumber?: number; + source?: string; + // Allow additional properties + [key: string]: any; +} + /** * download information * @@ -74,10 +127,10 @@ export interface IDownload { * info about the mod being downloaded. This will * be associated with the mod entry after its installation * - * @type {{ [key: string]: any }} + * @type {IModInfo} * @memberOf IDownload */ - modInfo: { [key: string]: any }; + modInfo: IModInfo; /** * id of the (last) mod installed from this archive. Will be undefined diff --git a/src/extensions/mod_management/InstallContext.ts b/src/extensions/mod_management/InstallContext.ts index 3c7c04fc9..b49b07131 100644 --- a/src/extensions/mod_management/InstallContext.ts +++ b/src/extensions/mod_management/InstallContext.ts @@ -224,7 +224,7 @@ class InstallContext implements IInstallContext { const isCollection = nexusIds?.collectionSlug != null && nexusIds?.revisionId != null; if (nexusIds?.fileId != null && !isCollection) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); this.mApi.events.emit('analytics-track-mixpanel-event', new ModsInstallationStartedEvent(nexusIds.modId, nexusIds.fileId, nexusIds.numericGameId, modUID, fileUID)); } } @@ -320,7 +320,7 @@ class InstallContext implements IInstallContext { case 'success': if (nexusIds?.fileId != null && !isCollection) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); this.mApi.events.emit('analytics-track-mixpanel-event', new ModsInstallationCompletedEvent(nexusIds.modId, nexusIds.fileId, nexusIds.numericGameId, modUID, fileUID, Date.now() - this.mStartTime)); } @@ -351,7 +351,7 @@ class InstallContext implements IInstallContext { case 'canceled': if (nexusIds?.fileId != null && !isCollection) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); this.mApi.events.emit('analytics-track-mixpanel-event', new ModsInstallationCancelledEvent(nexusIds.modId, nexusIds.fileId, nexusIds.numericGameId, modUID, fileUID)); } @@ -368,7 +368,7 @@ class InstallContext implements IInstallContext { default: if (nexusIds?.fileId != null && !isCollection) { - const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId, nexusIds.modId, nexusIds.fileId); + const { modUID, fileUID } = makeModAndFileUIDs(nexusIds.numericGameId.toString(), nexusIds.modId, nexusIds.fileId); this.mApi.events.emit('analytics-track-mixpanel-event', new ModsInstallationFailedEvent(nexusIds.modId, nexusIds.fileId, nexusIds.numericGameId, modUID, fileUID, "", this.mFailReason ?? 'unknown_error')); } diff --git a/src/extensions/mod_management/InstallManager.ts b/src/extensions/mod_management/InstallManager.ts index 16c32284e..71011b8f0 100644 --- a/src/extensions/mod_management/InstallManager.ts +++ b/src/extensions/mod_management/InstallManager.ts @@ -58,7 +58,8 @@ import gatherDependencies, { findDownloadByRef, findModByRef, lookupFromDownload import filterModInfo from './util/filterModInfo'; import metaLookupMatch from './util/metaLookupMatch'; import queryGameId from './util/queryGameId'; -import testModReference, { idOnlyRef, isFuzzyVersion, referenceEqual, testRefByIdentifiers } from './util/testModReference'; +import testModReference, { downloadToModRef, idOnlyRef, isFuzzyVersion, referenceEqual, testRefByIdentifiers } from './util/testModReference'; +import { getCSharpScriptAllowListForGame } from './util/cSharpScriptAllowList'; import { MAX_VARIANT_NAME, MIN_VARIANT_NAME, VORTEX_OVERRIDE_INSTRUCTIONS_FILENAME } from './constants'; import InstallContext from './InstallContext'; @@ -78,7 +79,6 @@ import * as Redux from 'redux'; import { generate as shortid } from 'shortid'; import { IInstallOptions } from './types/IInstallOptions'; import { generateCollectionSessionId } from '../collections_integration/util'; -import { th } from 'date-fns/locale'; // Interface for tracking active installation information interface IActiveInstallation { @@ -882,15 +882,21 @@ class InstallManager { this.mActiveInstalls.delete(installId); }; - if (archiveId && archiveId !== null) { + if (archiveId != null) { const download = api.getState().persistent.downloads.files[archiveId]; if (download && download.state !== 'finished') { const error = new Error(`Cannot install: download not finished (state: ${download.state})`); trackedCallback(error, undefined); return; + } else { + modReference = modReference || downloadToModRef(download); } } + const details: IInstallationDetails = { + modReference, + }; + // Use parallel installation concurrency limiter instead of sequential mQueue this.mMainInstallsLimit.do(() => { return new Promise((resolve, reject) => { @@ -1176,9 +1182,6 @@ class InstallManager { log('info', 'installing to', { modId, destinationPath }); installContext.setInstallPathCB(modId, destinationPath); tempPath = destinationPath + '.installing'; - const details: IInstallationDetails = { - modReference, - }; return this.installInner(api, archivePath, tempPath, destinationPath, installGameId, installContext, installationZip, forceInstaller, fullInfo.choices, fileList, unattended, details); @@ -1224,7 +1227,7 @@ class InstallManager { const startTime = Date.now(); return this.processInstructions(api, installContext, archivePath, tempPath, destinationPath, installGameId, modId, result, - fullInfo.choices, unattended) + fullInfo.choices, unattended, details) .tap(() => { const endTime = Date.now(); log('debug', 'processed instructions', { installId: activeInstall.installId, duration: endTime - startTime }); @@ -2555,7 +2558,8 @@ class InstallManager { hasXmlConfigXML, }; - if (hasCSScripts) { + const allowList = getCSharpScriptAllowListForGame(gameId); + if (hasCSScripts && !allowList.has(details?.modReference?.repo?.modId || '')) { const modName = details?.modReference?.id || path.basename(archivePath, path.extname(archivePath)); const t = api.translate; @@ -2566,10 +2570,10 @@ class InstallManager { 'question', t(`Unsafe Mod Detected`), { - message: t( - `"{{modName}}" contains C# scripts that can run code on your computer.\n` + - `These scripts give the mod full access to your system and can cause serious harm, including data loss or security breaches.\n` + - `Unless you personally reviewed and trust the source, we strongly recommend you do not install this mod.\n` + + bbcode: t( + `"{{modName}}" contains C# scripts that can run code on your computer.[br][/br][br][/br]` + + `These scripts give the mod full access to your system and can cause serious harm, including data loss or security breaches.[br][/br]` + + `Unless you personally reviewed and trust the source, we strongly recommend you do not install this mod.[br][/br][br][/br]` + `Are you sure you want to continue?`, { replace: { modName: modName } } ), @@ -2849,7 +2853,7 @@ class InstallManager { private processSubmodule(api: IExtensionApi, installContext: InstallContext, submodule: IInstruction[], destinationPath: string, gameId: string, modId: string, - choices: any, unattended: boolean): Bluebird { + choices: any, unattended: boolean, details: IInstallationDetails): Bluebird { return Bluebird.each(submodule, mod => { const tempPath = destinationPath + '.' + shortid() + '.installing'; @@ -2860,10 +2864,10 @@ class InstallManager { const submoduleZip = new Zip(); return this.installInner(api, mod.path, tempPath, destinationPath, gameId, subContext, submoduleZip, undefined, - choices, undefined, unattended) + choices, undefined, unattended, details) .then((resultInner) => this.processInstructions( api, installContext, mod.path, tempPath, destinationPath, - gameId, modId, resultInner, choices, unattended)) + gameId, modId, resultInner, choices, unattended, details)) .then(() => { if (mod.submoduleType !== undefined) { api.store.dispatch(setModType(gameId, modId, mod.submoduleType)); @@ -2973,7 +2977,7 @@ class InstallManager { tempPath: string, destinationPath: string, gameId: string, modId: string, result: { instructions: IInstruction[], overrideInstructions?: IInstruction[] }, - choices: any, unattended: boolean) { + choices: any, unattended: boolean, details: IInstallationDetails) { if (result.instructions === null) { // this is the signal that the installer has already reported what went // wrong. Not necessarily a "user canceled" but the error handling happened @@ -3099,7 +3103,7 @@ class InstallManager { gameId, modId)) .then(() => this.processSubmodule(api, installContext, instructionGroups.submodule, destinationPath, gameId, modId, - choices, unattended)) + choices, unattended, details)) .then(() => this.processAttribute(api, instructionGroups.attribute, gameId, modId)) .then(() => this.processEnableAllPlugins(api, instructionGroups.enableallplugins, gameId, modId)) @@ -5276,8 +5280,8 @@ class InstallManager { if (modId && fileId) { const altDownloadId = Object.keys(relevantDownloads).find(dlId => { const download = relevantDownloads[dlId]; - return download.modInfo?.nexus?.modId?.toString() === modId.toString() && - download.modInfo?.nexus?.fileId?.toString() === fileId.toString() && + return download.modInfo?.nexus?.ids?.modId?.toString() === modId.toString() && + download.modInfo?.nexus?.ids?.fileId?.toString() === fileId.toString() && download.state === 'finished'; }); if (altDownloadId) { @@ -5289,7 +5293,7 @@ class InstallManager { if (modId) { const altDownloadId = Object.keys(relevantDownloads).find(dlId => { const download = relevantDownloads[dlId]; - return download.modInfo?.nexus?.modId?.toString() === modId.toString() && + return download.modInfo?.nexus?.ids?.modId?.toString() === modId.toString() && download.state === 'finished'; }); if (altDownloadId) { diff --git a/src/extensions/mod_management/util/cSharpScriptAllowList.ts b/src/extensions/mod_management/util/cSharpScriptAllowList.ts new file mode 100644 index 000000000..71d20537f --- /dev/null +++ b/src/extensions/mod_management/util/cSharpScriptAllowList.ts @@ -0,0 +1,26 @@ +export interface IAllowListKey { + domainName: string; + numericGameId: number; + internalId: string; +} + +const allowList = new Map>([ + [{ domainName: 'newvegas', numericGameId: 130, internalId: 'falloutnv' }, new Set(['42507'])], + [{ domainName: 'fallout3', numericGameId: 120, internalId: 'fallout3' }, new Set([])], + [{ domainName: 'oblivion', numericGameId: 101, internalId: 'oblivion' }, new Set([])], +]); + +/** + * Get the CSharp script allow list for a specific game. + * @param gameId internal game id (i.e. falloutnv) + * @returns a set of allowed mod IDs + */ +export const getCSharpScriptAllowListForGame = (gameId: string): Set => { + const result = new Set(); + for (const [key, value] of allowList.entries()) { + if (key.internalId === gameId) { + value.forEach(modId => result.add(modId)); + } + } + return result; +}; \ No newline at end of file diff --git a/src/extensions/mod_management/util/testModReference.ts b/src/extensions/mod_management/util/testModReference.ts index f7a01b88b..104e9d7d9 100644 --- a/src/extensions/mod_management/util/testModReference.ts +++ b/src/extensions/mod_management/util/testModReference.ts @@ -4,6 +4,7 @@ import { truthy } from '../../../util/util'; import { log } from '../../../util/log'; import { IMod, IModReference, IFileListItem } from '../types/IMod'; +import { IDownload } from '../../download_management/types/IDownload'; import * as _ from 'lodash'; import minimatch from 'minimatch'; @@ -47,6 +48,36 @@ export function referenceEqual(lhs: IModReference, rhs: IModReference): boolean return _.isEqual(_.pick(lhs, REFERENCE_FIELDS), _.pick(rhs, REFERENCE_FIELDS)); } +/** + * Converts an IDownload object to an IModReference object. + * Extracts relevant metadata from the download's modInfo structure to populate + * the reference fields used for mod matching and dependency resolution. + * + * @param download - The download object to convert + * @returns IModReference object with populated fields from the download + */ +export function downloadToModRef(download: IDownload): IModReference { + // Extract modId and fileId from nested structures + // Priority: nexus.ids (preferred) -> meta.details (fallback) + const modId = download.modInfo?.nexus?.ids?.modId?.toString() + ?? download.modInfo?.meta?.details?.modId; + const fileId = download.modInfo?.nexus?.ids?.fileId?.toString() + ?? download.modInfo?.meta?.details?.fileId; + + const ref: IModReference = { + archiveId: download.id, + repo: download.modInfo?.source ? { + repository: download.modInfo.source, + modId: modId, + fileId: fileId, + } : undefined, + fileMD5: download.fileMD5, + gameId: download.game?.[0], + logicalFileName: download.modInfo?.meta?.logicalFileName ?? download.localPath, + }; + return ref; +} + export function sanitizeExpression(fileName: string): string { // Validate input - return empty string for invalid inputs if (fileName == null || typeof fileName !== 'string') { diff --git a/src/extensions/nexus_integration/selectors.ts b/src/extensions/nexus_integration/selectors.ts index 265fc851a..450c2d608 100644 --- a/src/extensions/nexus_integration/selectors.ts +++ b/src/extensions/nexus_integration/selectors.ts @@ -27,9 +27,9 @@ export const nexusIdsFromDownloadId = createSelector( const numericGameId = nexusGames().find(g => g.domain_name === (dl.modInfo?.nexus?.ids?.gameId || dl?.modInfo?.meta?.domainName)); return { gameDomainName: dl?.modInfo?.nexus?.ids?.gameId || dl?.modInfo?.meta?.domainName, - fileId: dl?.modInfo?.nexus?.ids?.fileId, - modId: dl?.modInfo?.nexus?.ids?.modId, - numericGameId: numericGameId?.id?.toString() || dl?.modInfo?.meta?.gameId?.toString(), + fileId: dl?.modInfo?.nexus?.ids?.fileId.toString(), + modId: dl?.modInfo?.nexus?.ids?.modId.toString(), + numericGameId: numericGameId?.id || parseInt(dl?.modInfo?.meta?.gameId), collectionSlug: dl?.modInfo?.nexus?.ids?.collectionSlug, collectionId: dl?.modInfo?.nexus?.ids?.collectionId?.toString() ?? dl?.modInfo?.nexus?.revisionInfo?.collection?.id?.toString(), revisionId: dl?.modInfo?.nexus?.ids?.revisionId?.toString(), From 6f6621cf62e639006764065348e62a7238fe2817 Mon Sep 17 00:00:00 2001 From: IDCs Date: Thu, 6 Nov 2025 15:13:08 +0000 Subject: [PATCH 2/2] fixing nexusIds selector --- src/extensions/nexus_integration/selectors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/nexus_integration/selectors.ts b/src/extensions/nexus_integration/selectors.ts index 450c2d608..337d69499 100644 --- a/src/extensions/nexus_integration/selectors.ts +++ b/src/extensions/nexus_integration/selectors.ts @@ -27,8 +27,8 @@ export const nexusIdsFromDownloadId = createSelector( const numericGameId = nexusGames().find(g => g.domain_name === (dl.modInfo?.nexus?.ids?.gameId || dl?.modInfo?.meta?.domainName)); return { gameDomainName: dl?.modInfo?.nexus?.ids?.gameId || dl?.modInfo?.meta?.domainName, - fileId: dl?.modInfo?.nexus?.ids?.fileId.toString(), - modId: dl?.modInfo?.nexus?.ids?.modId.toString(), + fileId: dl?.modInfo?.nexus?.ids?.fileId?.toString(), + modId: dl?.modInfo?.nexus?.ids?.modId?.toString(), numericGameId: numericGameId?.id || parseInt(dl?.modInfo?.meta?.gameId), collectionSlug: dl?.modInfo?.nexus?.ids?.collectionSlug, collectionId: dl?.modInfo?.nexus?.ids?.collectionId?.toString() ?? dl?.modInfo?.nexus?.revisionInfo?.collection?.id?.toString(),