Skip to content

Commit e22be08

Browse files
committed
Provide Endpoint to receive Server Mod Information
Added two new Endpoints: - `/mods/list` to get a list of all required mods to play on the server. - `/mods/download` to download the mod files from the server. Provides a `.zip` file with the generated output. Added two new configuration entries: - `mod.dirPath`: The path to the servers mod directory - `mod.configDirPath` : The path to the server mod configuration directory Added two new environment variables: - `MOD_DIR_PATH`: The same as the config `mod.dirPath` - `MOD_CONFIG_DIR_PATH`: The same as the config `mod.configDirPath` **About mod evaluation**: The `server-client` mods get evaluated on the server side and the generated output will be stored in a `os.tempdir()`. This would be `/temp` on Linux and `%temp%` on Windows.
1 parent 41c84ab commit e22be08

File tree

6 files changed

+152
-16
lines changed

6 files changed

+152
-16
lines changed

config.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@
1414
"host": "127.0.0.1",
1515
"port": 6380
1616
},
17-
"postgres": "postgres://viper:@127.0.0.1:5434/viper"
17+
"postgres": "postgres://viper:@127.0.0.1:5434/viper",
18+
"mod": {
19+
"dirPath": "mods",
20+
"configDirPath": "configs"
21+
}
1822
}

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"express": "^4.18.2",
2525
"http-proxy": "^1.18.1",
2626
"http-proxy-middleware": "^2.0.6",
27+
"jszip": "^3.10.1",
28+
"knockoutcity-mod-loader": "^1.0.0-alpha.15",
2729
"prisma": "^5.4.1",
2830
"redis": "^4.6.7"
2931
},

src/config.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,9 @@ export default {
3636
port: process.env.REDIS_PORT || config.redis.port,
3737
password: process.env.REDIS_PASSWORD || config.redis.password || undefined,
3838
},
39-
postgres: process.env.DATABASE_URL || config.postgres
39+
postgres: process.env.DATABASE_URL || config.postgres,
40+
mod: {
41+
dirPath: process.env.MOD_DIR_PATH || config.mod.dirPath,
42+
configDirPath: process.env.MOD_CONFIG_DIR_PATH || config.mod.configDirPath,
43+
},
4044
} satisfies config

src/index.ts

+71-14
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ import { PrismaClient } from '@prisma/client'
88
import Logger from './logger.js'
99

1010
import { authError, authResponse, authErrorData } from './interfaces'
11+
import { EvaluationResult, ModEvaluator, ModLoader, OutGenerator } from 'knockoutcity-mod-loader';
12+
import { createZipFromFolder } from './ziputil';
13+
14+
import path from 'node:path';
15+
import os from 'node:os';
1116

1217
const log = new Logger();
1318

14-
if(config.name == "ServerName") log.warn("Please change the name in the config.json or via the environment (SERVER_NAME)");
19+
if (config.name == "ServerName") log.warn("Please change the name in the config.json or via the environment (SERVER_NAME)");
1520

1621
const redis = createClient({
1722
socket: {
@@ -59,18 +64,70 @@ app.get('/stats/status', async (req, res) => {
5964
});
6065
});
6166

67+
const modLoader = new ModLoader({
68+
modDir: config.mod.dirPath,
69+
});
70+
71+
const createModZip = async (modPath: string): Promise<Buffer> => {
72+
const zip = await createZipFromFolder(modPath);
73+
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
74+
};
75+
76+
app.get('/mods/list', async (req, res) => {
77+
const mods = modLoader.loadModManifests();
78+
79+
return res.json(
80+
await Promise.all(
81+
mods
82+
.filter((mod) => mod.manifest.type === 'server-client')
83+
.map(async (mod) => ({
84+
name: mod.manifest.name,
85+
version: mod.manifest.version,
86+
}))
87+
)
88+
);
89+
});
90+
91+
app.get('/mods/download', async (req, res) => {
92+
const mods = modLoader.loadMods();
93+
const clientServerMods = mods.filter((mod) => mod.manifest.type === 'server-client');
94+
95+
if (clientServerMods.length === 0) {
96+
return res.status(400).send();
97+
}
98+
99+
const evaluationResults: EvaluationResult[] = [];
100+
for (const mod of clientServerMods) {
101+
const modEvaluator = new ModEvaluator(mod, { modsConfigDir: config.mod.configDirPath });
102+
evaluationResults.push(await modEvaluator.evaulate())
103+
}
104+
105+
const tempDirPath = path.join(os.tmpdir(), 'generated-mod-output');
106+
const outGenerator = new OutGenerator({ baseDir: tempDirPath});
107+
await outGenerator.generate(evaluationResults);
108+
109+
const zipBuffer = await createModZip(tempDirPath);
110+
return (
111+
res
112+
.header('Content-Disposition', `attachment; filename="mods.zip"`)
113+
.contentType('application/zip')
114+
.send(zipBuffer)
115+
);
116+
});
117+
118+
62119
app.use(async (req: express.Request, res: express.Response, next: express.NextFunction) => {
63120
log.info(`Request from ${req.ip} to ${req.url}`);
64121
res.set('X-Powered-By', 'KoCity Proxy');
65122

66-
if(!req.body.credentials) {
123+
if (!req.body.credentials) {
67124
log.info("No credentials");
68125
return next();
69126
}
70127

71128
const authkey = req.body.credentials.username
72129

73-
if(!authkey) {
130+
if (!authkey) {
74131
log.info("Invalid credentials");
75132
return res.status(401).send("Invalid credentials");
76133
}
@@ -80,27 +137,27 @@ app.use(async (req: express.Request, res: express.Response, next: express.NextFu
80137
server: config.publicAddr
81138
}).catch((err: authError): null => {
82139
res.status(401).send("Unauthorized");
83-
if(err.response) log.err(`${(err.response.data as authErrorData).type} ${(err.response.data as authErrorData).message}`);
140+
if (err.response) log.err(`${(err.response.data as authErrorData).type} ${(err.response.data as authErrorData).message}`);
84141
else log.err(err.message);
85142
return null;
86143
});
87144

88-
if(!response) return log.info("Request denied");
145+
if (!response) return log.info("Request denied");
89146

90-
if(!response.data?.username) {
147+
if (!response.data?.username) {
91148
log.info("Request denied");
92149
return res.status(401).send("Unauthorized");
93150
}
94151

95-
if(!response.data.velanID) {
152+
if (!response.data.velanID) {
96153
let localUser = await prisma.users.findFirst({
97154
where: {
98155
username: response.data.username,
99156
}
100157
});
101-
158+
102159
let velanID: number | undefined;
103-
if(!localUser) {
160+
if (!localUser) {
104161
const createdUser = await axios.post(`http://${config.internal.host}:${config.internal.port}/api/auth`, {
105162
credentials: {
106163
username: response.data.username,
@@ -124,17 +181,17 @@ app.use(async (req: express.Request, res: express.Response, next: express.NextFu
124181
velanID
125182
}).catch((err: authError): null => {
126183
res.status(401).send("Unauthorized");
127-
if(err.response) log.err(`${(err.response.data as authErrorData).type} ${(err.response.data as authErrorData).message}`);
184+
if (err.response) log.err(`${(err.response.data as authErrorData).type} ${(err.response.data as authErrorData).message}`);
128185
else log.err(err.message);
129186
return null;
130187
});
131-
if(!saved) return log.info("Request denied");
188+
if (!saved) return log.info("Request denied");
132189

133190
response.data.velanID = velanID;
134191
}
135-
if(!response.data.velanID) return log.info("Request denied");
192+
if (!response.data.velanID) return log.info("Request denied");
136193

137-
await prisma.users.update({
194+
await prisma.users.update({
138195
where: {
139196
id: Number(response.data.velanID)
140197
},
@@ -148,7 +205,7 @@ app.use(async (req: express.Request, res: express.Response, next: express.NextFu
148205
req.body.credentials.username = `${response.data.color ? `:${response.data.color}FF:` : ''}${response.data.username}`
149206
req.headers['content-length'] = Buffer.byteLength(JSON.stringify(req.body)).toString();
150207
next();
151-
})
208+
})
152209

153210
const proxy = createProxyMiddleware({
154211
target: `http://${config.internal.host}:${config.internal.port}`,

src/interfaces.ts

+6
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ export interface config {
3232
password?: string,
3333
},
3434
postgres: string,
35+
mod: {
36+
/** The path to the mods directory */
37+
dirPath: string,
38+
/** The path to the mods configuration directory */
39+
configDirPath: string,
40+
},
3541
}
3642

3743

src/ziputil.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Source: https://github.com/Stuk/jszip/issues/386
2+
3+
import fs from 'node:fs';
4+
import fsp from 'node:fs/promises';
5+
import path from 'node:path';
6+
import JSZip from 'jszip';
7+
8+
/**
9+
* Compresses a folder to the specified zip file.
10+
* @param {string} srcDir
11+
* @param {string} destFile
12+
*/
13+
export const compressFolder = async (srcDir: string, destFile: string): Promise<void> => {
14+
//node write stream wants dest dir to already be created
15+
await fsp.mkdir(path.dirname(destFile), { recursive: true });
16+
17+
const zip = await createZipFromFolder(srcDir);
18+
19+
return new Promise((resolve, reject) => {
20+
zip
21+
.generateNodeStream({ streamFiles: true, compression: 'DEFLATE' })
22+
.pipe(fs.createWriteStream(destFile))
23+
.on('error', (err) => reject(err))
24+
.on('finish', resolve);
25+
});
26+
};
27+
28+
/**
29+
* Returns a flat list of all files and subfolders for a directory (recursively).
30+
* @param {string} dir
31+
* @returns {Promise<string[]>}
32+
*/
33+
const getFilePathsRecursively = async (dir: string): Promise<string[]> => {
34+
// returns a flat array of absolute paths of all files recursively contained in the dir
35+
const list = await fsp.readdir(dir);
36+
const statPromises = list.map(async (file) => {
37+
const fullPath = path.resolve(dir, file);
38+
const stat = await fsp.stat(fullPath);
39+
if (stat && stat.isDirectory()) {
40+
return getFilePathsRecursively(fullPath);
41+
}
42+
return fullPath;
43+
});
44+
45+
// cast to string[] is ts hack
46+
// see: https://github.com/microsoft/TypeScript/issues/36554
47+
return (await Promise.all(statPromises)).flat(Number.POSITIVE_INFINITY) as string[];
48+
};
49+
50+
/**
51+
* Creates an in-memory zip stream from a folder in the file system
52+
* @param {string} dir
53+
* @returns {Promise<JSZip>}
54+
*/
55+
export const createZipFromFolder = async (dir: string): Promise<JSZip> => {
56+
const filePaths = await getFilePathsRecursively(dir);
57+
return filePaths.reduce((z, filePath) => {
58+
const relative = path.relative(dir, filePath);
59+
return z.file(relative, fs.createReadStream(filePath), {
60+
unixPermissions: '777', //you probably want less permissive permissions
61+
});
62+
}, new JSZip());
63+
};

0 commit comments

Comments
 (0)