Server 2.10.0: distribute MSCO mods from data/mods

- ModStore: mods in data/mods (id, name, version, SHA-256), console "mods [reload]"
- ModList is sent before Welcome, files on request in 60 KB parts (once per connection)
- ModMessage relayed between players (max 64 KB)
- Heartbeat reports the mods to the keymaster (automatic "modded" tag)
- Web panel page "Mods": upload, remove, reload (DE/EN)
- Tests for list, download, checksum, relay and limits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 01:00:23 +02:00
co-authored by Claude Opus 5.5
parent 5e07fe1001
commit 3a79c928d1
10 changed files with 355 additions and 8 deletions
+58
View File
@@ -171,6 +171,7 @@ const PAGES = [
{ id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
{ id: "mods", label: "Mods", icon: "⧉", role: "Admin", group: "Management", render: renderMods },
{ id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
{ id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate },
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
@@ -754,6 +755,63 @@ function renderWorld(page) {
every(15000, loadBackups);
}
// ------------------------------------------------------------------ Mods
function renderMods(page) {
const rows = h("tbody");
const fileInput = h("input", { type: "file", accept: ".dll" });
const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB";
const upload = async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) { toast("Choose a mod (.dll) first", true); return; }
try {
const res = await fetch("api/mods/upload?name=" + encodeURIComponent(file.name), { method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/octet-stream", "X-MSCO-Panel": "1" }, body: file });
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
toast(data.message);
fileInput.value = "";
load();
} catch (e) { toast(e.message, true); }
};
const remove = async (m) => {
const ok = await modal({ title: "Remove " + m.name + "?", danger: true, confirm: "Remove",
text: "New players no longer get this mod. Players who already have it keep it on their PC." });
if (!ok) return;
await act(api("POST", "mods/" + encodeURIComponent(m.id) + "/remove"));
load();
};
page.append(
h("div", { class: "cards wide" },
h("div", { class: "card" },
h("div", { class: "stat-label" }, "Add a mod"),
h("p", { class: "muted" }, "MSCO mods (.dll) from this list are offered to every player when they join. The player sees name, version, size and checksum and decides whether to install them. Only add mods you trust - they run on the players' PCs."),
h("div", { class: "row" }, fileInput,
h("button", { class: "btn primary", type: "button", onclick: upload }, "Upload"),
h("button", { class: "btn ghost", type: "button", onclick: async () => { await act(api("POST", "mods/reload")); load(); } }, "Reload"))),
h("div", { class: "card" },
h("div", { class: "stat-label" }, "Folder"),
h("p", { class: "muted" }, "The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload)."))),
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))));
async function load() {
try {
const list = await api("GET", "mods");
if (!list.length) { rows.replaceChildren(h("tr", {}, h("td", { colspan: 5, class: "empty" }, "No mods - players join without mods."))); return; }
rows.replaceChildren(...list.map((m) => h("tr", {},
h("td", {}, h("b", {}, m.name), h("div", { class: "muted mono" }, m.id + ".dll")),
h("td", {}, m.version || "–"), h("td", {}, kb(m.size)),
h("td", { class: "mono muted", title: m.sha256 }, m.sha256.slice(0, 16) + "…"),
h("td", {}, h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: () => remove(m) }, "Remove"))))));
} catch (e) { toast(e.message, true); }
}
load();
}
// ------------------------------------------------------------------ Benutzer
function renderUsers(page) {