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
4 changes: 2 additions & 2 deletions src/extensions/download_management/DownloadObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/extensions/download_management/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
57 changes: 55 additions & 2 deletions src/extensions/download_management/types/IDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/extensions/mod_management/InstallContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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));
}
Expand All @@ -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'));
}
Expand Down
44 changes: 24 additions & 20 deletions src/extensions/mod_management/InstallManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string>((resolve, reject) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;

Expand All @@ -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 } }
),
Expand Down Expand Up @@ -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<void> {
choices: any, unattended: boolean, details: IInstallationDetails): Bluebird<void> {
return Bluebird.each(submodule,
mod => {
const tempPath = destinationPath + '.' + shortid() + '.installing';
Expand All @@ -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));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions src/extensions/mod_management/util/cSharpScriptAllowList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface IAllowListKey {
domainName: string;
numericGameId: number;
internalId: string;
}

const allowList = new Map<IAllowListKey, Set<string>>([
[{ 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<string> => {
const result = new Set<string>();
for (const [key, value] of allowList.entries()) {
if (key.internalId === gameId) {
value.forEach(modId => result.add(modId));
}
}
return result;
};
31 changes: 31 additions & 0 deletions src/extensions/mod_management/util/testModReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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') {
Expand Down
6 changes: 3 additions & 3 deletions src/extensions/nexus_integration/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down