Phase5
This commit is contained in:
1
.turbo/cache/115c734a9a85e4b8-manifest.json
vendored
Normal file
1
.turbo/cache/115c734a9a85e4b8-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782470291579443200,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/115c734a9a85e4b8-meta.json
vendored
Normal file
1
.turbo/cache/115c734a9a85e4b8-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"115c734a9a85e4b8","duration":1265,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/115c734a9a85e4b8.tar.zst
vendored
Normal file
BIN
.turbo/cache/115c734a9a85e4b8.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/3adaaf4e70c65a9b-manifest.json
vendored
Normal file
1
.turbo/cache/3adaaf4e70c65a9b-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/3adaaf4e70c65a9b-meta.json
vendored
Normal file
1
.turbo/cache/3adaaf4e70c65a9b-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"3adaaf4e70c65a9b","duration":10643,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}
|
||||||
BIN
.turbo/cache/3adaaf4e70c65a9b.tar.zst
vendored
Normal file
BIN
.turbo/cache/3adaaf4e70c65a9b.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/3bfd370d7035ee42-manifest.json
vendored
Normal file
1
.turbo/cache/3bfd370d7035ee42-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/3bfd370d7035ee42-meta.json
vendored
Normal file
1
.turbo/cache/3bfd370d7035ee42-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"3bfd370d7035ee42","duration":1928,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/3bfd370d7035ee42.tar.zst
vendored
Normal file
BIN
.turbo/cache/3bfd370d7035ee42.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/3f1b3202485dbf64-manifest.json
vendored
Normal file
1
.turbo/cache/3f1b3202485dbf64-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/3f1b3202485dbf64-meta.json
vendored
Normal file
1
.turbo/cache/3f1b3202485dbf64-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"3f1b3202485dbf64","duration":4880,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}
|
||||||
BIN
.turbo/cache/3f1b3202485dbf64.tar.zst
vendored
Normal file
BIN
.turbo/cache/3f1b3202485dbf64.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/471f245a242db3dc-manifest.json
vendored
Normal file
1
.turbo/cache/471f245a242db3dc-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/471f245a242db3dc-meta.json
vendored
Normal file
1
.turbo/cache/471f245a242db3dc-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"471f245a242db3dc","duration":2980,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}
|
||||||
BIN
.turbo/cache/471f245a242db3dc.tar.zst
vendored
Normal file
BIN
.turbo/cache/471f245a242db3dc.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/5aa5da011660c2c9-manifest.json
vendored
Normal file
1
.turbo/cache/5aa5da011660c2c9-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/catalog/dist/compatibility.test.d.ts.map":{"size":130,"mtime_nanos":1782470266408191300,"mode":420,"is_dir":false},"packages/catalog/.turbo/turbo-build.log":{"size":107,"mtime_nanos":1782470266511030600,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.js":{"size":1955,"mtime_nanos":1782470266376086200,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.d.ts.map":{"size":1722,"mtime_nanos":1782470266362014300,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.js.map":{"size":1968,"mtime_nanos":1782470266404180100,"mode":420,"is_dir":false},"packages/catalog/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/catalog/dist/index.d.ts":{"size":444,"mtime_nanos":1782470266422648100,"mode":420,"is_dir":false},"packages/catalog/dist/index.js.map":{"size":343,"mtime_nanos":1782470266419299000,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.js.map":{"size":1972,"mtime_nanos":1782470266342638500,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.js.map":{"size":1649,"mtime_nanos":1782470266374304200,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.js":{"size":2546,"mtime_nanos":1782470266406175000,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.d.ts":{"size":645,"mtime_nanos":1782470266385958300,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.d.ts":{"size":59,"mtime_nanos":1782470266409181500,"mode":420,"is_dir":false},"packages/catalog/dist/index.js":{"size":1772,"mtime_nanos":1782470266420191800,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.d.ts":{"size":1701,"mtime_nanos":1782470266365023500,"mode":420,"is_dir":false},"packages/catalog/dist/index.d.ts.map":{"size":383,"mtime_nanos":1782470266422289400,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.d.ts.map":{"size":570,"mtime_nanos":1782470266385164500,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.js":{"size":2292,"mtime_nanos":1782470266344300200,"mode":420,"is_dir":false}},"order":["packages/catalog/.turbo/turbo-build.log","packages/catalog/dist","packages/catalog/dist/compatibility.d.ts","packages/catalog/dist/compatibility.d.ts.map","packages/catalog/dist/compatibility.js","packages/catalog/dist/compatibility.js.map","packages/catalog/dist/compatibility.test.d.ts","packages/catalog/dist/compatibility.test.d.ts.map","packages/catalog/dist/compatibility.test.js","packages/catalog/dist/compatibility.test.js.map","packages/catalog/dist/index.d.ts","packages/catalog/dist/index.d.ts.map","packages/catalog/dist/index.js","packages/catalog/dist/index.js.map","packages/catalog/dist/modrinth.d.ts","packages/catalog/dist/modrinth.d.ts.map","packages/catalog/dist/modrinth.js","packages/catalog/dist/modrinth.js.map"]}
|
||||||
1
.turbo/cache/5aa5da011660c2c9-meta.json
vendored
Normal file
1
.turbo/cache/5aa5da011660c2c9-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"5aa5da011660c2c9","duration":3151,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}
|
||||||
BIN
.turbo/cache/5aa5da011660c2c9.tar.zst
vendored
Normal file
BIN
.turbo/cache/5aa5da011660c2c9.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/7a132a51c00654b1-manifest.json
vendored
Normal file
1
.turbo/cache/7a132a51c00654b1-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/api/.turbo/turbo-test.log":{"size":3093,"mtime_nanos":1782470641215759300,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/7a132a51c00654b1-meta.json
vendored
Normal file
1
.turbo/cache/7a132a51c00654b1-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"7a132a51c00654b1","duration":9687,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"87eb3cd8cd7934b9c17d7dbfccea73bbc10df425becef0ce1b07d1250f19ec30"}
|
||||||
BIN
.turbo/cache/7a132a51c00654b1.tar.zst
vendored
Normal file
BIN
.turbo/cache/7a132a51c00654b1.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/7f7f9b46d2a471cc-manifest.json
vendored
Normal file
1
.turbo/cache/7f7f9b46d2a471cc-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782470296888709000,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782470271479266800,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":556,"mtime_nanos":1782470271486156700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782470271480812300,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":755,"mtime_nanos":1782470271488872300,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||||
1
.turbo/cache/7f7f9b46d2a471cc-meta.json
vendored
Normal file
1
.turbo/cache/7f7f9b46d2a471cc-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"7f7f9b46d2a471cc","duration":6572,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/7f7f9b46d2a471cc.tar.zst
vendored
Normal file
BIN
.turbo/cache/7f7f9b46d2a471cc.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/852f7ffa109cd04f-manifest.json
vendored
Normal file
1
.turbo/cache/852f7ffa109cd04f-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/852f7ffa109cd04f-meta.json
vendored
Normal file
1
.turbo/cache/852f7ffa109cd04f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"852f7ffa109cd04f","duration":33558,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}
|
||||||
BIN
.turbo/cache/852f7ffa109cd04f.tar.zst
vendored
Normal file
BIN
.turbo/cache/852f7ffa109cd04f.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d0658ee7899696b6-manifest.json
vendored
Normal file
1
.turbo/cache/d0658ee7899696b6-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/d0658ee7899696b6-meta.json
vendored
Normal file
1
.turbo/cache/d0658ee7899696b6-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"d0658ee7899696b6","duration":10968,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"83e0290356a6f639c530ca4978dc50a4755a8574a497f3865ec069d2420d336c"}
|
||||||
BIN
.turbo/cache/d0658ee7899696b6.tar.zst
vendored
Normal file
BIN
.turbo/cache/d0658ee7899696b6.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d124454312a82327-manifest.json
vendored
Normal file
1
.turbo/cache/d124454312a82327-manifest.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
.turbo/cache/d124454312a82327-meta.json
vendored
Normal file
1
.turbo/cache/d124454312a82327-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"d124454312a82327","duration":6130,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}
|
||||||
BIN
.turbo/cache/d124454312a82327.tar.zst
vendored
Normal file
BIN
.turbo/cache/d124454312a82327.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/d69b680c10694eca-manifest.json
vendored
Normal file
1
.turbo/cache/d69b680c10694eca-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782470294094072500,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/d69b680c10694eca-meta.json
vendored
Normal file
1
.turbo/cache/d69b680c10694eca-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"d69b680c10694eca","duration":3789,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/d69b680c10694eca.tar.zst
vendored
Normal file
BIN
.turbo/cache/d69b680c10694eca.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/ebec3f203693ffe7-manifest.json
vendored
Normal file
1
.turbo/cache/ebec3f203693ffe7-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782470271643748200,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":556,"mtime_nanos":1782470271486156700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782470271480812300,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":755,"mtime_nanos":1782470271488872300,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782470271479266800,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}
|
||||||
1
.turbo/cache/ebec3f203693ffe7-meta.json
vendored
Normal file
1
.turbo/cache/ebec3f203693ffe7-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"ebec3f203693ffe7","duration":8273,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}
|
||||||
BIN
.turbo/cache/ebec3f203693ffe7.tar.zst
vendored
Normal file
BIN
.turbo/cache/ebec3f203693ffe7.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/f2025934be2abb0b-manifest.json
vendored
Normal file
1
.turbo/cache/f2025934be2abb0b-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":834,"mtime_nanos":1782470298304747500,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/f2025934be2abb0b-meta.json
vendored
Normal file
1
.turbo/cache/f2025934be2abb0b-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"f2025934be2abb0b","duration":1406,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/f2025934be2abb0b.tar.zst
vendored
Normal file
BIN
.turbo/cache/f2025934be2abb0b.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/f241804a79446853-manifest.json
vendored
Normal file
1
.turbo/cache/f241804a79446853-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/catalog/.turbo/turbo-test.log":{"size":422,"mtime_nanos":1782470294044762800,"mode":420,"is_dir":false}},"order":["packages/catalog/.turbo/turbo-test.log"]}
|
||||||
1
.turbo/cache/f241804a79446853-meta.json
vendored
Normal file
1
.turbo/cache/f241804a79446853-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"f241804a79446853","duration":3739,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}
|
||||||
BIN
.turbo/cache/f241804a79446853.tar.zst
vendored
Normal file
BIN
.turbo/cache/f241804a79446853.tar.zst
vendored
Normal file
Binary file not shown.
@@ -92,7 +92,7 @@ make build
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
**Phase 4** (Welten & Backups) — abgeschlossen.
|
**Phase 5** (Software & Add-ons) — abgeschlossen.
|
||||||
|
|
||||||
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@ Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Detail
|
|||||||
| Spieler | Online-Spieler, Whitelist |
|
| Spieler | Online-Spieler, Whitelist |
|
||||||
| **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) |
|
| **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) |
|
||||||
| **Backups** | Manuell/geplant, Restore, Retention |
|
| **Backups** | Manuell/geplant, Restore, Retention |
|
||||||
|
| **Add-ons** | Modrinth-Suche, Paper-Plugins / Fabric-Mods installieren |
|
||||||
|
|
||||||
### Minecraft-Server lokal testen
|
### Minecraft-Server lokal testen
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"lint": "node -e \"process.exit(0)\"",
|
"lint": "node -e \"process.exit(0)\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "tsc && node --test test/**/*.test.js"
|
"test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
"@fastify/websocket": "^11.0.2",
|
"@fastify/websocket": "^11.0.2",
|
||||||
"@hexahost/auth": "workspace:*",
|
"@hexahost/auth": "workspace:*",
|
||||||
|
"@hexahost/catalog": "workspace:*",
|
||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/contracts": "workspace:*",
|
"@hexahost/contracts": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { CatalogModule } from './catalog/catalog.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { NodesModule } from './nodes/nodes.module';
|
import { NodesModule } from './nodes/nodes.module';
|
||||||
import { ServersModule } from './servers/servers.module';
|
import { ServersModule } from './servers/servers.module';
|
||||||
@@ -40,6 +41,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
CatalogModule,
|
||||||
ServersModule,
|
ServersModule,
|
||||||
NodesModule,
|
NodesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
|||||||
67
apps/api/src/catalog/catalog.controller.ts
Normal file
67
apps/api/src/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { catalogSearchQuerySchema } from '@hexahost/contracts';
|
||||||
|
|
||||||
|
import { SessionGuard } from '../auth/guards/session.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
|
@ApiTags('catalog')
|
||||||
|
@Controller('catalog')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class CatalogController {
|
||||||
|
constructor(
|
||||||
|
private readonly catalogService: CatalogService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('projects')
|
||||||
|
@ApiOperation({ summary: 'Search Modrinth projects' })
|
||||||
|
search(@Query() query: Record<string, string | undefined>) {
|
||||||
|
const input = catalogSearchQuerySchema.parse({
|
||||||
|
q: query['q'],
|
||||||
|
gameVersion: query['gameVersion'],
|
||||||
|
softwareFamily: query['softwareFamily'],
|
||||||
|
limit: query['limit'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.catalogService.searchProjects({
|
||||||
|
query: input.q,
|
||||||
|
gameVersion: input.gameVersion,
|
||||||
|
softwareFamily: input.softwareFamily,
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('projects/:projectId/versions')
|
||||||
|
@ApiOperation({ summary: 'List Modrinth project versions' })
|
||||||
|
async versions(
|
||||||
|
@Param('projectId') projectId: string,
|
||||||
|
@Query('serverId') serverId?: string,
|
||||||
|
@Query('gameVersion') gameVersion?: string,
|
||||||
|
@Query('softwareFamily') softwareFamily?: string,
|
||||||
|
) {
|
||||||
|
let resolvedGameVersion = gameVersion;
|
||||||
|
let resolvedSoftwareFamily = softwareFamily;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
const server = await this.prisma.gameServer.findUniqueOrThrow({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
resolvedGameVersion = server.minecraftVersion;
|
||||||
|
resolvedSoftwareFamily = server.softwareFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedGameVersion || !resolvedSoftwareFamily) {
|
||||||
|
throw new BadRequestException('gameVersion and softwareFamily are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.catalogService.listProjectVersions(
|
||||||
|
projectId,
|
||||||
|
resolvedGameVersion,
|
||||||
|
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/catalog/catalog.module.ts
Normal file
15
apps/api/src/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
import { CatalogController } from './catalog.controller';
|
||||||
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
controllers: [CatalogController],
|
||||||
|
providers: [CatalogService],
|
||||||
|
exports: [CatalogService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
81
apps/api/src/catalog/catalog.service.ts
Normal file
81
apps/api/src/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CatalogSearchResponse,
|
||||||
|
CatalogVersionsResponse,
|
||||||
|
} from '@hexahost/contracts';
|
||||||
|
import {
|
||||||
|
getModrinthProjectVersions,
|
||||||
|
isVersionCompatible,
|
||||||
|
resolveModrinthLoader,
|
||||||
|
resolveProjectType,
|
||||||
|
searchModrinthProjects,
|
||||||
|
type SoftwareFamily,
|
||||||
|
} from '@hexahost/catalog';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogService {
|
||||||
|
async searchProjects(input: {
|
||||||
|
query: string;
|
||||||
|
gameVersion: string;
|
||||||
|
softwareFamily: SoftwareFamily;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<CatalogSearchResponse> {
|
||||||
|
const projectType = resolveProjectType(input.softwareFamily);
|
||||||
|
const loader = resolveModrinthLoader(input.softwareFamily);
|
||||||
|
|
||||||
|
if (!projectType || !loader) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This server software does not support catalog add-ons',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await searchModrinthProjects({
|
||||||
|
query: input.query,
|
||||||
|
projectType,
|
||||||
|
gameVersion: input.gameVersion,
|
||||||
|
loader,
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: result.hits.map((project) => ({
|
||||||
|
id: project.project_id,
|
||||||
|
slug: project.slug,
|
||||||
|
name: project.title,
|
||||||
|
description: project.description,
|
||||||
|
projectType: project.project_type,
|
||||||
|
downloads: project.downloads,
|
||||||
|
iconUrl: project.icon_url ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listProjectVersions(
|
||||||
|
projectId: string,
|
||||||
|
gameVersion: string,
|
||||||
|
softwareFamily: SoftwareFamily,
|
||||||
|
): Promise<CatalogVersionsResponse> {
|
||||||
|
const loader = resolveModrinthLoader(softwareFamily);
|
||||||
|
if (!loader) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This server software does not support catalog add-ons',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await getModrinthProjectVersions(projectId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
versions: versions.map((version) => ({
|
||||||
|
id: version.id,
|
||||||
|
projectId: version.project_id,
|
||||||
|
name: version.name,
|
||||||
|
versionNumber: version.version_number,
|
||||||
|
gameVersions: version.game_versions,
|
||||||
|
loaders: version.loaders,
|
||||||
|
compatible: isVersionCompatible(version, gameVersion, loader),
|
||||||
|
publishedAt: version.date_published,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,7 +327,9 @@ export class NodesService {
|
|||||||
normalized === 'world.archive' ||
|
normalized === 'world.archive' ||
|
||||||
normalized === 'world.replace' ||
|
normalized === 'world.replace' ||
|
||||||
normalized === 'storage.upload' ||
|
normalized === 'storage.upload' ||
|
||||||
normalized === 'storage.download'
|
normalized === 'storage.download' ||
|
||||||
|
normalized === 'addon.install' ||
|
||||||
|
normalized === 'addon.remove'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import type { AddonListResponse, InstallAddonResponse } from '@hexahost/contracts';
|
||||||
|
import { installAddonRequestSchema } from '@hexahost/contracts';
|
||||||
|
|
||||||
|
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||||
|
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||||
|
|
||||||
|
import { AddonsService } from './addons.service';
|
||||||
|
|
||||||
|
@ApiTags('servers')
|
||||||
|
@Controller('servers/:serverId/addons')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class AddonsController {
|
||||||
|
constructor(private readonly addonsService: AddonsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List installed add-ons' })
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||||
|
): Promise<AddonListResponse> {
|
||||||
|
return this.addonsService.listAddons(user.id, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Install an add-on from the catalog' })
|
||||||
|
install(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
const input = installAddonRequestSchema.parse(body);
|
||||||
|
return this.addonsService.installAddon(user.id, serverId, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':addonId')
|
||||||
|
@ApiOperation({ summary: 'Remove an installed add-on' })
|
||||||
|
remove(
|
||||||
|
@CurrentUser() user: { id: string },
|
||||||
|
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||||
|
@Param('addonId', ParseUUIDPipe) addonId: string,
|
||||||
|
): Promise<InstallAddonResponse> {
|
||||||
|
return this.addonsService.removeAddon(user.id, serverId, addonId);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/api/src/servers/addons/addons.module.ts
Normal file
34
apps/api/src/servers/addons/addons.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
import { getConfig } from '@hexahost/config';
|
||||||
|
|
||||||
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
import { ServerStateService } from '../server-state.service';
|
||||||
|
|
||||||
|
import { AddonsController } from './addons.controller';
|
||||||
|
import { AddonsService, SERVER_ADDONS_QUEUE } from './addons.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
controllers: [AddonsController],
|
||||||
|
providers: [
|
||||||
|
AddonsService,
|
||||||
|
ServerStateService,
|
||||||
|
{
|
||||||
|
provide: SERVER_ADDONS_QUEUE,
|
||||||
|
useFactory: () => {
|
||||||
|
const config = getConfig();
|
||||||
|
return new Queue('server-addons', {
|
||||||
|
connection: {
|
||||||
|
url: config.REDIS_URL,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AddonsService],
|
||||||
|
})
|
||||||
|
export class AddonsModule {}
|
||||||
229
apps/api/src/servers/addons/addons.service.ts
Normal file
229
apps/api/src/servers/addons/addons.service.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AddonListResponse,
|
||||||
|
InstallAddonRequest,
|
||||||
|
InstallAddonResponse,
|
||||||
|
InstalledAddon,
|
||||||
|
} from '@hexahost/contracts';
|
||||||
|
import type { InstalledAddon as InstalledAddonRecord } from '@hexahost/database';
|
||||||
|
import {
|
||||||
|
getModrinthProject,
|
||||||
|
getModrinthVersion,
|
||||||
|
isVersionCompatible,
|
||||||
|
pickPrimaryFile,
|
||||||
|
resolveInstallDirectory,
|
||||||
|
resolveModrinthLoader,
|
||||||
|
resolveProjectType,
|
||||||
|
} from '@hexahost/catalog';
|
||||||
|
|
||||||
|
import { AuditService } from '../../auth/audit.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||||
|
import { ServerStateService } from '../server-state.service';
|
||||||
|
|
||||||
|
export const SERVER_ADDONS_QUEUE = Symbol('SERVER_ADDONS_QUEUE');
|
||||||
|
|
||||||
|
const STOPPED_ONLY = new Set(['STOPPED']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AddonsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly serverState: ServerStateService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
@Inject(SERVER_ADDONS_QUEUE) private readonly addonsQueue: Queue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listAddons(userId: string, serverId: string): Promise<AddonListResponse> {
|
||||||
|
await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
|
||||||
|
const addons = await this.prisma.installedAddon.findMany({
|
||||||
|
where: {
|
||||||
|
serverId,
|
||||||
|
status: { not: 'REMOVED' },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { addons: addons.map((addon) => this.toResponse(addon)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async installAddon(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
input: InstallAddonRequest,
|
||||||
|
): Promise<InstallAddonResponse> {
|
||||||
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
if (!server.nodeId) {
|
||||||
|
throw new BadRequestException('Server is not assigned to a node');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!STOPPED_ONLY.has(server.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Addons can only be installed while the server is STOPPED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectType = resolveProjectType(server.softwareFamily);
|
||||||
|
const loader = resolveModrinthLoader(server.softwareFamily);
|
||||||
|
|
||||||
|
if (!projectType || !loader) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This server software does not support add-on installation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await getModrinthVersion(input.versionId);
|
||||||
|
if (version.project_id !== input.projectId) {
|
||||||
|
throw new BadRequestException('Version does not belong to the selected project');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVersionCompatible(version, server.minecraftVersion, loader)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This version is not compatible with your server version and loader',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = pickPrimaryFile(version);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('No installable file found for this version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await getModrinthProject(input.projectId);
|
||||||
|
const projectName = project.title;
|
||||||
|
const projectSlug = project.slug;
|
||||||
|
|
||||||
|
const installDir = resolveInstallDirectory(projectType);
|
||||||
|
const filePath = `${installDir}/${file.filename}`;
|
||||||
|
|
||||||
|
const existing = await this.prisma.installedAddon.findFirst({
|
||||||
|
where: {
|
||||||
|
serverId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
status: { in: ['PENDING', 'INSTALLING', 'INSTALLED'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('This project is already installed on the server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const addonType =
|
||||||
|
projectType === 'plugin'
|
||||||
|
? 'PLUGIN'
|
||||||
|
: projectType === 'mod'
|
||||||
|
? 'MOD'
|
||||||
|
: projectType === 'modpack'
|
||||||
|
? 'MODPACK'
|
||||||
|
: 'DATAPACK';
|
||||||
|
|
||||||
|
const addon = await this.prisma.installedAddon.create({
|
||||||
|
data: {
|
||||||
|
serverId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
projectSlug,
|
||||||
|
projectName,
|
||||||
|
versionId: input.versionId,
|
||||||
|
versionNumber: version.version_number,
|
||||||
|
addonType,
|
||||||
|
fileName: file.filename,
|
||||||
|
filePath,
|
||||||
|
fileSha512: file.hashes['sha512'] ?? null,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await this.addonsQueue.add('install-addon', {
|
||||||
|
addonId: addon.id,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
action: 'server.addon.install',
|
||||||
|
userId,
|
||||||
|
entityType: 'game_server',
|
||||||
|
entityId: serverId,
|
||||||
|
metadata: {
|
||||||
|
addonId: addon.id,
|
||||||
|
projectId: input.projectId,
|
||||||
|
versionId: input.versionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addon: this.toResponse(addon),
|
||||||
|
jobId: job.id?.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAddon(
|
||||||
|
userId: string,
|
||||||
|
serverId: string,
|
||||||
|
addonId: string,
|
||||||
|
): Promise<InstallAddonResponse> {
|
||||||
|
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||||
|
this.serverState.assertNotInProgress(server.status);
|
||||||
|
|
||||||
|
if (!STOPPED_ONLY.has(server.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Addons can only be removed while the server is STOPPED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addon = await this.prisma.installedAddon.findFirstOrThrow({
|
||||||
|
where: { id: addonId, serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: { status: 'REMOVING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await this.addonsQueue.add('remove-addon', {
|
||||||
|
addonId,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
action: 'server.addon.remove',
|
||||||
|
userId,
|
||||||
|
entityType: 'game_server',
|
||||||
|
entityId: serverId,
|
||||||
|
metadata: { addonId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
addon: this.toResponse({ ...addon, status: 'REMOVING' }),
|
||||||
|
jobId: job.id?.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toResponse(addon: InstalledAddonRecord): InstalledAddon {
|
||||||
|
return {
|
||||||
|
id: addon.id,
|
||||||
|
serverId: addon.serverId,
|
||||||
|
provider: addon.provider,
|
||||||
|
projectId: addon.projectId,
|
||||||
|
projectSlug: addon.projectSlug,
|
||||||
|
projectName: addon.projectName,
|
||||||
|
versionId: addon.versionId,
|
||||||
|
versionNumber: addon.versionNumber,
|
||||||
|
addonType: addon.addonType as InstalledAddon['addonType'],
|
||||||
|
fileName: addon.fileName,
|
||||||
|
filePath: addon.filePath,
|
||||||
|
status: addon.status as InstalledAddon['status'],
|
||||||
|
failureReason: addon.failureReason,
|
||||||
|
installedAt: addon.installedAt?.toISOString() ?? null,
|
||||||
|
createdAt: addon.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
|||||||
import { AppConfigModule } from '../config/app-config.module';
|
import { AppConfigModule } from '../config/app-config.module';
|
||||||
|
|
||||||
import { ConsoleModule } from './console/console.module';
|
import { ConsoleModule } from './console/console.module';
|
||||||
|
import { AddonsModule } from './addons/addons.module';
|
||||||
import { BackupsModule } from './backups/backups.module';
|
import { BackupsModule } from './backups/backups.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { WorldsModule } from './worlds/worlds.module';
|
import { WorldsModule } from './worlds/worlds.module';
|
||||||
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
|
|||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
forwardRef(() => ConsoleModule),
|
forwardRef(() => ConsoleModule),
|
||||||
|
forwardRef(() => AddonsModule),
|
||||||
forwardRef(() => BackupsModule),
|
forwardRef(() => BackupsModule),
|
||||||
forwardRef(() => WorldsModule),
|
forwardRef(() => WorldsModule),
|
||||||
forwardRef(() => FilesModule),
|
forwardRef(() => FilesModule),
|
||||||
|
|||||||
42
apps/api/test/phase5.test.js
Normal file
42
apps/api/test/phase5.test.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
describe('phase 5 api contract', () => {
|
||||||
|
it('documents catalog and add-ons routes', () => {
|
||||||
|
const files = [
|
||||||
|
'catalog/catalog.controller.ts',
|
||||||
|
'servers/addons/addons.controller.ts',
|
||||||
|
'servers/addons/addons.service.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
|
||||||
|
assert.ok(source.length > 0, `${file} should exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'catalog/catalog.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(catalogSource.includes("@Get('projects')"));
|
||||||
|
assert.ok(catalogSource.includes('projects/:projectId/versions'));
|
||||||
|
|
||||||
|
const addonsSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'servers/addons/addons.controller.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(addonsSource.includes('servers/:serverId/addons'));
|
||||||
|
assert.ok(addonsSource.includes('@Post()'));
|
||||||
|
assert.ok(addonsSource.includes("@Delete(':addonId')"));
|
||||||
|
|
||||||
|
const serviceSource = readFileSync(
|
||||||
|
join(process.cwd(), 'src', 'servers/addons/addons.service.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.ok(serviceSource.includes('installAddon'));
|
||||||
|
assert.ok(serviceSource.includes('removeAddon'));
|
||||||
|
assert.ok(serviceSource.includes('AddonListResponse'));
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"baseUrl": "./"
|
"baseUrl": "./",
|
||||||
|
"incremental": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "test"]
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -189,6 +189,10 @@ func mapSoftwareFamily(family string) string {
|
|||||||
switch strings.ToUpper(strings.TrimSpace(family)) {
|
switch strings.ToUpper(strings.TrimSpace(family)) {
|
||||||
case "PAPER":
|
case "PAPER":
|
||||||
return "PAPER"
|
return "PAPER"
|
||||||
|
case "FABRIC":
|
||||||
|
return "FABRIC"
|
||||||
|
case "PURPUR":
|
||||||
|
return "PURPUR"
|
||||||
default:
|
default:
|
||||||
return "VANILLA"
|
return "VANILLA"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,30 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteFile removes a file relative to dataRoot.
|
||||||
|
func DeleteFile(dataRoot, relativePath string) error {
|
||||||
|
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return ErrIsDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Remove(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
func rejectSymlinks(dataRoot, target string) error {
|
func rejectSymlinks(dataRoot, target string) error {
|
||||||
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const (
|
|||||||
TypeServerWorldReplace MessageType = "server.world.replace"
|
TypeServerWorldReplace MessageType = "server.world.replace"
|
||||||
TypeServerStorageUpload MessageType = "server.storage.upload"
|
TypeServerStorageUpload MessageType = "server.storage.upload"
|
||||||
TypeServerStorageDownload MessageType = "server.storage.download"
|
TypeServerStorageDownload MessageType = "server.storage.download"
|
||||||
|
TypeServerAddonInstall MessageType = "server.addon.install"
|
||||||
|
TypeServerAddonRemove MessageType = "server.addon.remove"
|
||||||
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
|
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
|
||||||
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
|
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
|
||||||
)
|
)
|
||||||
@@ -80,6 +82,8 @@ var controlToAgentTypes = map[MessageType]struct{}{
|
|||||||
TypeServerWorldReplace: {},
|
TypeServerWorldReplace: {},
|
||||||
TypeServerStorageUpload: {},
|
TypeServerStorageUpload: {},
|
||||||
TypeServerStorageDownload: {},
|
TypeServerStorageDownload: {},
|
||||||
|
TypeServerAddonInstall: {},
|
||||||
|
TypeServerAddonRemove: {},
|
||||||
TypeServerMetricsSub: {},
|
TypeServerMetricsSub: {},
|
||||||
TypeServerLogsSubscribe: {},
|
TypeServerLogsSubscribe: {},
|
||||||
}
|
}
|
||||||
@@ -220,6 +224,20 @@ type ServerWorldReplacePayload struct {
|
|||||||
ArchivePath string `json:"archivePath"`
|
ArchivePath string `json:"archivePath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServerAddonInstallPayload downloads and installs an addon file.
|
||||||
|
type ServerAddonInstallPayload struct {
|
||||||
|
OperationMeta
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
RelativePath string `json:"relativePath"`
|
||||||
|
Sha512 string `json:"sha512,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerAddonRemovePayload removes an installed addon file.
|
||||||
|
type ServerAddonRemovePayload struct {
|
||||||
|
OperationMeta
|
||||||
|
RelativePath string `json:"relativePath"`
|
||||||
|
}
|
||||||
|
|
||||||
// FileEntry describes a file or directory in a list response.
|
// FileEntry describes a file or directory in a list response.
|
||||||
type FileEntry struct {
|
type FileEntry struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -570,6 +570,66 @@ func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleAddonInstall downloads and installs an addon JAR.
|
||||||
|
func (m *Manager) HandleAddonInstall(ctx context.Context, correlationID string, payload protocol.ServerAddonInstallPayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.State == ServerStateRunning {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_RUNNING", "server must be stopped before installing addons")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, err := files.ResolvePath(rec.DataPath, payload.RelativePath)
|
||||||
|
if err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.install", filesErrorCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transfer.DownloadFile(payload.DownloadURL, localPath); err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.install", "DOWNLOAD_FAILED", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := transfer.VerifySha512File(localPath, payload.Sha512); err != nil {
|
||||||
|
_ = os.Remove(localPath)
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.install", "HASH_MISMATCH", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "addon.install", map[string]string{
|
||||||
|
"path": payload.RelativePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleAddonRemove deletes an installed addon file.
|
||||||
|
func (m *Manager) HandleAddonRemove(ctx context.Context, correlationID string, payload protocol.ServerAddonRemovePayload) {
|
||||||
|
meta := payload.OperationMeta
|
||||||
|
rec, ok := m.getServer(meta.ServerID)
|
||||||
|
if !ok {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.State == ServerStateRunning {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_RUNNING", "server must be stopped before removing addons")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := files.DeleteFile(rec.DataPath, payload.RelativePath); err != nil {
|
||||||
|
m.failOperation(ctx, correlationID, meta, "addon.remove", filesErrorCode(err), err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.completeOperation(ctx, correlationID, meta, "addon.remove", map[string]string{
|
||||||
|
"path": payload.RelativePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|||||||
34
apps/node-agent/internal/transfer/hash.go
Normal file
34
apps/node-agent/internal/transfer/hash.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package transfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifySha512File checks that a file matches the expected sha512 hash.
|
||||||
|
func VerifySha512File(path, expected string) error {
|
||||||
|
if expected == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hasher := sha512.New()
|
||||||
|
if _, err := io.Copy(hasher, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
if actual != expected {
|
||||||
|
return fmt.Errorf("sha512 mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -370,6 +370,22 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
|||||||
}
|
}
|
||||||
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
|
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerAddonInstall:
|
||||||
|
var payload protocol.ServerAddonInstallPayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.addon.install", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleAddonInstall(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
|
case protocol.TypeServerAddonRemove:
|
||||||
|
var payload protocol.ServerAddonRemovePayload
|
||||||
|
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||||
|
c.log.Warn("decode server.addon.remove", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.runtime.HandleAddonRemove(ctx, env.MessageID, payload)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
c.log.Debug("ignored control message", "type", env.Type)
|
c.log.Debug("ignored control message", "type", env.Type)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,8 @@
|
|||||||
"properties": "Eigenschaften",
|
"properties": "Eigenschaften",
|
||||||
"players": "Spieler",
|
"players": "Spieler",
|
||||||
"worlds": "Welten",
|
"worlds": "Welten",
|
||||||
"backups": "Backups"
|
"backups": "Backups",
|
||||||
|
"addons": "Add-ons"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
@@ -317,6 +318,23 @@
|
|||||||
"created": "Erstellt",
|
"created": "Erstellt",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"empty": "Noch keine Backups vorhanden."
|
"empty": "Noch keine Backups vorhanden."
|
||||||
|
},
|
||||||
|
"addons": {
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"unsupported": "Add-ons werden für diese Server-Software noch nicht unterstützt. Verwenden Sie Paper oder Fabric.",
|
||||||
|
"stoppedHint": "Add-ons können nur installiert oder entfernt werden, wenn der Server gestoppt ist.",
|
||||||
|
"searchPlaceholder": "Modrinth durchsuchen…",
|
||||||
|
"search": "Suchen",
|
||||||
|
"searchError": "Katalogsuche fehlgeschlagen.",
|
||||||
|
"versionsError": "Versionen konnten nicht geladen werden.",
|
||||||
|
"installError": "Installation fehlgeschlagen.",
|
||||||
|
"removeError": "Entfernen fehlgeschlagen.",
|
||||||
|
"select": "Versionen",
|
||||||
|
"compatibleVersions": "Kompatible Versionen für {name}",
|
||||||
|
"install": "Installieren",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"installed": "Installiert",
|
||||||
|
"empty": "Noch keine Add-ons installiert."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,8 @@
|
|||||||
"properties": "Properties",
|
"properties": "Properties",
|
||||||
"players": "Players",
|
"players": "Players",
|
||||||
"worlds": "Worlds",
|
"worlds": "Worlds",
|
||||||
"backups": "Backups"
|
"backups": "Backups",
|
||||||
|
"addons": "Add-ons"
|
||||||
},
|
},
|
||||||
"console": {
|
"console": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
@@ -317,6 +318,23 @@
|
|||||||
"created": "Created",
|
"created": "Created",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"empty": "No backups yet."
|
"empty": "No backups yet."
|
||||||
|
},
|
||||||
|
"addons": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"unsupported": "Add-ons are not supported for this server software yet. Use Paper or Fabric.",
|
||||||
|
"stoppedHint": "Add-ons can only be installed or removed while the server is stopped.",
|
||||||
|
"searchPlaceholder": "Search Modrinth…",
|
||||||
|
"search": "Search",
|
||||||
|
"searchError": "Catalog search failed.",
|
||||||
|
"versionsError": "Could not load versions.",
|
||||||
|
"installError": "Installation failed.",
|
||||||
|
"removeError": "Remove failed.",
|
||||||
|
"select": "Versions",
|
||||||
|
"compatibleVersions": "Compatible versions for {name}",
|
||||||
|
"install": "Install",
|
||||||
|
"remove": "Remove",
|
||||||
|
"installed": "Installed",
|
||||||
|
"empty": "No add-ons installed yet."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { setRequestLocale } from "next-intl/server";
|
||||||
|
import { ServerAddons } from "@/components/servers/server-addons";
|
||||||
|
|
||||||
|
interface ServerAddonsPageProps {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServerAddonsPage({ params }: ServerAddonsPageProps) {
|
||||||
|
const { locale, id } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return <ServerAddons serverId={id} />;
|
||||||
|
}
|
||||||
222
apps/web/src/components/servers/server-addons.tsx
Normal file
222
apps/web/src/components/servers/server-addons.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { Button } from "@hexahost/ui";
|
||||||
|
|
||||||
|
import {
|
||||||
|
installAddon,
|
||||||
|
listCatalogVersions,
|
||||||
|
listInstalledAddons,
|
||||||
|
removeAddon,
|
||||||
|
searchCatalog,
|
||||||
|
type CatalogProject,
|
||||||
|
type CatalogVersion,
|
||||||
|
} from "@/lib/api/addons";
|
||||||
|
import { getServer } from "@/lib/api/servers";
|
||||||
|
|
||||||
|
interface ServerAddonsProps {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerAddons({ serverId }: ServerAddonsProps) {
|
||||||
|
const t = useTranslations("servers.addons");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<CatalogProject[]>([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<CatalogProject | null>(null);
|
||||||
|
const [versions, setVersions] = useState<CatalogVersion[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: server } = useQuery({
|
||||||
|
queryKey: ["server", serverId],
|
||||||
|
queryFn: () => getServer(serverId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshInstalled = useCallback(async () => {
|
||||||
|
const data = await listInstalledAddons(serverId);
|
||||||
|
return data.addons;
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
const [installed, setInstalled] = useState<Awaited<ReturnType<typeof refreshInstalled>>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshInstalled().then(setInstalled).catch(() => undefined);
|
||||||
|
}, [refreshInstalled]);
|
||||||
|
|
||||||
|
const supportsAddons =
|
||||||
|
server &&
|
||||||
|
["PAPER", "PURPUR", "FABRIC", "FORGE", "NEOFORGE", "QUILT"].includes(
|
||||||
|
server.softwareFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSearch(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!server || !supportsAddons || !query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await searchCatalog({
|
||||||
|
q: query.trim(),
|
||||||
|
gameVersion: server.minecraftVersion,
|
||||||
|
softwareFamily: server.softwareFamily,
|
||||||
|
});
|
||||||
|
setResults(response.projects);
|
||||||
|
setSelectedProject(null);
|
||||||
|
setVersions([]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("searchError"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectProject(project: CatalogProject) {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await listCatalogVersions(project.id, serverId);
|
||||||
|
setVersions(response.versions.filter((version) => version.compatible));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("versionsError"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstall(version: CatalogVersion) {
|
||||||
|
if (!selectedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await installAddon(serverId, selectedProject.id, version.id);
|
||||||
|
setInstalled(await refreshInstalled());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("installError"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(addonId: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await removeAddon(serverId, addonId);
|
||||||
|
setInstalled(await refreshInstalled());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("removeError"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsAddons) {
|
||||||
|
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("unsupported")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status !== "STOPPED") {
|
||||||
|
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("stoppedHint")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSearch(event)} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t("searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={busy}>
|
||||||
|
{t("search")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{results.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||||
|
{results.map((project) => (
|
||||||
|
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{project.name}</p>
|
||||||
|
<p className="text-xs text-zinc-500 line-clamp-2">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void handleSelectProject(project)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t("select")}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedProject && versions.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">{t("compatibleVersions", { name: selectedProject.name })}</h3>
|
||||||
|
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<li key={version.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
|
<span>{version.versionNumber}</span>
|
||||||
|
<Button size="sm" onClick={() => void handleInstall(version)} disabled={busy}>
|
||||||
|
{t("install")}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-sm font-medium">{t("installed")}</h3>
|
||||||
|
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||||
|
{installed.length === 0 ? (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
|
||||||
|
) : (
|
||||||
|
installed.map((addon) => (
|
||||||
|
<li key={addon.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{addon.projectName}</p>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
{addon.versionNumber} · {addon.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{addon.status === "INSTALLED" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => void handleRemove(addon.id)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t("remove")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const tabs = [
|
|||||||
{ key: "players", suffix: "/players" },
|
{ key: "players", suffix: "/players" },
|
||||||
{ key: "worlds", suffix: "/worlds" },
|
{ key: "worlds", suffix: "/worlds" },
|
||||||
{ key: "backups", suffix: "/backups" },
|
{ key: "backups", suffix: "/backups" },
|
||||||
|
{ key: "addons", suffix: "/addons" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ServerTabs({ serverId }: ServerTabsProps) {
|
export function ServerTabs({ serverId }: ServerTabsProps) {
|
||||||
|
|||||||
81
apps/web/src/lib/api/addons.ts
Normal file
81
apps/web/src/lib/api/addons.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { apiFetch } from "./auth";
|
||||||
|
import type { SoftwareFamily } from "./servers";
|
||||||
|
|
||||||
|
export interface CatalogProject {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
projectType: string;
|
||||||
|
downloads: number;
|
||||||
|
iconUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogVersion {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
versionNumber: string;
|
||||||
|
gameVersions: string[];
|
||||||
|
loaders: string[];
|
||||||
|
compatible: boolean;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledAddon {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
versionNumber: string;
|
||||||
|
addonType: string;
|
||||||
|
fileName: string;
|
||||||
|
status: string;
|
||||||
|
failureReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCatalog(input: {
|
||||||
|
q: string;
|
||||||
|
gameVersion: string;
|
||||||
|
softwareFamily: SoftwareFamily;
|
||||||
|
}): Promise<{ projects: CatalogProject[] }> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: input.q,
|
||||||
|
gameVersion: input.gameVersion,
|
||||||
|
softwareFamily: input.softwareFamily,
|
||||||
|
});
|
||||||
|
return apiFetch(`/catalog/projects?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCatalogVersions(
|
||||||
|
projectId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ versions: CatalogVersion[] }> {
|
||||||
|
const params = new URLSearchParams({ serverId });
|
||||||
|
return apiFetch(`/catalog/projects/${projectId}/versions?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInstalledAddons(
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ addons: InstalledAddon[] }> {
|
||||||
|
return apiFetch(`/servers/${serverId}/addons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installAddon(
|
||||||
|
serverId: string,
|
||||||
|
projectId: string,
|
||||||
|
versionId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/servers/${serverId}/addons`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ projectId, versionId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAddon(
|
||||||
|
serverId: string,
|
||||||
|
addonId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/servers/${serverId}/addons/${addonId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"test": "node --test test/**/*.test.js"
|
"test": "node --test test/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hexahost/catalog": "workspace:*",
|
||||||
"@hexahost/config": "workspace:*",
|
"@hexahost/config": "workspace:*",
|
||||||
"@hexahost/database": "workspace:*",
|
"@hexahost/database": "workspace:*",
|
||||||
"@hexahost/storage": "workspace:*",
|
"@hexahost/storage": "workspace:*",
|
||||||
|
|||||||
171
apps/worker/src/handlers/server-addons.ts
Normal file
171
apps/worker/src/handlers/server-addons.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { Job } from 'bullmq';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getModrinthVersion,
|
||||||
|
isVersionCompatible,
|
||||||
|
pickPrimaryFile,
|
||||||
|
resolveModrinthLoader,
|
||||||
|
} from '@hexahost/catalog';
|
||||||
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
|
import { sendAgentRequest } from '../agent-bridge';
|
||||||
|
import { logger } from '../logger';
|
||||||
|
|
||||||
|
const installAddonJobSchema = z.object({
|
||||||
|
addonId: z.string().uuid(),
|
||||||
|
serverId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAddonJobSchema = z.object({
|
||||||
|
addonId: z.string().uuid(),
|
||||||
|
serverId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ADDON_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function processInstallAddonJob(job: Job): Promise<{ status: string }> {
|
||||||
|
if (job.name !== 'install-addon') {
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addonId, serverId } = installAddonJobSchema.parse(job.data);
|
||||||
|
const addon = await prisma.installedAddon.findUniqueOrThrow({
|
||||||
|
where: { id: addonId },
|
||||||
|
});
|
||||||
|
const server = await prisma.gameServer.findUniqueOrThrow({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server.nodeId) {
|
||||||
|
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: { status: 'INSTALLING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const version = await getModrinthVersion(addon.versionId);
|
||||||
|
const loader = resolveModrinthLoader(server.softwareFamily);
|
||||||
|
|
||||||
|
if (!loader || !isVersionCompatible(version, server.minecraftVersion, loader)) {
|
||||||
|
throw new Error('Selected version is not compatible with this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = pickPrimaryFile(version);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No installable file found for this version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sendAgentRequest(
|
||||||
|
server.nodeId,
|
||||||
|
'server.addon.install',
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
generation: server.version,
|
||||||
|
downloadUrl: file.url,
|
||||||
|
relativePath: addon.filePath,
|
||||||
|
sha512: file.hashes['sha512'] ?? addon.fileSha512 ?? '',
|
||||||
|
},
|
||||||
|
ADDON_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.errorMessage ?? 'Addon install failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: {
|
||||||
|
status: 'INSTALLED',
|
||||||
|
installedAt: new Date(),
|
||||||
|
fileSha512: file.hashes['sha512'] ?? addon.fileSha512,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'installed' };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Addon install failed';
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
failureReason: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processRemoveAddonJob(job: Job): Promise<{ status: string }> {
|
||||||
|
if (job.name !== 'remove-addon') {
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addonId, serverId } = removeAddonJobSchema.parse(job.data);
|
||||||
|
const addon = await prisma.installedAddon.findUniqueOrThrow({
|
||||||
|
where: { id: addonId },
|
||||||
|
});
|
||||||
|
const server = await prisma.gameServer.findUniqueOrThrow({
|
||||||
|
where: { id: serverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!server.nodeId) {
|
||||||
|
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: { status: 'REMOVING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendAgentRequest(
|
||||||
|
server.nodeId,
|
||||||
|
'server.addon.remove',
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
generation: server.version,
|
||||||
|
relativePath: addon.filePath,
|
||||||
|
},
|
||||||
|
ADDON_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.errorMessage ?? 'Addon remove failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: {
|
||||||
|
status: 'REMOVED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'removed' };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Addon remove failed';
|
||||||
|
await prisma.installedAddon.update({
|
||||||
|
where: { id: addonId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
failureReason: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processAddonJob(job: Job): Promise<{ status: string }> {
|
||||||
|
switch (job.name) {
|
||||||
|
case 'install-addon':
|
||||||
|
return processInstallAddonJob(job);
|
||||||
|
case 'remove-addon':
|
||||||
|
return processRemoveAddonJob(job);
|
||||||
|
default:
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown addon job');
|
||||||
|
return { status: 'ignored' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
|
|||||||
import { prisma } from '@hexahost/database';
|
import { prisma } from '@hexahost/database';
|
||||||
|
|
||||||
import { processNotificationJob } from './handlers/notifications';
|
import { processNotificationJob } from './handlers/notifications';
|
||||||
|
import { processAddonJob } from './handlers/server-addons';
|
||||||
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
|
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
|
||||||
import { processLifecycleJob } from './handlers/server-lifecycle';
|
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||||
@@ -12,6 +13,7 @@ import { logger } from './logger';
|
|||||||
import { closeRedisConnections } from './redis';
|
import { closeRedisConnections } from './redis';
|
||||||
import {
|
import {
|
||||||
QUEUE_NOTIFICATIONS,
|
QUEUE_NOTIFICATIONS,
|
||||||
|
QUEUE_SERVER_ADDONS,
|
||||||
QUEUE_SERVER_BACKUPS,
|
QUEUE_SERVER_BACKUPS,
|
||||||
QUEUE_SERVER_LIFECYCLE,
|
QUEUE_SERVER_LIFECYCLE,
|
||||||
QUEUE_SERVER_PROVISIONING,
|
QUEUE_SERVER_PROVISIONING,
|
||||||
@@ -43,6 +45,10 @@ function createQueueWorker(
|
|||||||
return processBackupJob(job);
|
return processBackupJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (queueName === QUEUE_SERVER_ADDONS) {
|
||||||
|
return processAddonJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
||||||
return processLifecycleJob(job);
|
return processLifecycleJob(job);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const QUEUE_SERVER_ADDONS = 'server-addons' as const;
|
||||||
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
|
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
|
||||||
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
|
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
|
||||||
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
|
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
|
||||||
@@ -8,6 +9,7 @@ import { Queue } from 'bullmq';
|
|||||||
import { validateConfig } from '@hexahost/config';
|
import { validateConfig } from '@hexahost/config';
|
||||||
|
|
||||||
export const WORKER_QUEUES = [
|
export const WORKER_QUEUES = [
|
||||||
|
QUEUE_SERVER_ADDONS,
|
||||||
QUEUE_SERVER_BACKUPS,
|
QUEUE_SERVER_BACKUPS,
|
||||||
QUEUE_SERVER_LIFECYCLE,
|
QUEUE_SERVER_LIFECYCLE,
|
||||||
QUEUE_SERVER_PROVISIONING,
|
QUEUE_SERVER_PROVISIONING,
|
||||||
@@ -16,6 +18,22 @@ export const WORKER_QUEUES = [
|
|||||||
|
|
||||||
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
|
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
|
||||||
|
|
||||||
|
let addonsQueue: Queue | null = null;
|
||||||
|
|
||||||
|
export function getAddonsQueue(): Queue {
|
||||||
|
if (!addonsQueue) {
|
||||||
|
const config = validateConfig();
|
||||||
|
addonsQueue = new Queue(QUEUE_SERVER_ADDONS, {
|
||||||
|
connection: {
|
||||||
|
url: config.REDIS_URL,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return addonsQueue;
|
||||||
|
}
|
||||||
|
|
||||||
let backupsQueue: Queue | null = null;
|
let backupsQueue: Queue | null = null;
|
||||||
|
|
||||||
export function getBackupsQueue(): Queue {
|
export function getBackupsQueue(): Queue {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,68 +2,59 @@
|
|||||||
|
|
||||||
Last updated: 2026-06-26
|
Last updated: 2026-06-26
|
||||||
|
|
||||||
## Current phase: Phase 4 — Worlds & backups ✅
|
## Current phase: Phase 5 — Software & add-ons ✅
|
||||||
|
|
||||||
### Phase 4 completed
|
### Phase 5 completed
|
||||||
|
|
||||||
| Area | Status | Notes |
|
| Area | Status | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| Prisma backup models | Done | ServerBackup, BackupRestore, BackupSchedule, WorldUpload |
|
| Prisma InstalledAddon | Done | Plugin/mod install tracking |
|
||||||
| `@hexahost/storage` | Done | S3/MinIO presigned URLs, object keys |
|
| `@hexahost/catalog` | Done | Modrinth API + compatibility checks |
|
||||||
| `@hexahost/contracts` phase 4 | Done | backups, worlds schemas |
|
| `@hexahost/contracts` phase 5 | Done | catalog, addons schemas |
|
||||||
| Go agent v0.4 | Done | Archive, validate, RCON prepare/release, S3 transfer |
|
| Go agent v0.5 | Done | addon.install/remove with SHA512 verify |
|
||||||
| Worker backup queue | Done | create/restore/upload, retention, schedule tick |
|
| Worker server-addons queue | Done | install-addon, remove-addon jobs |
|
||||||
| API BackupsModule | Done | CRUD, restore, schedule, download URL |
|
| API CatalogModule | Done | Modrinth search + versions |
|
||||||
| API WorldsModule | Done | World info, upload, download via backup |
|
| API AddonsModule | Done | Install/remove with audit |
|
||||||
| Web worlds/backups tabs | Done | Upload, list, backup management |
|
| Web add-ons tab | Done | Search, install, list, remove |
|
||||||
| State machine | Done | BACKING_UP, RESTORING |
|
| Docker FABRIC/PURPUR | Done | Extended TYPE mapping for itzg image |
|
||||||
|
|
||||||
### Abnahmekriterium Phase 4
|
### Abnahmekriterium Phase 5
|
||||||
|
|
||||||
- [x] World backup to S3 (MinIO in dev)
|
- [x] Modrinth catalog search with loader + game version facets
|
||||||
- [x] World download (presigned URL)
|
- [x] Compatibility check before install
|
||||||
- [x] World upload and replace (server stopped)
|
- [x] Paper plugin → `plugins/` directory
|
||||||
- [x] Backup restore with safety backup
|
- [x] Fabric mod → `mods/` directory
|
||||||
- [x] Retention enforcement
|
- [x] SHA512 verification on agent
|
||||||
- [x] Scheduled backups (interval-based)
|
- [x] Install/remove only when server STOPPED
|
||||||
- [ ] Manual E2E validation (requires Docker + agent + MinIO)
|
- [ ] Manual E2E (Paper + Fabric server with agent)
|
||||||
|
|
||||||
### Local vertical slice
|
### Local test
|
||||||
|
|
||||||
```bash
|
1. Create **Paper** server → stop → **Add-ons** → search plugin (e.g. LuckPerms) → install
|
||||||
pnpm dev:infra # includes MinIO on :9000
|
2. Create **Fabric** server → stop → install compatible mod
|
||||||
pnpm db:migrate
|
3. Start server and verify JAR in container
|
||||||
pnpm db:seed
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# Node Agent (Go + Docker)
|
|
||||||
cd apps/node-agent && make run
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Stop server → **Welten** → `.tar.gz` hochladen
|
|
||||||
2. **Backups** → manuelles Backup erstellen → Download
|
|
||||||
3. Backup wiederherstellen (Server gestoppt)
|
|
||||||
4. Geplante Backups aktivieren
|
|
||||||
|
|
||||||
### Known limitations
|
### Known limitations
|
||||||
|
|
||||||
- Upload expects gzip-compressed tar (`.tar.gz`) with world folder or `level.dat` at root.
|
- Modpacks/datapacks UI deferred
|
||||||
- Restore/download require agent on node; worker orchestrates S3 transfer.
|
- No automatic update/rollback yet (metadata stored for future)
|
||||||
- Scheduled backups use simple `intervalHours` (no cron expressions yet).
|
- CurseForge not integrated
|
||||||
- No server-side encryption of backups beyond MinIO/S3 transport (deferred).
|
- Server must be reprovisioned to change software family (existing flow)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Worlds & backups ✅
|
||||||
|
|
||||||
|
S3 backups, world upload/download, restore, retention, schedules.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — Console, files, settings ✅
|
## Phase 3 — Console, files, settings ✅
|
||||||
|
|
||||||
Live console, file manager, `server.properties`, player lists, audit.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2 — Single-Node Vertical Slice ✅
|
## Phase 2 — Single-Node Vertical Slice ✅
|
||||||
|
|
||||||
Provisioning, start/stop, node agent, web panel.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1 — Authentication ✅
|
## Phase 1 — Authentication ✅
|
||||||
@@ -72,6 +63,6 @@ Provisioning, start/stop, node agent, web panel.
|
|||||||
|
|
||||||
## Phase 0 — Monorepo foundation ✅
|
## Phase 0 — Monorepo foundation ✅
|
||||||
|
|
||||||
### Next phase: Phase 5 — Software & add-ons
|
### Next phase: Phase 6 — Multi-Node and queue
|
||||||
|
|
||||||
- Software catalog, Modrinth, mods/plugins/modpacks
|
- Scheduler, resource reservation, start queue, drain
|
||||||
|
|||||||
18
packages/catalog/package.json
Normal file
18
packages/catalog/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@hexahost/catalog",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@hexahost/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
70
packages/catalog/src/compatibility.test.ts
Normal file
70
packages/catalog/src/compatibility.test.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
pickPrimaryFile,
|
||||||
|
resolveInstallDirectory,
|
||||||
|
resolveModrinthLoader,
|
||||||
|
resolveProjectType,
|
||||||
|
} from './compatibility';
|
||||||
|
import type { ModrinthVersion } from './modrinth';
|
||||||
|
|
||||||
|
describe('catalog compatibility', () => {
|
||||||
|
it('maps paper to plugin installs', () => {
|
||||||
|
expect(resolveModrinthLoader('PAPER')).toBe('paper');
|
||||||
|
expect(resolveProjectType('PAPER')).toBe('plugin');
|
||||||
|
expect(resolveInstallDirectory('plugin')).toBe('plugins');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps fabric to mod installs', () => {
|
||||||
|
expect(resolveModrinthLoader('FABRIC')).toBe('fabric');
|
||||||
|
expect(resolveProjectType('FABRIC')).toBe('mod');
|
||||||
|
expect(resolveInstallDirectory('mod')).toBe('mods');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks version compatibility', () => {
|
||||||
|
const version: ModrinthVersion = {
|
||||||
|
id: 'v1',
|
||||||
|
project_id: 'p1',
|
||||||
|
name: '1.0.0',
|
||||||
|
version_number: '1.0.0',
|
||||||
|
game_versions: ['1.21.1'],
|
||||||
|
loaders: ['paper'],
|
||||||
|
files: [],
|
||||||
|
date_published: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isVersionCompatible(version, '1.21.1', 'paper')).toBe(true);
|
||||||
|
expect(isVersionCompatible(version, '1.20.1', 'paper')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks primary jar file', () => {
|
||||||
|
const version: ModrinthVersion = {
|
||||||
|
id: 'v1',
|
||||||
|
project_id: 'p1',
|
||||||
|
name: '1.0.0',
|
||||||
|
version_number: '1.0.0',
|
||||||
|
game_versions: ['1.21.1'],
|
||||||
|
loaders: ['fabric'],
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filename: 'readme.txt',
|
||||||
|
url: 'https://example.com/readme.txt',
|
||||||
|
hashes: {},
|
||||||
|
primary: false,
|
||||||
|
size: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'mod.jar',
|
||||||
|
url: 'https://example.com/mod.jar',
|
||||||
|
hashes: { sha512: 'abc' },
|
||||||
|
primary: true,
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
date_published: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pickPrimaryFile(version)?.filename).toBe('mod.jar');
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/catalog/src/compatibility.ts
Normal file
75
packages/catalog/src/compatibility.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ModrinthProjectType, ModrinthVersion, SoftwareFamily } from './modrinth';
|
||||||
|
|
||||||
|
export function resolveModrinthLoader(
|
||||||
|
softwareFamily: SoftwareFamily,
|
||||||
|
): string | null {
|
||||||
|
switch (softwareFamily) {
|
||||||
|
case 'PAPER':
|
||||||
|
case 'PURPUR':
|
||||||
|
return 'paper';
|
||||||
|
case 'FABRIC':
|
||||||
|
return 'fabric';
|
||||||
|
case 'FORGE':
|
||||||
|
return 'forge';
|
||||||
|
case 'NEOFORGE':
|
||||||
|
return 'neoforge';
|
||||||
|
case 'QUILT':
|
||||||
|
return 'quilt';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectType(
|
||||||
|
softwareFamily: SoftwareFamily,
|
||||||
|
): ModrinthProjectType | null {
|
||||||
|
switch (softwareFamily) {
|
||||||
|
case 'PAPER':
|
||||||
|
case 'PURPUR':
|
||||||
|
return 'plugin';
|
||||||
|
case 'FABRIC':
|
||||||
|
case 'FORGE':
|
||||||
|
case 'NEOFORGE':
|
||||||
|
case 'QUILT':
|
||||||
|
return 'mod';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInstallDirectory(projectType: ModrinthProjectType): string {
|
||||||
|
switch (projectType) {
|
||||||
|
case 'plugin':
|
||||||
|
return 'plugins';
|
||||||
|
case 'mod':
|
||||||
|
return 'mods';
|
||||||
|
case 'modpack':
|
||||||
|
return 'modpacks';
|
||||||
|
default:
|
||||||
|
return 'datapacks';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVersionCompatible(
|
||||||
|
version: ModrinthVersion,
|
||||||
|
gameVersion: string,
|
||||||
|
loader: string,
|
||||||
|
): boolean {
|
||||||
|
const gameMatch = version.game_versions.some(
|
||||||
|
(value) => value === gameVersion || gameVersion.startsWith(value),
|
||||||
|
);
|
||||||
|
const loaderMatch = version.loaders.includes(loader);
|
||||||
|
return gameMatch && loaderMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickPrimaryFile(version: ModrinthVersion): ModrinthVersion['files'][number] | null {
|
||||||
|
const jarFiles = version.files.filter((file) =>
|
||||||
|
file.filename.toLowerCase().endsWith('.jar'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jarFiles.length === 0) {
|
||||||
|
return version.files[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jarFiles.find((file) => file.primary) ?? jarFiles[0] ?? null;
|
||||||
|
}
|
||||||
20
packages/catalog/src/index.ts
Normal file
20
packages/catalog/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export {
|
||||||
|
searchModrinthProjects,
|
||||||
|
getModrinthProject,
|
||||||
|
getModrinthProjectVersions,
|
||||||
|
getModrinthVersion,
|
||||||
|
type ModrinthProject,
|
||||||
|
type ModrinthSearchResponse,
|
||||||
|
type ModrinthVersion,
|
||||||
|
type ModrinthVersionFile,
|
||||||
|
type ModrinthProjectType,
|
||||||
|
type SoftwareFamily,
|
||||||
|
} from './modrinth';
|
||||||
|
|
||||||
|
export {
|
||||||
|
resolveModrinthLoader,
|
||||||
|
resolveProjectType,
|
||||||
|
resolveInstallDirectory,
|
||||||
|
isVersionCompatible,
|
||||||
|
pickPrimaryFile,
|
||||||
|
} from './compatibility';
|
||||||
148
packages/catalog/src/modrinth.ts
Normal file
148
packages/catalog/src/modrinth.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
export type SoftwareFamily =
|
||||||
|
| 'VANILLA'
|
||||||
|
| 'PAPER'
|
||||||
|
| 'PURPUR'
|
||||||
|
| 'FABRIC'
|
||||||
|
| 'FORGE'
|
||||||
|
| 'NEOFORGE'
|
||||||
|
| 'QUILT'
|
||||||
|
| 'BEDROCK_DEDICATED';
|
||||||
|
|
||||||
|
export type ModrinthProjectType = 'mod' | 'plugin' | 'modpack' | 'resourcepack' | 'shader';
|
||||||
|
|
||||||
|
export interface ModrinthProject {
|
||||||
|
project_id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
project_type: ModrinthProjectType;
|
||||||
|
downloads: number;
|
||||||
|
icon_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModrinthSearchResponse {
|
||||||
|
hits: ModrinthProject[];
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
total_hits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModrinthVersionFile {
|
||||||
|
hashes: Record<string, string>;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
primary: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModrinthVersion {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
name: string;
|
||||||
|
version_number: string;
|
||||||
|
game_versions: string[];
|
||||||
|
loaders: string[];
|
||||||
|
files: ModrinthVersionFile[];
|
||||||
|
date_published: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODRINTH_API = 'https://api.modrinth.com/v2';
|
||||||
|
|
||||||
|
export async function searchModrinthProjects(input: {
|
||||||
|
query: string;
|
||||||
|
projectType: ModrinthProjectType;
|
||||||
|
gameVersion: string;
|
||||||
|
loader?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<ModrinthSearchResponse> {
|
||||||
|
const facets: string[][] = [
|
||||||
|
[`project_type:${input.projectType}`],
|
||||||
|
[`versions:${input.gameVersion}`],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.loader) {
|
||||||
|
facets.push([`categories:${input.loader}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query: input.query,
|
||||||
|
limit: String(input.limit ?? 20),
|
||||||
|
index: 'relevance',
|
||||||
|
facets: JSON.stringify(facets),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${MODRINTH_API}/search?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Modrinth search failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ModrinthSearchResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModrinthProjectVersions(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<ModrinthVersion[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}/version`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Modrinth versions failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ModrinthVersion[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModrinthProjectDetails {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
project_type: ModrinthProjectType;
|
||||||
|
downloads: number;
|
||||||
|
icon_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModrinthProject(projectId: string): Promise<ModrinthProjectDetails> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Modrinth project failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ModrinthProjectDetails>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModrinthVersion(versionId: string): Promise<ModrinthVersion> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MODRINTH_API}/version/${encodeURIComponent(versionId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Modrinth version failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ModrinthVersion>;
|
||||||
|
}
|
||||||
9
packages/catalog/tsconfig.json
Normal file
9
packages/catalog/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@hexahost/typescript-config/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
49
packages/contracts/src/addons.ts
Normal file
49
packages/contracts/src/addons.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const addonTypeSchema = z.enum(['PLUGIN', 'MOD', 'MODPACK', 'DATAPACK']);
|
||||||
|
|
||||||
|
export const addonStatusSchema = z.enum([
|
||||||
|
'PENDING',
|
||||||
|
'INSTALLING',
|
||||||
|
'INSTALLED',
|
||||||
|
'FAILED',
|
||||||
|
'REMOVING',
|
||||||
|
'REMOVED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const installedAddonSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
serverId: z.string().uuid(),
|
||||||
|
provider: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
projectSlug: z.string(),
|
||||||
|
projectName: z.string(),
|
||||||
|
versionId: z.string(),
|
||||||
|
versionNumber: z.string(),
|
||||||
|
addonType: addonTypeSchema,
|
||||||
|
fileName: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
status: addonStatusSchema,
|
||||||
|
failureReason: z.string().nullable(),
|
||||||
|
installedAt: z.string().datetime().nullable(),
|
||||||
|
createdAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addonListResponseSchema = z.object({
|
||||||
|
addons: z.array(installedAddonSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installAddonRequestSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
versionId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const installAddonResponseSchema = z.object({
|
||||||
|
addon: installedAddonSchema,
|
||||||
|
jobId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InstalledAddon = z.infer<typeof installedAddonSchema>;
|
||||||
|
export type AddonListResponse = z.infer<typeof addonListResponseSchema>;
|
||||||
|
export type InstallAddonRequest = z.infer<typeof installAddonRequestSchema>;
|
||||||
|
export type InstallAddonResponse = z.infer<typeof installAddonResponseSchema>;
|
||||||
50
packages/contracts/src/catalog.ts
Normal file
50
packages/contracts/src/catalog.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const catalogProjectSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
projectType: z.enum(['mod', 'plugin', 'modpack', 'resourcepack', 'shader']),
|
||||||
|
downloads: z.number().int(),
|
||||||
|
iconUrl: z.string().url().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const catalogSearchQuerySchema = z.object({
|
||||||
|
q: z.string().min(1).max(128),
|
||||||
|
gameVersion: z.string().min(1).max(32),
|
||||||
|
softwareFamily: z.enum([
|
||||||
|
'PAPER',
|
||||||
|
'PURPUR',
|
||||||
|
'FABRIC',
|
||||||
|
'FORGE',
|
||||||
|
'NEOFORGE',
|
||||||
|
'QUILT',
|
||||||
|
]),
|
||||||
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const catalogSearchResponseSchema = z.object({
|
||||||
|
projects: z.array(catalogProjectSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const catalogVersionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
projectId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
versionNumber: z.string(),
|
||||||
|
gameVersions: z.array(z.string()),
|
||||||
|
loaders: z.array(z.string()),
|
||||||
|
compatible: z.boolean(),
|
||||||
|
publishedAt: z.string().datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const catalogVersionsResponseSchema = z.object({
|
||||||
|
versions: z.array(catalogVersionSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CatalogProject = z.infer<typeof catalogProjectSchema>;
|
||||||
|
export type CatalogSearchQuery = z.infer<typeof catalogSearchQuerySchema>;
|
||||||
|
export type CatalogSearchResponse = z.infer<typeof catalogSearchResponseSchema>;
|
||||||
|
export type CatalogVersion = z.infer<typeof catalogVersionSchema>;
|
||||||
|
export type CatalogVersionsResponse = z.infer<typeof catalogVersionsResponseSchema>;
|
||||||
@@ -154,3 +154,29 @@ export {
|
|||||||
type WorldUploadResponse,
|
type WorldUploadResponse,
|
||||||
type WorldDownloadUrlResponse,
|
type WorldDownloadUrlResponse,
|
||||||
} from './worlds';
|
} from './worlds';
|
||||||
|
|
||||||
|
export {
|
||||||
|
catalogProjectSchema,
|
||||||
|
catalogSearchQuerySchema,
|
||||||
|
catalogSearchResponseSchema,
|
||||||
|
catalogVersionSchema,
|
||||||
|
catalogVersionsResponseSchema,
|
||||||
|
type CatalogProject,
|
||||||
|
type CatalogSearchQuery,
|
||||||
|
type CatalogSearchResponse,
|
||||||
|
type CatalogVersion,
|
||||||
|
type CatalogVersionsResponse,
|
||||||
|
} from './catalog';
|
||||||
|
|
||||||
|
export {
|
||||||
|
addonTypeSchema,
|
||||||
|
addonStatusSchema,
|
||||||
|
installedAddonSchema,
|
||||||
|
addonListResponseSchema,
|
||||||
|
installAddonRequestSchema,
|
||||||
|
installAddonResponseSchema,
|
||||||
|
type InstalledAddon,
|
||||||
|
type AddonListResponse,
|
||||||
|
type InstallAddonRequest,
|
||||||
|
type InstallAddonResponse,
|
||||||
|
} from './addons';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
|
|||||||
|
-- Phase 5: Software & Add-ons
|
||||||
|
|
||||||
|
CREATE TYPE "AddonType" AS ENUM ('PLUGIN', 'MOD', 'MODPACK', 'DATAPACK');
|
||||||
|
CREATE TYPE "AddonInstallStatus" AS ENUM ('PENDING', 'INSTALLING', 'INSTALLED', 'FAILED', 'REMOVING', 'REMOVED');
|
||||||
|
|
||||||
|
CREATE TABLE "installed_addons" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL DEFAULT 'modrinth',
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"projectSlug" TEXT NOT NULL,
|
||||||
|
"projectName" TEXT NOT NULL,
|
||||||
|
"versionId" TEXT NOT NULL,
|
||||||
|
"versionNumber" TEXT NOT NULL,
|
||||||
|
"addonType" "AddonType" NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"filePath" TEXT NOT NULL,
|
||||||
|
"fileSha512" TEXT,
|
||||||
|
"status" "AddonInstallStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"failureReason" TEXT,
|
||||||
|
"installedAt" TIMESTAMPTZ(3),
|
||||||
|
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "installed_addons_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "installed_addons_serverId_idx" ON "installed_addons"("serverId");
|
||||||
|
CREATE INDEX "installed_addons_status_idx" ON "installed_addons"("status");
|
||||||
|
CREATE UNIQUE INDEX "installed_addons_serverId_projectId_versionId_key" ON "installed_addons"("serverId", "projectId", "versionId");
|
||||||
|
|
||||||
|
ALTER TABLE "installed_addons" ADD CONSTRAINT "installed_addons_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -96,6 +96,22 @@ enum WorldUploadStatus {
|
|||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AddonType {
|
||||||
|
PLUGIN
|
||||||
|
MOD
|
||||||
|
MODPACK
|
||||||
|
DATAPACK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AddonInstallStatus {
|
||||||
|
PENDING
|
||||||
|
INSTALLING
|
||||||
|
INSTALLED
|
||||||
|
FAILED
|
||||||
|
REMOVING
|
||||||
|
REMOVED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -265,6 +281,7 @@ model GameServer {
|
|||||||
backupRestores BackupRestore[]
|
backupRestores BackupRestore[]
|
||||||
backupSchedule BackupSchedule?
|
backupSchedule BackupSchedule?
|
||||||
worldUploads WorldUpload[]
|
worldUploads WorldUpload[]
|
||||||
|
installedAddons InstalledAddon[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([nodeId])
|
@@index([nodeId])
|
||||||
@@ -502,3 +519,30 @@ model WorldUpload {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("world_uploads")
|
@@map("world_uploads")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InstalledAddon {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
serverId String
|
||||||
|
provider String @default("modrinth")
|
||||||
|
projectId String
|
||||||
|
projectSlug String
|
||||||
|
projectName String
|
||||||
|
versionId String
|
||||||
|
versionNumber String
|
||||||
|
addonType AddonType
|
||||||
|
fileName String
|
||||||
|
filePath String
|
||||||
|
fileSha512 String?
|
||||||
|
status AddonInstallStatus @default(PENDING)
|
||||||
|
failureReason String?
|
||||||
|
installedAt DateTime? @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([serverId, projectId, versionId])
|
||||||
|
@@index([serverId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("installed_addons")
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type {
|
|||||||
BackupRestore,
|
BackupRestore,
|
||||||
BackupSchedule,
|
BackupSchedule,
|
||||||
WorldUpload,
|
WorldUpload,
|
||||||
|
InstalledAddon,
|
||||||
GameNode,
|
GameNode,
|
||||||
GameNodeHeartbeat,
|
GameNodeHeartbeat,
|
||||||
Plan,
|
Plan,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
'@hexahost/auth':
|
'@hexahost/auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
|
'@hexahost/catalog':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/catalog
|
||||||
'@hexahost/config':
|
'@hexahost/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/config
|
version: link:../../packages/config
|
||||||
@@ -172,6 +175,9 @@ importers:
|
|||||||
|
|
||||||
apps/worker:
|
apps/worker:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hexahost/catalog':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/catalog
|
||||||
'@hexahost/config':
|
'@hexahost/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/config
|
version: link:../../packages/config
|
||||||
@@ -235,6 +241,21 @@ importers:
|
|||||||
specifier: ^3.0.8
|
specifier: ^3.0.8
|
||||||
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
||||||
|
|
||||||
|
packages/catalog:
|
||||||
|
devDependencies:
|
||||||
|
'@hexahost/typescript-config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../typescript-config
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.10.2
|
||||||
|
version: 22.20.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.0.8
|
||||||
|
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
|
||||||
|
|
||||||
packages/config:
|
packages/config:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
|
|||||||
Reference in New Issue
Block a user