feat:星系广场的彻底实现

This commit is contained in:
Tianpao
2026-02-02 23:05:20 +08:00
parent b8b09fdb99
commit 45b28b3b34
9 changed files with 422 additions and 17 deletions

3
.gitignore vendored
View File

@@ -28,4 +28,5 @@ core-x86_64-pc-windows-msvc.exe
config.json
*.exe
*.jar
instance
instance
*.mrpack

View File

@@ -7,7 +7,7 @@
"type": "module",
"main": "main.js",
"scripts": {
"test": "tsc&&node dist/main.js",
"test": "set \"DEBUG=true\"&&tsc&&node dist/main.js",
"rollup": "rollup -c rollup.config.js",
"nexe": "nexe -i ./dist/bundle.js --build -t x86-22.13.0 --output ./dist/core.exe",
"build": "npm run rollup && npm run nexe"

View File

@@ -80,7 +80,7 @@ export class Dex {
}
private async _processModpack(buffer: Buffer, filename?: string): Promise<Buffer> {
if (!filename || (!filename.endsWith('.zip') && !filename.endsWith('.mrpack'))) {
if (!filename || !filename.endsWith('.zip')) {
return buffer;
}
@@ -94,7 +94,7 @@ export class Dex {
resolve(zipfile);
});
}) as Promise<yauzl.ZipFile>);
logger.info("Modpack zip file detected,It is a PCL packege,try to extract modpack.mrpack");
return new Promise((resolve, reject) => {
let mrpackBuffer: Buffer | null = null;
let hasProcessed = false;

View File

@@ -7,6 +7,7 @@ import { Config, IConfig } from "./utils/config.js";
import { Dex } from "./Dex.js";
import { logger } from "./utils/logger.js";
import { checkJava, JavaCheckResult } from "./utils/utils.js";
import { Galaxy } from "./galaxy.js";
export class Core {
private config: IConfig;
private readonly app: Application;
@@ -16,16 +17,18 @@ export class Core {
private readonly upload: multer.Multer;
private task: {} = {};
dex: Dex;
galaxy: Galaxy;
constructor(config: IConfig) {
this.config = config
this.app = express();
this.server = createServer(this.app);
this.upload = multer()
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()
this.upload = multer();
}
private async javachecker() {
@@ -129,6 +132,8 @@ export class Core {
res.status(500).json({ status: 500, message: "Failed to update config" });
}
});
this.app.use("/galaxy", this.galaxy.getRouter());
}
public async start() {

84
backend/src/galaxy.ts Normal file
View File

@@ -0,0 +1,84 @@
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()
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: "No file uploaded" });
return;
}
const modids = this.getModids(files);
logger.info("Uploaded modids", 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: "Invalid type" });
return;
}
const modid = req.body.modids as string;
if(!modid){
res.status(400).json({ status: 400, message: "No modid provided" });
return;
}
this.got.post(`api/mod/submit/${type}`,{
json: {
modid,
}
}).then((response)=>{
logger.info(`Submitted modids for ${type}`, response.body);
res.json(response.body).end();
}).catch((error)=>{
logger.error(`Failed to submit modids for ${type}`, error);
res.status(500).json({ status: 500, message: "Failed to submit modids" });
})
})
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
}
}

View File

@@ -1,20 +1,27 @@
import fs from "node:fs";
import crypto from "node:crypto";
import { Azip } from "./ziplib.js";
import got from "got";
import got, { Got } from "got";
import { Utils } from "./utils.js";
import config from "./config.js";
import { logger } from "./logger.js";
import toml from "smol-toml";
interface IMixinFile {
name: string;
data: string;
}
interface IInfoFile {
name: string;
data: string;
}
interface IFileInfo {
filename: string;
hash: string;
mixins: IMixinFile[];
infos: IInfoFile[];
}
interface IHashResponse {
@@ -27,23 +34,36 @@ interface IProjectInfo {
server_side: string;
}
interface checkDexpubForClientMods {
serverMods: string[];
clientMods: string[];
}
export class DeEarth {
private movePath: string;
private modsPath: string;
private files: IFileInfo[];
private utils: Utils;
private got: Got;
constructor(modsPath: string, movePath: string) {
this.utils = new Utils();
this.movePath = movePath;
this.modsPath = modsPath;
this.files = [];
this.got = got.extend({
prefixUrl: "https://galaxy.tianpao.top/",
headers: {
"User-Agent": "DeEarthX",
},
responseType: "json",
});
logger.debug("DeEarth instance created", { modsPath, movePath });
}
async Main(): Promise<void> {
logger.info("Starting DeEarth process");
if (!fs.existsSync(this.movePath)) {
logger.debug("Creating target directory", { path: this.movePath });
fs.mkdirSync(this.movePath, { recursive: true });
@@ -52,11 +72,11 @@ export class DeEarth {
await this.getFilesInfo();
const clientSideMods = await this.identifyClientSideMods();
await this.moveClientSideMods(clientSideMods);
logger.info("DeEarth process completed");
}
private async identifyClientSideMods(): Promise<string[]> {
private async identifyClientSideMods(): Promise<string[]> { // 识别客户端Mod主函数
const clientMods: string[] = [];
if (config.filter.hashes) {
@@ -69,11 +89,67 @@ export class DeEarth {
clientMods.push(...await this.checkMixinsForClientMods());
}
if (config.filter.dexpub) {
logger.info("Starting dexpub check for client-side mods");
const dexpubMods = await this.checkDexpubForClientMods();
clientMods.push(...dexpubMods.clientMods);
const serverModsListSet = new Set(dexpubMods.serverMods);
for(let i=0;i>=clientMods.length - 1;i--){
if (serverModsListSet.has(clientMods[i])){
clientMods.splice(i,1);
}
}
logger.info("Dexpub check completed", { serverMods: dexpubMods.serverMods, clientMods: dexpubMods.clientMods });
}
const uniqueMods = [...new Set(clientMods)];
logger.info("Client-side mods identified", { count: uniqueMods.length, mods: uniqueMods });
return uniqueMods;
}
private async checkDexpubForClientMods(): Promise<checkDexpubForClientMods> {
const clientMods: string[] = [];
const serverMods: string[] = [];
const modIds: string[] = [];
const map: Map<string, string> = new Map();
for (const file of this.files) {
for (const info of file.infos) {
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);
}
}
}
const modids = modIds;
const modIdToIsTypeMod = await this.got.post(`api/mod/check`,{
json: {
modids,
}
}).json<{[modId: string]: boolean}>()
const modIdToIsTypeModKeys = Object.keys(modIdToIsTypeMod);
for(const modId of modIdToIsTypeModKeys){
if(modIdToIsTypeMod[modId]){
const MapData = map.get(modId);
if(MapData){
clientMods.push(MapData);
}
}else{
const MapData = map.get(modId);
if(MapData){
serverMods.push(MapData);
}
}
}
logger.info("Galaxy check client-side mods", { count: clientMods.length, mods: clientMods });
return { serverMods, clientMods };
}
private async checkHashesForClientMods(): Promise<string[]> {
const hashToFilename = new Map<string, string>();
const hashes = this.files.map(file => {
@@ -164,17 +240,19 @@ export class DeEarth {
for (const jarFilename of jarFiles) {
const fullPath = `${this.modsPath}/${jarFilename}`;
try {
const fileData = fs.readFileSync(fullPath);
const mixins = await this.extractMixins(fileData);
const infos = await this.extractModInfo(fileData);
this.files.push({
filename: fullPath,
hash: crypto.createHash('sha1').update(fileData).digest('hex'),
mixins
mixins,
infos,
});
logger.debug("File processed", { filename: fullPath, mixinCount: mixins.length });
} catch (error: any) {
logger.error("Error processing file", { filename: fullPath, error: error.message });
@@ -189,6 +267,27 @@ export class DeEarth {
return fs.readdirSync(this.modsPath).filter(f => f.endsWith(".jar"));
}
private async extractModInfo(jarData: Buffer): Promise<IInfoFile[]> {
const infos: IInfoFile[] = [];
const zipEntries = Azip(jarData);
await Promise.all(zipEntries.map(async (entry) => {
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) {
logger.error(`Error extracting ${entry.entryName}`, error);
}
}
)
)
return infos;
}
private async extractMixins(jarData: Buffer): Promise<IMixinFile[]> {
const mixins: IMixinFile[] = [];
const zipEntries = Azip(jarData);

View File

@@ -26,7 +26,7 @@ const DEFAULT_CONFIG: IConfig = {
},
filter: {
hashes: true,
dexpub: false,
dexpub: true,
mixins: true,
},
oaf: true

View File

@@ -11,9 +11,225 @@
</h1>
<p class="tw:text-gray-500 tw:mt-2">让所有的模组都在这里发光</p>
</div>
<!-- 模组提交 -->
<div class="tw:bg-white tw:rounded-lg tw:shadow-sm tw:p-6 tw:mb-6">
<h2 class="tw:text-lg tw:font-semibold tw:text-gray-800 tw:mb-4 tw:flex tw:items-center tw:gap-2">
<span class="tw:w-2 tw:h-2 tw:bg-purple-500 tw:rounded-full"></span>
模组提交
</h2>
<div class="tw:flex tw:flex-col tw:gap-4">
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
模组类型
</label>
<a-radio-group v-model:value="modType" size="default" button-style="solid">
<a-radio-button value="client">客户端模组</a-radio-button>
<a-radio-button value="server">服务端模组</a-radio-button>
</a-radio-group>
</div>
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
Modid
</label>
<a-input
v-model:value="modidInput"
placeholder="请输入 Modid多个用逗号分隔或上传文件自动获取"
size="large"
allow-clear
/>
<p class="tw:text-xs tw:text-gray-400 tw:mt-1">
当前已添加 {{ modidList.length }} Modid
</p>
</div>
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
上传文件
</label>
<a-upload-dragger
:fileList="fileList"
:before-upload="beforeUpload"
@remove="handleRemove"
accept=".jar"
multiple
>
<p class="tw-ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="tw-ant-upload-text">点击或拖拽文件到此区域上传</p>
<p class="tw-ant-upload-hint">
支持 .jar 格式文件可多选
</p>
</a-upload-dragger>
<div v-if="fileList.length > 0" class="tw:mt-4">
<p class="tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
已选择 {{ fileList.length }} 个文件
</p>
<a-button
type="primary"
size="large"
:loading="uploading"
block
@click="handleUpload"
>
<template #icon>
<UploadOutlined />
</template>
{{ uploading ? '上传中...' : '开始上传' }}
</a-button>
</div>
</div>
<div v-if="modidList.length > 0" class="tw:mt-2">
<a-button
type="primary"
size="large"
:loading="submitting"
block
@click="handleSubmit"
>
<template #icon>
<SendOutlined />
</template>
{{ submitting ? '提交中...' : `提交${modType === 'client' ? '客户端' : '服务端'}模组` }}
</a-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
</script>
import { ref, computed } from 'vue';
import { UploadOutlined, InboxOutlined, SendOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import type { UploadFile, UploadProps } from 'ant-design-vue';
const modType = ref<'client' | 'server'>('client');
const modidList = ref<string[]>([]);
const uploading = ref(false);
const submitting = ref(false);
const fileList = ref<UploadFile[]>([]);
const modidInput = computed({
get: () => modidList.value.join(','),
set: (value: string) => {
modidList.value = value
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
}
});
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
console.log(file.name);
const uploadFile: UploadFile = {
uid: `${Date.now()}-${Math.random()}`,
name: file.name,
status: 'done',
url: '',
originFileObj: file,
};
fileList.value = [...fileList.value, uploadFile];
return false;
};
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.indexOf(file);
const newFileList = fileList.value.slice();
newFileList.splice(index, 1);
fileList.value = newFileList;
};
const handleUpload = async () => {
if (fileList.value.length === 0) {
message.warning('请先选择文件');
return;
}
uploading.value = true;
const formData = new FormData();
fileList.value.forEach((file) => {
if (file.originFileObj) {
const blob = file.originFileObj;
const encodedFileName = encodeURIComponent(file.name);
const fileWithCorrectName = new File([blob], encodedFileName, { type: blob.type });
formData.append('files', fileWithCorrectName);
}
});
try {
const response = await fetch('http://localhost:37019/galaxy/upload', {
method: 'POST',
body: formData,
});
if (response.ok) {
const data = await response.json();
console.log(data);
if (data.modids && Array.isArray(data.modids)) {
let addedCount = 0;
data.modids.forEach((modid: string) => {
if (modid && !modidList.value.includes(modid)) {
modidList.value.push(modid);
addedCount++;
}
});
message.success(`成功上传 ${addedCount} 个文件`);
} else {
message.error('返回数据格式错误');
}
} else {
message.error('上传失败');
}
} catch (error) {
message.error('上传出错,请重试');
} finally {
uploading.value = false;
fileList.value = [];
}
};
const handleSubmit = () => {
Modal.confirm({
title: '确认提交',
content: `确定要提交 ${modidList.value.length}${modType.value === 'client' ? '客户端' : '服务端'}模组吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
submitting.value = true;
try {
const apiUrl = modType.value === 'client'
? 'http://localhost:37019/galaxy/submit/client'
: 'http://localhost:37019/galaxy/submit/server';
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
modids: modidList.value,
}),
});
if (response.ok) {
message.success(`${modType.value === 'client' ? '客户端' : '服务端'}模组提交成功`);
modidList.value = [];
} else {
message.error('提交失败');
}
} catch (error) {
message.error('提交出错,请重试');
} finally {
submitting.value = false;
}
},
});
};
</script>

View File

@@ -60,8 +60,8 @@ const settings: SettingCategory[] = [
},
{
key: 'dexpub',
name: 'DePIS过滤',
description: '过滤 DeEarth Public Info Services 平台中记录的客户端文件',
name: 'Galaxy Square 过滤',
description: '过滤 Galaxy Square 平台中记录的客户端文件',
path: 'filter.dexpub',
defaultValue: false
},