Server 2.10.3: browse the MSCO mod list in the web panel
Mods -> MSCO mod list: search the approved mods from msco.mbm.wtf/mods and add or update one with a click. The server lists and downloads only from the official keymaster (never from a URL in the answer), checks the SHA-256 and stores it in data/mods like an uploaded mod. Admins only. 6 new tests (154 pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -525,6 +525,32 @@ public sealed class AdminPanel
|
|||||||
return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs");
|
return await RunText(s => s.ReloadMods(), () => "Mod uploaded - players get it when the server runs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mod-Liste des Keymasters (msco.mbm.wtf/mods) durchsuchen und als Server-Mod übernehmen
|
||||||
|
app.MapGet("/api/mods/catalog", async (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
|
List<ModCatalog.Entry> list;
|
||||||
|
try { list = await ModCatalog.ListAsync(); }
|
||||||
|
catch (Exception e) { return Error(502, "The MSCO mod list is not reachable (" + e.Message + ")"); }
|
||||||
|
var installed = ModStore.Load(dataDir, null).ToDictionary(m => m.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return Results.Json(list.Select(m => new
|
||||||
|
{
|
||||||
|
id = m.Id, name = m.Name, description = m.Description, author = m.Author, version = m.Version,
|
||||||
|
size = m.Size, changelog = m.Changelog, downloads = m.Downloads, updated = m.UpdatedUtc,
|
||||||
|
installed = installed.ContainsKey(m.Id),
|
||||||
|
upToDate = installed.TryGetValue(m.Id, out var have) && string.Equals(have.Sha256, m.Sha256, StringComparison.OrdinalIgnoreCase),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/mods/catalog/{id}/install", async (HttpContext ctx, string id) =>
|
||||||
|
{
|
||||||
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
|
string error = await ModCatalog.InstallAsync(dataDir, id);
|
||||||
|
if (error != null) return Error(400, error);
|
||||||
|
GameServer.Log($"[panel] {By(user)} added the mod {id} from the MSCO mod list");
|
||||||
|
return await RunText(s => s.ReloadMods(), () => "Mod added - players get it when the server runs");
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) =>
|
app.MapPost("/api/mods/{id}/remove", async (HttpContext ctx, string id) =>
|
||||||
{
|
{
|
||||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>MSCO.Server</RootNamespace>
|
<RootNamespace>MSCO.Server</RootNamespace>
|
||||||
<AssemblyName>MSCO.Server</AssemblyName>
|
<AssemblyName>MSCO.Server</AssemblyName>
|
||||||
<Version>2.10.2</Version>
|
<Version>2.10.3</Version>
|
||||||
<Company>MBM</Company>
|
<Company>MBM</Company>
|
||||||
<Product>MSCO Dedicated Server</Product>
|
<Product>MSCO Dedicated Server</Product>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MSCO.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mod-Liste des Keymasters (msco.mbm.wtf/mods: Mods von Spielern, von MSCO-Admins geprüft) im Web-Panel durchsuchen
|
||||||
|
/// und mit einem Klick als Server-Mod übernehmen. Geladen wird nur vom offiziellen Master, die SHA-256 wird geprüft;
|
||||||
|
/// danach liegt die Mod wie eine hochgeladene in data/mods (Datei <id>.dll).
|
||||||
|
/// </summary>
|
||||||
|
public static class ModCatalog
|
||||||
|
{
|
||||||
|
public sealed record Entry(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("description")] string Description,
|
||||||
|
[property: JsonPropertyName("author")] string Author,
|
||||||
|
[property: JsonPropertyName("version")] string Version,
|
||||||
|
[property: JsonPropertyName("sha256")] string Sha256,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("changelog")] string Changelog,
|
||||||
|
[property: JsonPropertyName("updatedUtc")] string UpdatedUtc,
|
||||||
|
[property: JsonPropertyName("downloads")] int Downloads);
|
||||||
|
|
||||||
|
record ListResponse([property: JsonPropertyName("mods")] List<Entry> Mods);
|
||||||
|
|
||||||
|
static readonly HttpClient http = CreateHttp();
|
||||||
|
|
||||||
|
/// <summary>Adresse der Mod-Liste: der offizielle Master (nur die Tests setzen hier einen eigenen).</summary>
|
||||||
|
public static string BaseUrl { get; set; } = MasterServer.Url;
|
||||||
|
|
||||||
|
static HttpClient CreateHttp()
|
||||||
|
{
|
||||||
|
var c = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
|
||||||
|
c.DefaultRequestHeaders.UserAgent.ParseAdd("MSCO-Server");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Freigegebene Mods vom Keymaster. Wirft bei Netzwerkfehlern.</summary>
|
||||||
|
public static async Task<List<Entry>> ListAsync()
|
||||||
|
{
|
||||||
|
var res = await http.GetFromJsonAsync<ListResponse>(BaseUrl + "/api/mods");
|
||||||
|
return res?.Mods ?? new List<Entry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mod aus der Liste in data/mods übernehmen (bzw. aktualisieren). Fehlertext oder null.</summary>
|
||||||
|
public static async Task<string> InstallAsync(string dataDir, string id)
|
||||||
|
{
|
||||||
|
List<Entry> list;
|
||||||
|
try { list = await ListAsync(); }
|
||||||
|
catch (Exception e) { return "The MSCO mod list is not reachable (" + e.Message + ")"; }
|
||||||
|
var entry = list.FirstOrDefault(m => m.Id == id);
|
||||||
|
if (entry == null) return "This mod is not in the MSCO mod list (any more)";
|
||||||
|
if (entry.Size > ModStore.MaxModBytes) return "This mod is too large";
|
||||||
|
|
||||||
|
// Immer vom offiziellen Master laden - nie von einer Adresse aus der Antwort
|
||||||
|
string url = BaseUrl + "/mods/download/" + Uri.EscapeDataString(entry.Id) + "/" + Uri.EscapeDataString(entry.Version);
|
||||||
|
byte[] data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var res = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
if (!res.IsSuccessStatusCode) return "Download failed (HTTP " + (int)res.StatusCode + ")";
|
||||||
|
await using var stream = await res.Content.ReadAsStreamAsync();
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
var chunk = new byte[81920];
|
||||||
|
int read;
|
||||||
|
while ((read = await stream.ReadAsync(chunk)) > 0)
|
||||||
|
{
|
||||||
|
ms.Write(chunk, 0, read);
|
||||||
|
if (ms.Length > ModStore.MaxModBytes) return "This mod is too large";
|
||||||
|
}
|
||||||
|
data = ms.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception e) { return "Download failed (" + e.Message + ")"; }
|
||||||
|
|
||||||
|
string sha = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||||
|
if (!string.Equals(sha, entry.Sha256, StringComparison.OrdinalIgnoreCase)) return "The download is damaged (checksum mismatch) - try again";
|
||||||
|
return ModStore.Add(dataDir, entry.Id + ".dll", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-1
@@ -761,6 +761,10 @@ function renderWorld(page) {
|
|||||||
|
|
||||||
function renderMods(page) {
|
function renderMods(page) {
|
||||||
const rows = h("tbody");
|
const rows = h("tbody");
|
||||||
|
const catalogRows = h("tbody", {}, h("tr", {}, h("td", { colspan: 4, class: "empty" }, "Loading …")));
|
||||||
|
const search = h("input", { type: "search", placeholder: "Search mods …", maxlength: 60 });
|
||||||
|
let catalog = null;
|
||||||
|
search.addEventListener("input", () => drawCatalog());
|
||||||
const fileInput = h("input", { type: "file", accept: ".dll" });
|
const fileInput = h("input", { type: "file", accept: ".dll" });
|
||||||
const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB";
|
const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB";
|
||||||
const upload = async () => {
|
const upload = async () => {
|
||||||
@@ -798,7 +802,45 @@ function renderMods(page) {
|
|||||||
h("div", { class: "section" },
|
h("div", { class: "section" },
|
||||||
h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")),
|
h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")),
|
||||||
h("div", { class: "table-wrap" }, h("table", {},
|
h("div", { class: "table-wrap" }, h("table", {},
|
||||||
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows))));
|
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows))),
|
||||||
|
h("div", { class: "section" },
|
||||||
|
h("div", { class: "section-head" }, h("h2", {}, "MSCO mod list"), h("span", { class: "muted" }, "mods by players, checked by the MSCO admins")),
|
||||||
|
h("div", { class: "section-body" }, h("div", { class: "row" }, search,
|
||||||
|
h("button", { class: "btn ghost small", type: "button", onclick: openList }, "msco.mbm.wtf/mods"))),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", {},
|
||||||
|
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Downloads"), h("th", {}, ""))), catalogRows))));
|
||||||
|
|
||||||
|
function openList() { window.open("https://msco.mbm.wtf/mods", "_blank", "noopener"); }
|
||||||
|
|
||||||
|
async function loadCatalog() {
|
||||||
|
try { catalog = await api("GET", "mods/catalog"); }
|
||||||
|
catch (e) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, e.message))); return; }
|
||||||
|
drawCatalog();
|
||||||
|
}
|
||||||
|
function drawCatalog() {
|
||||||
|
if (!catalog) return;
|
||||||
|
const q = search.value.trim().toLowerCase();
|
||||||
|
const list = catalog.filter((m) => !q || (m.name + " " + m.description + " " + m.author).toLowerCase().includes(q));
|
||||||
|
if (!catalog.length) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, "The MSCO mod list is empty."))); return; }
|
||||||
|
if (!list.length) { catalogRows.replaceChildren(h("tr", {}, h("td", { colspan: 4, class: "empty" }, "No mod matches the search."))); return; }
|
||||||
|
catalogRows.replaceChildren(...list.map((m) => h("tr", {},
|
||||||
|
h("td", {}, h("b", {}, m.name), h("span", { class: "muted" }, " · " + m.author),
|
||||||
|
m.description ? h("div", { class: "muted" }, m.description) : null),
|
||||||
|
h("td", {}, m.version, h("div", { class: "muted" }, kb(m.size))),
|
||||||
|
h("td", {}, String(m.downloads)),
|
||||||
|
h("td", {}, h("div", { class: "actions" },
|
||||||
|
m.upToDate ? h("span", { class: "muted" }, "Added")
|
||||||
|
: h("button", { class: "btn small primary", type: "button", onclick: (e) => install(m, e.target) }, m.installed ? "Update" : "Add to server"))))));
|
||||||
|
}
|
||||||
|
async function install(m, button) {
|
||||||
|
const ok = await modal({ title: (m.installed ? "Update " : "Add ") + m.name + "?", confirm: m.installed ? "Update" : "Add",
|
||||||
|
text: "Every player gets this mod offered when they join and decides whether to install it. It was checked by the MSCO admins - still only add mods you want on your server." });
|
||||||
|
if (!ok) return;
|
||||||
|
button.disabled = true;
|
||||||
|
await act(api("POST", "mods/catalog/" + encodeURIComponent(m.id) + "/install"));
|
||||||
|
load();
|
||||||
|
loadCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -812,6 +854,7 @@ function renderMods(page) {
|
|||||||
} catch (e) { toast(e.message, true); }
|
} catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
loadCatalog();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Benutzer
|
// ------------------------------------------------------------------ Benutzer
|
||||||
|
|||||||
@@ -30,6 +30,19 @@
|
|||||||
"The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload).":
|
"The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload).":
|
||||||
"Die Mods liegen in data/mods. Von Hand dorthin kopierte Dateien gelten nach „Neu laden“ (Konsole: mods reload).",
|
"Die Mods liegen in data/mods. Von Hand dorthin kopierte Dateien gelten nach „Neu laden“ (Konsole: mods reload).",
|
||||||
"Server mods": "Server-Mods",
|
"Server mods": "Server-Mods",
|
||||||
|
"MSCO mod list": "MSCO-Modliste",
|
||||||
|
"mods by players, checked by the MSCO admins": "Mods von Spielern, von den MSCO-Admins geprüft",
|
||||||
|
"Search mods …": "Mods suchen …",
|
||||||
|
"The MSCO mod list is empty.": "Die MSCO-Modliste ist leer.",
|
||||||
|
"No mod matches the search.": "Keine Mod passt zur Suche.",
|
||||||
|
"Downloads": "Downloads",
|
||||||
|
"Added": "Hinzugefügt",
|
||||||
|
"Add to server": "Zum Server hinzufügen",
|
||||||
|
"Add": "Hinzufügen",
|
||||||
|
"Every player gets this mod offered when they join and decides whether to install it. It was checked by the MSCO admins - still only add mods you want on your server.":
|
||||||
|
"Jeder Spieler bekommt diese Mod beim Beitreten angeboten und entscheidet, ob er sie installiert. Sie wurde von den MSCO-Admins geprüft - trotzdem nur Mods hinzufügen, die du auf deinem Server willst.",
|
||||||
|
"Mod added - players get it when the server runs": "Mod hinzugefügt - Spieler bekommen sie, sobald der Server läuft",
|
||||||
|
"Loading …": "Lädt …",
|
||||||
"players get these when they join": "bekommen Spieler beim Beitreten",
|
"players get these when they join": "bekommen Spieler beim Beitreten",
|
||||||
"Mod": "Mod",
|
"Mod": "Mod",
|
||||||
"Size": "Größe",
|
"Size": "Größe",
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ im Web-Panel unter **Mods** hochladen. Jeder Spieler bekommt beim Beitreten die
|
|||||||
nach seiner Zustimmung direkt vom Server (SHA-256 geprüft, höchstens 30 Mods à 20 MB). In der Serverliste bekommt der
|
nach seiner Zustimmung direkt vom Server (SHA-256 geprüft, höchstens 30 Mods à 20 MB). In der Serverliste bekommt der
|
||||||
Server automatisch den Tag **MODDED** und zeigt seine Mods an. Nach Änderungen im Ordner: `mods reload` (Panel: automatisch).
|
Server automatisch den Tag **MODDED** und zeigt seine Mods an. Nach Änderungen im Ordner: `mods reload` (Panel: automatisch).
|
||||||
|
|
||||||
|
Im Panel unter **Mods → MSCO-Modliste** lassen sich Mods aus der offiziellen Liste
|
||||||
|
([msco.mbm.wtf/mods](https://msco.mbm.wtf/mods), von den MSCO-Admins geprüft) durchsuchen und mit einem Klick zum Server
|
||||||
|
hinzufügen oder aktualisieren – der Server lädt sie nur vom offiziellen Keymaster und prüft die SHA-256.
|
||||||
|
|
||||||
> Mods laufen auf den PCs der Spieler – nur Mods anbieten, denen du vertraust.
|
> Mods laufen auf den PCs der Spieler – nur Mods anbieten, denen du vertraust.
|
||||||
|
|
||||||
## Konsolenbefehle
|
## Konsolenbefehle
|
||||||
|
|||||||
@@ -431,6 +431,65 @@ async Task PanelTests()
|
|||||||
|
|
||||||
var cmd = await Call(owner, "POST", "api/console", new { command = "status" });
|
var cmd = await Call(owner, "POST", "api/console", new { command = "status" });
|
||||||
Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output");
|
Check(cmd.status == 200 && cmd.json.GetProperty("message").GetString().Contains("players"), "Console command runs and returns its output");
|
||||||
|
// Mod-Liste des Keymasters: kleiner Ersatz-Keymaster auf localhost
|
||||||
|
{
|
||||||
|
var modBytes = new byte[3000];
|
||||||
|
Random.Shared.NextBytes(modBytes);
|
||||||
|
modBytes[0] = 0x4d; modBytes[1] = 0x5a;
|
||||||
|
string modSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(modBytes)).ToLowerInvariant();
|
||||||
|
bool serveBroken = false;
|
||||||
|
int catalogPort = 39000 + Random.Shared.Next(900);
|
||||||
|
var listener = new System.Net.HttpListener();
|
||||||
|
listener.Prefixes.Add($"http://127.0.0.1:{catalogPort}/");
|
||||||
|
listener.Start();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (listener.IsListening)
|
||||||
|
{
|
||||||
|
System.Net.HttpListenerContext c;
|
||||||
|
try { c = await listener.GetContextAsync(); } catch { break; }
|
||||||
|
byte[] body;
|
||||||
|
if (c.Request.Url.AbsolutePath == "/api/mods")
|
||||||
|
{
|
||||||
|
body = System.Text.Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
mods = new[] { new { id = "catalogmod", name = "Catalog Mod", description = "From the list", author = "Tester", version = "1.0.0",
|
||||||
|
sha256 = modSha, size = modBytes.Length, changelog = "", updatedUtc = "2026-10-03", downloads = 3,
|
||||||
|
url = "http://evil.example/steal.dll" } }
|
||||||
|
}));
|
||||||
|
c.Response.ContentType = "application/json";
|
||||||
|
}
|
||||||
|
else if (c.Request.Url.AbsolutePath == "/mods/download/catalogmod/1.0.0")
|
||||||
|
{
|
||||||
|
body = serveBroken ? new byte[] { 0x4d, 0x5a, 1, 2, 3 } : modBytes;
|
||||||
|
}
|
||||||
|
else { c.Response.StatusCode = 404; body = Array.Empty<byte>(); }
|
||||||
|
await c.Response.OutputStream.WriteAsync(body);
|
||||||
|
c.Response.Close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ModCatalog.BaseUrl = $"http://127.0.0.1:{catalogPort}";
|
||||||
|
var catalog = await Call(owner, "GET", "api/mods/catalog");
|
||||||
|
Check(catalog.status == 200 && catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && !m.GetProperty("installed").GetBoolean()),
|
||||||
|
"Panel lists the MSCO mod list (not yet added)");
|
||||||
|
serveBroken = true;
|
||||||
|
var broken = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
|
||||||
|
Check(broken.status == 400 && !File.Exists(Path.Combine(dir, "mods", "catalogmod.dll")), "A download with the wrong checksum is not added");
|
||||||
|
serveBroken = false;
|
||||||
|
Check((await Call(mod1Placeholder(), "POST", "api/mods/catalog/catalogmod/install")).status is 401 or 403, "Without admin rights nothing can be added");
|
||||||
|
var installMod = await Call(owner, "POST", "api/mods/catalog/catalogmod/install");
|
||||||
|
Check(installMod.status == 200 && File.Exists(Path.Combine(dir, "mods", "catalogmod.dll"))
|
||||||
|
&& File.ReadAllBytes(Path.Combine(dir, "mods", "catalogmod.dll")).SequenceEqual(modBytes),
|
||||||
|
"Admin adds a mod from the list (downloaded from the keymaster, checksum checked)");
|
||||||
|
catalog = await Call(owner, "GET", "api/mods/catalog");
|
||||||
|
Check(catalog.json.EnumerateArray().Any(m => m.GetProperty("id").GetString() == "catalogmod" && m.GetProperty("upToDate").GetBoolean()),
|
||||||
|
"The list shows the mod as added");
|
||||||
|
Check((await Call(owner, "POST", "api/mods/catalog/doesnotexist/install")).status == 400, "Unknown mods cannot be added");
|
||||||
|
await Call(owner, "POST", "api/mods/catalogmod/remove");
|
||||||
|
listener.Stop();
|
||||||
|
ModCatalog.BaseUrl = MasterServer.Url;
|
||||||
|
}
|
||||||
|
HttpClient mod1Placeholder() => Browser();
|
||||||
var log = await Call(owner, "GET", "api/console?after=0");
|
var log = await Call(owner, "GET", "api/console?after=0");
|
||||||
Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");
|
Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user