diff --git a/AdminPanel.cs b/AdminPanel.cs index 1e6fe8f..38d14e2 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -525,6 +525,32 @@ public sealed class AdminPanel 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 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) => { var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 524fbb6..ab700c3 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -7,7 +7,7 @@ enable MSCO.Server MSCO.Server - 2.10.2 + 2.10.3 MBM MSCO Dedicated Server true diff --git a/ModCatalog.cs b/ModCatalog.cs new file mode 100644 index 0000000..146bb09 --- /dev/null +++ b/ModCatalog.cs @@ -0,0 +1,81 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json.Serialization; + +namespace MSCO.Server; + +/// +/// 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). +/// +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 Mods); + + static readonly HttpClient http = CreateHttp(); + + /// Adresse der Mod-Liste: der offizielle Master (nur die Tests setzen hier einen eigenen). + 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; + } + + /// Freigegebene Mods vom Keymaster. Wirft bei Netzwerkfehlern. + public static async Task> ListAsync() + { + var res = await http.GetFromJsonAsync(BaseUrl + "/api/mods"); + return res?.Mods ?? new List(); + } + + /// Mod aus der Liste in data/mods übernehmen (bzw. aktualisieren). Fehlertext oder null. + public static async Task InstallAsync(string dataDir, string id) + { + List 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); + } +} diff --git a/Panel/app.js b/Panel/app.js index 16fa50c..b7069b2 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -761,6 +761,10 @@ function renderWorld(page) { function renderMods(page) { 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 kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB"; const upload = async () => { @@ -798,7 +802,45 @@ function renderMods(page) { 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: "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() { try { @@ -812,6 +854,7 @@ function renderMods(page) { } catch (e) { toast(e.message, true); } } load(); + loadCatalog(); } // ------------------------------------------------------------------ Benutzer diff --git a/Panel/i18n.js b/Panel/i18n.js index 64d4560..b0d551a 100644 --- a/Panel/i18n.js +++ b/Panel/i18n.js @@ -30,6 +30,19 @@ "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).", "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", "Mod": "Mod", "Size": "Größe", diff --git a/README.md b/README.md index 14f1a55..d8d8b1f 100644 --- a/README.md +++ b/README.md @@ -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 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. ## Konsolenbefehle diff --git a/Tests/Program.cs b/Tests/Program.cs index 5bf4bd6..199befd 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -431,6 +431,65 @@ async Task PanelTests() 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"); + // 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(); } + 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"); Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");