@zaxlck/modder-installer 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/data/mods.json +114 -0
- package/backend/package-lock.json +987 -0
- package/backend/package.json +23 -0
- package/backend/server.js +476 -0
- package/bin/modder-cli.js +489 -0
- package/bin/modder-format.js +767 -0
- package/bin/modder-launcher.cmd +2 -0
- package/bin/modder.js +72 -0
- package/modder.js +91 -0
- package/package.json +32 -6
- package/scripts/install.ps1 +189 -0
- package/README.md +0 -3
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "backend",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"modder": "modder.js"
|
|
6
|
+
},
|
|
7
|
+
"description": "",
|
|
8
|
+
"main": "server.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
11
|
+
"start": "node server.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"type": "commonjs",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"cors": "^2.8.6",
|
|
19
|
+
"express": "^5.3.0",
|
|
20
|
+
"fflate": "^0.8.3",
|
|
21
|
+
"multer": "^2.5.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const cors = require("cors");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const crypto = require("crypto");
|
|
7
|
+
const multer = require("multer");
|
|
8
|
+
|
|
9
|
+
const app = express();
|
|
10
|
+
const PORT = 3000;
|
|
11
|
+
|
|
12
|
+
app.use(cors());
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
|
|
15
|
+
// Catalogue storage.
|
|
16
|
+
const MODS_FILE = path.join(__dirname, "data", "mods.json");
|
|
17
|
+
|
|
18
|
+
function loadMods() {
|
|
19
|
+
return JSON.parse(fs.readFileSync(MODS_FILE, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveMods(mods) {
|
|
23
|
+
fs.writeFileSync(
|
|
24
|
+
MODS_FILE,
|
|
25
|
+
JSON.stringify(mods, null, 2),
|
|
26
|
+
"utf8"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Upload directories.
|
|
31
|
+
const MOD_UPLOAD_DIR = path.join(__dirname, "uploads", "mods");
|
|
32
|
+
const SCREENSHOT_UPLOAD_DIR = path.join(
|
|
33
|
+
__dirname,
|
|
34
|
+
"uploads",
|
|
35
|
+
"screenshots"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
fs.mkdirSync(MOD_UPLOAD_DIR, { recursive: true });
|
|
39
|
+
fs.mkdirSync(SCREENSHOT_UPLOAD_DIR, { recursive: true });
|
|
40
|
+
|
|
41
|
+
// Generate unique filenames.
|
|
42
|
+
const storage = multer.diskStorage({
|
|
43
|
+
destination: (req, file, callback) => {
|
|
44
|
+
if (file.fieldname === "modFile") {
|
|
45
|
+
callback(null, MOD_UPLOAD_DIR);
|
|
46
|
+
} else {
|
|
47
|
+
callback(null, SCREENSHOT_UPLOAD_DIR);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
filename: (req, file, callback) => {
|
|
52
|
+
const extension = path.extname(file.originalname).toLowerCase();
|
|
53
|
+
callback(null, crypto.randomUUID() + extension);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Check accepted file types.
|
|
58
|
+
function fileFilter(req, file, callback) {
|
|
59
|
+
const extension = path.extname(file.originalname).toLowerCase();
|
|
60
|
+
|
|
61
|
+
if (file.fieldname === "modFile") {
|
|
62
|
+
if (extension === ".geode" || extension === ".zip") {
|
|
63
|
+
return callback(null, true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return callback(
|
|
67
|
+
new Error("Mod files must be .geode or .zip")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (file.fieldname === "screenshots") {
|
|
72
|
+
const allowedImages = [
|
|
73
|
+
".png",
|
|
74
|
+
".jpg",
|
|
75
|
+
".jpeg",
|
|
76
|
+
".webp"
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
if (allowedImages.includes(extension)) {
|
|
80
|
+
return callback(null, true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return callback(
|
|
84
|
+
new Error("Screenshots must be PNG, JPG or WEBP images")
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
callback(new Error("Unexpected upload field"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const upload = multer({
|
|
92
|
+
storage,
|
|
93
|
+
fileFilter,
|
|
94
|
+
limits: {
|
|
95
|
+
fileSize: 50 * 1024 * 1024,
|
|
96
|
+
files: 6
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Only screenshots are publicly accessible.
|
|
101
|
+
// Mod files must go through the protected download endpoint.
|
|
102
|
+
app.use(
|
|
103
|
+
"/uploads/screenshots",
|
|
104
|
+
express.static(SCREENSHOT_UPLOAD_DIR, {
|
|
105
|
+
dotfiles: "deny",
|
|
106
|
+
index: false,
|
|
107
|
+
fallthrough: false
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
// Backend homepage.
|
|
111
|
+
app.get("/", (req, res) => {
|
|
112
|
+
res.send(`
|
|
113
|
+
<!DOCTYPE html>
|
|
114
|
+
<html lang="en">
|
|
115
|
+
<head>
|
|
116
|
+
<meta charset="UTF-8">
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
118
|
+
<title>Modder API</title>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
* {
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
body {
|
|
126
|
+
margin: 0;
|
|
127
|
+
min-height: 100vh;
|
|
128
|
+
display: grid;
|
|
129
|
+
place-items: center;
|
|
130
|
+
padding: 24px;
|
|
131
|
+
background: #07110b;
|
|
132
|
+
color: #f0fff4;
|
|
133
|
+
font-family: Arial, sans-serif;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.container {
|
|
137
|
+
width: 100%;
|
|
138
|
+
max-width: 650px;
|
|
139
|
+
padding: 45px;
|
|
140
|
+
background: #101e15;
|
|
141
|
+
border: 1px solid #294b35;
|
|
142
|
+
border-radius: 22px;
|
|
143
|
+
text-align: center;
|
|
144
|
+
box-shadow: 0 20px 70px #0008;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.logo {
|
|
148
|
+
font-size: 55px;
|
|
149
|
+
margin-bottom: 12px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
h1 {
|
|
153
|
+
font-size: 38px;
|
|
154
|
+
margin: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
h1 span {
|
|
158
|
+
color: #63f5a5;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.subtitle {
|
|
162
|
+
color: #a5b9aa;
|
|
163
|
+
margin: 14px 0 28px;
|
|
164
|
+
line-height: 1.6;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.status {
|
|
168
|
+
display: inline-block;
|
|
169
|
+
padding: 9px 16px;
|
|
170
|
+
background: #153b27;
|
|
171
|
+
color: #63f5a5;
|
|
172
|
+
border-radius: 30px;
|
|
173
|
+
font-size: 14px;
|
|
174
|
+
margin-bottom: 28px;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.links {
|
|
178
|
+
display: grid;
|
|
179
|
+
gap: 12px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
a {
|
|
183
|
+
display: block;
|
|
184
|
+
padding: 15px;
|
|
185
|
+
color: #63f5a5;
|
|
186
|
+
background: #17291d;
|
|
187
|
+
border: 1px solid #31583d;
|
|
188
|
+
border-radius: 12px;
|
|
189
|
+
text-decoration: none;
|
|
190
|
+
transition: 0.2s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
a:hover {
|
|
194
|
+
background: #20432c;
|
|
195
|
+
transform: translateY(-2px);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
footer {
|
|
199
|
+
margin-top: 30px;
|
|
200
|
+
color: #718779;
|
|
201
|
+
font-size: 13px;
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
204
|
+
</head>
|
|
205
|
+
|
|
206
|
+
<body>
|
|
207
|
+
<main class="container">
|
|
208
|
+
<div class="logo">🌴</div>
|
|
209
|
+
|
|
210
|
+
<h1><span>Modder</span> API</h1>
|
|
211
|
+
|
|
212
|
+
<p class="subtitle">
|
|
213
|
+
The backend powering the Modder community.
|
|
214
|
+
Manage mod listings and serve uploaded files.
|
|
215
|
+
</p>
|
|
216
|
+
|
|
217
|
+
<div class="status">
|
|
218
|
+
● All systems operational
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div class="links">
|
|
222
|
+
<a href="/api/health">View API Health</a>
|
|
223
|
+
<a href="/api/mods">Explore Mod Catalogue</a>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<footer>
|
|
227
|
+
Modder API v0.5.0 · Built for the Geometry Dash community
|
|
228
|
+
</footer>
|
|
229
|
+
</main>
|
|
230
|
+
</body>
|
|
231
|
+
</html>
|
|
232
|
+
`);
|
|
233
|
+
});
|
|
234
|
+
// API health check.
|
|
235
|
+
app.get("/api/health", (req, res) => {
|
|
236
|
+
res.json({
|
|
237
|
+
status: "online",
|
|
238
|
+
name: "Modder API",
|
|
239
|
+
version: "0.5.0"
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Get all mod listings.
|
|
244
|
+
app.get("/api/mods", (req, res) => {
|
|
245
|
+
const mods = loadMods();
|
|
246
|
+
|
|
247
|
+
res.json({
|
|
248
|
+
count: mods.length,
|
|
249
|
+
mods
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Get one mod by ID.
|
|
254
|
+
app.get("/api/mods/:id", (req, res) => {
|
|
255
|
+
const mods = loadMods();
|
|
256
|
+
const mod = mods.find(item => item.id === req.params.id);
|
|
257
|
+
|
|
258
|
+
if (!mod) {
|
|
259
|
+
return res.status(404).json({
|
|
260
|
+
error: "Mod not found"
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
res.json(mod);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Download an authorised mod.
|
|
268
|
+
app.get("/api/mods/:id/download", (req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const mods = loadMods();
|
|
271
|
+
const mod = mods.find(item => item.id === req.params.id);
|
|
272
|
+
|
|
273
|
+
if (!mod) {
|
|
274
|
+
return res.status(404).json({
|
|
275
|
+
error: "Mod not found"
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Authorisation is enforced by the backend.
|
|
280
|
+
if (mod.authorised !== true) {
|
|
281
|
+
return res.status(403).json({
|
|
282
|
+
error: "This mod has not been authorised for download."
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
typeof mod.download !== "string" ||
|
|
288
|
+
!mod.download.startsWith("/uploads/mods/")
|
|
289
|
+
) {
|
|
290
|
+
return res.status(404).json({
|
|
291
|
+
error: "Mod file not found."
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Extract only the filename, never trust a supplied filesystem path.
|
|
296
|
+
const filename = path.posix.basename(mod.download);
|
|
297
|
+
|
|
298
|
+
if (
|
|
299
|
+
filename === "." ||
|
|
300
|
+
filename === ".." ||
|
|
301
|
+
!/^[a-f0-9-]+\.(geode|zip)$/i.test(filename)
|
|
302
|
+
) {
|
|
303
|
+
return res.status(400).json({
|
|
304
|
+
error: "Invalid mod file reference."
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const filePath = path.join(MOD_UPLOAD_DIR, filename);
|
|
309
|
+
|
|
310
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
311
|
+
return res.status(404).json({
|
|
312
|
+
error: "Mod file is missing from storage."
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
317
|
+
|
|
318
|
+
res.download(filePath, filename, error => {
|
|
319
|
+
if (error && !res.headersSent) {
|
|
320
|
+
console.error("Mod download error:", error);
|
|
321
|
+
|
|
322
|
+
res.status(500).json({
|
|
323
|
+
error: "Could not download mod."
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error("Protected download error:", error);
|
|
330
|
+
|
|
331
|
+
if (!res.headersSent) {
|
|
332
|
+
res.status(500).json({
|
|
333
|
+
error: "Could not process download."
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
// Submit a new mod with its files.
|
|
341
|
+
app.post("/api/mods", (req, res, next) => {
|
|
342
|
+
upload.fields([
|
|
343
|
+
{ name: "modFile", maxCount: 1 },
|
|
344
|
+
{ name: "screenshots", maxCount: 5 }
|
|
345
|
+
])(req, res, async (error) => {
|
|
346
|
+
if (error) {
|
|
347
|
+
return res.status(400).json({
|
|
348
|
+
error: error.message
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const uploadedFiles = [
|
|
353
|
+
...(req.files?.modFile || []),
|
|
354
|
+
...(req.files?.screenshots || [])
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
function removeUploadedFiles() {
|
|
358
|
+
for (const file of uploadedFiles) {
|
|
359
|
+
fs.unlink(file.path, () => {});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const {
|
|
364
|
+
name,
|
|
365
|
+
category,
|
|
366
|
+
description,
|
|
367
|
+
version,
|
|
368
|
+
compatibility,
|
|
369
|
+
author
|
|
370
|
+
} = req.body;
|
|
371
|
+
|
|
372
|
+
if (!name || !category || !description) {
|
|
373
|
+
removeUploadedFiles();
|
|
374
|
+
|
|
375
|
+
return res.status(400).json({
|
|
376
|
+
error: "Name, category and description are required"
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const modFile = req.files?.modFile?.[0];
|
|
381
|
+
|
|
382
|
+
if (!modFile) {
|
|
383
|
+
removeUploadedFiles();
|
|
384
|
+
|
|
385
|
+
return res.status(400).json({
|
|
386
|
+
error: "Please upload a .geode or .zip mod file"
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const screenshots = req.files?.screenshots || [];
|
|
391
|
+
|
|
392
|
+
// Apply a smaller limit to screenshots.
|
|
393
|
+
if (screenshots.some(file => file.size > 5 * 1024 * 1024)) {
|
|
394
|
+
removeUploadedFiles();
|
|
395
|
+
|
|
396
|
+
return res.status(400).json({
|
|
397
|
+
error: "Each screenshot must be 5 MB or smaller"
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
const mods = loadMods();
|
|
403
|
+
|
|
404
|
+
const newMod = {
|
|
405
|
+
id: crypto.randomUUID(),
|
|
406
|
+
name: name.trim(),
|
|
407
|
+
category: category.trim(),
|
|
408
|
+
description: description.trim(),
|
|
409
|
+
version: version?.trim() || "1.0.0",
|
|
410
|
+
compatibility: compatibility?.trim() || "Not verified",
|
|
411
|
+
author: author?.trim() || "Community",
|
|
412
|
+
download: `/uploads/mods/${modFile.filename}`,
|
|
413
|
+
screenshots: screenshots.map(
|
|
414
|
+
file => `/uploads/screenshots/${file.filename}`
|
|
415
|
+
),
|
|
416
|
+
featured: false,
|
|
417
|
+
authorised: false
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
mods.push(newMod);
|
|
421
|
+
saveMods(mods);
|
|
422
|
+
|
|
423
|
+
res.status(201).json({
|
|
424
|
+
message: "Mod submitted successfully",
|
|
425
|
+
mod: newMod
|
|
426
|
+
});
|
|
427
|
+
} catch (saveError) {
|
|
428
|
+
removeUploadedFiles();
|
|
429
|
+
next(saveError);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Handle upload and server errors.
|
|
435
|
+
app.use((error, req, res, next) => {
|
|
436
|
+
console.error(error);
|
|
437
|
+
|
|
438
|
+
res.status(500).json({
|
|
439
|
+
error: "Something went wrong on the server"
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
// Check Modder Guard connection.
|
|
443
|
+
app.get("/api/guard/status", async (req, res) => {
|
|
444
|
+
try {
|
|
445
|
+
const response = await fetch(
|
|
446
|
+
"http://127.0.0.1:4000/health"
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
return res.status(502).json({
|
|
451
|
+
backend: "online",
|
|
452
|
+
guard: "unavailable"
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const guard = await response.json();
|
|
457
|
+
|
|
458
|
+
res.json({
|
|
459
|
+
backend: "online",
|
|
460
|
+
guard: guard
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
} catch (error) {
|
|
464
|
+
res.status(503).json({
|
|
465
|
+
backend: "online",
|
|
466
|
+
guard: "offline",
|
|
467
|
+
message: "Could not connect to Modder Guard."
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Start the server.
|
|
473
|
+
app.listen(PORT, () => {
|
|
474
|
+
console.log("🌴 Modder API is running!");
|
|
475
|
+
console.log(`Local address: http://localhost:${PORT}`);
|
|
476
|
+
});
|