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");
|
||||
});
|
||||
|
||||
// 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) =>
|
||||
{
|
||||
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MSCO.Server</RootNamespace>
|
||||
<AssemblyName>MSCO.Server</AssemblyName>
|
||||
<Version>2.10.2</Version>
|
||||
<Version>2.10.3</Version>
|
||||
<Company>MBM</Company>
|
||||
<Product>MSCO Dedicated Server</Product>
|
||||
<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) {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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");
|
||||
Check(log.status == 200 && log.json.GetProperty("lines").GetArrayLength() > 0, "Live console log is readable");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user