项目迁移
Some checks failed
CI/CD / Code Check (push) Has been cancelled
CI/CD / Build Windows (push) Has been cancelled

This commit is contained in:
2026-03-14 21:11:59 +08:00
commit 4654f36202
153 changed files with 55923 additions and 0 deletions

14
backend/config1.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mirror": {
"bmclapi": true,
"mcimirror": true
},
"filter": {
"hashes": true,
"dexpub": true,
"mixins": true
},
"oaf": true,
"port": 37019,
"host": "localhost"
}

54
backend/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "dex-v3-core",
"version": "1.0.0",
"description": "",
"license": "MIT",
"author": "Tianpao",
"type": "module",
"main": "dist/bundle.js",
"bin": "dist/bundle.js",
"scripts": {
"test": "set \"DEBUG=true\"&&tsc&&node dist/main.js",
"rollup": "rollup -c rollup.config.js",
"sea": "node --experimental-sea-config sea-config.json",
"sea:build": "node -e \"require('fs').copyFileSync(process.execPath, './dist/core.exe')\" && npx postject ./dist/core.exe NODE_SEA_BLOB ./dist/sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
"build": "npm run rollup && npm run sea && npm run sea:build"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.6",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.4",
"@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.3",
"jest": "^30.2.0",
"postject": "^1.0.0-alpha.6",
"rollup": "^4.50.1",
"ts-jest": "^29.4.6",
"typescript": "^5.9.2"
},
"dependencies": {
"@types/yazl": "^3.3.0",
"adm-zip": "^0.5.16",
"cors": "^2.8.5",
"express": "^5.1.0",
"fs-extra": "^11.3.1",
"got": "^14.4.8",
"multer": "^2.0.2",
"p-map": "^7.0.3",
"p-retry": "^7.0.0",
"picocolors": "^1.1.1",
"smol-toml": "^1.6.0",
"ws": "^8.18.3",
"yauzl": "^3.2.0",
"yazl": "^3.3.1"
}
}

45
backend/rollup.config.js Normal file
View File

@@ -0,0 +1,45 @@
import typescript from '@rollup/plugin-typescript'
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs'
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
export default {
input: 'src/main.ts',
output: {
file: 'dist/bundle.js',
format: 'cjs',
inlineDynamicImports: true,
sourcemap: false
},
plugins: [
typescript({
tsconfig: './tsconfig.json',
module: 'Node16',
compilerOptions: {
module: 'Node16'
}
}),
resolve({
preferBuiltins: true,
browser: false,
extensions: ['.ts', '.js', '.json'],
dedupe: ['tslib']
}),
commonjs({
transformMixedEsModules: true
}),
json(),
terser({
compress: true,
mangle: false
})
],
onwarn: (warning, warn) => {
if (warning.code === 'CIRCULAR_DEPENDENCY') return;
if (warning.code === 'THIS_IS_UNDEFINED') return;
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return;
if (warning.code === 'UNRESOLVED_IMPORT') return;
warn(warning);
}
};

7
backend/sea-config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"main": "./dist/bundle.js",
"output": "./dist/sea-prep.blob",
"disableExperimentalSEAWarning": true,
"useSnapshot": false,
"useCodeCache": true
}

365
backend/src/Dex.ts Normal file
View 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
View 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);
});
}
}

View 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;
}
}

View 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;
}
}

View 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";

View 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;
}
}

View 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 [];
}
}
}

View 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)];
}
}

View 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;
}
}

View 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
View 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;
}

View 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"));
}
}

View 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
View 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
View 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 服务端

View 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}`;
}
}
}

View 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}`;
}
}
}

View 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`);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}
}

View 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
View 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;
}
}

View 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;
}
}

View 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
View 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
View 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);
}
}
}

View 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);
});
});
});
}

View 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;
}

110
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,110 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "Node16", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
},"include": ["src/**/*"]
}