import { FileExtractor } from "./utils/FileExtractor.js"; import { HashFilter } from "./strategies/HashFilter.js"; import { MixinFilter } from "./strategies/MixinFilter.js"; import { DexpubFilter } from "./strategies/DexpubFilter.js"; import { ModrinthFilter } from "./strategies/ModrinthFilter.js"; import { IModCheckResult, IModCheckConfig, IFileInfo, ModSide } from "./types.js"; import { JarParser } from "../utils/jar-parser.js"; import { logger } from "../utils/logger.js"; import * as fs from "fs"; import * as path from "path"; import crypto from "node:crypto"; const DEFAULT_CONFIG: IModCheckConfig = { enableDexpub: true, enableModrinth: true, enableMixin: true, enableHash: true, timeout: 30000, }; export class ModCheckService { private readonly extractor: FileExtractor; private readonly config: IModCheckConfig; constructor(modsDir: string, config?: Partial) { this.extractor = new FileExtractor(modsDir); this.config = { ...DEFAULT_CONFIG, ...config }; } async checkMods(): Promise { logger.info("开始模组检查流程"); const files = await this.extractor.extractFilesInfo(); const results: IModCheckResult[] = []; for (const file of files) { const result = await this.checkSingleFile(file); results.push(result); } logger.info("模组检查流程完成", { 总模组数: results.length }); return results; } async checkModsWithBundle(bundleName: string): Promise { logger.info("开始模组检查流程(带整合包)", { bundleName }); const files = await this.extractor.extractFilesInfo(); const results: IModCheckResult[] = []; const clientMods = await this.identifyClientSideMods(files); for (const file of files) { const filename = file.filename; const isClient = clientMods.includes(filename); results.push({ filename: path.basename(filename), filePath: filename, clientSide: isClient ? 'required' : 'unknown', serverSide: isClient ? 'unsupported' : 'unknown', source: isClient ? 'Multiple' : 'none', checked: isClient, allResults: isClient ? [{ source: 'Multiple', clientSide: 'required', serverSide: 'unsupported', checked: true }] : [] }); } if (clientMods.length > 0) { await this.moveClientMods(clientMods, bundleName); logger.info(`已移动 ${clientMods.length} 个客户端模组到 .rubbish/${bundleName}`); } logger.info("模组检查流程完成", { 总模组数: results.length, 客户端模组数: clientMods.length }); return results; } private async identifyClientSideMods(files: IFileInfo[]): Promise { const clientMods: string[] = []; const processedFiles = new Set(); if (this.config.enableDexpub) { logger.info("开始 Galaxy Square (dexpub) 检查客户端模组"); const dexpubStrategy = new DexpubFilter(); const dexpubMods = await dexpubStrategy.filter(files); const serverModsListSet = new Set(await dexpubStrategy.getServerMods(files)); dexpubMods.forEach(mod => processedFiles.add(mod)); serverModsListSet.forEach(mod => processedFiles.add(mod)); clientMods.push(...dexpubMods); } if (this.config.enableModrinth) { logger.info("开始 Modrinth API 检查客户端模组"); let serverModsSet = new Set(); if (this.config.enableDexpub) { const dexpubStrategy = new DexpubFilter(); serverModsSet = new Set(await dexpubStrategy.getServerMods(files)); } const unprocessedFiles = files.filter(f => !processedFiles.has(f.filename)); const modrinthMods = await new ModrinthFilter().filter(unprocessedFiles); modrinthMods.forEach(mod => processedFiles.add(mod)); clientMods.push(...modrinthMods); } if (this.config.enableMixin) { logger.info("开始 Mixin 检查客户端模组"); const unprocessedFiles = files.filter(f => !processedFiles.has(f.filename)); const mixinMods = await new MixinFilter().filter(unprocessedFiles); mixinMods.forEach(mod => processedFiles.add(mod)); clientMods.push(...mixinMods); } if (this.config.enableHash) { logger.info("开始 Hash 检查客户端模组"); const unprocessedFiles = files.filter(f => !processedFiles.has(f.filename)); const hashMods = await new HashFilter().filter(unprocessedFiles); clientMods.push(...hashMods); } const uniqueMods = [...new Set(clientMods)]; logger.info("识别到客户端模组", { 数量: uniqueMods.length }); return uniqueMods; } private async moveClientMods(clientModFilePaths: string[], bundleName: string): Promise { const rubbishDir = path.join('.rubbish', bundleName); try { await fs.promises.mkdir(rubbishDir, { recursive: true }); logger.info(`创建目录: ${rubbishDir}`); } catch (error: any) { logger.error(`创建目录失败: ${rubbishDir}`, error); throw error; } for (const filePath of clientModFilePaths) { const filename = path.basename(filePath); const destPath = path.join(rubbishDir, filename); try { await fs.promises.rename(filePath, destPath); logger.debug(`移动模组: ${filename} -> ${destPath}`); } catch (error: any) { logger.error(`移动模组失败: ${filename}`, error); } } } async checkUploadedFiles(uploadedFiles: Array<{ originalname: string; buffer: Buffer }>): Promise { logger.info("开始检查上传文件", { 文件数量: uploadedFiles.length }); const results: IModCheckResult[] = []; for (const uploadedFile of uploadedFiles) { try { const fileData = uploadedFile.buffer; const mixins = await JarParser.extractMixins(fileData); const infos = await JarParser.extractModInfo(fileData); const fileInfo: IFileInfo = { filename: uploadedFile.originalname, hash: crypto.createHash('sha1').update(fileData).digest('hex'), mixins, infos, fileData, }; const result = await this.checkSingleFile(fileInfo); results.push(result); } catch (error: any) { logger.error("处理上传文件时出错", { 文件名: uploadedFile.originalname, 错误: error.message }); results.push({ filename: uploadedFile.originalname, filePath: uploadedFile.originalname, clientSide: "unknown", serverSide: "unknown", source: "none", checked: false, errors: [error.message], allResults: [], }); } } logger.info("上传文件模组检查完成", { 总模组数: results.length }); return results; } async checkSingleMod(filePath: string): Promise { const filename = path.basename(filePath); const hash = await this.calculateHash(filePath); const extractor = new FileExtractor(path.dirname(filePath)); const files = await extractor.extractFilesInfo(); const fileInfo = files.find(f => f.filename === filename); if (!fileInfo) { return { filename, filePath, clientSide: "unknown", serverSide: "unknown", source: "none", checked: false, errors: ["文件未找到或无法提取"], allResults: [], }; } return this.checkSingleFile(fileInfo); } private async checkSingleFile(file: IFileInfo): Promise { const result: IModCheckResult = { filename: file.filename, filePath: file.filename, clientSide: "unknown", serverSide: "unknown", source: "none", checked: false, errors: [], allResults: [], }; const allResults = await this.collectAllResultsParallel(file); result.allResults = allResults; const bestResult = this.mergeResults(allResults); result.clientSide = bestResult.clientSide; result.serverSide = bestResult.serverSide; result.source = bestResult.source; result.checked = bestResult.checked; result.errors = bestResult.errors; const modInfo = await this.extractModInfoDetails(file); if (modInfo) { result.modId = modInfo.id; result.iconUrl = modInfo.iconUrl; result.description = modInfo.description; result.author = modInfo.author; } return result; } private async collectAllResultsParallel(file: IFileInfo): Promise> { const checkPromises: Promise<{ clientSide: ModSide; serverSide: ModSide; source: string; checked: boolean; error?: string; }>[] = []; if (this.config.enableDexpub) { checkPromises.push(this.runCheckWithTimeout(this.checkDexpub, file, "Dexpub")); } if (this.config.enableModrinth) { checkPromises.push(this.runCheckWithTimeout(this.checkModrinth, file, "Modrinth")); } if (this.config.enableMixin) { checkPromises.push(this.runCheckWithTimeout(this.checkMixin, file, "Mixin")); } if (this.config.enableHash) { checkPromises.push(this.runCheckWithTimeout(this.checkHash, file, "Hash")); } return Promise.all(checkPromises); } private async runCheckWithTimeout( checkFn: (file: IFileInfo) => Promise<{ clientSide: ModSide; serverSide: ModSide } | null>, file: IFileInfo, source: string ): Promise<{ clientSide: ModSide; serverSide: ModSide; source: string; checked: boolean; error?: string; }> { return this.runWithTimeout( checkFn(file), `${source} 检查超时: ${file.filename}` ).then(result => { if (result) { return { clientSide: result.clientSide, serverSide: result.serverSide, source, checked: true, }; } return { clientSide: "unknown" as ModSide, serverSide: "unknown" as ModSide, source, checked: false, }; }).catch((error: any) => { logger.warn(`${file.filename} 的 ${source} 检查失败`, { 错误: error.message }); return { clientSide: "unknown" as ModSide, serverSide: "unknown" as ModSide, source, checked: false, error: error.message, }; }); } private mergeResults(results: Array<{ clientSide: ModSide; serverSide: ModSide; source: string; checked: boolean; error?: string; }>): { clientSide: ModSide; serverSide: ModSide; source: string; checked: boolean; errors: string[]; } { const errors: string[] = []; const successfulResults = results.filter(r => r.checked); for (const r of results) { if (r.error) { errors.push(`${r.source}: ${r.error}`); } } if (successfulResults.length === 0) { return { clientSide: "unknown", serverSide: "unknown", source: "none", checked: false, errors, }; } const priority: { [key: string]: number } = { "Dexpub": 1, "Modrinth": 2, "Mixin": 3, "Hash": 4, }; successfulResults.sort((a, b) => priority[a.source] - priority[b.source]); const best = successfulResults[0]; return { clientSide: best.clientSide, serverSide: best.serverSide, source: best.source, checked: true, errors, }; } private async checkDexpub(file: IFileInfo): Promise<{ clientSide: ModSide; serverSide: ModSide } | null> { const strategy = new DexpubFilter(); const files = [file]; const clientMods = await strategy.filter(files); const serverMods = await strategy.getServerMods(files); const filename = path.basename(file.filename); if (clientMods.some(f => path.basename(f) === filename)) { return { clientSide: "required", serverSide: "unsupported" }; } else if (serverMods.some(f => path.basename(f) === filename)) { return { clientSide: "unsupported", serverSide: "required" }; } return null; } private async checkModrinth(file: IFileInfo): Promise<{ clientSide: ModSide; serverSide: ModSide } | null> { const strategy = new ModrinthFilter(); const files = [file]; const clientMods = await strategy.filter(files); const filename = path.basename(file.filename); if (clientMods.some(f => path.basename(f) === filename)) { return { clientSide: "required", serverSide: "unsupported" }; } for (const info of file.infos) { if (info.name === "modrinth.index.json" || info.name === "modrinth.json") { try { const data = JSON.parse(info.data); const clientSide = this.mapClientSide(data.client_side); const serverSide = this.mapServerSide(data.server_side); return { clientSide, serverSide }; } catch { continue; } } } return null; } private async checkMixin(file: IFileInfo): Promise<{ clientSide: ModSide; serverSide: ModSide } | null> { for (const mixin of file.mixins) { try { const config = JSON.parse(mixin.data); if (!config.mixins?.length && config.client?.length > 0 && !file.filename.includes("lib")) { return { clientSide: "required", serverSide: "unsupported" }; } } catch { continue; } } return null; } private async checkHash(file: IFileInfo): Promise<{ clientSide: ModSide; serverSide: ModSide } | null> { const strategy = new HashFilter(); const files = [file]; const clientMods = await strategy.filter(files); const filename = path.basename(file.filename); if (clientMods.some(f => path.basename(f) === filename)) { return { clientSide: "required", serverSide: "unsupported" }; } return null; } private mapClientSide(value: string | undefined): ModSide { if (value === "required") return "required"; if (value === "optional") return "optional"; if (value === "unsupported") return "unsupported"; return "unknown"; } private mapServerSide(value: string | undefined): ModSide { if (value === "required") return "required"; if (value === "optional") return "optional"; if (value === "unsupported") return "unsupported"; return "unknown"; } private async runWithTimeout(promise: Promise, timeoutMessage: string): Promise { let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(timeoutMessage)); }, this.config.timeout); }); try { const result = await Promise.race([promise, timeoutPromise]); clearTimeout(timeoutId!); return result; } catch (error) { clearTimeout(timeoutId!); throw error; } } private async calculateHash(filePath: string): Promise { const fileData = fs.readFileSync(filePath); return crypto.createHash('sha1').update(fileData).digest('hex'); } private async extractModInfoDetails(file: IFileInfo): Promise<{ id?: string; iconUrl?: string; description?: string; author?: string; } | null> { for (const info of file.infos) { try { if (info.name.endsWith("mods.toml") || info.name.endsWith("neoforge.mods.toml")) { const { default: toml } = await import("smol-toml"); const data = toml.parse(info.data) as any; if (data.mods && Array.isArray(data.mods) && data.mods.length > 0) { const mod = data.mods[0] as any; let iconUrl: string | undefined; if (mod.logoFile) { iconUrl = await this.extractIconFile(file, mod.logoFile); } return { id: mod.modId || mod.modid, iconUrl, description: mod.description, author: mod.authors || mod.author, }; } } else if (info.name.endsWith("fabric.mod.json")) { const data = JSON.parse(info.data); return { id: data.id, iconUrl: data.icon, description: data.description, author: data.authors?.join(", ") || data.author, }; } else if (info.name === "modrinth.index.json" || info.name === "modrinth.json") { const data = JSON.parse(info.data); return { id: data.project_id || data.id, description: data.summary || data.description, }; } } catch (error: any) { logger.debug(`解析 ${info.name} 失败:`, error.message); continue; } } return null; } private async extractIconFile(file: IFileInfo, iconPath: string): Promise { try { let jarData: Buffer; if (file.fileData) { jarData = file.fileData; } else { jarData = fs.readFileSync(file.filename); } const { yauzl_promise } = await import("../utils/ziplib"); const zipEntries = await yauzl_promise(jarData); for (const entry of zipEntries) { if (entry.fileName === iconPath || entry.fileName.endsWith(iconPath)) { const data = await entry.ReadEntry; const ext = iconPath.split('.').pop()?.toLowerCase(); const mimeType = ext === 'png' ? 'png' : 'jpeg'; return `data:image/${mimeType};base64,${data.toString('base64')}`; } } } catch (error: any) { logger.debug(`提取图标文件 ${iconPath} 失败:`, error.message); } return undefined; } }