diff --git a/AdminPanel.cs b/AdminPanel.cs index 0489cf5..79ca81f 100644 --- a/AdminPanel.cs +++ b/AdminPanel.cs @@ -251,7 +251,7 @@ public sealed class AdminPanel void MapFiles() { - byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png"); + byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), i18n = Resource("panel.i18n.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png"); app.MapGet("/", (HttpContext ctx) => { // /panel ohne Schrägstrich -> /panel/ (sonst zeigen die relativen Adressen der Seite ins Leere) @@ -260,6 +260,7 @@ public sealed class AdminPanel return Results.Bytes(html, "text/html; charset=utf-8"); }); app.MapGet("/app.js", () => Results.Bytes(js, "text/javascript; charset=utf-8")); + app.MapGet("/i18n.js", () => Results.Bytes(i18n, "text/javascript; charset=utf-8")); app.MapGet("/app.css", () => Results.Bytes(css, "text/css; charset=utf-8")); if (logo != null) app.MapGet("/logo.png", () => Results.Bytes(logo, "image/png")); } @@ -410,6 +411,35 @@ public sealed class AdminPanel return await RunText(s => s.PanelUnban(id, by), () => OfflineBan(id, false, by)); }); + // --- Ingame-Admins (dürfen im Spiel /admin benutzen) + app.MapGet("/api/admins", async (HttpContext ctx) => + { + var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied; + return await Run(s => s.PanelAdmins(), () => new + { + steamVerification = !string.IsNullOrWhiteSpace(config.SteamWebApiKey), + admins = config.Admins.Select(id => new { steamId = id, name = (string)null, online = false }).ToList() + }); + }); + + app.MapPost("/api/admins/{action}", async (HttpContext ctx, string action) => + { + var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied; + var body = await Body(ctx); + string id = Str(body, "id").Trim(), by = By(user); + if (id.Length != 17 || !id.All(char.IsDigit)) return Error(400, "Enter a SteamID64 (17 digits)"); + if (action != "add" && action != "remove") return Error(404, "Unknown action"); + bool add = action == "add"; + return await RunText(s => s.RunCommandCaptured((add ? "op " : "deop ") + id, by), () => + { + config.Admins.Remove(id); + if (add) config.Admins.Add(id); + try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; } + GameServer.Log($"SteamID {id} is {(add ? "now an admin" : "no longer an admin")} ({by})"); + return $"SteamID {id} is {(add ? "now an admin" : "no longer an admin")}"; + }); + }); + // --- Konsole app.MapGet("/api/console", (HttpContext ctx) => { @@ -512,9 +542,18 @@ public sealed class AdminPanel zip = ms.ToArray(); } catch (Exception e) { return Error(400, "Upload failed: " + e.Message); } - if (zip.Length == 0) return Error(400, "Choose a ZIP file"); + if (zip.Length == 0) return Error(400, "Choose a ZIP, RAR or 7z file"); string by = By(user); - return await RunText(s => s.ImportSaveZip(zip, by), () => SaveStore.ImportZipOffline(dataDir, zip, by)); + var server = host.Server; + string result; + if (server == null || host.State != HostState.Running) result = SaveStore.ImportZipOffline(dataDir, zip, by); + else + { + var task = server.Invoke(() => server.ImportSaveZip(zip, by)); + if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy"); + result = await task; + } + return result.StartsWith("Save game uploaded") ? Ok(result) : Error(400, result); }); // --- Benutzer (nur Owner) diff --git a/GameServer.cs b/GameServer.cs index 4051385..21ffcd1 100644 --- a/GameServer.cs +++ b/GameServer.cs @@ -1097,6 +1097,17 @@ public sealed class GameServer public object PanelBans() => config.BannedSteamIds.ToList(); + /// Ingame-Admins (SteamID64) mit Namen, falls gerade online. + public object PanelAdmins() => new + { + steamVerification = steam.Enabled, + admins = config.Admins.Select(id => + { + var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == id); + return new { steamId = id, name = online?.Name, online = online != null }; + }).ToList() + }; + // ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet) /// Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden). @@ -1149,7 +1160,7 @@ public sealed class GameServer /// public string ImportSaveZip(byte[] zip, string by) { - string error = SaveStore.ExtractZip(zip, out var found); + string error = SaveStore.ExtractArchive(zip, out var found); if (error != null) return error; try { diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj index 797a70e..4d750eb 100644 --- a/MSCO.Server.csproj +++ b/MSCO.Server.csproj @@ -20,6 +20,7 @@ + @@ -30,4 +31,8 @@ + + + + diff --git a/Panel/app.css b/Panel/app.css index a4da64b..e19dd4a 100644 --- a/Panel/app.css +++ b/Panel/app.css @@ -82,19 +82,51 @@ label input, label select { margin-top: 6px; text-transform: none; letter-spacin .brand-sub { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } /* ---------------------------------------------------------------- Layout */ -.layout { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; } -.sidebar { background: var(--sidebar); padding: 20px 14px; display: flex; flex-direction: column; gap: 22px; - position: sticky; top: 0; height: 100vh; } -.sidebar .brand { padding: 0 8px; } +.layout { display: grid; grid-template-columns: 248px 1fr; min-height: 100%; } +.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 18px; padding: 20px 14px 16px; + background: linear-gradient(180deg, #121216 0%, #0d0d10 100%); border-right: 1px solid var(--border); overflow-y: auto; } +.sidebar .brand { padding: 2px 8px 0; } + +.server-card { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border-radius: 12px; + background: rgba(255, 255, 255, .03); border: 1px solid var(--border); } +.sc-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--pending); box-shadow: 0 0 0 4px rgba(242, 194, 48, .12); } +.sc-dot.ok { background: var(--online); box-shadow: 0 0 0 4px rgba(61, 214, 140, .14); } +.sc-dot.bad { background: var(--danger); box-shadow: 0 0 0 4px rgba(229, 72, 77, .14); } +.sc-text { min-width: 0; display: grid; } +.sc-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sc-text small { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + nav { display: grid; gap: 2px; } -nav button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent; - border: 0; color: var(--muted); font: inherit; font-weight: 600; padding: 10px 12px; border-radius: 8px; cursor: pointer; } -nav button:hover { background: #18181d; color: var(--text); } -nav button.active { background: var(--panel-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); } -nav .ico { width: 18px; text-align: center; opacity: .9; } -.sidebar-foot { margin-top: auto; display: grid; gap: 8px; padding: 0 8px; } -.me { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; overflow: hidden; } -.me span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-group { font-size: 10.5px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: #5f5f6b; padding: 14px 12px 6px; } +.nav-group:first-child { padding-top: 2px; } +nav button { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; background: transparent; border: 0; + color: #a4a4af; font: inherit; font-weight: 600; padding: 7px 10px; border-radius: 10px; cursor: pointer; transition: background .12s, color .12s; } +nav button:hover { background: rgba(255, 255, 255, .04); color: var(--text); } +nav .ico { width: 28px; height: 28px; flex: none; display: grid; place-items: center; border-radius: 8px; font-size: 13px; + background: rgba(255, 255, 255, .04); color: #c9c9d2; transition: background .12s, color .12s; } +nav button.active { background: linear-gradient(90deg, rgba(242, 107, 29, .16), rgba(242, 107, 29, .04)); color: #fff; } +nav button.active .ico { background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(242, 107, 29, .35); } +.nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.sidebar-foot { margin-top: auto; display: grid; gap: 10px; } +.me-card { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 12px; background: rgba(255, 255, 255, .03); border: 1px solid var(--border); } +.me-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; background: transparent; border: 0; color: var(--text); + font: inherit; text-align: left; padding: 4px; border-radius: 8px; cursor: pointer; } +.me-main:hover, .me-main.active { background: rgba(255, 255, 255, .05); } +.avatar { width: 32px; height: 32px; flex: none; border-radius: 50%; display: grid; place-items: center; font-weight: 800; + background: linear-gradient(135deg, #ffa24d, #e5561a); color: #fff; } +.me-text { min-width: 0; display: grid; } +.me-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.me-text small { color: var(--muted); font-size: 11.5px; } +.icon-btn { width: 32px; height: 32px; flex: none; border: 0; border-radius: 8px; background: transparent; color: var(--muted); + font-size: 15px; cursor: pointer; } +.icon-btn:hover { background: rgba(229, 72, 77, .14); color: #ff8a8d; } + +.lang-switch { display: inline-flex; padding: 3px; border-radius: 999px; background: rgba(255, 255, 255, .04); border: 1px solid var(--border); justify-self: start; } +.lang-switch button { border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12px; font-weight: 700; + padding: 4px 12px; border-radius: 999px; cursor: pointer; } +.lang-switch button.on { background: var(--panel-2); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, .04); } +.auth-lang { position: fixed; top: 16px; right: 16px; } .content { min-width: 0; display: flex; flex-direction: column; } .topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border); @@ -108,8 +140,14 @@ nav .ico { width: 18px; text-align: center; opacity: .9; } .page { padding: 24px 28px 40px; display: grid; gap: 20px; align-content: start; } /* ---------------------------------------------------------------- Karten */ -.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; } -.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; } +.cards.wide { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; min-width: 0; + display: flex; flex-direction: column; gap: 10px; overflow-wrap: anywhere; } +.card p { margin: 0; } +.card > .btn, .card > .row { margin-top: auto; } +.card .row { gap: 8px; } +.card .row > input, .card .row > input[type=file] { flex: 1 1 100%; min-width: 0; width: 100%; } .stat-label { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } .stat-value { font-size: 26px; font-weight: 800; margin-top: 6px; font-variant-numeric: tabular-nums; } .stat-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } @@ -118,7 +156,9 @@ nav .ico { width: 18px; text-align: center; opacity: .9; } .section-head h2 { margin-right: auto; } .section-body { padding: 18px; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } -.row > input { flex: 1; min-width: 180px; } +.row > input { flex: 1; min-width: min(180px, 100%); } +.small-text { font-size: 13px; padding-top: 0; } +.page { min-width: 0; } .empty { color: var(--muted); padding: 26px 18px; text-align: center; } /* ---------------------------------------------------------------- Tabellen */ @@ -183,7 +223,7 @@ input[type=file]::file-selector-button { background: var(--panel); color: var(-- /* ---------------------------------------------------------------- Handy */ @media (max-width: 820px) { .layout { grid-template-columns: 1fr; } - .sidebar { position: fixed; inset: 0 auto 0 0; width: 250px; z-index: 40; transform: translateX(-100%); transition: transform .18s; } + .sidebar { position: fixed; inset: 0 auto 0 0; width: 268px; z-index: 40; transform: translateX(-100%); transition: transform .18s; } .sidebar.open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); } .menu { display: inline-block; } .topbar { padding: 14px 16px; } diff --git a/Panel/app.js b/Panel/app.js index d2c9c2c..c1a7175 100644 --- a/Panel/app.js +++ b/Panel/app.js @@ -165,23 +165,32 @@ $("menuToggle").addEventListener("click", () => document.querySelector(".sidebar // ------------------------------------------------------------------ Navigation const PAGES = [ - { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", render: renderDashboard }, - { id: "players", label: "Players", icon: "☺", role: "Moderator", render: renderPlayers }, - { id: "console", label: "Console", icon: "›_", role: "Moderator", render: renderConsole }, - { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", render: renderBans }, - { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", render: renderWorld }, - { id: "settings", label: "Settings", icon: "☰", role: "Admin", render: renderSettings }, - { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", render: renderUsers }, - { id: "account", label: "My Account", icon: "⚙", role: "Moderator", render: renderAccount }, + { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", group: "Server", render: renderDashboard }, + { id: "players", label: "Players", icon: "☺", role: "Moderator", group: "Server", render: renderPlayers }, + { id: "console", label: "Console", icon: "›_", role: "Moderator", group: "Server", render: renderConsole }, + { 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: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings }, + { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers }, + { id: "account", label: "My Account", icon: "☻", role: "Moderator", group: null, render: renderAccount }, ]; function renderNav() { const nav = $("nav"); - nav.replaceChildren(...PAGES.filter((p) => can(p.role)).map((p) => - h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) }, - h("span", { class: "ico" }, p.icon), p.label))); + const items = []; + let group; + for (const p of PAGES.filter((x) => can(x.role) && x.group)) { + if (p.group !== group) { group = p.group; items.push(h("div", { class: "nav-group" }, group)); } + items.push(h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) }, + h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label))); + } + nav.replaceChildren(...items); + $("meButton").classList.toggle("active", state.page === "account"); } +$("meButton").addEventListener("click", () => go("account")); + function go(pageId) { const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0]; state.page = p.id; @@ -205,6 +214,9 @@ async function refreshTopbar() { ? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}` : `server ${s.state} · v${s.version}`; $("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : ""); + $("scName").textContent = s.name; + $("scState").textContent = running ? `${s.players} / ${s.maxPlayers} players online` : "Server " + s.state; + $("scDot").className = "sc-dot " + (running ? "ok" : s.state === "error" ? "bad" : ""); $("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : ""); document.title = `${s.name} · MSCO Panel`; } catch { @@ -240,7 +252,7 @@ function renderDashboard(page) { card("Uptime", duration(s.uptimeSeconds), "version " + s.version), card("World", s.objects.toLocaleString(), `moved objects · ${s.actions} part actions · ${s.boughtItems} bought items`), card("Save game", s.saveSyncMinutes > 0 ? `every ${s.saveSyncMinutes} min` : "on join", - s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString() : "no reset point yet"), + s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString(window.MSCO_LANG === "de" ? "de-DE" : "en-GB") : "no reset point yet"), card("Backups", s.backups, "save game backups"), card("Admins / Bans", `${s.admins} / ${s.bans}`, s.steamVerification ? "Steam verification on" : "Steam verification OFF (no Web API key)")); } catch (e) { cards.replaceChildren(h("div", { class: "card" }, e.message)); } @@ -330,10 +342,11 @@ function renderSettings(page) { h("span", { class: "label-row" }, label, envNote(name), restart ? h("span", { class: "pill gray" }, "restart") : null), hint ? h("small", { class: "hint" }, hint) : null)); }; - const secret = (name, label, isSet) => { + const secret = (name, label, isSet, link) => { const input = h("input", { type: "password", placeholder: isSet ? "•••••••• (set - leave empty to keep)" : "not set", autocomplete: "new-password", disabled: !can("Owner") }); fields[name] = () => input.value; - return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input); + return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input, + link ? h("small", { class: "hint" }, link) : null); }; const save = async () => { @@ -374,7 +387,9 @@ function renderSettings(page) { h("div", { class: "section-head" }, h("h2", {}, "Keys"), h("span", { class: "muted" }, can("Owner") ? "only owners can change keys" : "owner only")), h("div", { class: "section-body form-grid wide" }, secret("licenseKey", "License key", c.licenseKeySet), - secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet))), + secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet, + h("span", {}, "Needed for admins (verifies Steam accounts). ", + h("a", { href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get your free key on Steam ↗"))))), h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"), h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart / start the server now?", text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"))); @@ -512,6 +527,51 @@ function renderConsole(page) { if (can("Admin")) input.focus(); } +// ------------------------------------------------------------------ Ingame-Admins + +function renderAdmins(page) { + const list = h("tbody"); + const warning = h("div"); + const id = h("input", { placeholder: "SteamID64 (17 digits)", maxlength: 17, inputmode: "numeric" }); + const add = async () => { + if (!/^\d{17}$/.test(id.value.trim())) { toast("Enter a SteamID64 (17 digits)", true); return; } + if (await act(api("POST", "admins/add", { id: id.value.trim() }))) { id.value = ""; load(); } + }; + id.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); }); + page.append( + warning, + can("Admin") ? h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "Add an admin"), h("span", { class: "muted" }, "also works when the player is offline")), + h("div", { class: "section-body row" }, id, h("button", { class: "btn primary", type: "button", onclick: add }, "Add admin")), + h("div", { class: "section-body muted small-text" }, "Find a SteamID64 on the player's Steam profile or with ", + h("a", { href: "https://steamid.io", target: "_blank", rel: "noopener noreferrer" }, "steamid.io ↗"), + ". Online players can also be made admin on the Players page.")) : null, + h("div", { class: "section" }, + h("div", { class: "section-head" }, h("h2", {}, "In-game admins"), h("span", { class: "muted" }, "can use /admin in the game: kick, ban, teleport, items, world")), + h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "SteamID"), h("th", {}, "Player"), h("th", {}, ""))), list)))); + + async function load() { + try { + const r = await api("GET", "admins"); + warning.replaceChildren(r.steamVerification ? "" : h("div", { class: "notice bad" }, + h("div", {}, h("b", {}, "Admin rights are disabled"), + h("div", {}, "Without a Steam Web API key the server cannot verify Steam accounts - anyone could pretend to be an admin.")), + h("div", { class: "row" }, + h("a", { class: "btn small primary", href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get a Steam key ↗"), + can("Owner") ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter it in Settings") : null))); + if (!r.admins.length) { list.replaceChildren(h("tr", {}, h("td", { colspan: 3, class: "empty" }, "No admins yet."))); return; } + list.replaceChildren(...r.admins.map((a) => h("tr", {}, + h("td", {}, h("a", { href: "https://steamcommunity.com/profiles/" + a.steamId, target: "_blank", rel: "noopener noreferrer", class: "mono" }, a.steamId)), + h("td", {}, a.online ? h("span", { class: "player-name" }, a.name, h("span", { class: "pill green" }, "online")) : h("span", { class: "muted" }, "offline")), + h("td", {}, can("Admin") ? h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: async () => { + const ok = await modal({ title: "Remove admin " + (a.name || a.steamId) + "?", danger: true, confirm: "Remove" }); + if (ok && await act(api("POST", "admins/remove", { id: a.steamId }))) load(); + } }, "Remove")) : null)))); + } catch (e) { toast(e.message, true); } + } + every(10000, load); +} + // ------------------------------------------------------------------ Banns function renderBans(page) { @@ -549,16 +609,16 @@ function renderBans(page) { function renderWorld(page) { const backups = h("tbody"); - const zipInput = h("input", { type: "file", accept: ".zip,application/zip" }); + const zipInput = h("input", { type: "file", accept: ".zip,.rar,.7z,application/zip,application/x-rar-compressed,application/vnd.rar,application/x-7z-compressed" }); const uploadZip = async () => { const file = zipInput.files && zipInput.files[0]; - if (!file) { toast("Choose a ZIP file first", true); return; } + if (!file) { toast("Choose a ZIP, RAR or 7z file first", true); return; } const ok = await modal({ title: "Upload " + file.name + "?", danger: true, confirm: "Upload", text: "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects." }); if (!ok) return; try { const res = await fetch("api/save/upload", { method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/zip", "X-MSCO-Panel": "1" }, body: file }); + 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); @@ -576,7 +636,7 @@ function renderWorld(page) { }; page.append( - h("div", { class: "cards" }, + h("div", { class: "cards wide" }, h("div", { class: "card" }, h("div", { class: "stat-label" }, "Reset world"), h("p", { class: "muted" }, "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects."), @@ -590,8 +650,8 @@ function renderWorld(page) { h("p", { class: "muted" }, "Use the current save game as the new reset point for “Reset world”."), h("button", { class: "btn", type: "button", onclick: () => worldAction("resetpoint", { title: "Set a new reset point?", text: "“Reset world” will go back to the save game as it is right now.", button: "Set reset point" }) }, "Set reset point")), h("div", { class: "card" }, - h("div", { class: "stat-label" }, "Upload save game (ZIP)"), - h("p", { class: "muted" }, "ZIP the files from AppData\\LocalLow\\Amistech\\My Summer Car. They replace the server save and become the reset point. Everyone reconnects."), + h("div", { class: "stat-label" }, "Upload save game"), + h("p", { class: "muted" }, "Pack the files from AppData\\LocalLow\\Amistech\\My Summer Car as ZIP, RAR or 7z. They replace the server save and become the reset point. Everyone reconnects."), h("div", { class: "row" }, zipInput, h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"), h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))), @@ -702,6 +762,8 @@ async function boot() { $("app").classList.remove("hidden"); $("meName").textContent = session.user.name; $("meRole").textContent = session.user.role; + $("meAvatar").textContent = session.user.name.slice(0, 1).toUpperCase(); + $("scName").textContent = session.serverName; $("serverName").textContent = session.serverName; let page = "dashboard"; try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ } diff --git a/Panel/i18n.js b/Panel/i18n.js new file mode 100644 index 0000000..168a7ab --- /dev/null +++ b/Panel/i18n.js @@ -0,0 +1,419 @@ +"use strict"; +// Sprache des Panels (Englisch / Deutsch). Die Oberfläche wird auf Englisch gebaut; diese Schicht übersetzt Texte, +// Platzhalter und Tooltips, sobald sie auf der Seite erscheinen. Spielernamen, SteamIDs, der Servername und das +// Konsolen-Log werden nie übersetzt. +(function () { + const KEY = "msco-panel-lang"; + let lang = "en"; + try { + lang = localStorage.getItem(KEY) || ((navigator.language || "").toLowerCase().startsWith("de") ? "de" : "en"); + } catch { /* egal */ } + if (lang !== "de") lang = "en"; + document.documentElement.lang = lang; + window.MSCO_LANG = lang; + + const DE = { + // Anmeldung + "Sign in": "Anmelden", + "Set up the panel": "Panel einrichten", + "Create account": "Konto anlegen", + "Setup code": "Einrichtungs-Code", + "Username": "Benutzername", + "Password": "Passwort", + "Repeat password": "Passwort wiederholen", + "Server Panel": "Server-Panel", + "Create the owner account. The setup code is shown in the server console (docker logs msco-server).": + "Lege das Owner-Konto an. Den Einrichtungs-Code findest du in der Server-Konsole (docker logs msco-server).", + "The passwords do not match": "Die Passwörter stimmen nicht überein", + "Log out": "Abmelden", + "Menu": "Menü", + "My Account": "Mein Konto", + "My account": "Mein Konto", + "Server status": "Serverstatus", + // Navigation + "Server": "Server", + "Moderation": "Moderation", + "Management": "Verwaltung", + "Dashboard": "Übersicht", + "Players": "Spieler", + "Console": "Konsole", + "Admins": "Admins", + "Bans": "Banns", + "World & Backups": "Welt & Backups", + "Settings": "Einstellungen", + "Panel Users": "Panel-Benutzer", + "Panel users": "Panel-Benutzer", + // Dashboard + "Running - players can join": "Läuft - Spieler können beitreten", + "Starting …": "Startet …", + "Stopped - players cannot join. The panel keeps running.": "Gestoppt - niemand kann beitreten. Das Panel läuft weiter.", + "Not running": "Läuft nicht", + "▶ Start": "▶ Starten", + "■ Stop": "■ Stoppen", + "⟳ Restart": "⟳ Neu starten", + "Stop the server?": "Server stoppen?", + "All players are disconnected and nobody can join until you start it again. The panel keeps running.": + "Alle Spieler werden getrennt und niemand kann beitreten, bis du ihn wieder startest. Das Panel läuft weiter.", + "Stop": "Stoppen", + "Restart the server?": "Server neu starten?", + "Settings (server.json), license and save game are loaded again. Players reconnect automatically.": + "Einstellungen (server.json), Lizenz und Spielstand werden neu geladen. Spieler verbinden sich automatisch neu.", + "Restart": "Neu starten", + "The server could not start": "Der Server konnte nicht starten", + "Note": "Hinweis", + "Enter license key": "Lizenzschlüssel eintragen", + "Upload save game": "Spielstand hochladen", + "Open console": "Konsole öffnen", + "Recent activity": "Letzte Aktivität", + "Uptime": "Laufzeit", + "World": "Welt", + "Save game": "Spielstand", + "Backups": "Backups", + "Admins / Bans": "Admins / Banns", + "listed in the server browser": "in der Serverliste sichtbar", + "not listed (private)": "nicht gelistet (privat)", + "on join": "beim Beitreten", + "no reset point yet": "noch kein Reset-Punkt", + "save game backups": "Spielstand-Backups", + "Steam verification on": "Steam-Prüfung an", + "Steam verification OFF (no Web API key)": "Steam-Prüfung AUS (kein Web-API-Key)", + // Spieler + "ID": "ID", + "Name": "Name", + "SteamID": "SteamID", + "Address": "Adresse", + "Online": "Online", + "Nobody is online right now.": "Gerade ist niemand online.", + "Server message": "Nachricht an alle", + "Message to all players…": "Nachricht an alle Spieler …", + "Send": "Senden", + "Kick": "Kicken", + "Ban": "Bannen", + "Make admin": "Zum Admin machen", + "Remove admin": "Admin entfernen", + "ADMIN": "ADMIN", + "unverified": "nicht bestätigt", + "SteamID not verified by Steam": "SteamID nicht von Steam bestätigt", + "The player is kicked and cannot join again until you unban them.": "Der Spieler fliegt raus und kann erst wieder beitreten, wenn du ihn entbannst.", + "The player sees the reason and can join again.": "Der Spieler sieht den Grund und kann wieder beitreten.", + "Reason (shown to the player)": "Grund (sieht der Spieler)", + "optional": "optional", + "In-game admins can kick, ban, spawn items and reset the world.": "Ingame-Admins können kicken, bannen, Items spawnen und die Welt zurücksetzen.", + "Remove": "Entfernen", + "Cancel": "Abbrechen", + "OK": "OK", + // Konsole + "Live console": "Live-Konsole", + "chat, joins, admin actions and command output": "Chat, Beitritte, Admin-Aktionen und Befehlsausgaben", + "Command… (help for a list)": "Befehl … (help für eine Liste)", + "Only admins can enter commands": "Nur Admins können Befehle eingeben", + "Run": "Ausführen", + // Admins + "Add an admin": "Admin hinzufügen", + "also works when the player is offline": "geht auch, wenn der Spieler offline ist", + "SteamID64 (17 digits)": "SteamID64 (17 Ziffern)", + "Add admin": "Admin hinzufügen", + "Find a SteamID64 on the player's Steam profile or with ": "Die SteamID64 findest du im Steam-Profil des Spielers oder mit ", + ". Online players can also be made admin on the Players page.": ". Spieler, die online sind, kannst du auch auf der Spieler-Seite zum Admin machen.", + "In-game admins": "Ingame-Admins", + "can use /admin in the game: kick, ban, teleport, items, world": "dürfen im Spiel /admin benutzen: kicken, bannen, teleportieren, Items, Welt", + "Player": "Spieler", + "offline": "offline", + "online": "online", + "No admins yet.": "Noch keine Admins.", + "Admin rights are disabled": "Admin-Rechte sind ausgeschaltet", + "Without a Steam Web API key the server cannot verify Steam accounts - anyone could pretend to be an admin.": + "Ohne Steam-Web-API-Key kann der Server Steam-Konten nicht prüfen - jeder könnte sich als Admin ausgeben.", + "Get a Steam key ↗": "Steam-Key holen ↗", + "Enter it in Settings": "In den Einstellungen eintragen", + "Enter a SteamID64 (17 digits)": "Gib eine SteamID64 ein (17 Ziffern)", + // Banns + "Ban a SteamID": "SteamID bannen", + "SteamID64 (17 digits) or name:playername": "SteamID64 (17 Ziffern) oder name:spielername", + "Banned players": "Gebannte Spieler", + "Identity": "Identität", + "Nobody is banned.": "Niemand ist gebannt.", + "Unban": "Entbannen", + // Welt & Backups + "Reset world": "Welt zurücksetzen", + "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects.": + "Alles geht zurück auf den Reset-Punkt (den Spielstand, wie er auf den Server hochgeladen wurde). Der aktuelle Stand wird vorher gesichert. Alle verbinden sich neu.", + "Reset the whole world?": "Die ganze Welt zurücksetzen?", + "Moved objects, bought items, doors, vehicle values and player positions are reset to the reset point. The current state is kept as a backup.": + "Bewegte Objekte, gekaufte Items, Türen, Fahrzeugwerte und Spielerpositionen gehen zurück auf den Reset-Punkt. Der aktuelle Stand bleibt als Backup erhalten.", + "Backup": "Backup", + "Save a copy of the current server save game. The last 5 backups are kept.": "Sichert eine Kopie des aktuellen Server-Spielstands. Die letzten 5 Backups bleiben erhalten.", + "Backup now": "Jetzt sichern", + "Reset point": "Reset-Punkt", + "Use the current save game as the new reset point for “Reset world”.": "Den aktuellen Spielstand als neuen Reset-Punkt für „Welt zurücksetzen“ nehmen.", + "Set reset point": "Reset-Punkt setzen", + "Set a new reset point?": "Neuen Reset-Punkt setzen?", + "“Reset world” will go back to the save game as it is right now.": "„Welt zurücksetzen“ geht dann auf den Spielstand von jetzt zurück.", + "Pack the files from AppData\\LocalLow\\Amistech\\My Summer Car as ZIP, RAR or 7z. They replace the server save and become the reset point. Everyone reconnects.": + "Pack die Dateien aus AppData\\LocalLow\\Amistech\\My Summer Car als ZIP, RAR oder 7z. Sie ersetzen den Server-Spielstand und werden der Reset-Punkt. Alle verbinden sich neu.", + "Upload": "Hochladen", + "Download current": "Aktuellen herunterladen", + "Choose a ZIP, RAR or 7z file first": "Wähle zuerst eine ZIP-, RAR- oder 7z-Datei", + "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects.": + "Der aktuelle Spielstand wird gesichert und dann ersetzt. Alle Änderungen in der Welt werden zurückgesetzt, alle verbinden sich neu.", + "Save game files": "Spielstand-Dateien", + "Copied new save files into data/save? Load them (they also become the reset point).": "Neue Spielstand-Dateien nach data/save kopiert? Hier laden (sie werden auch der Reset-Punkt).", + "Reload save": "Spielstand laden", + "Save world state": "Weltzustand speichern", + "Load the save game from data/save?": "Spielstand aus data/save laden?", + "Everyone reconnects and gets this save game.": "Alle verbinden sich neu und bekommen diesen Spielstand.", + "Load": "Laden", + "Save game backups": "Spielstand-Backups", + "newest first": "neueste zuerst", + "Created": "Erstellt", + "Folder": "Ordner", + "No backups yet.": "Noch keine Backups.", + "Restore": "Wiederherstellen", + "The server goes back to this save game (the current one is backed up first). Everyone reconnects.": + "Der Server geht auf diesen Spielstand zurück (der aktuelle wird vorher gesichert). Alle verbinden sich neu.", + // Einstellungen + "General": "Allgemein", + "applies immediately": "gilt sofort", + "Server name": "Servername", + "Description": "Beschreibung", + "Max players": "Max. Spieler", + "Public address": "Öffentliche Adresse", + "automatic": "automatisch", + "IP or domain players connect to (empty = automatic)": "IP oder Domain, zu der Spieler verbinden (leer = automatisch)", + "Listed in the server browser": "In der Serverliste anzeigen", + "Steam required": "Steam erforderlich", + "Off = players without Steam can join too": "Aus = auch Spieler ohne Steam können beitreten", + "Saving": "Speichern", + "Take the save game from a player every … minutes": "Spielstand alle … Minuten von einem Spieler holen", + "0 = only when someone joins": "0 = nur wenn jemand beitritt", + "Write the world state every … seconds": "Weltzustand alle … Sekunden speichern", + "Network & panel": "Netzwerk & Panel", + "needs a restart": "braucht einen Neustart", + "restart": "Neustart", + "Game port": "Spiel-Port", + "Panel port": "Panel-Port", + "0 = same port as the game (recommended)": "0 = derselbe Port wie das Spiel (empfohlen)", + "Panel bind address": "Panel-Bind-Adresse", + "only with an own panel port - 127.0.0.1 = only via a local reverse proxy": "nur mit eigenem Panel-Port - 127.0.0.1 = nur über einen lokalen Reverse-Proxy", + "Panel sub path": "Panel-Unterpfad", + "e.g. /panel when the proxy serves it there": "z.B. /panel, wenn der Proxy es dort ausliefert", + "Behind a reverse proxy": "Hinter einem Reverse-Proxy", + "Trust X-Forwarded-For / -Proto / -Prefix": "X-Forwarded-For / -Proto / -Prefix vertrauen", + "Web panel enabled": "Web-Panel eingeschaltet", + "Turning it off locks you out of this panel after the next program start": "Ausschalten sperrt dich nach dem nächsten Programmstart aus diesem Panel aus", + "Keys": "Schlüssel", + "only owners can change keys": "nur Owner können Schlüssel ändern", + "owner only": "nur Owner", + "License key": "Lizenzschlüssel", + "Steam Web API key": "Steam-Web-API-Key", + "Needed for admins (verifies Steam accounts). ": "Nötig für Admins (prüft Steam-Konten). ", + "Get your free key on Steam ↗": "Kostenlosen Key bei Steam holen ↗", + "not set": "nicht gesetzt", + "•••••••• (set - leave empty to keep)": "•••••••• (gesetzt - leer lassen zum Behalten)", + "Also set by an environment variable (.env) - that value wins on restart": "Auch per Umgebungsvariable (.env) gesetzt - dieser Wert gilt nach einem Neustart", + "Save settings": "Einstellungen speichern", + "Restart to apply": "Neu starten zum Übernehmen", + "Restart / start the server now?": "Server jetzt neu starten / starten?", + "Loads the saved settings. Players reconnect automatically.": "Lädt die gespeicherten Einstellungen. Spieler verbinden sich automatisch neu.", + // Benutzer + "+ Add user": "+ Benutzer hinzufügen", + "Role": "Rolle", + "Last login": "Letzte Anmeldung", + "never": "nie", + "you": "du", + "Password (min. 8 characters)": "Passwort (mind. 8 Zeichen)", + "Add panel user": "Panel-Benutzer hinzufügen", + "Create": "Anlegen", + "Save": "Speichern", + "Set password": "Passwort setzen", + "New password": "Neues Passwort", + "They are logged out everywhere.": "Er wird überall abgemeldet.", + "They can no longer log in to the panel.": "Er kann sich dann nicht mehr im Panel anmelden.", + "Delete": "Löschen", + ": players, kick, server messages, read console & bans. ": ": Spieler, kicken, Nachrichten, Konsole & Banns lesen. ", + ": everything on the server (ban, console commands, world reset, backups). ": ": alles am Server (bannen, Konsolenbefehle, Welt-Reset, Backups). ", + ": additionally manages panel users.": ": verwaltet zusätzlich die Panel-Benutzer.", + // Konto + "Current password": "Aktuelles Passwort", + "New password (min. 8 characters)": "Neues Passwort (mind. 8 Zeichen)", + "Repeat new password": "Neues Passwort wiederholen", + "Change password": "Passwort ändern", + "The new passwords do not match": "Die neuen Passwörter stimmen nicht überein", + // Meldungen vom Server + "Please log in": "Bitte anmelden", + "Wrong username or password": "Falscher Benutzername oder falsches Passwort", + "Too many failed logins - wait a few minutes": "Zu viele Fehlversuche - warte ein paar Minuten", + "Too many attempts - wait a few minutes": "Zu viele Versuche - warte ein paar Minuten", + "Wrong setup code (see the server console / docker logs)": "Falscher Einrichtungs-Code (siehe Server-Konsole / docker logs)", + "The game server is not running - start it on the dashboard": "Der Gameserver läuft nicht - starte ihn in der Übersicht", + "The server is busy or stopping": "Der Server ist beschäftigt oder stoppt gerade", + "Starting the server": "Server wird gestartet", + "Stopping the server - the panel keeps running": "Server wird gestoppt - das Panel läuft weiter", + "Restarting - the server is back in a few seconds": "Neustart - der Server ist in ein paar Sekunden wieder da", + "The server is not running": "Der Server läuft nicht", + "Nothing changed": "Nichts geändert", + "Sent": "Gesendet", + "Password changed": "Passwort geändert", + "Done": "Erledigt", + "Backup created": "Backup erstellt", + "World state saved": "Weltzustand gespeichert", + "World reset": "Welt zurückgesetzt", + "World reset to the original save game": "Welt auf den ursprünglichen Spielstand zurückgesetzt", + "World reset to the reset point": "Welt auf den Reset-Punkt zurückgesetzt", + "The current save game is now the reset point": "Der aktuelle Spielstand ist jetzt der Reset-Punkt", + "No save game to use as reset point": "Kein Spielstand für den Reset-Punkt vorhanden", + "Player not found": "Spieler nicht gefunden", + "Your role (Moderator) is not allowed to do this": "Deine Rolle (Moderator) darf das nicht", + "Your role (Admin) is not allowed to do this": "Deine Rolle (Admin) darf das nicht", + "Only the owner can change keys": "Nur der Owner kann Schlüssel ändern", + "You cannot delete yourself": "Du kannst dich nicht selbst löschen", + "The last owner cannot be deleted": "Der letzte Owner kann nicht gelöscht werden", + "There must be at least one owner": "Es muss mindestens einen Owner geben", + "A user with this name already exists": "Diesen Benutzernamen gibt es schon", + "Password: at least 8 characters": "Passwort: mindestens 8 Zeichen", + "Username: 3-32 characters (letters, numbers, _ . -)": "Benutzername: 3-32 Zeichen (Buchstaben, Zahlen, _ . -)", + "Current password is wrong": "Das aktuelle Passwort ist falsch", + "The server name cannot be empty": "Der Servername darf nicht leer sein", + "There is no save game on the server yet": "Auf dem Server gibt es noch keinen Spielstand", + "The archive is password protected": "Das Archiv ist passwortgeschützt", + "The save game is too large": "Der Spielstand ist zu groß", + "Enter a command": "Gib einen Befehl ein", + "Choose a ZIP, RAR or 7z file": "Wähle eine ZIP-, RAR- oder 7z-Datei", + }; + + // Zusammengesetzte Texte (Name, Zahl ... darin) + const PATTERNS = [ + [/^Kick (.+)$/, "$1 kicken"], + [/^Ban (.+)$/, "$1 bannen"], + [/^Make (.+) an admin\?$/, "$1 zum Admin machen?"], + [/^Remove admin: (.+)$/, "Admin entfernen: $1"], + [/^Remove admin (.+)\?$/, "Admin $1 entfernen?"], + [/^Upload (.+)\?$/, "$1 hochladen?"], + [/^Restore (.+)\?$/, "$1 wiederherstellen?"], + [/^Role of (.+)$/, "Rolle von $1"], + [/^New password for (.+)$/, "Neues Passwort für $1"], + [/^Delete (.+)\?$/, "$1 löschen?"], + [/^(.+) ago$/, "vor $1"], + [/^just now$/, "gerade eben"], + [/^version (.+)$/, "Version $1"], + [/^every (\d+) min$/, "alle $1 Min."], + [/^reset point from (.+)$/, "Reset-Punkt vom $1"], + [/^moved objects · (\d+) part actions · (\d+) bought items$/, "bewegte Objekte · $1 Teile-Aktionen · $2 gekaufte Items"], + [/^(\d+) \/ (\d+) players online$/, "$1 / $2 Spieler online"], + [/^(\d+)\/(\d+) players · v(.+) · up (.+)$/, "$1/$2 Spieler · v$3 · seit $4"], + [/^server (\w+) · v(.+)$/, (m, s, v) => "Server " + STATE[s] + " · v" + v], + [/^Server (running|stopped|starting|error)(: .*)?$/, (m, s, rest) => "Server " + STATE[s] + (rest ? ": " + tr1(rest.slice(2)) : "")], + [/^(.+) was kicked$/, "$1 wurde gekickt"], + [/^(.+) was banned$/, "$1 wurde gebannt"], + [/^(.+) is banned$/, "$1 ist gebannt"], + [/^(.+) is no longer banned$/, "$1 ist nicht mehr gebannt"], + [/^(.+) is already banned$/, "$1 ist schon gebannt"], + [/^SteamID (\d+) is now an admin$/, "SteamID $1 ist jetzt Admin"], + [/^SteamID (\d+) is no longer an admin$/, "SteamID $1 ist kein Admin mehr"], + [/^(.+) is now an admin$/, "$1 ist jetzt Admin"], + [/^(.+) is no longer an admin$/, "$1 ist kein Admin mehr"], + [/^User (.+) created$/, "Benutzer $1 angelegt"], + [/^Backup created: (.+)$/, "Backup erstellt: $1"], + [/^Restored (.+?)( - start the server to use it)?$/, (m, n, s) => n + " wiederhergestellt" + (s ? " - starte den Server, um ihn zu benutzen" : "")], + [/^Save game uploaded \((\d+) files\) - everyone reconnects$/, "Spielstand hochgeladen ($1 Dateien) - alle verbinden sich neu"], + [/^Save game uploaded \((\d+) files\) - start the server to use it$/, "Spielstand hochgeladen ($1 Dateien) - starte den Server, um ihn zu benutzen"], + [/^Settings saved(.*)$/, (m, rest) => "Einstellungen gespeichert" + rest.replace(" - some changes need a restart", " - manche Änderungen brauchen einen Neustart")], + [/^The server is already (\w+)$/, (m, s) => "Der Server " + (s === "running" ? "läuft schon" : "startet schon")], + [/^No save game yet - upload one as ZIP in the panel \(World & Backups\) or copy it to data\/save$/, + "Noch kein Spielstand - lade einen als ZIP/RAR im Panel hoch (Welt & Backups) oder kopiere ihn nach data/save"], + [/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"], + [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"], + [/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"], + [/^Crashed: (.+)$/, "Abgestürzt: $1"], + [/^This file could not be read - upload a ZIP, RAR or 7z archive (.+)$/, "Die Datei konnte nicht gelesen werden - lade ein ZIP-, RAR- oder 7z-Archiv hoch $1"], + [/^No defaultES2File\.txt in the archive(.*)$/, "Keine defaultES2File.txt im Archiv - pack die Dateien aus %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"], + ]; + const STATE = { running: "läuft", stopped: "gestoppt", starting: "startet", error: "Fehler" }; + + function tr1(text) { + if (Object.prototype.hasOwnProperty.call(DE, text)) return DE[text]; + for (const [re, rep] of PATTERNS) if (re.test(text)) return text.replace(re, rep); + return null; + } + + function translateText(value) { + if (lang === "en" || !value || !value.trim()) return null; + const lead = value.match(/^\s*/)[0], trail = value.match(/\s*$/)[0]; + const core = value.slice(lead.length, value.length - trail.length); + let out = tr1(core); + if (out === null && Object.prototype.hasOwnProperty.call(DE, value)) out = DE[value]; // Texte mit Leerzeichen am Rand + if (out === null) return null; + return (DE[value] !== undefined ? "" : lead) + out + (DE[value] !== undefined ? "" : trail); + } + + // Nutzerdaten nie übersetzen + const RAW = ".player-name, .mono, code, .console, #serverName, #scName, #meName, #authServer, .brand-title"; + function isRaw(el) { + if (!el) return true; + if (el.closest(".console")) return true; + if (el.matches(RAW)) return true; + return false; + } + + function translateNode(node) { + if (node.nodeType === 3) { + if (isRaw(node.parentElement)) return; + const out = translateText(node.nodeValue); + if (out !== null && out !== node.nodeValue) node.nodeValue = out; + return; + } + if (node.nodeType !== 1) return; + for (const attr of ["placeholder", "title", "aria-label"]) { + const v = node.getAttribute && node.getAttribute(attr); + if (!v) continue; + const out = translateText(v); + if (out !== null && out !== v) node.setAttribute(attr, out); + } + if (node.closest && node.closest(".console")) return; + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + if (n.nodeType === 3) translateNode(n); + else for (const attr of ["placeholder", "title", "aria-label"]) { + const v = n.getAttribute(attr); + if (!v) continue; + const out = translateText(v); + if (out !== null && out !== v) n.setAttribute(attr, out); + } + } + } + + function renderSwitches() { + document.querySelectorAll("[data-lang-switch]").forEach((box) => { + box.replaceChildren(); + for (const code of ["en", "de"]) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = code.toUpperCase(); + b.className = code === lang ? "on" : ""; + b.title = code === "de" ? "Deutsch" : "English"; + b.addEventListener("click", () => { + if (code === lang) return; + try { localStorage.setItem(KEY, code); } catch { /* egal */ } + location.reload(); + }); + box.append(b); + } + }); + } + + function start() { + renderSwitches(); + if (lang === "en") return; + translateNode(document.body); + new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "characterData") translateNode(m.target); + else if (m.type === "attributes") translateNode(m.target); + else m.addedNodes.forEach(translateNode); + } + }).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["placeholder", "title"] }); + } + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); + else start(); +})(); diff --git a/Panel/index.html b/Panel/index.html index 8dd17ca..1d26651 100644 --- a/Panel/index.html +++ b/Panel/index.html @@ -6,11 +6,13 @@ MSCO Server Panel +
+
@@ -48,10 +50,23 @@
Server Panel
+
+ +
+ MSCO Server + … +
+
diff --git a/README.md b/README.md index 06b48fa..2e7f6c0 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,9 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms | Dashboard | Spieler, Laufzeit, Weltzustand, Backups – **Start / Stop / Restart** des Gameservers, Grund wenn er nicht läuft | | Players | Liste mit ID, Name, SteamID, IP · Kick, Ban, Admin geben/nehmen · Nachricht an alle | | Console | Live-Log (Chat, Beitritte, Admin-Aktionen) und Befehle eingeben (mit Verlauf ↑/↓) | +| Admins | Ingame-Admins per SteamID hinzufügen/entfernen (braucht einen [Steam Web API Key](https://steamcommunity.com/dev/apikey)) | | Bans | Gebannte ansehen, entbannen, SteamID auch offline bannen | -| World & Backups | Welt zurücksetzen, Backup, Reset-Punkt, Backups wiederherstellen, **Spielstand als ZIP hoch-/herunterladen** | +| World & Backups | Welt zurücksetzen, Backup, Reset-Punkt, Backups wiederherstellen, **Spielstand als ZIP, RAR oder 7z hochladen**, als ZIP herunterladen | | Settings | `server.json` bearbeiten (Name, Spielerzahl, Sichtbarkeit, Ports, Panel, Schlüssel …) | | Panel Users | weitere Benutzer anlegen: **Moderator** (Spieler/Chat), **Admin** (alles am Server), **Owner** (+ Benutzer) | @@ -151,6 +152,7 @@ Mit diesem Code legst du im Browser das **Owner**-Konto an (erneut anzeigen: `ms wieder, **Restart** lädt Einstellungen, Lizenz und Spielstand neu. Nach einem Absturz startet er automatisch neu. - Erster Start ganz ohne Einrichtung: `docker compose up -d`, Panel öffnen, Einrichtungs-Code aus dem Log eingeben, Lizenzschlüssel und Spielstand im Panel hinterlegen, Start. +- Oberfläche auf **Deutsch oder Englisch** (Umschalter unten in der Seitenleiste). - Passwörter werden nur als PBKDF2-Hash in `data/panel_users.json` gespeichert, Login mit Sperre nach Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF. diff --git a/SaveStore.cs b/SaveStore.cs index 2c14916..dba2b71 100644 --- a/SaveStore.cs +++ b/SaveStore.cs @@ -97,31 +97,62 @@ public static class SaveStore } /// - /// Spielstand aus einer ZIP lesen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem Ordner - /// der ZIP) - Pfade aus der ZIP werden nie benutzt. Gibt einen Fehler zurück oder null. + /// Spielstand aus einem Archiv lesen (ZIP, RAR, 7z, tar ...). Nur die bekannten Spielstand-Dateien werden genommen + /// (egal in welchem Ordner im Archiv) - Pfade aus dem Archiv werden nie benutzt. Gibt einen Fehler zurück oder null. /// - public static string ExtractZip(byte[] zip, out Dictionary found) + public static string ExtractArchive(byte[] data, out Dictionary found) { found = new Dictionary(StringComparer.OrdinalIgnoreCase); try { - using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read); + using var archive = SharpCompress.Archives.ArchiveFactory.OpenArchive(new MemoryStream(data), null); long total = 0; - foreach (var entry in archive.Entries) + var result = found; + // Eintrag lesen: nur bekannte Spielstand-Dateien, Größe begrenzt. Gibt einen Fehler zurück oder null. + string Take(SharpCompress.Common.IEntry entry, Func open) { - string file = Path.GetFileName(entry.FullName.Replace('\\', '/')); + if (entry.IsDirectory || entry.Key == null) return null; + string file = Path.GetFileName(entry.Key.Replace('\\', '/')); string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); - if (known == null) continue; - if (entry.Length > MaxZipBytes || (total += entry.Length) > MaxZipBytes) return "The save game is too large"; - using var s = entry.Open(); + if (known == null) return null; + if (entry.IsEncrypted) return "The archive is password protected"; + using var s = open(); using var ms = new MemoryStream(); - s.CopyTo(ms); - found[known] = ms.ToArray(); + var chunk = new byte[81920]; + int read; + while ((read = s.Read(chunk, 0, chunk.Length)) > 0) + { + total += read; + if (total > MaxZipBytes) return "The save game is too large"; + ms.Write(chunk, 0, read); + } + result[known] = ms.ToArray(); + return null; } + + string error = null; + if (archive.IsSolid || archive.Type == SharpCompress.Common.ArchiveType.SevenZip) + { + // "Solide" RAR/7z: nur der Reihe nach lesbar + using var reader = archive.ExtractAllEntries(); + while (error == null && reader.MoveToNextEntry()) error = Take(reader.Entry, reader.OpenEntryStream); + } + else + { + foreach (var entry in archive.Entries) + { + error = Take(entry, entry.OpenEntryStream); + if (error != null) break; + } + } + if (error != null) return error; + } + catch (Exception e) when (e is not OutOfMemoryException) + { + return "This file could not be read - upload a ZIP, RAR or 7z archive (" + e.Message + ")"; } - catch (InvalidDataException) { return "This is not a valid ZIP file"; } if (!found.ContainsKey("defaultES2File.txt")) - return "No defaultES2File.txt in the ZIP - zip the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; + return "No defaultES2File.txt in the archive - pack the files from %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"; return null; } @@ -145,7 +176,7 @@ public static class SaveStore /// Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer). public static string ImportZipOffline(string dataDir, byte[] zip, string by) { - string error = ExtractZip(zip, out var files); + string error = ExtractArchive(zip, out var files); if (error != null) return error; try { diff --git a/Tests/Fixtures/save.rar b/Tests/Fixtures/save.rar new file mode 100644 index 0000000..ec9fc39 Binary files /dev/null and b/Tests/Fixtures/save.rar differ diff --git a/Tests/MSCO.Server.Tests.csproj b/Tests/MSCO.Server.Tests.csproj index 56f6c1a..73cc8cd 100644 --- a/Tests/MSCO.Server.Tests.csproj +++ b/Tests/MSCO.Server.Tests.csproj @@ -5,6 +5,9 @@ disable enable + + + diff --git a/Tests/Program.cs b/Tests/Program.cs index 5285ce5..63fe48d 100644 --- a/Tests/Program.cs +++ b/Tests/Program.cs @@ -466,6 +466,21 @@ async Task PanelTests() "A save game ZIP can be uploaded: " + await upRes.Content.ReadAsStringAsync()); Check(!File.Exists(Path.Combine(dir, "..", "evil.txt")) && !File.Exists(Path.Combine(dir, "evil.txt")), "Paths inside the ZIP are never used"); Check(File.ReadAllText(Path.Combine(dir, "save_original", "defaultES2File.txt")) == "ZIPPED SAVE", "The uploaded save is the new reset point"); + // RAR (mit WinRAR gepackt, "solid", Unterordner, zusätzliche fremde Datei) + var rarBytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "save.rar")); + var rarUp = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(rarBytes) }; + rarUp.Headers.Add("X-MSCO-Panel", "1"); + rarUp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + var rarRes = await owner.SendAsync(rarUp); + Check(rarRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "RAR SAVE" + && File.ReadAllText(Path.Combine(dir, "save", "items.txt")) == "rar items" && !File.Exists(Path.Combine(dir, "save", "readme.txt")), + "A RAR archive can be uploaded (only the save files are taken): " + await rarRes.Content.ReadAsStringAsync()); + var junk = new HttpRequestMessage(HttpMethod.Post, "api/save/upload") { Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("this is no archive")) }; + junk.Headers.Add("X-MSCO-Panel", "1"); + var junkRes = await owner.SendAsync(junk); + Check(!junkRes.IsSuccessStatusCode && File.ReadAllText(Path.Combine(dir, "save", "defaultES2File.txt")) == "RAR SAVE", + "A broken upload is rejected and the save stays untouched"); + var down = await owner.GetAsync("api/save/download"); byte[] downZip = await down.Content.ReadAsByteArrayAsync(); using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))