Files
DeEarthX-CE/backend/src/dearth/ModCheckService.ts
15736060610 61673809c8
Some checks failed
CI/CD / Code Check (push) Has been cancelled
CI/CD / Build Windows (push) Has been cancelled
修复
2026-03-17 22:02:34 +08:00

572 lines
18 KiB
TypeScript

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<IModCheckConfig>) {
this.extractor = new FileExtractor(modsDir);
this.config = { ...DEFAULT_CONFIG, ...config };
}
async checkMods(): Promise<IModCheckResult[]> {
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<IModCheckResult[]> {
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<string[]> {
const clientMods: string[] = [];
const processedFiles = new Set<string>();
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<string>();
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<void> {
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<IModCheckResult[]> {
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<IModCheckResult> {
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<IModCheckResult> {
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<Array<{
clientSide: ModSide;
serverSide: ModSide;
source: string;
checked: boolean;
error?: string;
}>> {
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<T>(promise: Promise<T>, timeoutMessage: string): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, 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<string> {
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<string | undefined> {
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;
}
}