项目迁移
This commit is contained in:
365
backend/src/Dex.ts
Normal file
365
backend/src/Dex.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import fs from "node:fs";
|
||||
import p from "node:path";
|
||||
import websocket, { WebSocketServer } from "ws";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { platform, what_platform } from "./platform/index.js";
|
||||
import { ModFilterService } from "./dearth/index.js";
|
||||
import { dinstall, mlsetup } from "./modloader/index.js";
|
||||
import { Config } from "./utils/config.js";
|
||||
import { execPromise, getAppDir } from "./utils/utils.js";
|
||||
import { MessageWS } from "./utils/ws.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import { yauzl_promise } from "./utils/ziplib.js";
|
||||
import yauzl from "yauzl";
|
||||
import archiver from "archiver";
|
||||
|
||||
export class Dex {
|
||||
wsx!: WebSocketServer;
|
||||
message!: MessageWS;
|
||||
|
||||
constructor(ws: WebSocketServer) {
|
||||
this.wsx = ws;
|
||||
this.wsx.on("connection", (e) => {
|
||||
this.message = new MessageWS(e);
|
||||
});
|
||||
}
|
||||
|
||||
public async Main(buffer: Buffer, dser: boolean, filename?: string, template?: string) {
|
||||
try {
|
||||
const first = Date.now();
|
||||
await this.processModpack(buffer, filename, first, dser, template);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
logger.error("主流程执行失败", err);
|
||||
this.message.handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async processModpack(buffer: Buffer, filename: string | undefined, startTime: number, isServerMode: boolean, template?: string) {
|
||||
const processedBuffer = await this._processModpack(buffer, filename);
|
||||
const zps = await this._zips(processedBuffer);
|
||||
const { contain, info } = await zps._getinfo();
|
||||
|
||||
if (!contain || !info) {
|
||||
logger.error("整合包信息为空");
|
||||
this.message.handleError(new Error("该整合包似乎不是有效的整合包。"));
|
||||
return;
|
||||
}
|
||||
|
||||
const plat = what_platform(contain);
|
||||
logger.debug("检测到平台", { 平台: plat });
|
||||
logger.debug("整合包信息", info);
|
||||
|
||||
const mpname = info.name;
|
||||
const unpath = p.join(getAppDir(), "instance", mpname);
|
||||
|
||||
await this.parallelTasks(zps, mpname, plat, info, unpath);
|
||||
await this.filterMods(unpath, mpname);
|
||||
await this.installModLoader(plat, info, unpath, isServerMode, template);
|
||||
await this.completeTask(startTime, unpath, mpname, isServerMode);
|
||||
}
|
||||
|
||||
private async parallelTasks(zps: any, mpname: string, plat: string | undefined, info: any, unpath: string) {
|
||||
await Promise.all([
|
||||
zps._unzip(mpname),
|
||||
platform(plat).downloadfile(info, unpath, this.message)
|
||||
]).catch(e => {
|
||||
logger.error("并行任务执行异常", e);
|
||||
});
|
||||
this.message.statusChange();
|
||||
}
|
||||
|
||||
private async filterMods(unpath: string, mpname: string) {
|
||||
const config = Config.getConfig();
|
||||
await new ModFilterService(p.join(unpath, "mods"), p.join(getAppDir(), ".rubbish", mpname), config.filter, this.message).filter();
|
||||
this.message.statusChange();
|
||||
}
|
||||
|
||||
private async installModLoader(plat: string | undefined, info: any, unpath: string, isServerMode: boolean, template?: string) {
|
||||
const mlinfo = await platform(plat).getinfo(info);
|
||||
if (isServerMode) {
|
||||
await mlsetup(
|
||||
mlinfo.loader,
|
||||
mlinfo.minecraft,
|
||||
mlinfo.loader_version,
|
||||
unpath,
|
||||
this.message,
|
||||
template
|
||||
)
|
||||
} else {
|
||||
dinstall(
|
||||
mlinfo.loader,
|
||||
mlinfo.minecraft,
|
||||
mlinfo.loader_version,
|
||||
unpath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async completeTask(startTime: number, unpath: string, mpname: string, isServerMode: boolean) {
|
||||
const config = Config.getConfig();
|
||||
const latest = Date.now();
|
||||
const duration = latest - startTime;
|
||||
|
||||
if (isServerMode) {
|
||||
this.message.serverInstallComplete(unpath, duration);
|
||||
} else {
|
||||
this.message.finish(startTime, latest);
|
||||
}
|
||||
|
||||
if (!isServerMode && config.autoZip) {
|
||||
await this._createZip(unpath, mpname);
|
||||
}
|
||||
|
||||
if (config.oaf) {
|
||||
await execPromise(`start ${p.join(getAppDir(), "instance")}`);
|
||||
}
|
||||
|
||||
logger.info(`任务完成,耗时 ${duration}ms`);
|
||||
}
|
||||
|
||||
private async _processModpack(buffer: Buffer, filename?: string): Promise<Buffer> {
|
||||
if (!filename || !filename.endsWith('.zip')) {
|
||||
logger.debug("文件名无效或非 ZIP 格式,直接返回原始缓冲区", { 文件名: filename });
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const bufferSize = buffer.length;
|
||||
logger.info("开始处理整合包", { 文件名: filename, 文件大小: `${(bufferSize / 1024 / 1024).toFixed(2)} MB` });
|
||||
|
||||
try {
|
||||
const zip = await (new Promise<yauzl.ZipFile>((resolve, reject) => {
|
||||
yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
logger.error("解析 ZIP 文件失败", { 文件名: filename, 错误: err.message });
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
logger.debug("ZIP 文件解析成功", { 文件名: filename });
|
||||
resolve(zipfile);
|
||||
});
|
||||
}));
|
||||
|
||||
logger.info("检测到 PCL 整合包格式,尝试提取 modpack.mrpack 文件");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let mrpackBuffer: Buffer | null = null;
|
||||
let hasProcessed = false;
|
||||
let entryCount = 0;
|
||||
|
||||
zip.on('entry', (entry: yauzl.Entry) => {
|
||||
entryCount++;
|
||||
|
||||
if (hasProcessed) {
|
||||
zip.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileName === 'modpack.mrpack') {
|
||||
logger.info("找到 modpack.mrpack 文件,开始读取", { 文件大小: `${(entry.uncompressedSize / 1024).toFixed(2)} KB` });
|
||||
hasProcessed = true;
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
logger.error("打开 modpack.mrpack 读取流失败", { 错误: err.message });
|
||||
zip.close();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let bytesRead = 0;
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
bytesRead += chunk.length;
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
mrpackBuffer = Buffer.concat(chunks);
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info("modpack.mrpack 提取成功", {
|
||||
原始大小: `${(bufferSize / 1024 / 1024).toFixed(2)} MB`,
|
||||
提取大小: `${(mrpackBuffer.length / 1024).toFixed(2)} KB`,
|
||||
耗时: `${duration}ms`
|
||||
});
|
||||
zip.close();
|
||||
resolve(mrpackBuffer);
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
logger.error("读取 modpack.mrpack 数据失败", { 错误: err.message });
|
||||
zip.close();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
zip.readEntry();
|
||||
}
|
||||
});
|
||||
|
||||
zip.on('end', () => {
|
||||
if (!hasProcessed) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.warn("未找到 modpack.mrpack 文件,使用原始缓冲区", {
|
||||
扫描条目数: entryCount,
|
||||
耗时: `${duration}ms`
|
||||
});
|
||||
zip.close();
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
|
||||
zip.on('error', (err) => {
|
||||
logger.error("ZIP 文件处理异常", { 错误: err.message });
|
||||
zip.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
zip.readEntry();
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error("处理整合包失败,使用原始缓冲区", {
|
||||
文件名: filename,
|
||||
错误: err.message,
|
||||
耗时: `${duration}ms`
|
||||
});
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
private async _zips(buffer: Buffer) {
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("zip 数据为空");
|
||||
}
|
||||
const zip = await yauzl_promise(buffer);
|
||||
let index = 0;
|
||||
const _getinfo = async () => {
|
||||
const importantFiles = ["manifest.json", "modrinth.index.json"];
|
||||
for await (const entry of zip) {
|
||||
if (importantFiles.includes(entry.fileName)) {
|
||||
const content = await entry.ReadEntry;
|
||||
const info = JSON.parse(content.toString());
|
||||
logger.debug("找到关键文件", { fileName: entry.fileName, info });
|
||||
return { contain: entry.fileName, info };
|
||||
}
|
||||
index++;
|
||||
}
|
||||
throw new Error("整合包中未找到清单文件");
|
||||
}
|
||||
if (index === zip.length) {
|
||||
throw new Error("整合包中未找到清单文件");
|
||||
}
|
||||
const _unzip = async (instancename: string) => {
|
||||
logger.info("开始解压流程", { 实例名称: instancename });
|
||||
const instancePath = p.join(getAppDir(), "instance", instancename);
|
||||
let index = 1;
|
||||
for await (const entry of zip) {
|
||||
const isDir = entry.fileName.endsWith("/");
|
||||
logger.info(`进度: ${index}/${zip.length}, 文件: ${entry.fileName}`);
|
||||
|
||||
if (!entry.fileName.startsWith("overrides/")) {
|
||||
logger.info("跳过非 overrides 文件", entry.fileName);
|
||||
this.message.unzip(entry.fileName, zip.length, index);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.fileName === "overrides/") {
|
||||
logger.info("跳过 overrides 目录", entry.fileName);
|
||||
this.message.unzip(entry.fileName, zip.length, index);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this._ublack(entry.fileName)) {
|
||||
logger.info("跳过黑名单文件", entry.fileName);
|
||||
this.message.unzip(entry.fileName, zip.length, index);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDir) {
|
||||
let targetPath = entry.fileName.replace("overrides/", "");
|
||||
await fs.promises.mkdir(p.join(instancePath, targetPath), {
|
||||
recursive: true,
|
||||
});
|
||||
} else {
|
||||
let targetPath = entry.fileName.replace("overrides/", "");
|
||||
|
||||
const dirPath = p.join(instancePath, targetPath.substring(0, targetPath.lastIndexOf("/")));
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
|
||||
const fullPath = p.join(instancePath, targetPath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
logger.info("文件已存在,跳过解压", targetPath);
|
||||
} else {
|
||||
const stream = await entry.openReadStream;
|
||||
const write = fs.createWriteStream(fullPath);
|
||||
await pipeline(stream, write);
|
||||
}
|
||||
}
|
||||
this.message.unzip(entry.fileName, zip.length, index);
|
||||
index++;
|
||||
}
|
||||
logger.info("解压流程完成", { 实例名称: instancename, 总文件数: zip.length });
|
||||
}
|
||||
return { _getinfo, _unzip };
|
||||
}
|
||||
|
||||
private _ublack(filename: string): boolean {
|
||||
const blacklist = [
|
||||
"overrides/options.txt",
|
||||
"overrides/shaderpacks",
|
||||
"overrides/essential",
|
||||
"overrides/resourcepacks",
|
||||
"overrides/PCL",
|
||||
"overrides/CustomSkinLoader"
|
||||
];
|
||||
|
||||
if (filename === "overrides/" || filename === "overrides") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return blacklist.some(item => {
|
||||
const normalizedItem = item.endsWith("/") ? item : item + "/";
|
||||
const normalizedFilename = filename.endsWith("/") ? filename : filename + "/";
|
||||
return normalizedFilename === normalizedItem || normalizedFilename.startsWith(normalizedItem);
|
||||
});
|
||||
}
|
||||
|
||||
private async _createZip(sourcePath: string, mpname: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outputPath = p.join(getAppDir(), "instance", `${mpname}.zip`);
|
||||
const output = fs.createWriteStream(outputPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 }
|
||||
});
|
||||
|
||||
output.on('close', () => {
|
||||
logger.info(`打包成功: ${outputPath} (${archive.pointer()} 字节)`);
|
||||
this.message.info(`服务端已打包: ${mpname}.zip`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', (err: Error) => {
|
||||
logger.error('打包失败', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.on('warning', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') {
|
||||
logger.warn('打包警告', err);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(sourcePath, false);
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
}
|
||||
755
backend/src/core.ts
Normal file
755
backend/src/core.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import express, { Application } from "express";
|
||||
import multer from "multer";
|
||||
import cors from "cors"
|
||||
import websocket, { WebSocketServer } from "ws"
|
||||
import { createServer, Server } from "node:http";
|
||||
import { Config, IConfig } from "./utils/config.js";
|
||||
import { Dex } from "./Dex.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import { checkJava, JavaCheckResult, detectJavaPaths } from "./utils/utils.js";
|
||||
import { Galaxy } from "./galaxy.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
export class Core {
|
||||
private config: IConfig;
|
||||
private readonly app: Application;
|
||||
private readonly server: Server;
|
||||
public ws!: WebSocketServer;
|
||||
private wsx!: websocket;
|
||||
private readonly upload: multer.Multer;
|
||||
dex: Dex;
|
||||
galaxy: Galaxy;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
this.config = config
|
||||
this.app = express();
|
||||
this.server = createServer(this.app);
|
||||
this.ws = new WebSocketServer({ server: this.server })
|
||||
this.ws.on("connection",(e)=>{
|
||||
this.wsx = e
|
||||
})
|
||||
this.dex = new Dex(this.ws)
|
||||
this.galaxy = new Galaxy()
|
||||
const storage = multer.memoryStorage();
|
||||
this.upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 2 * 1024 * 1024 * 1024,
|
||||
files: 10
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async javachecker() {
|
||||
try {
|
||||
const result: JavaCheckResult = await checkJava();
|
||||
|
||||
if (result.exists && result.version) {
|
||||
logger.info(`检测到 Java: ${result.version.fullVersion} (${result.version.vendor})`);
|
||||
|
||||
if (this.wsx) {
|
||||
this.wsx.send(JSON.stringify({
|
||||
type: "info",
|
||||
message: `检测到 Java: ${result.version.fullVersion} (${result.version.vendor})`,
|
||||
data: result.version
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
logger.error("Java 检查失败", result.error);
|
||||
|
||||
if (this.wsx) {
|
||||
this.wsx.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: result.error || "未找到 Java 或版本检查失败",
|
||||
data: result
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Java 检查异常", error as Error);
|
||||
|
||||
if (this.wsx) {
|
||||
this.wsx.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Java 检查遇到异常"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupExpressRoutes() {
|
||||
this.setupMiddleware();
|
||||
this.setupHealthRoutes();
|
||||
this.setupTaskRoutes();
|
||||
this.setupConfigRoutes();
|
||||
this.setupModCheckRoutes();
|
||||
this.setupGalaxyRoutes();
|
||||
this.setupJavaRoutes();
|
||||
this.setupTemplateRoutes();
|
||||
}
|
||||
|
||||
private setupMiddleware() {
|
||||
this.app.use(cors());
|
||||
this.app.use(express.json({ limit: '2gb' }));
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '2gb' }));
|
||||
|
||||
// 全局错误处理中间件
|
||||
this.app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error("全局错误捕获", err);
|
||||
res.status(err.status || 500).json({
|
||||
status: err.status || 500,
|
||||
message: err.message || "服务器内部错误",
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupHealthRoutes() {
|
||||
// 健康检查路由(ping 接口)
|
||||
this.app.get('/', (req, res) => {
|
||||
const pingTime = new Date().toISOString();
|
||||
logger.debug("收到 Ping 请求", { time: pingTime, ip: req.ip });
|
||||
res.json({
|
||||
status: 200,
|
||||
by: "DeEarthX.Core",
|
||||
qqg: "559349662",
|
||||
bilibili: "https://space.bilibili.com/1728953419 ",
|
||||
ping: pingTime
|
||||
});
|
||||
});
|
||||
|
||||
// 版本信息路由
|
||||
this.app.get('/version', (req, res) => {
|
||||
logger.debug("请求版本信息", { ip: req.ip });
|
||||
res.json({
|
||||
status: 200,
|
||||
version: "1.0.0",
|
||||
name: "DeEarthX.Core",
|
||||
buildTime: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupTaskRoutes() {
|
||||
// 启动任务路由
|
||||
this.app.post("/start", this.upload.single("file"), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ status: 400, message: "未上传文件" });
|
||||
}
|
||||
if (!req.query.mode) {
|
||||
return res.status(400).json({ status: 400, message: "缺少 mode 参数" });
|
||||
}
|
||||
|
||||
// 文件类型检查
|
||||
const allowedExtensions = ['.zip', '.mrpack'];
|
||||
const fileExtension = req.file.originalname.toLowerCase().substring(req.file.originalname.lastIndexOf('.'));
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
return res.status(400).json({ status: 400, message: "只支持 .zip 和 .mrpack 文件" });
|
||||
}
|
||||
|
||||
const isServerMode = req.query.mode === "server";
|
||||
const template = req.query.template as string || "";
|
||||
logger.info("正在启动任务", { 是否服务端模式: isServerMode, 文件名: req.file.originalname, 文件大小: req.file.size, 模板: template || "官方模组加载器" });
|
||||
|
||||
// 非阻塞执行主要任务
|
||||
this.dex.Main(req.file.buffer, isServerMode, req.file.originalname, template).catch(err => {
|
||||
logger.error("任务执行失败", err);
|
||||
});
|
||||
|
||||
res.json({ status: 200, message: "任务已提交,正在处理中" });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/start 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "服务器内部错误" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupConfigRoutes() {
|
||||
// 获取配置路由
|
||||
this.app.get('/config/get', (req, res) => {
|
||||
try {
|
||||
this.config = Config.getConfig();
|
||||
res.json(this.config);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/config/get 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "获取配置失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新配置路由
|
||||
this.app.post('/config/post', (req, res) => {
|
||||
try {
|
||||
Config.writeConfig(req.body);
|
||||
this.config = req.body;
|
||||
Config.clearCache();
|
||||
logger.info("配置已更新");
|
||||
res.json({ status: 200 });
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/config/post 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "更新配置失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupModCheckRoutes() {
|
||||
// 模组检查路由 - 通过路径检查
|
||||
this.app.get('/modcheck', async (req, res) => {
|
||||
try {
|
||||
const modsPath = req.query.path as string;
|
||||
if (!modsPath) {
|
||||
return res.status(400).json({ status: 400, message: "缺少 path 参数" });
|
||||
}
|
||||
|
||||
const { ModCheckService } = await import('./dearth/index.js');
|
||||
const checkService = new ModCheckService(modsPath);
|
||||
const results = await checkService.checkMods();
|
||||
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/modcheck 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "模组检查失败" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 模组检查路由 - 通过文件夹路径和整合包名字检查
|
||||
this.app.post('/modcheck/folder', async (req, res) => {
|
||||
try {
|
||||
const { folderPath, bundleName } = req.body;
|
||||
|
||||
if (!folderPath) {
|
||||
logger.warn("请求中缺少文件夹路径");
|
||||
return res.status(400).json({ status: 400, message: "缺少文件夹路径" });
|
||||
}
|
||||
|
||||
if (!bundleName || !bundleName.trim()) {
|
||||
logger.warn("请求中缺少整合包名字");
|
||||
return res.status(400).json({ status: 400, message: "缺少整合包名字" });
|
||||
}
|
||||
|
||||
logger.info("收到模组检查文件夹请求", {
|
||||
folderPath,
|
||||
bundleName: bundleName.trim()
|
||||
});
|
||||
|
||||
const { ModCheckService } = await import('./dearth/index.js');
|
||||
const checkService = new ModCheckService(folderPath);
|
||||
const results = await checkService.checkModsWithBundle(bundleName.trim());
|
||||
|
||||
logger.info("模组检查完成", { resultsCount: results.length });
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/modcheck/folder 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "模组检查失败: " + error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupGalaxyRoutes() {
|
||||
this.app.use("/galaxy", this.galaxy.getRouter());
|
||||
}
|
||||
|
||||
private setupJavaRoutes() {
|
||||
// 检查Java版本
|
||||
this.app.get('/java/check', async (req, res) => {
|
||||
try {
|
||||
const javaPath = req.query.path as string;
|
||||
const result: JavaCheckResult = await checkJava(javaPath);
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/java/check 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "Java检查失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 自动检测Java路径
|
||||
this.app.get('/java/detect', async (req, res) => {
|
||||
try {
|
||||
const paths = await detectJavaPaths();
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
data: paths
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/java/detect 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "Java路径检测失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupTemplateRoutes() {
|
||||
// 获取模板列表
|
||||
this.app.get('/templates', async (req, res) => {
|
||||
try {
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
const templates = await templateManager.getTemplates();
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
data: templates
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/templates 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "获取模板列表失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建模板
|
||||
this.app.post('/templates', async (req, res) => {
|
||||
try {
|
||||
const { name, version, description, author } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ status: 400, message: "模板名称不能为空" });
|
||||
return;
|
||||
}
|
||||
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
|
||||
const templateId = `template-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
await templateManager.createTemplate(templateId, {
|
||||
name,
|
||||
version: version || '1.0.0',
|
||||
description: description || '',
|
||||
author: author || '',
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
type: 'template'
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "模板创建成功",
|
||||
data: { id: templateId }
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/templates POST 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "创建模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除模板
|
||||
this.app.delete('/templates/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateService = (templateModule as any).TemplateService;
|
||||
const templateService = new TemplateService();
|
||||
|
||||
const success = await templateService.deleteTemplate(id);
|
||||
|
||||
if (success) {
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "模板删除成功"
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({ status: 404, message: "模板不存在" });
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error(`/templates/${req.params.id} DELETE 路由错误`, error);
|
||||
res.status(500).json({ status: 500, message: "删除模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 修改模板信息
|
||||
this.app.put('/templates/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, version, description, author } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ status: 400, message: "模板名称不能为空" });
|
||||
return;
|
||||
}
|
||||
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
|
||||
await templateManager.updateTemplate(id, {
|
||||
name,
|
||||
version: version || '1.0.0',
|
||||
description: description || '',
|
||||
author: author || '',
|
||||
type: 'template'
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "模板更新成功"
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error(`/templates/${req.params.id} PUT 路由错误`, error);
|
||||
res.status(500).json({ status: 500, message: "更新模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 打开模板文件夹
|
||||
this.app.get('/templates/:id/path', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const path = await import('path');
|
||||
const { exec } = await import('child_process');
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
|
||||
const templateManager = new TemplateManager();
|
||||
const templatesPath = (templateManager as any).templatesPath;
|
||||
const templatePath = path.resolve(templatesPath, id);
|
||||
|
||||
const platform = process.platform;
|
||||
let command: string;
|
||||
|
||||
if (platform === 'win32') {
|
||||
command = `explorer "${templatePath}"`;
|
||||
} else if (platform === 'darwin') {
|
||||
command = `open "${templatePath}"`;
|
||||
} else {
|
||||
command = `xdg-open "${templatePath}"`;
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "文件夹已打开"
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error(`/templates/${req.params.id}/path 路由错误`, error);
|
||||
res.status(500).json({ status: 500, message: "打开文件夹失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 导出模板
|
||||
this.app.get('/templates/:id/export', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
|
||||
// 生成临时文件路径
|
||||
const os = await import('os');
|
||||
const path = await import('path');
|
||||
const tempDir = os.tmpdir();
|
||||
const outputPath = path.join(tempDir, `template-${id}.zip`);
|
||||
|
||||
// 导出模板
|
||||
await templateManager.exportTemplate(id, outputPath);
|
||||
|
||||
// 发送文件
|
||||
res.download(outputPath, `template-${id}.zip`, (err) => {
|
||||
// 下载完成后删除临时文件
|
||||
fs.unlink(outputPath, () => {});
|
||||
if (err) {
|
||||
logger.error(`导出模板失败: ${err.message}`);
|
||||
res.status(500).json({ status: 500, message: "导出模板失败" });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error(`/templates/${req.params.id}/export 路由错误`, error);
|
||||
res.status(500).json({ status: 500, message: "导出模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 导入模板
|
||||
this.app.post('/templates/import', this.upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ status: 400, message: "未上传文件" });
|
||||
}
|
||||
|
||||
// 文件类型检查
|
||||
const fileExtension = req.file.originalname.toLowerCase().substring(req.file.originalname.lastIndexOf('.'));
|
||||
if (fileExtension !== '.zip') {
|
||||
return res.status(400).json({ status: 400, message: "只支持 .zip 文件" });
|
||||
}
|
||||
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
|
||||
// 导入模板
|
||||
const templateId = await templateManager.importTemplate(req.file.buffer);
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "模板导入成功",
|
||||
data: { id: templateId }
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/templates/import 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "导入模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 存储SSE连接
|
||||
const sseConnections = new Map();
|
||||
|
||||
// 存储下载状态
|
||||
const downloadStates = new Map();
|
||||
|
||||
// 从URL安装模板 - POST请求启动下载
|
||||
this.app.post('/templates/install-from-url', async (req, res) => {
|
||||
try {
|
||||
const { url, requestId, resumeFrom = 0 } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ status: 400, message: "缺少 url 参数" });
|
||||
}
|
||||
|
||||
// 下载文件并流式处理
|
||||
const { default: got } = await import('got');
|
||||
const { createWriteStream, readFileSync, statSync, unlinkSync } = await import('fs');
|
||||
const { tmpdir } = await import('os');
|
||||
const { join } = await import('path');
|
||||
|
||||
// 创建临时文件
|
||||
const tempFilePath = join(tmpdir(), `template-${Date.now()}.zip`);
|
||||
const writeStream = createWriteStream(tempFilePath, {
|
||||
flags: resumeFrom > 0 ? 'a' : 'w' // 支持断点续传
|
||||
});
|
||||
|
||||
// 构建请求选项
|
||||
const requestOptions = {
|
||||
headers: {} as Record<string, string>
|
||||
};
|
||||
|
||||
// 如果是续传,设置Range头
|
||||
if (resumeFrom > 0) {
|
||||
requestOptions.headers['Range'] = `bytes=${resumeFrom}-`;
|
||||
}
|
||||
|
||||
// 流式下载(支持分块)
|
||||
const request = await got.stream(url, requestOptions);
|
||||
|
||||
let totalSize = 0;
|
||||
let downloadedSize = resumeFrom;
|
||||
|
||||
// 获取文件大小(如果可用)
|
||||
request.on('response', (response) => {
|
||||
// 检查是否支持分块下载
|
||||
const acceptRanges = response.headers['accept-ranges'];
|
||||
console.log(`服务器支持分块下载: ${acceptRanges}`);
|
||||
|
||||
// 获取文件大小
|
||||
let contentLength = response.headers['content-length'];
|
||||
if (!contentLength) {
|
||||
// 如果没有content-length,尝试从content-range获取
|
||||
const contentRange = response.headers['content-range'];
|
||||
if (contentRange) {
|
||||
const matches = contentRange.match(/bytes \d+-\d+\/(\d+)/);
|
||||
if (matches && matches[1]) {
|
||||
contentLength = matches[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength) {
|
||||
totalSize = parseInt(contentLength);
|
||||
// 发送初始化信息,包含文件大小
|
||||
if (sseConnections.has(requestId)) {
|
||||
const sseRes = sseConnections.get(requestId);
|
||||
sseRes.write(`data: ${JSON.stringify({
|
||||
type: 'init',
|
||||
totalSize,
|
||||
resumeFrom
|
||||
})}\n\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听数据传输,计算进度
|
||||
request.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize > 0) {
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
// 向后端日志输出进度
|
||||
console.log(`下载进度: ${progress}%`);
|
||||
// 发送进度信息到SSE连接
|
||||
if (sseConnections.has(requestId)) {
|
||||
const sseRes = sseConnections.get(requestId);
|
||||
sseRes.write(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
progress,
|
||||
downloadedSize,
|
||||
totalSize
|
||||
})}\n\n`);
|
||||
}
|
||||
} else {
|
||||
// 无法计算总大小时,发送假进度
|
||||
const progress = Math.min(90, Math.round((downloadedSize / 1024 / 1024) * 10));
|
||||
if (sseConnections.has(requestId)) {
|
||||
const sseRes = sseConnections.get(requestId);
|
||||
sseRes.write(`data: ${JSON.stringify({
|
||||
type: 'progress',
|
||||
progress,
|
||||
downloadedSize
|
||||
})}\n\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 管道到临时文件
|
||||
await new Promise((resolve, reject) => {
|
||||
request.pipe(writeStream)
|
||||
.on('finish', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
// 读取临时文件
|
||||
const buffer = readFileSync(tempFilePath);
|
||||
|
||||
// 清理临时文件
|
||||
unlinkSync(tempFilePath);
|
||||
|
||||
// 导入模板
|
||||
const templateModule = await import('./template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
|
||||
const templateId = await templateManager.importTemplate(buffer);
|
||||
|
||||
// 发送完成响应到SSE连接
|
||||
if (sseConnections.has(requestId)) {
|
||||
const sseRes = sseConnections.get(requestId);
|
||||
sseRes.write(`data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
status: 200,
|
||||
message: "模板安装成功",
|
||||
data: { id: templateId }
|
||||
})}\n\n`);
|
||||
sseRes.end();
|
||||
sseConnections.delete(requestId);
|
||||
}
|
||||
|
||||
// 清理下载状态
|
||||
downloadStates.delete(requestId);
|
||||
|
||||
// 发送POST响应
|
||||
res.json({
|
||||
status: 200,
|
||||
message: "模板安装成功",
|
||||
data: { id: templateId }
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
const { requestId } = req.body;
|
||||
logger.error("/templates/install-from-url 路由错误", error);
|
||||
|
||||
// 发送错误信息到SSE连接
|
||||
if (sseConnections.has(requestId)) {
|
||||
const sseRes = sseConnections.get(requestId);
|
||||
sseRes.write(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
status: 500,
|
||||
message: "安装模板失败"
|
||||
})}\n\n`);
|
||||
sseRes.end();
|
||||
sseConnections.delete(requestId);
|
||||
}
|
||||
|
||||
// 清理下载状态
|
||||
downloadStates.delete(requestId);
|
||||
|
||||
res.status(500).json({ status: 500, message: "安装模板失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// SSE连接 - GET请求
|
||||
this.app.get('/templates/install-from-url', (req, res) => {
|
||||
const { requestId } = req.query;
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).json({ status: 400, message: "缺少 requestId 参数" });
|
||||
}
|
||||
|
||||
// 设置SSE响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// 存储连接
|
||||
sseConnections.set(requestId, res);
|
||||
|
||||
// 发送初始信息
|
||||
res.write(`data: ${JSON.stringify({ type: 'init' })}\n\n`);
|
||||
|
||||
// 处理连接关闭
|
||||
req.on('close', () => {
|
||||
sseConnections.delete(requestId);
|
||||
console.log(`SSE连接已关闭: ${requestId}`);
|
||||
});
|
||||
});
|
||||
|
||||
// 获取模板商店数据
|
||||
this.app.get('/templates/store', async (req, res) => {
|
||||
try {
|
||||
const { default: got } = await import('got');
|
||||
|
||||
// 从指定URL获取模板商店数据
|
||||
const response = await got('http://dex.xcclyc.cn/template/template_stor.json');
|
||||
const data = JSON.parse(response.body);
|
||||
|
||||
// 确保返回的数据结构符合前端预期
|
||||
if (!data.templates) {
|
||||
return res.json({
|
||||
status: 200,
|
||||
data: { templates: [] }
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 200,
|
||||
data: data
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logger.error("/templates/store 路由错误", error);
|
||||
res.status(500).json({ status: 500, message: "获取模板商店数据失败" });
|
||||
}
|
||||
});
|
||||
}
|
||||
public async start() {
|
||||
|
||||
this.setupExpressRoutes();
|
||||
const port = this.config.port || 37019;
|
||||
const host = this.config.host || 'localhost';
|
||||
this.server.listen(port, host, async () => {
|
||||
logger.info(`服务器正在运行于 http://${host}:${port}`);
|
||||
await this.javachecker();
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error("服务器错误", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
571
backend/src/dearth/ModCheckService.ts
Normal file
571
backend/src/dearth/ModCheckService.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
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 { Azip } = await import("../utils/ziplib.js");
|
||||
const zipEntries = Azip(jarData);
|
||||
|
||||
for (const entry of zipEntries) {
|
||||
if (entry.entryName === iconPath || entry.entryName.endsWith(iconPath)) {
|
||||
const data = await entry.getData();
|
||||
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;
|
||||
}
|
||||
}
|
||||
134
backend/src/dearth/ModFilterService.ts
Normal file
134
backend/src/dearth/ModFilterService.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { FileExtractor } from "./utils/FileExtractor.js";
|
||||
import { FileOperator } from "./utils/FileOperator.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 { IFilterConfig } from "./types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { MessageWS } from "../utils/ws.js";
|
||||
import path from "node:path";
|
||||
|
||||
export class ModFilterService {
|
||||
private readonly extractor: FileExtractor;
|
||||
private readonly operator: FileOperator;
|
||||
private readonly config: IFilterConfig;
|
||||
private messageWS?: MessageWS;
|
||||
|
||||
constructor(modsPath: string, movePath: string, config: IFilterConfig, messageWS?: MessageWS) {
|
||||
this.extractor = new FileExtractor(modsPath);
|
||||
this.operator = new FileOperator(movePath);
|
||||
this.config = config;
|
||||
this.messageWS = messageWS;
|
||||
}
|
||||
|
||||
async filter(): Promise<void> {
|
||||
logger.info("开始模组筛选流程");
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const files = await this.extractor.extractFilesInfo();
|
||||
|
||||
if (this.messageWS) {
|
||||
this.messageWS.filterModsStart(files.length);
|
||||
}
|
||||
|
||||
const clientMods = await this.identifyClientSideMods(files);
|
||||
const result = await this.operator.moveClientSideMods(clientMods);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (this.messageWS) {
|
||||
this.messageWS.filterModsComplete(clientMods.length, result.success, duration);
|
||||
}
|
||||
|
||||
logger.info("模组筛选流程完成", {
|
||||
识别到的客户端模组: clientMods.length,
|
||||
成功移动: result.success,
|
||||
跳过: result.skipped,
|
||||
失败: result.error
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.messageWS) {
|
||||
this.messageWS.filterModsError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async identifyClientSideMods(files: Array<{ filename: string; hash: string; mixins: any[]; infos: any[] }>): Promise<string[]> {
|
||||
const clientMods: string[] = [];
|
||||
const processedFiles = new Set<string>();
|
||||
|
||||
if (this.config.dexpub) {
|
||||
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.messageWS) {
|
||||
this.messageWS.filterModsProgress(processedFiles.size, files.length, "Galaxy Square (dexpub) 检查");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.modrinth) {
|
||||
logger.info("开始 Modrinth API 检查客户端模组");
|
||||
|
||||
let serverModsSet = new Set<string>();
|
||||
if (this.config.dexpub) {
|
||||
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.messageWS) {
|
||||
this.messageWS.filterModsProgress(processedFiles.size, files.length, "Modrinth API 检查");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.mixins) {
|
||||
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.messageWS) {
|
||||
this.messageWS.filterModsProgress(processedFiles.size, files.length, "Mixin 检查");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.hashes) {
|
||||
logger.info("开始 Hash 检查客户端模组");
|
||||
|
||||
const unprocessedFiles = files.filter(f => !processedFiles.has(f.filename));
|
||||
const hashMods = await new HashFilter().filter(unprocessedFiles);
|
||||
|
||||
clientMods.push(...hashMods);
|
||||
|
||||
if (this.messageWS) {
|
||||
this.messageWS.filterModsProgress(processedFiles.size, files.length, "Hash 检查");
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueMods = [...new Set(clientMods)];
|
||||
logger.info("识别到客户端模组", { 数量: uniqueMods.length, 模组: uniqueMods });
|
||||
|
||||
if (uniqueMods.length > 0) {
|
||||
logger.debug("第一个模组路径", { 原始路径: uniqueMods[0], 绝对路径: path.resolve(uniqueMods[0]), cwd: process.cwd() });
|
||||
}
|
||||
|
||||
return uniqueMods;
|
||||
}
|
||||
}
|
||||
25
backend/src/dearth/index.ts
Normal file
25
backend/src/dearth/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export { ModFilterService } from "./ModFilterService.js";
|
||||
export { FileExtractor } from "./utils/FileExtractor.js";
|
||||
export { FileOperator } from "./utils/FileOperator.js";
|
||||
export { ModCheckService } from "./ModCheckService.js";
|
||||
|
||||
export type {
|
||||
IFileInfo,
|
||||
IInfoFile,
|
||||
IMixinFile,
|
||||
IHashResponse,
|
||||
IProjectInfo,
|
||||
IDexpubCheckResult,
|
||||
IFilterStrategy,
|
||||
IFilterConfig,
|
||||
IModCheckResult,
|
||||
IModCheckConfig,
|
||||
ModSide
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
HashFilter,
|
||||
MixinFilter,
|
||||
DexpubFilter,
|
||||
ModrinthFilter
|
||||
} from "./strategies/index.js";
|
||||
80
backend/src/dearth/strategies/DexpubFilter.ts
Normal file
80
backend/src/dearth/strategies/DexpubFilter.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import got, { Got } from "got";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { IFilterStrategy, IFileInfo, IDexpubCheckResult } from "../types.js";
|
||||
|
||||
export class DexpubFilter implements IFilterStrategy {
|
||||
name = "DexpubFilter";
|
||||
private got: Got;
|
||||
|
||||
constructor() {
|
||||
this.got = got.extend({
|
||||
prefixUrl: "https://galaxy.tianpao.top/",
|
||||
headers: {
|
||||
"User-Agent": "DeEarthX",
|
||||
},
|
||||
responseType: "json",
|
||||
});
|
||||
}
|
||||
|
||||
async filter(files: IFileInfo[]): Promise<string[]> {
|
||||
const result = await this.checkDexpubForClientMods(files);
|
||||
logger.info("Galaxy Square 检查完成", { 服务端模组: result.serverMods, 客户端模组: result.clientMods });
|
||||
return result.clientMods;
|
||||
}
|
||||
|
||||
private async checkDexpubForClientMods(files: IFileInfo[]): Promise<IDexpubCheckResult> {
|
||||
const clientMods: string[] = [];
|
||||
const serverMods: string[] = [];
|
||||
const modIds: string[] = [];
|
||||
const map: Map<string, string> = new Map();
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
for (const info of file.infos) {
|
||||
try {
|
||||
const config = JSON.parse(info.data);
|
||||
const keys = Object.keys(config);
|
||||
|
||||
if (keys.includes("id")) {
|
||||
modIds.push(config.id);
|
||||
map.set(config.id, file.filename);
|
||||
} else if (keys.includes("mods")) {
|
||||
modIds.push(config.mods[0].modId);
|
||||
map.set(config.mods[0].modId, file.filename);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("检查模组信息文件失败,文件名: " + file.filename, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modIdToIsTypeMod = await this.got.post(`api/mod/check`, {
|
||||
json: {
|
||||
modids: modIds,
|
||||
}
|
||||
}).json<{ [modId: string]: boolean }>();
|
||||
|
||||
const modIdToIsTypeModKeys = Object.keys(modIdToIsTypeMod);
|
||||
|
||||
for (const modId of modIdToIsTypeModKeys) {
|
||||
const mapData = map.get(modId);
|
||||
if (!mapData) continue;
|
||||
|
||||
if (modIdToIsTypeMod[modId]) {
|
||||
clientMods.push(mapData);
|
||||
} else {
|
||||
serverMods.push(mapData);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("Dexpub 检查失败", error);
|
||||
}
|
||||
|
||||
return { serverMods, clientMods };
|
||||
}
|
||||
|
||||
async getServerMods(files: IFileInfo[]): Promise<string[]> {
|
||||
const result = await this.checkDexpubForClientMods(files);
|
||||
return result.serverMods;
|
||||
}
|
||||
}
|
||||
53
backend/src/dearth/strategies/HashFilter.ts
Normal file
53
backend/src/dearth/strategies/HashFilter.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import got from "got";
|
||||
import { Utils } from "../../utils/utils.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { IFilterStrategy, IFileInfo, IHashResponse, IProjectInfo } from "../types.js";
|
||||
|
||||
export class HashFilter implements IFilterStrategy {
|
||||
name = "HashFilter";
|
||||
private utils: Utils;
|
||||
|
||||
constructor() {
|
||||
this.utils = new Utils();
|
||||
}
|
||||
|
||||
async filter(files: IFileInfo[]): Promise<string[]> {
|
||||
const hashToFilename = new Map<string, string>();
|
||||
const hashes = files.map(file => {
|
||||
hashToFilename.set(file.hash, file.filename);
|
||||
return file.hash;
|
||||
});
|
||||
|
||||
logger.debug("Checking mod hashes with Modrinth API", { fileCount: files.length });
|
||||
|
||||
try {
|
||||
const fileInfoResponse = await got.post(`${this.utils.modrinth_url}/v2/version_files`, {
|
||||
headers: { "User-Agent": "DeEarth", "Content-Type": "application/json" },
|
||||
json: { hashes, algorithm: "sha1" }
|
||||
}).json<IHashResponse>();
|
||||
|
||||
const projectIdToFilename = new Map<string, string>();
|
||||
const projectIds = Object.entries(fileInfoResponse)
|
||||
.map(([hash, info]) => {
|
||||
const filename = hashToFilename.get(hash);
|
||||
if (filename) projectIdToFilename.set(info.project_id, filename);
|
||||
return info.project_id;
|
||||
});
|
||||
|
||||
const projectsResponse = await got.get(`${this.utils.modrinth_url}/v2/projects?ids=${JSON.stringify(projectIds)}`, {
|
||||
headers: { "User-Agent": "DeEarth" }
|
||||
}).json<IProjectInfo[]>();
|
||||
|
||||
const clientMods = projectsResponse
|
||||
.filter(p => p.client_side === "required" && p.server_side === "unsupported")
|
||||
.map(p => projectIdToFilename.get(p.id))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
logger.debug("Hash check completed", { count: clientMods.length });
|
||||
return clientMods;
|
||||
} catch (error: any) {
|
||||
logger.error("Hash check failed", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
27
backend/src/dearth/strategies/MixinFilter.ts
Normal file
27
backend/src/dearth/strategies/MixinFilter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { IFilterStrategy, IFileInfo } from "../types.js";
|
||||
|
||||
export class MixinFilter implements IFilterStrategy {
|
||||
name = "MixinFilter";
|
||||
|
||||
async filter(files: IFileInfo[]): Promise<string[]> {
|
||||
const clientMods: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
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")) {
|
||||
clientMods.push(file.filename);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn("Failed to parse mixin config", { filename: file.filename, mixin: mixin.name, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Mixins check completed", { count: clientMods.length });
|
||||
return [...new Set(clientMods)];
|
||||
}
|
||||
}
|
||||
107
backend/src/dearth/strategies/ModrinthFilter.ts
Normal file
107
backend/src/dearth/strategies/ModrinthFilter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { IFilterStrategy, IFileInfo } from "../types.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
|
||||
interface IModrinthProject {
|
||||
client_side: string;
|
||||
server_side: string;
|
||||
project_type: string;
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export class ModrinthFilter implements IFilterStrategy {
|
||||
name = "ModrinthFilter";
|
||||
private readonly API_BASE = "https://api.modrinth.com/v2";
|
||||
|
||||
private extractProjectId(infos: { name: string; data: string }[]): string | null {
|
||||
for (const info of infos) {
|
||||
if (info.name === "modrinth.index.json" || info.name === "modrinth.json") {
|
||||
try {
|
||||
const data = JSON.parse(info.data);
|
||||
return data.project_id || null;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async fetchProjectInfo(projectIds: string[]): Promise<Map<string, IModrinthProject>> {
|
||||
const projectMap = new Map<string, IModrinthProject>();
|
||||
const batchSize = 50;
|
||||
|
||||
for (let i = 0; i < projectIds.length; i += batchSize) {
|
||||
const batch = projectIds.slice(i, i + batchSize);
|
||||
const idsParam = batch.join(",");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/projects?ids=${encodeURIComponent(idsParam)}`, {
|
||||
headers: {
|
||||
'User-Agent': 'DeEarthX-V3/1.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const projects: Array<any> = await response.json();
|
||||
|
||||
for (const project of projects) {
|
||||
if (project && project.id) {
|
||||
projectMap.set(project.id, {
|
||||
client_side: project.client_side,
|
||||
server_side: project.server_side,
|
||||
project_type: project.project_type,
|
||||
categories: project.categories || []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("获取 Modrinth 项目信息失败", { error, batchSize });
|
||||
}
|
||||
}
|
||||
|
||||
return projectMap;
|
||||
}
|
||||
|
||||
private isClientMod(project: IModrinthProject): boolean {
|
||||
const clientSide = project.client_side;
|
||||
const serverSide = project.server_side;
|
||||
|
||||
return (
|
||||
clientSide === "required" ||
|
||||
(clientSide === "optional" && serverSide === "unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
async filter(files: IFileInfo[]): Promise<string[]> {
|
||||
const clientMods: string[] = [];
|
||||
const projectIds: Array<{ filename: string; projectId: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const projectId = this.extractProjectId(file.infos);
|
||||
if (projectId) {
|
||||
projectIds.push({ filename: file.filename, projectId });
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
logger.info("未找到 Modrinth 项目 ID");
|
||||
return clientMods;
|
||||
}
|
||||
|
||||
logger.info(`找到 ${projectIds.length} 个 Modrinth 项目`, { 数量: projectIds.length });
|
||||
|
||||
const uniqueProjectIds = [...new Set(projectIds.map(p => p.projectId))];
|
||||
const projectMap = await this.fetchProjectInfo(uniqueProjectIds);
|
||||
|
||||
for (const { filename, projectId } of projectIds) {
|
||||
const project = projectMap.get(projectId);
|
||||
if (project && this.isClientMod(project)) {
|
||||
clientMods.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Modrinth 筛选完成", { 客户端模组数: clientMods.length });
|
||||
return clientMods;
|
||||
}
|
||||
}
|
||||
4
backend/src/dearth/strategies/index.ts
Normal file
4
backend/src/dearth/strategies/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { HashFilter } from "./HashFilter.js";
|
||||
export { MixinFilter } from "./MixinFilter.js";
|
||||
export { DexpubFilter } from "./DexpubFilter.js";
|
||||
export { ModrinthFilter } from "./ModrinthFilter.js";
|
||||
126
backend/src/dearth/types.ts
Normal file
126
backend/src/dearth/types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 模组筛选模块 - 类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mixin 配置文件信息
|
||||
*/
|
||||
export interface IMixinFile {
|
||||
name: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模组信息文件
|
||||
*/
|
||||
export interface IInfoFile {
|
||||
name: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模组文件信息
|
||||
*/
|
||||
export interface IFileInfo {
|
||||
filename: string;
|
||||
hash: string;
|
||||
mixins: IMixinFile[];
|
||||
infos: IInfoFile[];
|
||||
fileData?: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modrinth Hash 响应
|
||||
*/
|
||||
export interface IHashResponse {
|
||||
[hash: string]: { project_id: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Modrinth 项目信息
|
||||
*/
|
||||
export interface IProjectInfo {
|
||||
id: string;
|
||||
client_side: string;
|
||||
server_side: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dexpub 检查结果
|
||||
*/
|
||||
export interface IDexpubCheckResult {
|
||||
serverMods: string[];
|
||||
clientMods: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选策略接口
|
||||
*/
|
||||
export interface IFilterStrategy {
|
||||
/**
|
||||
* 策略名称
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* 筛选客户端模组
|
||||
* @param files 模组文件信息数组
|
||||
* @returns 客户端模组文件名数组
|
||||
*/
|
||||
filter(files: IFileInfo[]): Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选配置
|
||||
*/
|
||||
export interface IFilterConfig {
|
||||
hashes: boolean;
|
||||
dexpub: boolean;
|
||||
mixins: boolean;
|
||||
modrinth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模组兼容性类型
|
||||
*/
|
||||
export type ModSide = "required" | "optional" | "unsupported" | "unknown";
|
||||
|
||||
/**
|
||||
* 单个检查方法的结果
|
||||
*/
|
||||
export interface ISingleCheckResult {
|
||||
source: string;
|
||||
clientSide: ModSide;
|
||||
serverSide: ModSide;
|
||||
checked: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模组检查结果 - 包含所有检查方法的结果
|
||||
*/
|
||||
export interface IModCheckResult {
|
||||
filename: string;
|
||||
filePath: string;
|
||||
clientSide: ModSide;
|
||||
serverSide: ModSide;
|
||||
source: string;
|
||||
checked: boolean;
|
||||
errors?: string[];
|
||||
allResults: ISingleCheckResult[];
|
||||
modId?: string;
|
||||
iconUrl?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模组检查配置
|
||||
*/
|
||||
export interface IModCheckConfig {
|
||||
enableDexpub: boolean;
|
||||
enableModrinth: boolean;
|
||||
enableMixin: boolean;
|
||||
enableHash: boolean;
|
||||
timeout: number;
|
||||
}
|
||||
57
backend/src/dearth/utils/FileExtractor.ts
Normal file
57
backend/src/dearth/utils/FileExtractor.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { JarParser } from "../../utils/jar-parser.js";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { IFileInfo } from "../types.js";
|
||||
|
||||
export class FileExtractor {
|
||||
private readonly modsPath: string;
|
||||
|
||||
constructor(modsPath: string) {
|
||||
this.modsPath = path.isAbsolute(modsPath) ? modsPath : path.resolve(modsPath);
|
||||
}
|
||||
|
||||
async extractFilesInfo(): Promise<IFileInfo[]> {
|
||||
const jarFiles = this.getJarFiles();
|
||||
const files: IFileInfo[] = [];
|
||||
|
||||
logger.info("获取文件信息", { 文件数量: jarFiles.length });
|
||||
|
||||
for (const jarFilename of jarFiles) {
|
||||
const fullPath = path.join(this.modsPath, jarFilename);
|
||||
|
||||
try {
|
||||
let fileData: Buffer | null = null;
|
||||
try {
|
||||
fileData = fs.readFileSync(fullPath);
|
||||
const mixins = await JarParser.extractMixins(fileData);
|
||||
const infos = await JarParser.extractModInfo(fileData);
|
||||
|
||||
files.push({
|
||||
filename: fullPath,
|
||||
hash: crypto.createHash('sha1').update(fileData).digest('hex'),
|
||||
mixins,
|
||||
infos,
|
||||
});
|
||||
|
||||
logger.debug("文件已处理", { 文件名: fullPath, 绝对路径: path.resolve(fullPath), Mixin数量: mixins.length });
|
||||
} finally {
|
||||
fileData = null;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("处理文件时出错", { 文件名: fullPath, 错误: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("文件信息收集完成", { 已处理文件: files.length });
|
||||
return files;
|
||||
}
|
||||
|
||||
private getJarFiles(): string[] {
|
||||
if (!fs.existsSync(this.modsPath)) {
|
||||
fs.mkdirSync(this.modsPath, { recursive: true });
|
||||
}
|
||||
return fs.readdirSync(this.modsPath).filter(f => f.endsWith(".jar"));
|
||||
}
|
||||
}
|
||||
61
backend/src/dearth/utils/FileOperator.ts
Normal file
61
backend/src/dearth/utils/FileOperator.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
|
||||
export class FileOperator {
|
||||
private readonly movePath: string;
|
||||
|
||||
constructor(movePath: string) {
|
||||
this.movePath = movePath;
|
||||
}
|
||||
|
||||
async moveClientSideMods(clientMods: string[]): Promise<{ success: number; error: number; skipped: number }> {
|
||||
if (!clientMods.length) {
|
||||
logger.info("No client-side mods to move");
|
||||
return { success: 0, error: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const absoluteMovePath = path.isAbsolute(this.movePath) ? this.movePath : path.resolve(this.movePath);
|
||||
logger.debug("Target directory", { path: absoluteMovePath, exists: fsSync.existsSync(absoluteMovePath) });
|
||||
|
||||
if (!fsSync.existsSync(absoluteMovePath)) {
|
||||
logger.debug("Creating target directory", { path: absoluteMovePath });
|
||||
await fs.mkdir(absoluteMovePath, { recursive: true });
|
||||
}
|
||||
|
||||
let successCount = 0, errorCount = 0, skippedCount = 0;
|
||||
|
||||
for (const sourcePath of clientMods) {
|
||||
try {
|
||||
const absoluteSourcePath = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(sourcePath);
|
||||
|
||||
logger.debug("Checking file", { originalPath: sourcePath, resolvedPath: absoluteSourcePath, cwd: process.cwd() });
|
||||
|
||||
try {
|
||||
await fs.access(absoluteSourcePath);
|
||||
} catch (accessError) {
|
||||
logger.warn("File does not exist, skipping", { path: absoluteSourcePath, error: (accessError as Error).message });
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filename = path.basename(absoluteSourcePath);
|
||||
const targetPath = path.join(absoluteMovePath, filename);
|
||||
|
||||
logger.info("Moving file", { source: absoluteSourcePath, target: targetPath, filename: filename });
|
||||
|
||||
await fs.copyFile(absoluteSourcePath, targetPath);
|
||||
await fs.unlink(absoluteSourcePath);
|
||||
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
logger.error("Failed to move file", { source: sourcePath, error: error.message, code: error.code });
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("File movement completed", { total: clientMods.length, success: successCount, error: errorCount, skipped: skippedCount });
|
||||
return { success: successCount, error: errorCount, skipped: skippedCount };
|
||||
}
|
||||
}
|
||||
90
backend/src/galaxy.ts
Normal file
90
backend/src/galaxy.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import express from "express";
|
||||
import toml from "smol-toml";
|
||||
import multer, { Multer } from "multer";
|
||||
import AdmZip from "adm-zip";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import got, { Got } from "got";
|
||||
|
||||
export class Galaxy {
|
||||
private readonly upload: multer.Multer;
|
||||
got: Got;
|
||||
constructor() {
|
||||
this.upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 2 * 1024 * 1024 * 1024,
|
||||
files: 10
|
||||
}
|
||||
})
|
||||
this.got = got.extend({
|
||||
prefixUrl: "https://galaxy.tianpao.top/ ",
|
||||
//prefixUrl: "http://localhost:3000/",
|
||||
headers: {
|
||||
"User-Agent": "DeEarthX",
|
||||
},
|
||||
responseType: "json",
|
||||
});
|
||||
}
|
||||
getRouter() {
|
||||
const router = express.Router();
|
||||
router.use(express.json()); // 解析 JSON 请求体
|
||||
router.post("/upload",this.upload.array("files"), (req, res) => {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if(!files || files.length === 0){
|
||||
res.status(400).json({ status: 400, message: "未上传文件" });
|
||||
return;
|
||||
}
|
||||
const modids = this.getModids(files);
|
||||
logger.info("已上传模组 ID", { 模组ID: modids });
|
||||
res.json({modids}).end();
|
||||
});
|
||||
router.post("/submit/:type",(req,res)=>{
|
||||
const type = req.params.type;
|
||||
if(type !== "server" && type !== "client"){
|
||||
res.status(400).json({ status: 400, message: "无效的类型参数" });
|
||||
return;
|
||||
}
|
||||
const modid = req.body.modids as string;
|
||||
if(!modid){
|
||||
res.status(400).json({ status: 400, message: "未提供 modid" });
|
||||
return;
|
||||
}
|
||||
this.got.post(`api/mod/submit/${type}`,{
|
||||
json: {
|
||||
modid,
|
||||
}
|
||||
}).then((response)=>{
|
||||
logger.info(`已成功提交 ${type} 端模组 ID`, response.body);
|
||||
res.json(response.body).end();
|
||||
}).catch((error)=>{
|
||||
logger.error(`提交 ${type} 端模组 ID 失败`, error);
|
||||
res.status(500).json({ status: 500, message: "提交模组 ID 失败" });
|
||||
})
|
||||
})
|
||||
return router;
|
||||
}
|
||||
|
||||
getModids(files:Express.Multer.File[]):string[] {
|
||||
let modid:string[] = [];
|
||||
for(const file of files){
|
||||
const zip = new AdmZip(file.buffer);
|
||||
const entries = zip.getEntries();
|
||||
for(const entry of entries){
|
||||
if(entry.entryName.endsWith("mods.toml")){
|
||||
const content = entry.getData().toString("utf8");
|
||||
const config = toml.parse(content) as any;
|
||||
modid.push(config.mods[0].modId as string)
|
||||
}else if(entry.entryName.endsWith("neoforge.mods.toml")){
|
||||
const content = entry.getData().toString("utf8");
|
||||
const config = toml.parse(content) as any;
|
||||
modid.push(config.mods[0].modId as string)
|
||||
}else if(entry.entryName.endsWith("fabric.mod.json")){
|
||||
const content = entry.getData().toString("utf8");
|
||||
const config = JSON.parse(content);
|
||||
modid.push(config.id as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
return modid
|
||||
}
|
||||
}
|
||||
27
backend/src/main.ts
Normal file
27
backend/src/main.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Config } from "./utils/config.js";
|
||||
import { Core } from "./core.js";
|
||||
|
||||
// 创建核心实例并启动服务
|
||||
const config = Config.getConfig();
|
||||
const core = new Core(config);
|
||||
|
||||
core.start();
|
||||
|
||||
// ==================== 调试/测试代码区域(已注释) ====================
|
||||
|
||||
// 版本比较测试
|
||||
// console.log(version_compare("1.18.1", "1.16.5"))
|
||||
|
||||
// DeEarth 模块测试
|
||||
// await new DeEarth("./mods").Main()
|
||||
|
||||
// Dex 函数定义示例
|
||||
// async function Dex(buffer: Buffer) {
|
||||
// }
|
||||
|
||||
// 模组加载器安装测试
|
||||
// new Forge("1.20.1", "47.3.10").setup() // 安装 Forge 服务端
|
||||
// await new NeoForge("1.21.1", "21.1.1").setup() // 安装 NeoForge 服务端
|
||||
// await new Minecraft("forge", "1.20.1", "0").setup() // 安装 Minecraft + Forge
|
||||
// await new Minecraft("forge", "1.16.5", "0").setup() // 安装 Minecraft + Forge (1.16.5)
|
||||
// await new Fabric("1.20.1", "0.17.2").setup() // 安装 Fabric 服务端
|
||||
123
backend/src/modloader/fabric.ts
Normal file
123
backend/src/modloader/fabric.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import got, { Got } from "got";
|
||||
import fs from "node:fs";
|
||||
import { execPromise, fastdownload, verifySHA1, calculateSHA1 } from "../utils/utils.js";
|
||||
import { Config } from "../utils/config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
interface ILatestLoader {
|
||||
url: string;
|
||||
stable: boolean;
|
||||
}
|
||||
|
||||
interface IServer {
|
||||
libraries: {
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class Fabric {
|
||||
minecraft: string;
|
||||
loaderVersion: string;
|
||||
got: Got;
|
||||
path: string;
|
||||
|
||||
constructor(minecraft: string, loaderVersion: string, path: string) {
|
||||
this.minecraft = minecraft;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.path = path;
|
||||
this.got = got.extend({
|
||||
prefixUrl: "https://bmclapi2.bangbang93.com/",
|
||||
headers: {
|
||||
"User-Agent": "DeEarthX"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setup(): Promise<void> {
|
||||
await this.installer();
|
||||
const config = Config.getConfig();
|
||||
if (config.mirror.bmclapi) {
|
||||
await this.libraries();
|
||||
}
|
||||
await this.install();
|
||||
await this.wshell();
|
||||
}
|
||||
|
||||
async install() {
|
||||
const config = Config.getConfig();
|
||||
const javaCmd = config.javaPath || 'java';
|
||||
await execPromise(`${javaCmd} -jar fabric-installer.jar server -dir . -mcversion ${this.minecraft} -loader ${this.loaderVersion}`, {
|
||||
cwd: this.path
|
||||
}).catch(e => console.log(e));
|
||||
}
|
||||
|
||||
private async wshell() {
|
||||
const config = Config.getConfig();
|
||||
const javaCmd = config.javaPath || 'java';
|
||||
const cmd = `${javaCmd} -jar fabric-server-launch.jar`;
|
||||
await fs.promises.writeFile(`${this.path}/run.bat`, `@echo off\n${cmd}`);
|
||||
await fs.promises.writeFile(`${this.path}/run.sh`, `#!/bin/bash\n${cmd}`);
|
||||
}
|
||||
|
||||
async libraries() {
|
||||
const config = Config.getConfig();
|
||||
const res = await this.got.get(`fabric-meta/v2/versions/loader/${this.minecraft}/${this.loaderVersion}/server/json`).json<IServer>();
|
||||
const _downlist: [string, string, string?][] = [];
|
||||
res.libraries.forEach(e => {
|
||||
const path = this.MTP(e.name);
|
||||
_downlist.push([`https://bmclapi2.bangbang93.com/maven/${path}`, `${this.path}/libraries/${path}`]);
|
||||
});
|
||||
|
||||
await fastdownload(_downlist as any);
|
||||
|
||||
if (config.mirror.bmclapi) {
|
||||
logger.info(`验证 ${_downlist.length} 个 Fabric 库文件的完整性...`);
|
||||
let verifiedCount = 0;
|
||||
for (const [, filePath] of _downlist) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const hash = calculateSHA1(filePath);
|
||||
logger.debug(`${filePath}: SHA1 = ${hash}`);
|
||||
verifiedCount++;
|
||||
}
|
||||
}
|
||||
logger.info(`Fabric 库文件验证完成,共验证 ${verifiedCount}/${_downlist.length} 个文件`);
|
||||
}
|
||||
}
|
||||
|
||||
async installer() {
|
||||
let downurl = "";
|
||||
const res = await this.got.get("fabric-meta/v2/versions/installer").json<ILatestLoader[]>();
|
||||
res.forEach(e => {
|
||||
if (e.stable) {
|
||||
downurl = e.url;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const filePath = `${this.path}/fabric-installer.jar`;
|
||||
await fastdownload([downurl, filePath]);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const hash = calculateSHA1(filePath);
|
||||
logger.debug(`Fabric installer 下载完成,SHA1: ${hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
private MTP(string: string) {
|
||||
const mjp = string.replace(/^\[|\]$/g, '');
|
||||
const OriginalName = mjp.split("@")[0];
|
||||
const x = OriginalName.split(":");
|
||||
const _mappingType = mjp.split('@')[1];
|
||||
let mappingType = "";
|
||||
if (_mappingType) {
|
||||
mappingType = _mappingType;
|
||||
} else {
|
||||
mappingType = "jar";
|
||||
}
|
||||
if (x[3]) {
|
||||
return `${x[0].replace(/\./g, '/')}/${x[1]}/${x[2]}/${x[1]}-${x[2]}-${x[3]}.${mappingType}`;
|
||||
} else {
|
||||
return `${x[0].replace(/\./g, '/')}/${x[1]}/${x[2]}/${x[1]}-${x[2]}.${mappingType}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
backend/src/modloader/forge.ts
Normal file
187
backend/src/modloader/forge.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import got, { Got } from "got";
|
||||
import fs from "node:fs";
|
||||
import fse from "fs-extra";
|
||||
import { execPromise, fastdownload, version_compare, verifySHA1 } from "../utils/utils.js";
|
||||
import { Azip } from "../utils/ziplib.js";
|
||||
import { Config } from "../utils/config.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
interface IForge {
|
||||
data: {
|
||||
MOJMAPS: {
|
||||
server: string;
|
||||
};
|
||||
MAPPINGS: {
|
||||
server: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface IVersion {
|
||||
downloads: {
|
||||
server_mappings: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface IForgeFile {
|
||||
format: string;
|
||||
category: string;
|
||||
hash: string;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
interface IForgeBuild {
|
||||
branch: string;
|
||||
build: number;
|
||||
mcversion: string;
|
||||
modified: string;
|
||||
version: string;
|
||||
_id: string;
|
||||
files: IForgeFile[];
|
||||
}
|
||||
|
||||
export class Forge {
|
||||
minecraft: string;
|
||||
loaderVersion: string;
|
||||
got: Got;
|
||||
path: string;
|
||||
|
||||
constructor(minecraft: string, loaderVersion: string, path: string) {
|
||||
this.minecraft = minecraft;
|
||||
this.loaderVersion = loaderVersion;
|
||||
this.path = path;
|
||||
const config = Config.getConfig();
|
||||
this.got = got.extend({
|
||||
headers: { "User-Agent": "DeEarthX" },
|
||||
hooks: {
|
||||
init: [
|
||||
(options) => {
|
||||
if (config.mirror.bmclapi) {
|
||||
options.prefixUrl = "https://bmclapi2.bangbang93.com";
|
||||
} else {
|
||||
options.prefixUrl = "http://maven.minecraftforge.net/";
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this.installer();
|
||||
const config = Config.getConfig();
|
||||
if (config.mirror.bmclapi) {
|
||||
await this.library();
|
||||
}
|
||||
await this.install();
|
||||
if (version_compare(this.minecraft, "1.18") === -1) {
|
||||
await this.wshell();
|
||||
}
|
||||
}
|
||||
|
||||
async library() {
|
||||
const _downlist: [string, string][] = [];
|
||||
const data = await fs.promises.readFile(`${this.path}/forge-${this.minecraft}-${this.loaderVersion}-installer.jar`);
|
||||
const zip = Azip(data);
|
||||
|
||||
for await (const entry of zip) {
|
||||
if (entry.entryName === "version.json" || entry.entryName === "install_profile.json") {
|
||||
JSON.parse((entry.getData()).toString()).libraries.forEach(async (e: any) => {
|
||||
const t = e.downloads.artifact.path;
|
||||
_downlist.push([`https://bmclapi2.bangbang93.com/maven/${t}`, `${this.path}/libraries/${t}`]);
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.entryName === "install_profile.json") {
|
||||
const json = JSON.parse((entry.getData()).toString()) as IForge;
|
||||
const vjson = await this.got.get(`version/${this.minecraft}/json`).json<IVersion>();
|
||||
console.log(`${new URL(vjson.downloads.server_mappings.url).pathname}`);
|
||||
const mojpath = this.MTP(json.data.MOJMAPS.server);
|
||||
_downlist.push([`https://bmclapi2.bangbang93.com/${new URL(vjson.downloads.server_mappings.url).pathname.slice(1)}`, `${this.path}/libraries/${mojpath}`]);
|
||||
|
||||
const mappingobj = json.data.MAPPINGS.server;
|
||||
const path = this.MTP(mappingobj.replace(":mappings@txt", "@zip"));
|
||||
_downlist.push([`https://bmclapi2.bangbang93.com/maven/${path}`, `${this.path}/libraries/${path}`]);
|
||||
}
|
||||
}
|
||||
|
||||
const downlist = [...new Set(_downlist)];
|
||||
await fastdownload(downlist);
|
||||
}
|
||||
|
||||
async install() {
|
||||
const config = Config.getConfig();
|
||||
const javaCmd = config.javaPath || 'java';
|
||||
let cmd = `${javaCmd} -jar forge-${this.minecraft}-${this.loaderVersion}-installer.jar --installServer`;
|
||||
if (config.mirror.bmclapi) {
|
||||
cmd += ` --mirror https://bmclapi2.bangbang93.com/maven/`;
|
||||
}
|
||||
await execPromise(cmd, { cwd: this.path }).catch((e) => {
|
||||
logger.error(`Forge 安装失败: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
async installer() {
|
||||
const config = Config.getConfig();
|
||||
let url = `forge/download?mcversion=${this.minecraft}&version=${this.loaderVersion}&category=installer&format=jar`;
|
||||
let expectedHash: string | undefined;
|
||||
|
||||
if (config.mirror?.bmclapi) {
|
||||
try {
|
||||
const forgeInfo = await this.got.get(`forge/minecraft/${this.minecraft}`).json<IForgeBuild[]>();
|
||||
const forgeVersion = forgeInfo.find(f => f.version === this.loaderVersion);
|
||||
if (forgeVersion) {
|
||||
const installerFile = forgeVersion.files.find(f => f.category === 'installer' && f.format === 'jar');
|
||||
if (installerFile) {
|
||||
expectedHash = installerFile.hash;
|
||||
logger.debug(`获取到 Forge installer hash: ${expectedHash}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`获取 Forge hash 信息失败,将跳过 hash 验证`, error);
|
||||
}
|
||||
} else {
|
||||
url = `net/minecraftforge/forge/${this.minecraft}-${this.loaderVersion}/forge-${this.minecraft}-${this.loaderVersion}-installer.jar`;
|
||||
}
|
||||
|
||||
const res = (await this.got.get(url)).rawBody;
|
||||
const filePath = `${this.path}/forge-${this.minecraft}-${this.loaderVersion}-installer.jar`;
|
||||
await fse.outputFile(filePath, res);
|
||||
|
||||
if (expectedHash) {
|
||||
if (!verifySHA1(filePath, expectedHash)) {
|
||||
logger.warn(`Forge installer hash 验证失败,删除文件并重试`);
|
||||
fs.unlinkSync(filePath);
|
||||
const res2 = (await this.got.get(url)).rawBody;
|
||||
await fse.outputFile(filePath, res2);
|
||||
|
||||
if (!verifySHA1(filePath, expectedHash)) {
|
||||
throw new Error(`Forge installer hash 验证失败,文件可能已损坏`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async wshell() {
|
||||
const config = Config.getConfig();
|
||||
const javaCmd = config.javaPath || 'java';
|
||||
const cmd = `${javaCmd} -jar forge-${this.minecraft}-${this.loaderVersion}.jar`;
|
||||
await fs.promises.writeFile(`${this.path}/run.bat`, `@echo off\n${cmd}`);
|
||||
await fs.promises.writeFile(`${this.path}/run.sh`, `#!/bin/bash\n${cmd}`);
|
||||
}
|
||||
|
||||
private MTP(string: string) {
|
||||
const mjp = string.replace(/^\[|\]$/g, '');
|
||||
const OriginalName = mjp.split("@")[0];
|
||||
const x = OriginalName.split(":");
|
||||
const mappingType = mjp.split('@')[1];
|
||||
if (x[3]) {
|
||||
return `${x[0].replace(/\./g, '/')}/${x[1]}/${x[2]}/${x[1]}-${x[2]}-${x[3]}.${mappingType}`;
|
||||
} else {
|
||||
return `${x[0].replace(/\./g, '/')}/${x[1]}/${x[2]}/${x[1]}-${x[2]}.${mappingType}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
backend/src/modloader/index.ts
Normal file
129
backend/src/modloader/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Fabric } from "./fabric.js";
|
||||
import { Forge } from "./forge.js";
|
||||
import { Minecraft } from "./minecraft.js";
|
||||
import { NeoForge } from "./neoforge.js";
|
||||
import fs from "node:fs";
|
||||
import { MessageWS } from "../utils/ws.js";
|
||||
import { getAppDir } from "../utils/utils.js";
|
||||
|
||||
interface XModloader {
|
||||
setup(): Promise<void>;
|
||||
installer(): Promise<void>;
|
||||
}
|
||||
|
||||
export function modloader(ml: string, mcv: string, mlv: string, path: string): XModloader {
|
||||
switch (ml) {
|
||||
case "fabric":
|
||||
case "fabric-loader":
|
||||
return new Fabric(mcv, mlv, path);
|
||||
case "forge":
|
||||
return new Forge(mcv, mlv, path);
|
||||
case "neoforge":
|
||||
return new NeoForge(mcv, mlv, path);
|
||||
default:
|
||||
return new Minecraft(ml, mcv, mlv, path);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mlsetup(ml: string, mcv: string, mlv: string, path: string, messageWS?: MessageWS, template?: string): Promise<void> {
|
||||
const totalSteps = template && template !== '0' ? 1 : (template ? 3 : 2);
|
||||
|
||||
try {
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallStart("Server Installation", mcv, ml, mlv);
|
||||
}
|
||||
|
||||
if (template && template !== '0') {
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallStep(`Applying Template: ${template}`, 1, totalSteps);
|
||||
}
|
||||
|
||||
const templateModule = await import('../template/index.js');
|
||||
const TemplateManager = (templateModule as any).TemplateManager;
|
||||
const templateManager = new TemplateManager();
|
||||
const templates = await templateManager.getTemplates();
|
||||
const selectedTemplate = templates.find((t: { id: string; metadata: any }) => t.id === template);
|
||||
|
||||
if (selectedTemplate) {
|
||||
const pathModule = await import('node:path');
|
||||
const templatePath = pathModule.join(getAppDir(), "templates", template);
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
try {
|
||||
const dataPath = pathModule.join(templatePath, 'data');
|
||||
const files = await fs.readdir(dataPath, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
const srcPath = pathModule.join(dataPath, file);
|
||||
const stat = await fs.stat(srcPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
const destPath = pathModule.join(path, file);
|
||||
const destDir = pathModule.dirname(destPath);
|
||||
await fs.mkdir(destDir, { recursive: true });
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallProgress(`Applied Template: ${template}`, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to apply template ${template}:`, error);
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallError(`Failed to apply template: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Template ${template} not found`);
|
||||
}
|
||||
} else {
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallStep("Installing Minecraft Server", 1, totalSteps);
|
||||
}
|
||||
|
||||
const minecraft = new Minecraft(ml, mcv, mlv, path);
|
||||
await minecraft.setup();
|
||||
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallProgress("Installing Minecraft Server", 100);
|
||||
messageWS.serverInstallStep(`Installing ${ml} Loader`, 2, totalSteps);
|
||||
}
|
||||
|
||||
await modloader(ml, mcv, mlv, path).setup();
|
||||
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallProgress(`Installing ${ml} Loader`, 100);
|
||||
}
|
||||
|
||||
if (template && template === '0') {
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallStep(`No template selected, using official mod loader`, 3, totalSteps);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (messageWS) {
|
||||
messageWS.serverInstallError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dinstall(ml: string, mcv: string, mlv: string, path: string): Promise<void> {
|
||||
await modloader(ml, mcv, mlv, path).installer();
|
||||
|
||||
let cmd = '';
|
||||
if (ml === 'forge' || ml === 'neoforge') {
|
||||
cmd = `java -jar forge-${mcv}-${mlv}-installer.jar --installServer`;
|
||||
} else if (ml === 'fabric' || ml === 'fabric-loader') {
|
||||
await fs.promises.writeFile(`${path}/run.bat`,`@echo off\njava -jar fabric-server-launch.jar\n`)
|
||||
await fs.promises.writeFile(`${path}/run.sh`,`#!/bin/bash\njava -jar fabric-server-launch.jar\n`)
|
||||
cmd = `java -jar fabric-installer.jar server -dir . -mcversion ${mcv} -loader ${mlv} -downloadMinecraft`;
|
||||
}
|
||||
|
||||
if (cmd) {
|
||||
await fs.promises.writeFile(`${path}/install.bat`, `@echo off\n${cmd}\necho Install Successfully,Enter Some Key to Exit!\npause\n`);
|
||||
await fs.promises.writeFile(`${path}/install.sh`, `#!/bin/bash\n${cmd}\n`);
|
||||
}
|
||||
}
|
||||
102
backend/src/modloader/minecraft.ts
Normal file
102
backend/src/modloader/minecraft.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import fs from "node:fs";
|
||||
import { fastdownload, version_compare } from "../utils/utils.js";
|
||||
import got from "got";
|
||||
import p from "path";
|
||||
import { Azip } from "../utils/ziplib.js";
|
||||
import { Config } from "../utils/config.js";
|
||||
|
||||
interface ILInfo {
|
||||
libraries: {
|
||||
downloads: {
|
||||
artifact: {
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export class Minecraft {
|
||||
loader: string;
|
||||
minecraft: string;
|
||||
loaderVersion: string;
|
||||
path: string;
|
||||
|
||||
constructor(loader: string, minecraft: string, lv: string, path: string) {
|
||||
this.path = path;
|
||||
this.loader = loader;
|
||||
this.minecraft = minecraft;
|
||||
this.loaderVersion = lv;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this.eula();
|
||||
const config = Config.getConfig();
|
||||
if (!config.mirror.bmclapi) {
|
||||
return;
|
||||
}
|
||||
switch (this.loader) {
|
||||
case "forge":
|
||||
await this.forge_setup();
|
||||
break;
|
||||
case "neoforge":
|
||||
await this.forge_setup();
|
||||
break;
|
||||
case "fabric":
|
||||
await this.fabric_setup();
|
||||
break;
|
||||
case "fabric-loader":
|
||||
await this.fabric_setup();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async forge_setup() {
|
||||
if (version_compare(this.minecraft, "1.18") === 1) {
|
||||
const mcpath = `${this.path}/libraries/net/minecraft/server/${this.minecraft}/server-${this.minecraft}.jar`;
|
||||
await fastdownload([`https://bmclapi2.bangbang93.com/version/${this.minecraft}/server`, mcpath]);
|
||||
const zip = await Azip(await fs.promises.readFile(mcpath));
|
||||
for await (const entry of zip) {
|
||||
if (entry.entryName.startsWith("META-INF/libraries/") && !entry.entryName.endsWith("/")) {
|
||||
console.log(entry.entryName);
|
||||
const data = entry.getData();
|
||||
const filepath = `${this.path}/libraries/${entry.entryName.replace("META-INF/libraries/", "")}`;
|
||||
const dir = p.dirname(filepath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(filepath, data);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lowv = `${this.path}/minecraft_server.${this.minecraft}.jar`;
|
||||
const dmc = fastdownload([`https://bmclapi2.bangbang93.com/version/${this.minecraft}/server`, lowv]);
|
||||
|
||||
const download = (async () => {
|
||||
console.log("并行");
|
||||
const json = await got.get(`https://bmclapi2.bangbang93.com/version/${this.minecraft}/json`, {
|
||||
headers: {
|
||||
"User-Agent": "DeEarthX"
|
||||
}
|
||||
}).json<ILInfo>();
|
||||
|
||||
await Promise.all(json.libraries.map(async e => {
|
||||
const path = e.downloads.artifact.path;
|
||||
await fastdownload([`https://bmclapi2.bangbang93.com/maven/${path}`, `${this.path}/libraries/${path}`]);
|
||||
}));
|
||||
})();
|
||||
|
||||
await Promise.all([dmc, download]);
|
||||
}
|
||||
}
|
||||
|
||||
async fabric_setup() {
|
||||
const mcpath = `${this.path}/server.jar`;
|
||||
await fastdownload([`https://bmclapi2.bangbang93.com/version/${this.minecraft}/server`, mcpath]);
|
||||
}
|
||||
|
||||
async installer() {
|
||||
}
|
||||
|
||||
async eula() {
|
||||
const context = `#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://aka.ms/MinecraftEULA).\n#Spawn by DeEarthX(QQgroup:559349662) Tianpao:(https://space.bilibili.com/1728953419)\neula=true`;
|
||||
await fs.promises.writeFile(`${this.path}/eula.txt`, context);
|
||||
}
|
||||
}
|
||||
46
backend/src/modloader/neoforge.ts
Normal file
46
backend/src/modloader/neoforge.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fse from "fs-extra";
|
||||
import { Forge } from "./forge.js";
|
||||
import { Config } from "../utils/config.js";
|
||||
import { Got, got } from "got";
|
||||
|
||||
export class NeoForge extends Forge {
|
||||
got: Got;
|
||||
|
||||
constructor(minecraft: string, loaderVersion: string, path: string) {
|
||||
super(minecraft, loaderVersion, path);
|
||||
const config = Config.getConfig();
|
||||
this.got = got.extend({
|
||||
headers: { "User-Agent": "DeEarthX" },
|
||||
hooks: {
|
||||
init: [
|
||||
(options) => {
|
||||
if (config.mirror?.bmclapi) {
|
||||
options.prefixUrl = "https://bmclapi2.bangbang93.com/";
|
||||
} else {
|
||||
options.prefixUrl = "https://maven.neoforged.net/releases/";
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await this.installer();
|
||||
const config = Config.getConfig();
|
||||
if (config.mirror.bmclapi) {
|
||||
await this.library();
|
||||
}
|
||||
await this.install();
|
||||
}
|
||||
|
||||
async installer() {
|
||||
const config = Config.getConfig();
|
||||
let url = `neoforge/version/${this.loaderVersion}/download/installer.jar`;
|
||||
if (!config.mirror?.bmclapi) {
|
||||
url = `net/neoforged/neoforge/${this.loaderVersion}/neoforge-${this.loaderVersion}-installer.jar`;
|
||||
}
|
||||
const res = (await this.got.get(url)).rawBody;
|
||||
await fse.outputFile(`${this.path}/forge-${this.minecraft}-${this.loaderVersion}-installer.jar`, res);
|
||||
}
|
||||
}
|
||||
73
backend/src/platform/curseforge.ts
Normal file
73
backend/src/platform/curseforge.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import got, { Got } from "got";
|
||||
import { join } from "node:path";
|
||||
import { Wfastdownload, Utils } from "../utils/utils.js";
|
||||
import { modpack_info, XPlatform } from "./index.js";
|
||||
import { MessageWS } from "../utils/ws.js";
|
||||
|
||||
export interface CurseForgeManifest {
|
||||
minecraft: {
|
||||
version: string;
|
||||
modLoaders: Array<{ id: string }>;
|
||||
};
|
||||
files: Array<{ projectID: number; fileID: number }>;
|
||||
}
|
||||
|
||||
export class CurseForge implements XPlatform {
|
||||
private utils: Utils;
|
||||
private got: Got;
|
||||
|
||||
constructor() {
|
||||
this.utils = new Utils();
|
||||
this.got = got.extend({
|
||||
prefixUrl: this.utils.curseforge_url,
|
||||
headers: {
|
||||
"User-Agent": "DeEarthX",
|
||||
"x-api-key": "$2a$10$ydk0TLDG/Gc6uPMdz7mad.iisj2TaMDytVcIW4gcVP231VKngLBKy",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getinfo(manifest: object): Promise<modpack_info> {
|
||||
let result: modpack_info = Object.create({});
|
||||
const local_manifest = manifest as CurseForgeManifest;
|
||||
if (result && local_manifest)
|
||||
result.minecraft = local_manifest.minecraft.version;
|
||||
const id = local_manifest.minecraft.modLoaders[0].id;
|
||||
const loader_all = id.match(/(.*)-/) as RegExpMatchArray;
|
||||
result.loader = loader_all[1];
|
||||
result.loader_version = id.replace(loader_all[0], "");
|
||||
return result;
|
||||
}
|
||||
|
||||
async downloadfile(manifest: object, path: string, ws: MessageWS): Promise<void> {
|
||||
const local_manifest = manifest as CurseForgeManifest;
|
||||
if (local_manifest.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const FileID = JSON.stringify({
|
||||
fileIds: local_manifest.files.map(
|
||||
(file: { fileID: number }) => file.fileID
|
||||
),
|
||||
});
|
||||
let tmp: string[][] = [];
|
||||
await this.got
|
||||
.post("v1/mods/files", {
|
||||
body: FileID,
|
||||
})
|
||||
.json()
|
||||
.then((res: any) => {
|
||||
res.data.forEach(
|
||||
(e: { fileName: string; downloadUrl: null | string }) => {
|
||||
if (e.fileName.endsWith(".zip") || e.downloadUrl == null) {
|
||||
return;
|
||||
}
|
||||
const unpath = join(path + "/mods/", e.fileName);
|
||||
const url = e.downloadUrl.replace("https://edge.forgecdn.net", this.utils.curseforge_Durl);
|
||||
tmp.push([url, unpath]);
|
||||
}
|
||||
);
|
||||
});
|
||||
await Wfastdownload(tmp, ws, true, true);
|
||||
}
|
||||
}
|
||||
38
backend/src/platform/index.ts
Normal file
38
backend/src/platform/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { MessageWS } from "../utils/ws.js";
|
||||
import { CurseForge } from "./curseforge.js";
|
||||
import { Modrinth } from "./modrinth.js";
|
||||
|
||||
export interface XPlatform {
|
||||
getinfo(manifest: object): Promise<modpack_info>;
|
||||
downloadfile(manifest: object, path: string, ws: MessageWS): Promise<void>;
|
||||
}
|
||||
|
||||
export interface modpack_info {
|
||||
minecraft: string;
|
||||
loader: string;
|
||||
loader_version: string;
|
||||
}
|
||||
|
||||
export function platform(plat: string | undefined): XPlatform {
|
||||
let platform: XPlatform = Object.create({});
|
||||
switch (plat) {
|
||||
case "curseforge":
|
||||
platform = new CurseForge();
|
||||
break;
|
||||
case "modrinth":
|
||||
platform = new Modrinth();
|
||||
break;
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
export function what_platform(dud_files: string | "manifest.json" | "modrinth.index.json") {
|
||||
switch (dud_files) {
|
||||
case "manifest.json":
|
||||
return "curseforge";
|
||||
case "modrinth.index.json":
|
||||
return "modrinth";
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
54
backend/src/platform/modrinth.ts
Normal file
54
backend/src/platform/modrinth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { Wfastdownload, Utils } from "../utils/utils.js";
|
||||
import { modpack_info, XPlatform } from "./index.js";
|
||||
import { MessageWS } from "../utils/ws.js";
|
||||
|
||||
interface ModrinthManifest {
|
||||
files: Array<{ path: string; downloads: string[]; fileSize: number; }>;
|
||||
dependencies: {
|
||||
minecraft: string;
|
||||
forge: string;
|
||||
neoforge: string;
|
||||
"fabric-loader": string;
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class Modrinth implements XPlatform {
|
||||
private utils: Utils;
|
||||
|
||||
constructor() {
|
||||
this.utils = new Utils();
|
||||
}
|
||||
|
||||
async getinfo(manifest: object): Promise<modpack_info> {
|
||||
let result: modpack_info = Object.create({});
|
||||
const local_manifest = manifest as ModrinthManifest;
|
||||
const depkey = Object.keys(local_manifest.dependencies);
|
||||
const loader = ["forge", "neoforge", "fabric-loader"];
|
||||
result.minecraft = local_manifest.dependencies.minecraft;
|
||||
for (let i = 0; i < depkey.length; i++) {
|
||||
const key = depkey[i];
|
||||
if (key !== "minecraft" && loader.includes(key)) {
|
||||
result.loader = key;
|
||||
result.loader_version = local_manifest.dependencies[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async downloadfile(manifest: object, path: string, ws: MessageWS): Promise<void> {
|
||||
const index = manifest as ModrinthManifest;
|
||||
let tmp: [string, string][] = [];
|
||||
for (const e of index.files) {
|
||||
if (e.path.endsWith(".zip")) {
|
||||
continue;
|
||||
}
|
||||
const url = e.downloads[0].replace("https://cdn.modrinth.com", this.utils.modrinth_Durl);
|
||||
const unpath = join(path, e.path);
|
||||
tmp.push([url, unpath]);
|
||||
}
|
||||
await Wfastdownload(tmp, ws, true, true);
|
||||
}
|
||||
}
|
||||
229
backend/src/template/TemplateManager.ts
Normal file
229
backend/src/template/TemplateManager.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { getAppDir } from "../utils/utils.js";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
|
||||
interface TemplateMetadata {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
created: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class TemplateManager {
|
||||
private readonly templatesPath: string;
|
||||
|
||||
constructor(templatesPath?: string) {
|
||||
this.templatesPath = templatesPath || path.join(getAppDir(), "templates");
|
||||
}
|
||||
|
||||
async ensureDefaultTemplate(): Promise<void> {
|
||||
// 确保templates文件夹存在
|
||||
await fs.mkdir(this.templatesPath, { recursive: true });
|
||||
|
||||
const examplePath = path.join(this.templatesPath, "example");
|
||||
const metadataPath = path.join(examplePath, "metadata.json");
|
||||
const dataPath = path.join(examplePath, "data");
|
||||
|
||||
try {
|
||||
await fs.access(metadataPath);
|
||||
} catch {
|
||||
await this.createTemplate("example", {
|
||||
name: "example",
|
||||
version: "1.0.0",
|
||||
description: "Example template for DeEarthX",
|
||||
author: "DeEarthX",
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
type: "template",
|
||||
});
|
||||
|
||||
await fs.mkdir(dataPath, { recursive: true });
|
||||
|
||||
const readmePath = path.join(dataPath, "README.txt");
|
||||
await fs.writeFile(readmePath, "This is an example template for DeEarthX.\nPlace your server files in this data folder.");
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(name: string, metadata: Partial<TemplateMetadata>): Promise<void> {
|
||||
const templatePath = path.join(this.templatesPath, name);
|
||||
|
||||
await fs.mkdir(templatePath, { recursive: true });
|
||||
|
||||
const defaultMetadata: TemplateMetadata = {
|
||||
name,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: "",
|
||||
created: new Date().toISOString().split("T")[0],
|
||||
type: "template",
|
||||
...metadata,
|
||||
};
|
||||
|
||||
const metadataPath = path.join(templatePath, "metadata.json");
|
||||
await fs.writeFile(metadataPath, JSON.stringify(defaultMetadata, null, 2));
|
||||
|
||||
const dataPath = path.join(templatePath, "data");
|
||||
await fs.mkdir(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
async getTemplates(): Promise<Array<{ id: string; metadata: TemplateMetadata }>> {
|
||||
try {
|
||||
// 确保templates文件夹存在
|
||||
await fs.mkdir(this.templatesPath, { recursive: true });
|
||||
|
||||
const entries = await fs.readdir(this.templatesPath, { withFileTypes: true });
|
||||
const templates: Array<{ id: string; metadata: TemplateMetadata }> = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const templateId = entry.name;
|
||||
const metadataPath = path.join(this.templatesPath, templateId, "metadata.json");
|
||||
|
||||
try {
|
||||
const metadataContent = await fs.readFile(metadataPath, "utf-8");
|
||||
const metadata: TemplateMetadata = JSON.parse(metadataContent);
|
||||
templates.push({ id: templateId, metadata });
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read metadata for template ${templateId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return templates;
|
||||
} catch (error) {
|
||||
console.error("Failed to read templates directory:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, metadata: Partial<TemplateMetadata>): Promise<void> {
|
||||
const templatePath = path.join(this.templatesPath, templateId);
|
||||
const metadataPath = path.join(templatePath, "metadata.json");
|
||||
|
||||
try {
|
||||
await fs.access(metadataPath);
|
||||
} catch {
|
||||
throw new Error(`Template ${templateId} does not exist`);
|
||||
}
|
||||
|
||||
const existingMetadataContent = await fs.readFile(metadataPath, "utf-8");
|
||||
const existingMetadata: TemplateMetadata = JSON.parse(existingMetadataContent);
|
||||
|
||||
const updatedMetadata: TemplateMetadata = {
|
||||
...existingMetadata,
|
||||
...metadata,
|
||||
};
|
||||
|
||||
await fs.writeFile(metadataPath, JSON.stringify(updatedMetadata, null, 2));
|
||||
}
|
||||
|
||||
async exportTemplate(templateId: string, outputPath: string): Promise<void> {
|
||||
const templatePath = path.join(this.templatesPath, templateId);
|
||||
const metadataPath = path.join(templatePath, "metadata.json");
|
||||
|
||||
try {
|
||||
await fs.access(metadataPath);
|
||||
} catch {
|
||||
throw new Error(`Template ${templateId} does not exist`);
|
||||
}
|
||||
|
||||
const zipfile = new yazl.ZipFile();
|
||||
|
||||
// 读取并添加metadata.json
|
||||
const metadataContent = await fs.readFile(metadataPath, "utf-8");
|
||||
zipfile.addBuffer(Buffer.from(metadataContent), "metadata.json");
|
||||
|
||||
// 添加data目录
|
||||
const dataPath = path.join(templatePath, "data");
|
||||
try {
|
||||
await fs.access(dataPath);
|
||||
const dataFiles = await this.getFilesRecursively(dataPath);
|
||||
|
||||
for (const file of dataFiles) {
|
||||
const relativePath = path.relative(templatePath, file);
|
||||
zipfile.addFile(file, relativePath);
|
||||
}
|
||||
} catch {
|
||||
// data目录不存在,跳过
|
||||
}
|
||||
|
||||
// 生成zip文件
|
||||
return new Promise((resolve, reject) => {
|
||||
zipfile.outputStream.pipe(createWriteStream(outputPath))
|
||||
.on("close", () => resolve())
|
||||
.on("error", (err) => reject(err));
|
||||
zipfile.end();
|
||||
});
|
||||
}
|
||||
|
||||
async importTemplate(zipBuffer: Buffer, templateId?: string): Promise<string> {
|
||||
const newTemplateId = templateId || `template-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const templatePath = path.join(this.templatesPath, newTemplateId);
|
||||
|
||||
// 确保模板目录存在
|
||||
await fs.mkdir(templatePath, { recursive: true });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.fromBuffer(zipBuffer, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.on("entry", async (entry) => {
|
||||
if (entry.fileName.endsWith("/")) {
|
||||
// 目录,跳过
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = path.join(templatePath, entry.fileName);
|
||||
const entryDir = path.dirname(entryPath);
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(entryDir, { recursive: true });
|
||||
|
||||
// 读取并写入文件
|
||||
zipfile.openReadStream(entry, (err, readStream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const writeStream = createWriteStream(entryPath);
|
||||
pipeline(readStream, writeStream)
|
||||
.then(() => zipfile.readEntry())
|
||||
.catch((err) => reject(err));
|
||||
});
|
||||
});
|
||||
|
||||
zipfile.on("end", () => resolve(newTemplateId));
|
||||
zipfile.on("error", (err) => reject(err));
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getFilesRecursively(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await this.getFilesRecursively(fullPath);
|
||||
files.push(...subFiles);
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
}
|
||||
94
backend/src/template/index.ts
Normal file
94
backend/src/template/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { TemplateManager } from "./TemplateManager.js";
|
||||
import { getAppDir } from "../utils/utils.js";
|
||||
|
||||
export { TemplateManager };
|
||||
|
||||
interface TemplateMetadata {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
created: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
private readonly templatesPath: string;
|
||||
|
||||
constructor(templatesPath?: string) {
|
||||
this.templatesPath = templatesPath || path.join(getAppDir(), "templates");
|
||||
}
|
||||
|
||||
async getTemplate(name: string): Promise<TemplateMetadata | null> {
|
||||
const metadataPath = path.join(this.templatesPath, name, "metadata.json");
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(metadataPath, "utf-8");
|
||||
return JSON.parse(data) as TemplateMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(name: string, metadata: Partial<TemplateMetadata>): Promise<boolean> {
|
||||
const currentMetadata = await this.getTemplate(name);
|
||||
|
||||
if (!currentMetadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedMetadata = { ...currentMetadata, ...metadata };
|
||||
const metadataPath = path.join(this.templatesPath, name, "metadata.json");
|
||||
await fs.writeFile(metadataPath, JSON.stringify(updatedMetadata, null, 2));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteTemplate(name: string): Promise<boolean> {
|
||||
const templatePath = path.join(this.templatesPath, name);
|
||||
|
||||
try {
|
||||
await fs.rm(templatePath, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listTemplates(): Promise<TemplateMetadata[]> {
|
||||
const templates: TemplateMetadata[] = [];
|
||||
|
||||
try {
|
||||
// 确保templates文件夹存在
|
||||
await fs.mkdir(this.templatesPath, { recursive: true });
|
||||
|
||||
const entries = await fs.readdir(this.templatesPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const metadata = await this.getTemplate(entry.name);
|
||||
if (metadata) {
|
||||
templates.push(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
async templateExists(name: string): Promise<boolean> {
|
||||
const metadataPath = path.join(this.templatesPath, name, "metadata.json");
|
||||
|
||||
try {
|
||||
await fs.access(metadataPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
backend/src/utils/colors.ts
Normal file
58
backend/src/utils/colors.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// ANSI 颜色码
|
||||
const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
bright: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
|
||||
// 前景色
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
|
||||
// 背景色(可选)
|
||||
bgRed: "\x1b[41m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
} as const;
|
||||
|
||||
// 日志级别对应的颜色
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
error: COLORS.red,
|
||||
warn: COLORS.yellow,
|
||||
info: COLORS.green,
|
||||
debug: COLORS.cyan,
|
||||
};
|
||||
|
||||
// 是否支持彩色输出(检测终端)
|
||||
const supportsColor = () => {
|
||||
if (process.env.FORCE_COLOR === "0") return false;
|
||||
if (process.env.FORCE_COLOR === "1") return true;
|
||||
return process.stdout.isTTY;
|
||||
};
|
||||
|
||||
// 格式化颜色文本
|
||||
export const colorize = (text: string, color: string): string => {
|
||||
if (!supportsColor()) return text;
|
||||
return `${color}${text}${COLORS.reset}`;
|
||||
};
|
||||
|
||||
// 格式化日志级别标签
|
||||
export const formatLevel = (level: string): string => {
|
||||
const color = LEVEL_COLORS[level.toLowerCase()] || COLORS.white;
|
||||
const label = `[${level.toUpperCase()}]`;
|
||||
return colorize(label, COLORS.bright + color);
|
||||
};
|
||||
|
||||
// 格式化时间戳
|
||||
export const formatTime = (): string => {
|
||||
const now = new Date();
|
||||
const time = now.toISOString().replace("T", " ").slice(0, 19);
|
||||
return colorize(time, COLORS.dim);
|
||||
};
|
||||
|
||||
export { COLORS };
|
||||
169
backend/src/utils/config.ts
Normal file
169
backend/src/utils/config.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 应用配置接口
|
||||
*/
|
||||
export interface IConfig {
|
||||
mirror: {
|
||||
bmclapi: boolean;
|
||||
mcimirror: boolean;
|
||||
};
|
||||
filter: {
|
||||
hashes: boolean;
|
||||
dexpub: boolean;
|
||||
mixins: boolean;
|
||||
modrinth: boolean;
|
||||
};
|
||||
oaf: boolean;
|
||||
autoZip: boolean;
|
||||
port?: number;
|
||||
host?: string;
|
||||
javaPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
const DEFAULT_CONFIG: IConfig = {
|
||||
mirror: {
|
||||
bmclapi: true,
|
||||
mcimirror: true,
|
||||
},
|
||||
filter: {
|
||||
hashes: true,
|
||||
dexpub: true,
|
||||
mixins: true,
|
||||
modrinth: false,
|
||||
},
|
||||
oaf: true,
|
||||
autoZip: false,
|
||||
port: 37019,
|
||||
host: 'localhost',
|
||||
javaPath: undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取可执行文件所在目录
|
||||
* 在开发环境返回当前目录,在生产环境返回可执行文件所在目录
|
||||
*/
|
||||
function getAppDir(): string {
|
||||
const execPath = process.execPath;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// 检查是否在开发环境中运行
|
||||
// 如果 execPath 指向 node.exe 且当前目录不是 node 安装目录,说明是开发环境
|
||||
const isDevelopment = execPath.toLowerCase().includes('node.exe') &&
|
||||
!cwd.toLowerCase().includes('program files') &&
|
||||
!cwd.toLowerCase().includes('nodejs');
|
||||
|
||||
if (isDevelopment) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
return path.dirname(execPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置文件路径 - 使用可执行文件所在目录
|
||||
*/
|
||||
const CONFIG_PATH = path.join(getAppDir(), "config.json");
|
||||
|
||||
/**
|
||||
* 从环境变量获取配置
|
||||
* @param key 环境变量键
|
||||
* @param defaultValue 默认值
|
||||
* @returns 环境变量值或默认值
|
||||
*/
|
||||
function getEnv<T>(key: string, defaultValue: T): T {
|
||||
const value = process.env[key];
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof defaultValue === 'boolean') {
|
||||
return (value.toLowerCase() === 'true') as unknown as T;
|
||||
}
|
||||
|
||||
if (typeof defaultValue === 'number') {
|
||||
const num = parseInt(value, 10);
|
||||
return (isNaN(num) ? defaultValue : num) as unknown as T;
|
||||
}
|
||||
|
||||
return value as unknown as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置管理器
|
||||
*/
|
||||
export class Config {
|
||||
private static cachedConfig: IConfig | null = null;
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
* @returns 配置对象
|
||||
*/
|
||||
public static getConfig(): IConfig {
|
||||
if (this.cachedConfig) {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
let config: IConfig;
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
||||
config = DEFAULT_CONFIG;
|
||||
} else {
|
||||
try {
|
||||
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
|
||||
config = JSON.parse(content);
|
||||
} catch (err) {
|
||||
logger.error("Failed to read config file, using defaults", err as Error);
|
||||
config = DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
// 从环境变量覆盖配置
|
||||
const envConfig: IConfig = {
|
||||
mirror: {
|
||||
bmclapi: getEnv('DEEARTHX_MIRROR_BMCLAPI', config.mirror.bmclapi),
|
||||
mcimirror: getEnv('DEEARTHX_MIRROR_MCIMIRROR', config.mirror.mcimirror)
|
||||
},
|
||||
filter: {
|
||||
hashes: getEnv('DEEARTHX_FILTER_HASHES', config.filter.hashes),
|
||||
dexpub: getEnv('DEEARTHX_FILTER_DEXPUB', config.filter.dexpub),
|
||||
mixins: getEnv('DEEARTHX_FILTER_MIXINS', config.filter.mixins),
|
||||
modrinth: getEnv('DEEARTHX_FILTER_MODRINTH', config.filter.modrinth)
|
||||
},
|
||||
oaf: getEnv('DEEARTHX_OAF', config.oaf),
|
||||
autoZip: getEnv('DEEARTHX_AUTO_ZIP', config.autoZip),
|
||||
port: getEnv('DEEARTHX_PORT', config.port || DEFAULT_CONFIG.port),
|
||||
host: getEnv('DEEARTHX_HOST', config.host || DEFAULT_CONFIG.host)
|
||||
};
|
||||
|
||||
this.cachedConfig = envConfig;
|
||||
logger.debug("Loaded config", envConfig);
|
||||
return envConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入配置
|
||||
* @param config 配置对象
|
||||
*/
|
||||
public static writeConfig(config: IConfig): void {
|
||||
try {
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
this.cachedConfig = config;
|
||||
logger.info("Config file written successfully");
|
||||
} catch (err) {
|
||||
logger.error("Failed to write config file", err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置缓存(强制下次读取时重新从文件加载)
|
||||
*/
|
||||
public static clearCache(): void {
|
||||
this.cachedConfig = null;
|
||||
}
|
||||
}
|
||||
44
backend/src/utils/jar-parser.ts
Normal file
44
backend/src/utils/jar-parser.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { IInfoFile, IMixinFile } from "../dearth/types.js";
|
||||
import { Azip } from "./ziplib.js";
|
||||
import toml from "smol-toml";
|
||||
|
||||
export class JarParser {
|
||||
static async extractModInfo(jarData: Buffer): Promise<IInfoFile[]> {
|
||||
const infos: IInfoFile[] = [];
|
||||
const zipEntries = Azip(jarData);
|
||||
|
||||
for (const entry of zipEntries) {
|
||||
try {
|
||||
if (entry.entryName.endsWith("neoforge.mods.toml") || entry.entryName.endsWith("mods.toml")) {
|
||||
const data = await entry.getData();
|
||||
infos.push({ name: entry.entryName, data: JSON.stringify(toml.parse(data.toString())) });
|
||||
} else if (entry.entryName.endsWith("fabric.mod.json")) {
|
||||
const data = await entry.getData();
|
||||
infos.push({ name: entry.entryName, data: data.toString() });
|
||||
}
|
||||
} catch (error: any) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return infos;
|
||||
}
|
||||
|
||||
static async extractMixins(jarData: Buffer): Promise<IMixinFile[]> {
|
||||
const mixins: IMixinFile[] = [];
|
||||
const zipEntries = Azip(jarData);
|
||||
|
||||
for (const entry of zipEntries) {
|
||||
if (entry.entryName.endsWith(".mixins.json") && !entry.entryName.includes("/")) {
|
||||
try {
|
||||
const data = await entry.getData();
|
||||
mixins.push({ name: entry.entryName, data: data.toString() });
|
||||
} catch (error: any) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mixins;
|
||||
}
|
||||
}
|
||||
97
backend/src/utils/logger.ts
Normal file
97
backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { formatLevel, formatTime, colorize, COLORS } from "./colors.js";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface Logger {
|
||||
debug: (message: string, meta?: any) => void;
|
||||
info: (message: string, meta?: any) => void;
|
||||
warn: (message: string, meta?: any) => void;
|
||||
error: (message: string, meta?: any) => void;
|
||||
}
|
||||
|
||||
function getAppDir(): string {
|
||||
const execPath = process.execPath;
|
||||
const cwd = process.cwd();
|
||||
|
||||
const isDevelopment = execPath.toLowerCase().includes('node.exe') &&
|
||||
!cwd.toLowerCase().includes('program files') &&
|
||||
!cwd.toLowerCase().includes('nodejs');
|
||||
|
||||
if (isDevelopment) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
return path.dirname(execPath);
|
||||
}
|
||||
|
||||
const logsDir = path.join(getAppDir(), "logs");
|
||||
|
||||
const ensureLogsDir = () => {
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
const generateLogFileName = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const timestamp = Date.now();
|
||||
return `${year}-${month}-${day}-${timestamp}.log`;
|
||||
};
|
||||
|
||||
const logFilePath = path.join(logsDir, generateLogFileName());
|
||||
|
||||
const writeToFile = (level: LogLevel, message: string, meta?: any) => {
|
||||
const timestamp = formatTime();
|
||||
let metaStr = "";
|
||||
if (meta) {
|
||||
try {
|
||||
const metaContent = typeof meta === "object"
|
||||
? JSON.stringify(meta)
|
||||
: String(meta);
|
||||
metaStr = ` ${metaContent}`;
|
||||
} catch {
|
||||
metaStr = " [元数据解析错误]";
|
||||
}
|
||||
}
|
||||
const logLine = `${timestamp} [${level.toUpperCase()}] ${message}${metaStr}\n`;
|
||||
fs.appendFileSync(logFilePath, logLine, "utf-8");
|
||||
};
|
||||
|
||||
ensureLogsDir();
|
||||
|
||||
const log = (level: LogLevel, message: string, meta?: any) => {
|
||||
const timestamp = formatTime();
|
||||
const levelTag = formatLevel(level);
|
||||
|
||||
writeToFile(level, message, meta);
|
||||
|
||||
let metaStr = "";
|
||||
if (meta) {
|
||||
try {
|
||||
const metaContent = typeof meta === "object"
|
||||
? JSON.stringify(meta)
|
||||
: String(meta);
|
||||
metaStr = ` ${colorize(metaContent, COLORS.dim)}`;
|
||||
} catch {
|
||||
metaStr = ` ${colorize("[元数据解析错误]", COLORS.red)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const msg = level === "error"
|
||||
? colorize(message, COLORS.bright)
|
||||
: message;
|
||||
|
||||
console.log(`${timestamp} ${levelTag} ${msg}${metaStr}`);
|
||||
};
|
||||
|
||||
export const logger: Logger = {
|
||||
debug: (msg, meta) => log("debug", msg, meta),
|
||||
info: (msg, meta) => log("info", msg, meta),
|
||||
warn: (msg, meta) => log("warn", msg, meta),
|
||||
error: (msg, meta) => log("error", msg, meta),
|
||||
};
|
||||
555
backend/src/utils/utils.ts
Normal file
555
backend/src/utils/utils.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import pMap from "p-map";
|
||||
import { Config } from "./config.js";
|
||||
import got from "got";
|
||||
import pRetry from "p-retry";
|
||||
import fs from "node:fs";
|
||||
import fse from "fs-extra";
|
||||
import { SpawnOptions, exec, spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { MessageWS } from "./ws.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export function getAppDir(): string {
|
||||
const execPath = process.execPath;
|
||||
const cwd = process.cwd();
|
||||
|
||||
const isDevelopment = execPath.toLowerCase().includes('node.exe') &&
|
||||
!cwd.toLowerCase().includes('program files') &&
|
||||
!cwd.toLowerCase().includes('nodejs');
|
||||
|
||||
if (isDevelopment) {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
return path.dirname(execPath);
|
||||
}
|
||||
|
||||
export interface JavaVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
fullVersion: string;
|
||||
vendor: string;
|
||||
runtimeVersion?: string;
|
||||
}
|
||||
|
||||
export interface JavaCheckResult {
|
||||
exists: boolean;
|
||||
version?: JavaVersion;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class Utils {
|
||||
public modrinth_url: string;
|
||||
public curseforge_url: string;
|
||||
public curseforge_Durl: string;
|
||||
public modrinth_Durl: string;
|
||||
|
||||
constructor() {
|
||||
const config = Config.getConfig();
|
||||
this.modrinth_url = "https://api.modrinth.com";
|
||||
this.curseforge_url = "https://api.curseforge.com";
|
||||
this.modrinth_Durl = "https://cdn.modrinth.com";
|
||||
this.curseforge_Durl = "https://edge.forgecdn.net";
|
||||
if (config.mirror.mcimirror) {
|
||||
this.modrinth_url = "https://mod.mcimirror.top/modrinth";
|
||||
this.curseforge_url = "https://mod.mcimirror.top/curseforge";
|
||||
this.modrinth_Durl = "https://mod.mcimirror.top";
|
||||
this.curseforge_Durl = "https://mod.mcimirror.top";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mavenToUrl(
|
||||
coordinate: { split: (arg0: string) => [any, any, any, any] },
|
||||
base = "maven"
|
||||
) {
|
||||
const [g, a, v, ce] = coordinate.split(":");
|
||||
const [c, e = "jar"] = (ce || "").split("@");
|
||||
return `${base.replace(/\/$/, "")}/${g.replace(
|
||||
/\./g,
|
||||
"/"
|
||||
)}/${a}/${v}/${a}-${v}${c ? "-" + c : ""}.${e}`;
|
||||
}
|
||||
|
||||
export function version_compare(v1: string, v2: string) {
|
||||
const v1_arr = v1.split(".");
|
||||
const v2_arr = v2.split(".");
|
||||
for (let i = 0; i < v1_arr.length; i++) {
|
||||
if (v1_arr[i] !== v2_arr[i]) {
|
||||
return v1_arr[i] > v2_arr[i] ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function checkJava(javaPath?: string): Promise<JavaCheckResult> {
|
||||
try {
|
||||
const javaCmd = javaPath || "java";
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
exec(`${javaCmd} -version`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
logger.error("Java 检查失败", err);
|
||||
reject(new Error("Java not found"));
|
||||
return;
|
||||
}
|
||||
resolve(stderr);
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug(`Java version output: ${output}`);
|
||||
|
||||
const versionRegex = /version "(\d+)(\.(\d+))?(\.(\d+))?/;
|
||||
const vendorRegex = /(Java\(TM\)|OpenJDK).*Runtime Environment.*by (.*)/;
|
||||
|
||||
const versionMatch = output.match(versionRegex);
|
||||
const vendorMatch = output.match(vendorRegex);
|
||||
|
||||
if (!versionMatch) {
|
||||
return {
|
||||
exists: true,
|
||||
error: "解析 Java 版本失败"
|
||||
};
|
||||
}
|
||||
|
||||
const major = parseInt(versionMatch[1], 10);
|
||||
const minor = versionMatch[3] ? parseInt(versionMatch[3], 10) : 0;
|
||||
const patch = versionMatch[5] ? parseInt(versionMatch[5], 10) : 0;
|
||||
|
||||
const versionInfo: JavaVersion = {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
fullVersion: versionMatch[0].replace("version ", ""),
|
||||
vendor: vendorMatch ? vendorMatch[2] : "Unknown"
|
||||
};
|
||||
|
||||
logger.info(`检测到 Java: ${JSON.stringify(versionInfo)}`);
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
version: versionInfo
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Java 检查异常", error as Error);
|
||||
return {
|
||||
exists: false,
|
||||
error: (error as Error).message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectJavaPaths(): Promise<string[]> {
|
||||
const javaPaths: string[] = [];
|
||||
|
||||
const windowsPaths = [
|
||||
"C:\\Program Files\\Java\\",
|
||||
"C:\\Program Files (x86)\\Java\\",
|
||||
"C:\\Program Files\\Eclipse Adoptium\\",
|
||||
"C:\\Program Files\\Eclipse Foundation\\",
|
||||
"C:\\Program Files\\Microsoft\\",
|
||||
"C:\\Program Files\\Amazon Corretto\\",
|
||||
"C:\\Program Files\\BellSoft\\",
|
||||
"C:\\Program Files\\Zulu\\",
|
||||
"C:\\Program Files\\Semeru\\",
|
||||
"C:\\Program Files\\Oracle\\",
|
||||
"C:\\Program Files\\RedHat\\",
|
||||
];
|
||||
|
||||
for (const basePath of windowsPaths) {
|
||||
try {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const versions = fs.readdirSync(basePath);
|
||||
for (const version of versions) {
|
||||
const javaExe = `${basePath}${version}\\bin\\java.exe`;
|
||||
if (fs.existsSync(javaExe)) {
|
||||
javaPaths.push(javaExe);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const pathOutput = await new Promise<string>((resolve, reject) => {
|
||||
exec("where java", (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
|
||||
const wherePaths = pathOutput.split('\n').filter(p => p.trim() !== '');
|
||||
for (const path of wherePaths) {
|
||||
if (!javaPaths.includes(path.trim())) {
|
||||
javaPaths.push(path.trim());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
return [...new Set(javaPaths)];
|
||||
}
|
||||
|
||||
function safeLog(level: 'debug' | 'error', message: string): void {
|
||||
try {
|
||||
if (level === 'debug') {
|
||||
logger.debug(message);
|
||||
} else {
|
||||
logger.error(message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[logger fallback] ${level}: ${message}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function execPromise(cmd: string, options?: SpawnOptions): Promise<number> {
|
||||
safeLog('debug', `执行命令: ${cmd}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, {
|
||||
...options,
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk: unknown) => {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
|
||||
safeLog('debug', text.trim());
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: unknown) => {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
|
||||
safeLog('error', text.trim());
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
safeLog('error', `命令执行错误: ${cmd}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
safeLog('debug', `命令执行完成,退出码: ${code}`);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Command failed with exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateSHA1(filePath: string): string {
|
||||
const hash = crypto.createHash('sha1');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
hash.update(fileBuffer);
|
||||
return hash.digest('hex').toLowerCase();
|
||||
}
|
||||
|
||||
export function verifySHA1(filePath: string, expectedHash: string): boolean {
|
||||
const actualHash = calculateSHA1(filePath);
|
||||
const expectedHashLower = expectedHash.toLowerCase();
|
||||
const isMatch = actualHash === expectedHashLower;
|
||||
|
||||
if (!isMatch) {
|
||||
logger.error(`文件哈希验证失败: ${filePath}`);
|
||||
logger.error(`期望: ${expectedHashLower}`);
|
||||
logger.error(`实际: ${actualHash}`);
|
||||
} else {
|
||||
logger.debug(`文件哈希验证成功: ${filePath} (sha1: ${actualHash})`);
|
||||
}
|
||||
|
||||
return isMatch;
|
||||
}
|
||||
|
||||
interface DownloadOptions {
|
||||
url: string;
|
||||
filePath: string;
|
||||
expectedHash?: string;
|
||||
forceDownload?: boolean;
|
||||
}
|
||||
|
||||
async function chunkedDownload(url: string, filePath: string, chunkSize = 5 * 1024 * 1024, concurrency = 4): Promise<void> {
|
||||
logger.debug(`开始分块下载 ${url},块大小: ${chunkSize / 1024 / 1024}MB,并发数: ${concurrency}`);
|
||||
|
||||
const isBMCLAPI = url.includes('bmclapi2');
|
||||
|
||||
if (isBMCLAPI) {
|
||||
logger.debug(`检测到 BMCLAPI 下载,使用普通下载: ${url}`);
|
||||
const res = await got.get(url, {
|
||||
responseType: "buffer",
|
||||
headers: { "user-agent": "DeEarthX" },
|
||||
followRedirect: true,
|
||||
});
|
||||
fse.outputFileSync(filePath, res.rawBody);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = `${filePath}.chunks`;
|
||||
await fse.ensureDir(tempDir);
|
||||
|
||||
try {
|
||||
const response = await got.head(url, {
|
||||
headers: { "user-agent": "DeEarthX" },
|
||||
followRedirect: true,
|
||||
timeout: { request: 30000 }
|
||||
});
|
||||
|
||||
const fileSize = parseInt(response.headers['content-length'] || '0', 10);
|
||||
const acceptRanges = response.headers['accept-ranges'];
|
||||
|
||||
if (fileSize <= chunkSize || acceptRanges !== 'bytes') {
|
||||
logger.debug(`文件较小或服务器不支持分块下载,使用普通下载: ${url}`);
|
||||
const res = await got.get(url, {
|
||||
responseType: "buffer",
|
||||
headers: { "user-agent": "DeEarthX" },
|
||||
followRedirect: true,
|
||||
});
|
||||
fse.outputFileSync(filePath, res.rawBody);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalChunks = Math.ceil(fileSize / chunkSize);
|
||||
logger.debug(`文件大小: ${(fileSize / 1024 / 1024).toFixed(2)}MB,分 ${totalChunks} 个块下载`);
|
||||
|
||||
let supportsChunkedDownload = true;
|
||||
let currentConcurrency = Math.min(concurrency, totalChunks);
|
||||
let rate429Count = 0;
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const downloadChunk = async (chunkIndex: number): Promise<void> => {
|
||||
const start = chunkIndex * chunkSize;
|
||||
const end = Math.min(start + chunkSize - 1, fileSize - 1);
|
||||
const chunkPath = `${tempDir}/chunk_${chunkIndex}`;
|
||||
|
||||
logger.debug(`下载块 ${chunkIndex + 1}/${totalChunks}: bytes ${start}-${end}`);
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const res = await got.get(url, {
|
||||
responseType: "buffer",
|
||||
headers: {
|
||||
"user-agent": "DeEarthX",
|
||||
"Range": `bytes=${start}-${end}`
|
||||
},
|
||||
followRedirect: true,
|
||||
timeout: { request: 60000 }
|
||||
});
|
||||
|
||||
if (res.statusCode === 206) {
|
||||
fse.writeFileSync(chunkPath, res.rawBody);
|
||||
return;
|
||||
} else if (res.statusCode === 200) {
|
||||
supportsChunkedDownload = false;
|
||||
throw new Error('服务器不支持 Range 请求');
|
||||
} else if (res.statusCode === 429) {
|
||||
rate429Count++;
|
||||
|
||||
const retryAfter = res.headers['retry-after'];
|
||||
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(5000 * Math.pow(2, retryCount), 60000);
|
||||
|
||||
logger.warn(`遇到 429 错误,等待 ${waitTime / 1000} 秒后重试 (${retryCount + 1}/${maxRetries})`);
|
||||
await sleep(waitTime);
|
||||
retryCount++;
|
||||
continue;
|
||||
} else {
|
||||
supportsChunkedDownload = false;
|
||||
throw new Error(`服务器返回状态码 ${res.statusCode},不支持分块下载`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.statusCode === 429) {
|
||||
rate429Count++;
|
||||
|
||||
const retryAfter = error.response.headers['retry-after'];
|
||||
const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(5000 * Math.pow(2, retryCount), 60000);
|
||||
|
||||
logger.warn(`遇到 429 错误,等待 ${waitTime / 1000} 秒后重试 (${retryCount + 1}/${maxRetries})`);
|
||||
await sleep(waitTime);
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.response?.statusCode) {
|
||||
supportsChunkedDownload = false;
|
||||
logger.warn(`Range 请求失败,状态码: ${error.response.statusCode}`);
|
||||
throw new Error(`服务器返回状态码 ${error.response.statusCode},不支持分块下载`);
|
||||
}
|
||||
|
||||
if (error.message.includes('Range') || error.message.includes('不支持')) {
|
||||
supportsChunkedDownload = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`下载块失败,已重试 ${maxRetries} 次`);
|
||||
};
|
||||
|
||||
const chunks = Array.from({ length: totalChunks }, (_, i) => i);
|
||||
|
||||
try {
|
||||
await pMap(chunks, downloadChunk, { concurrency: currentConcurrency });
|
||||
} catch (error: any) {
|
||||
if (!supportsChunkedDownload) {
|
||||
logger.warn(`服务器不支持分块下载,切换到普通下载: ${url}`);
|
||||
await fse.remove(tempDir);
|
||||
const res = await got.get(url, {
|
||||
responseType: "buffer",
|
||||
headers: { "user-agent": "DeEarthX" },
|
||||
followRedirect: true,
|
||||
});
|
||||
fse.outputFileSync(filePath, res.rawBody);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rate429Count > 0) {
|
||||
const newConcurrency = Math.max(1, Math.floor(currentConcurrency / 2));
|
||||
logger.warn(`检测到 ${rate429Count} 次 429 错误,降低并发数从 ${currentConcurrency} 到 ${newConcurrency},重新下载`);
|
||||
|
||||
await fse.remove(tempDir);
|
||||
await fse.ensureDir(tempDir);
|
||||
|
||||
rate429Count = 0;
|
||||
currentConcurrency = newConcurrency;
|
||||
|
||||
await pMap(chunks, downloadChunk, { concurrency: currentConcurrency });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsChunkedDownload) {
|
||||
logger.debug(`所有块下载完成,开始合并文件`);
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunkPath = `${tempDir}/chunk_${i}`;
|
||||
const chunkBuffer = fs.readFileSync(chunkPath);
|
||||
writeStream.write(chunkBuffer);
|
||||
fs.unlinkSync(chunkPath);
|
||||
}
|
||||
|
||||
writeStream.end();
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve));
|
||||
|
||||
logger.debug(`文件合并完成: ${filePath}`);
|
||||
}
|
||||
} finally {
|
||||
await fse.remove(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, filePath: string, expectedHash?: string, forceDownload = false, useChunked = false) {
|
||||
await pRetry(
|
||||
async () => {
|
||||
if (fs.existsSync(filePath) && !forceDownload) {
|
||||
logger.debug(`文件已存在,跳过: ${filePath}`);
|
||||
if (expectedHash && !verifySHA1(filePath, expectedHash)) {
|
||||
logger.warn(`已存在文件哈希不匹配,将重新下载: ${filePath}`);
|
||||
fs.unlinkSync(filePath);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`正在下载 ${url} 到 ${filePath}`);
|
||||
try {
|
||||
await fse.ensureDir(path.dirname(filePath));
|
||||
|
||||
if (useChunked) {
|
||||
await chunkedDownload(url, filePath);
|
||||
} else {
|
||||
const res = await got.get(url, {
|
||||
responseType: "buffer",
|
||||
headers: { "user-agent": "DeEarthX" },
|
||||
followRedirect: true,
|
||||
});
|
||||
fse.outputFileSync(filePath, res.rawBody);
|
||||
}
|
||||
|
||||
logger.debug(`下载 ${url} 成功`);
|
||||
|
||||
if (expectedHash && !verifySHA1(filePath, expectedHash)) {
|
||||
throw new Error(`文件哈希验证失败,下载的文件可能已损坏`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: 3,
|
||||
onFailedAttempt: (error) => {
|
||||
logger.warn(`${url} 下载失败,正在重试 (${error.attemptNumber}/3)`);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface DownloadItem {
|
||||
url: string;
|
||||
filePath: string;
|
||||
expectedHash?: string;
|
||||
}
|
||||
|
||||
export async function fastdownload(data: [string, string] | string[][], enableHashVerify = true) {
|
||||
let downloadList: Array<[string, string, string?]>;
|
||||
|
||||
if (Array.isArray(data[0])) {
|
||||
downloadList = (data as string[][]).map((item): [string, string, string?] =>
|
||||
item.length >= 3 ? [item[0], item[1], item[2]] : [item[0], item[1]]
|
||||
);
|
||||
} else {
|
||||
const singleItem = data as [string, string];
|
||||
downloadList = [[singleItem[0], singleItem[1]]];
|
||||
}
|
||||
|
||||
logger.info(`开始快速下载 ${downloadList.length} 个文件${enableHashVerify ? '(启用 hash 验证)' : ''}`);
|
||||
|
||||
return await pMap(
|
||||
downloadList,
|
||||
async (item: [string, string, string?]) => {
|
||||
const [url, filePath, expectedHash] = item;
|
||||
try {
|
||||
await downloadFile(url, filePath, enableHashVerify ? expectedHash : undefined);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download ${url} after 3 attempts`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ concurrency: 16 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function Wfastdownload(data: string[][], ws: MessageWS, enableHashVerify = true, useChunked = false) {
|
||||
logger.info(`开始 Web 下载 ${data.length} 个文件${enableHashVerify ? '(启用 hash 验证)' : ''}${useChunked ? '(启用分块下载)' : ''}`);
|
||||
const completed = new Set<number>();
|
||||
return await pMap(
|
||||
data,
|
||||
async (item: string[], index: number) => {
|
||||
const [url, filePath, expectedHash] = item;
|
||||
try {
|
||||
await downloadFile(url, filePath, enableHashVerify ? expectedHash : undefined, false, useChunked);
|
||||
if (!completed.has(index)) {
|
||||
completed.add(index);
|
||||
ws.download(data.length, completed.size, filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${url} 下载失败,已重试 3 次`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ concurrency: 24 }
|
||||
);
|
||||
}
|
||||
209
backend/src/utils/ws.ts
Normal file
209
backend/src/utils/ws.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import websocket, { WebSocketServer } from "ws";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export class MessageWS {
|
||||
private ws!: websocket;
|
||||
|
||||
constructor(ws: websocket) {
|
||||
this.ws = ws;
|
||||
|
||||
// 监听WebSocket错误
|
||||
this.ws.on('error', (err) => {
|
||||
logger.error("WebSocket error", err);
|
||||
});
|
||||
|
||||
// 监听连接关闭
|
||||
this.ws.on('close', (code, reason) => {
|
||||
logger.info("WebSocket connection closed", { code, reason: reason.toString() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送完成消息
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
*/
|
||||
finish(startTime: number, endTime: number) {
|
||||
this.send("finish", endTime - startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送解压进度消息
|
||||
* @param entryName 文件名
|
||||
* @param total 总文件数
|
||||
* @param current 当前文件索引
|
||||
*/
|
||||
unzip(entryName: string, total: number, current: number) {
|
||||
this.send("unzip", { name: entryName, total, current });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送下载进度消息
|
||||
* @param total 总文件数
|
||||
* @param index 当前文件索引
|
||||
* @param name 文件名
|
||||
*/
|
||||
download(total: number, index: number, name: string) {
|
||||
this.send("downloading", { total, index, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送状态变更消息
|
||||
*/
|
||||
statusChange() {
|
||||
this.send("changed", undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误消息
|
||||
* @param error 错误对象
|
||||
*/
|
||||
handleError(error: Error) {
|
||||
this.send("error", error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送信息消息
|
||||
* @param message 消息内容
|
||||
*/
|
||||
info(message: string) {
|
||||
this.send("info", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送服务端安装开始消息
|
||||
* @param modpackName 整合包名称
|
||||
* @param minecraftVersion Minecraft版本
|
||||
* @param loaderType 加载器类型
|
||||
* @param loaderVersion 加载器版本
|
||||
*/
|
||||
serverInstallStart(modpackName: string, minecraftVersion: string, loaderType: string, loaderVersion: string) {
|
||||
this.send("server_install_start", {
|
||||
modpackName,
|
||||
minecraftVersion,
|
||||
loaderType,
|
||||
loaderVersion
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送服务端安装步骤消息
|
||||
* @param step 当前步骤名称
|
||||
* @param stepIndex 步骤索引
|
||||
* @param totalSteps 总步骤数
|
||||
* @param message 步骤详情
|
||||
*/
|
||||
serverInstallStep(step: string, stepIndex: number, totalSteps: number, message?: string) {
|
||||
this.send("server_install_step", {
|
||||
step,
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送服务端安装进度消息
|
||||
* @param step 当前步骤
|
||||
* @param progress 进度百分比 (0-100)
|
||||
* @param message 进度详情
|
||||
*/
|
||||
serverInstallProgress(step: string, progress: number, message?: string) {
|
||||
this.send("server_install_progress", {
|
||||
step,
|
||||
progress,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送服务端安装完成消息
|
||||
* @param installPath 安装路径
|
||||
* @param duration 耗时(毫秒)
|
||||
*/
|
||||
serverInstallComplete(installPath: string, duration: number) {
|
||||
this.send("server_install_complete", {
|
||||
installPath,
|
||||
duration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送服务端安装错误消息
|
||||
* @param error 错误信息
|
||||
* @param step 出错的步骤
|
||||
*/
|
||||
serverInstallError(error: string, step?: string) {
|
||||
this.send("server_install_error", {
|
||||
error,
|
||||
step
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送筛选模组开始消息
|
||||
* @param totalMods 总模组数
|
||||
*/
|
||||
filterModsStart(totalMods: number) {
|
||||
this.send("filter_mods_start", {
|
||||
totalMods
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送筛选模组进度消息
|
||||
* @param current 当前处理的模组索引
|
||||
* @param total 总模组数
|
||||
* @param modName 模组名称
|
||||
*/
|
||||
filterModsProgress(current: number, total: number, modName: string) {
|
||||
this.send("filter_mods_progress", {
|
||||
current,
|
||||
total,
|
||||
modName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送筛选模组完成消息
|
||||
* @param filteredCount 筛选出的客户端模组数
|
||||
* @param movedCount 成功移动的数量
|
||||
* @param duration 耗时(毫秒)
|
||||
*/
|
||||
filterModsComplete(filteredCount: number, movedCount: number, duration: number) {
|
||||
this.send("filter_mods_complete", {
|
||||
filteredCount,
|
||||
movedCount,
|
||||
duration
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送筛选模组错误消息
|
||||
* @param error 错误信息
|
||||
*/
|
||||
filterModsError(error: string) {
|
||||
this.send("filter_mods_error", {
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用消息发送方法
|
||||
* @param status 消息状态
|
||||
* @param result 消息内容
|
||||
*/
|
||||
private send(status: string, result: any) {
|
||||
try {
|
||||
if (this.ws.readyState === websocket.OPEN) {
|
||||
const message = JSON.stringify({ status, result });
|
||||
logger.debug("Sending WebSocket message", { status, result });
|
||||
this.ws.send(message);
|
||||
} else {
|
||||
logger.warn(`WebSocket not open, cannot send message: ${status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to send WebSocket message", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
backend/src/utils/yauzl.promise.ts1
Normal file
89
backend/src/utils/yauzl.promise.ts1
Normal file
@@ -0,0 +1,89 @@
|
||||
import yauzl from "yauzl";
|
||||
import Stream from "node:stream"
|
||||
|
||||
export interface IentryP extends yauzl.Entry {
|
||||
openReadStream: Promise<Stream.Readable>;
|
||||
ReadEntry: Promise<Buffer>;
|
||||
}
|
||||
|
||||
export async function yauzl_promise(buffer: Buffer): Promise<IentryP[]>{
|
||||
const zip = await (new Promise((resolve,reject)=>{
|
||||
yauzl.fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err){
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(zipfile);
|
||||
});
|
||||
}) as Promise<yauzl.ZipFile>);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const entries: IentryP[] = [];
|
||||
let entryCount = 0;
|
||||
|
||||
zip.on("entry", (entry: yauzl.Entry) => {
|
||||
// 创建新对象并复制所有entry属性,避免yauzl重用对象导致的引用问题
|
||||
const _entry = Object.assign({}, entry) as IentryP;
|
||||
_entry.openReadStream = _openReadStream(zip, entry);
|
||||
_entry.ReadEntry = _ReadEntry(zip, entry);
|
||||
|
||||
entries.push(_entry);
|
||||
entryCount++;
|
||||
//console.log(entryCount, entry.fileName);
|
||||
// 继续读取下一个条目
|
||||
zip.readEntry();
|
||||
});
|
||||
|
||||
zip.on("end", () => {
|
||||
zip.close();
|
||||
console.log(entryCount, "entries read");
|
||||
if(entryCount === zip.entryCount){
|
||||
console.log("All entries read");
|
||||
resolve(entries);
|
||||
}
|
||||
});
|
||||
|
||||
zip.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 开始读取第一个条目
|
||||
zip.readEntry();
|
||||
});
|
||||
}
|
||||
|
||||
async function _openReadStream(zip: yauzl.ZipFile, entry: yauzl.Entry): Promise<Stream.Readable>{
|
||||
return new Promise((resolve, reject) => {
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _ReadEntry(zip: yauzl.ZipFile, entry: yauzl.Entry): Promise<Buffer>{
|
||||
return new Promise((resolve, reject) => {
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
|
||||
stream.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
88
backend/src/utils/ziplib.ts
Normal file
88
backend/src/utils/ziplib.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import admZip from "adm-zip";
|
||||
import yauzl from "yauzl";
|
||||
import Stream from "node:stream";
|
||||
|
||||
export interface IentryP extends yauzl.Entry {
|
||||
openReadStream: Promise<Stream.Readable>;
|
||||
ReadEntry: Promise<Buffer>;
|
||||
}
|
||||
|
||||
export async function yauzl_promise(buffer: Buffer): Promise<IentryP[]> {
|
||||
const zip = await (new Promise((resolve, reject) => {
|
||||
yauzl.fromBuffer(
|
||||
buffer,
|
||||
/*{lazyEntries:true},*/ (err, zipfile) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(zipfile);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}) as Promise<yauzl.ZipFile>);
|
||||
|
||||
const _ReadEntry = async (
|
||||
zip: yauzl.ZipFile,
|
||||
entry: yauzl.Entry
|
||||
): Promise<Buffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk) => {
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
chunks.push(chunk);
|
||||
} else if (typeof chunk === 'string') {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const _openReadStream = async (
|
||||
zip: yauzl.ZipFile,
|
||||
entry: yauzl.Entry
|
||||
): Promise<Stream.Readable> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: IentryP[] = [];
|
||||
zip.on("entry", async (entry: yauzl.Entry) => {
|
||||
const entryP = entry as IentryP;
|
||||
//console.log(entry.fileName);
|
||||
entryP.openReadStream = _openReadStream(zip, entry);
|
||||
entryP.ReadEntry = _ReadEntry(zip, entry);
|
||||
entries.push(entryP);
|
||||
if (zip.entryCount === entries.length) {
|
||||
zip.close();
|
||||
resolve(entries);
|
||||
}
|
||||
});
|
||||
zip.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function Azip(buffer: Buffer) {
|
||||
const zip = new admZip(buffer);
|
||||
const entries = zip.getEntries();
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user