Panel: German/English, redesigned sidebar, in-game admins page, Steam Web API key link, RAR/7z save upload (SharpCompress), responsive cards; upload errors return 400; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 19:40:46 +02:00
co-authored by Claude Opus 5.5
parent c74c48dfc8
commit 8df6cbcafc
12 changed files with 700 additions and 58 deletions
+42 -3
View File
@@ -251,7 +251,7 @@ public sealed class AdminPanel
void MapFiles() 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) => app.MapGet("/", (HttpContext ctx) =>
{ {
// /panel ohne Schrägstrich -> /panel/ (sonst zeigen die relativen Adressen der Seite ins Leere) // /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"); return Results.Bytes(html, "text/html; charset=utf-8");
}); });
app.MapGet("/app.js", () => Results.Bytes(js, "text/javascript; 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")); app.MapGet("/app.css", () => Results.Bytes(css, "text/css; charset=utf-8"));
if (logo != null) app.MapGet("/logo.png", () => Results.Bytes(logo, "image/png")); 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)); 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 // --- Konsole
app.MapGet("/api/console", (HttpContext ctx) => app.MapGet("/api/console", (HttpContext ctx) =>
{ {
@@ -512,9 +542,18 @@ public sealed class AdminPanel
zip = ms.ToArray(); zip = ms.ToArray();
} }
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); } 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); 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) // --- Benutzer (nur Owner)
+12 -1
View File
@@ -1097,6 +1097,17 @@ public sealed class GameServer
public object PanelBans() => config.BannedSteamIds.ToList(); public object PanelBans() => config.BannedSteamIds.ToList();
/// <summary>Ingame-Admins (SteamID64) mit Namen, falls gerade online.</summary>
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) // ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet)
/// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary> /// <summary>Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).</summary>
@@ -1149,7 +1160,7 @@ public sealed class GameServer
/// </summary> /// </summary>
public string ImportSaveZip(byte[] zip, string by) 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; if (error != null) return error;
try try
{ {
+5
View File
@@ -20,6 +20,7 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<EmbeddedResource Include="Panel/index.html" LogicalName="panel.index.html" /> <EmbeddedResource Include="Panel/index.html" LogicalName="panel.index.html" />
<EmbeddedResource Include="Panel/app.js" LogicalName="panel.app.js" /> <EmbeddedResource Include="Panel/app.js" LogicalName="panel.app.js" />
<EmbeddedResource Include="Panel/i18n.js" LogicalName="panel.i18n.js" />
<EmbeddedResource Include="Panel/app.css" LogicalName="panel.app.css" /> <EmbeddedResource Include="Panel/app.css" LogicalName="panel.app.css" />
<EmbeddedResource Include="Panel/logo.png" LogicalName="panel.logo.png" /> <EmbeddedResource Include="Panel/logo.png" LogicalName="panel.logo.png" />
</ItemGroup> </ItemGroup>
@@ -30,4 +31,8 @@
<None Remove="Tests/**" /> <None Remove="Tests/**" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup>
</Project> </Project>
+56 -16
View File
@@ -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; } .brand-sub { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
/* ---------------------------------------------------------------- Layout */ /* ---------------------------------------------------------------- Layout */
.layout { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; } .layout { display: grid; grid-template-columns: 248px 1fr; min-height: 100%; }
.sidebar { background: var(--sidebar); padding: 20px 14px; display: flex; flex-direction: column; gap: 22px; .sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 18px; padding: 20px 14px 16px;
position: sticky; top: 0; height: 100vh; } background: linear-gradient(180deg, #121216 0%, #0d0d10 100%); border-right: 1px solid var(--border); overflow-y: auto; }
.sidebar .brand { padding: 0 8px; } .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 { display: grid; gap: 2px; }
nav button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent; .nav-group { font-size: 10.5px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: #5f5f6b; padding: 14px 12px 6px; }
border: 0; color: var(--muted); font: inherit; font-weight: 600; padding: 10px 12px; border-radius: 8px; cursor: pointer; } .nav-group:first-child { padding-top: 2px; }
nav button:hover { background: #18181d; color: var(--text); } nav button { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; background: transparent; border: 0;
nav button.active { background: var(--panel-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); } color: #a4a4af; font: inherit; font-weight: 600; padding: 7px 10px; border-radius: 10px; cursor: pointer; transition: background .12s, color .12s; }
nav .ico { width: 18px; text-align: center; opacity: .9; } nav button:hover { background: rgba(255, 255, 255, .04); color: var(--text); }
.sidebar-foot { margin-top: auto; display: grid; gap: 8px; padding: 0 8px; } nav .ico { width: 28px; height: 28px; flex: none; display: grid; place-items: center; border-radius: 8px; font-size: 13px;
.me { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; overflow: hidden; } background: rgba(255, 255, 255, .04); color: #c9c9d2; transition: background .12s, color .12s; }
.me span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 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; } .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); .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; } .page { padding: 24px 28px 40px; display: grid; gap: 20px; align-content: start; }
/* ---------------------------------------------------------------- Karten */ /* ---------------------------------------------------------------- Karten */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; } .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-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-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; } .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-head h2 { margin-right: auto; }
.section-body { padding: 18px; } .section-body { padding: 18px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .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; } .empty { color: var(--muted); padding: 26px 18px; text-align: center; }
/* ---------------------------------------------------------------- Tabellen */ /* ---------------------------------------------------------------- Tabellen */
@@ -183,7 +223,7 @@ input[type=file]::file-selector-button { background: var(--panel); color: var(--
/* ---------------------------------------------------------------- Handy */ /* ---------------------------------------------------------------- Handy */
@media (max-width: 820px) { @media (max-width: 820px) {
.layout { grid-template-columns: 1fr; } .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); } .sidebar.open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); }
.menu { display: inline-block; } .menu { display: inline-block; }
.topbar { padding: 14px 16px; } .topbar { padding: 14px 16px; }
+83 -21
View File
@@ -165,23 +165,32 @@ $("menuToggle").addEventListener("click", () => document.querySelector(".sidebar
// ------------------------------------------------------------------ Navigation // ------------------------------------------------------------------ Navigation
const PAGES = [ const PAGES = [
{ id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", render: renderDashboard }, { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", group: "Server", render: renderDashboard },
{ id: "players", label: "Players", icon: "☺", role: "Moderator", render: renderPlayers }, { id: "players", label: "Players", icon: "☺", role: "Moderator", group: "Server", render: renderPlayers },
{ id: "console", label: "Console", icon: "›_", role: "Moderator", render: renderConsole }, { id: "console", label: "Console", icon: "›_", role: "Moderator", group: "Server", render: renderConsole },
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", render: renderBans }, { id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", render: renderWorld }, { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
{ id: "settings", label: "Settings", icon: "☰", role: "Admin", render: renderSettings }, { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", render: renderUsers }, { id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
{ id: "account", label: "My Account", icon: "⚙", role: "Moderator", render: renderAccount }, { 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() { function renderNav() {
const nav = $("nav"); const nav = $("nav");
nav.replaceChildren(...PAGES.filter((p) => can(p.role)).map((p) => const items = [];
h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) }, let group;
h("span", { class: "ico" }, p.icon), p.label))); 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) { function go(pageId) {
const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0]; const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0];
state.page = p.id; state.page = p.id;
@@ -205,6 +214,9 @@ async function refreshTopbar() {
? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}` ? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`
: `server ${s.state} · v${s.version}`; : `server ${s.state} · v${s.version}`;
$("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : ""); $("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 : ""); $("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : "");
document.title = `${s.name} · MSCO Panel`; document.title = `${s.name} · MSCO Panel`;
} catch { } catch {
@@ -240,7 +252,7 @@ function renderDashboard(page) {
card("Uptime", duration(s.uptimeSeconds), "version " + s.version), card("Uptime", duration(s.uptimeSeconds), "version " + s.version),
card("World", s.objects.toLocaleString(), `moved objects · ${s.actions} part actions · ${s.boughtItems} bought items`), 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", 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("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)")); 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)); } } 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), h("span", { class: "label-row" }, label, envNote(name), restart ? h("span", { class: "pill gray" }, "restart") : null),
hint ? h("small", { class: "hint" }, hint) : 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") }); 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; 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 () => { 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-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" }, h("div", { class: "section-body form-grid wide" },
secret("licenseKey", "License key", c.licenseKeySet), 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("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?", 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"))); 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(); 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 // ------------------------------------------------------------------ Banns
function renderBans(page) { function renderBans(page) {
@@ -549,16 +609,16 @@ function renderBans(page) {
function renderWorld(page) { function renderWorld(page) {
const backups = h("tbody"); 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 uploadZip = async () => {
const file = zipInput.files && zipInput.files[0]; 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", 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." }); text: "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects." });
if (!ok) return; if (!ok) return;
try { try {
const res = await fetch("api/save/upload", { method: "POST", credentials: "same-origin", 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); const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status); if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
toast(data.message); toast(data.message);
@@ -576,7 +636,7 @@ function renderWorld(page) {
}; };
page.append( page.append(
h("div", { class: "cards" }, h("div", { class: "cards wide" },
h("div", { class: "card" }, h("div", { class: "card" },
h("div", { class: "stat-label" }, "Reset world"), 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."), 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("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("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: "card" },
h("div", { class: "stat-label" }, "Upload save game (ZIP)"), h("div", { class: "stat-label" }, "Upload save game"),
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("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("div", { class: "row" }, zipInput,
h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"), h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"),
h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))), h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))),
@@ -702,6 +762,8 @@ async function boot() {
$("app").classList.remove("hidden"); $("app").classList.remove("hidden");
$("meName").textContent = session.user.name; $("meName").textContent = session.user.name;
$("meRole").textContent = session.user.role; $("meRole").textContent = session.user.role;
$("meAvatar").textContent = session.user.name.slice(0, 1).toUpperCase();
$("scName").textContent = session.serverName;
$("serverName").textContent = session.serverName; $("serverName").textContent = session.serverName;
let page = "dashboard"; let page = "dashboard";
try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ } try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ }
+419
View File
@@ -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();
})();
+17 -2
View File
@@ -6,11 +6,13 @@
<title>MSCO Server Panel</title> <title>MSCO Server Panel</title>
<link rel="icon" href="logo.png"> <link rel="icon" href="logo.png">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<script src="i18n.js" defer></script>
<script src="app.js" defer></script> <script src="app.js" defer></script>
</head> </head>
<body> <body>
<!-- Anmeldung / Einrichtung --> <!-- Anmeldung / Einrichtung -->
<main id="auth" class="auth hidden"> <main id="auth" class="auth hidden">
<div class="auth-lang lang-switch" data-lang-switch></div>
<form id="authForm" class="auth-card" autocomplete="on"> <form id="authForm" class="auth-card" autocomplete="on">
<div class="brand"> <div class="brand">
<img src="logo.png" alt=""> <img src="logo.png" alt="">
@@ -48,10 +50,23 @@
<div class="brand-sub">Server Panel</div> <div class="brand-sub">Server Panel</div>
</div> </div>
</div> </div>
<div class="server-card" id="serverCard">
<span class="sc-dot" id="scDot"></span>
<div class="sc-text">
<b id="scName">MSCO Server</b>
<small id="scState">…</small>
</div>
</div>
<nav id="nav"></nav> <nav id="nav"></nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
<div class="me"><span id="meName"></span><span id="meRole" class="pill"></span></div> <div class="lang-switch" data-lang-switch></div>
<button class="btn ghost small" id="logout" type="button">Log out</button> <div class="me-card">
<button class="me-main" id="meButton" type="button" title="My Account">
<span class="avatar" id="meAvatar">?</span>
<span class="me-text"><b id="meName"></b><small id="meRole"></small></span>
</button>
<button class="icon-btn" id="logout" type="button" title="Log out" aria-label="Log out">⏻</button>
</div>
</div> </div>
</aside> </aside>
<section class="content"> <section class="content">
+3 -1
View File
@@ -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 | | 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 | | 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 ↑/↓) | | 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 | | 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 …) | | 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) | | 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. 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, - 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. 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 - 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. Fehlversuchen, Sitzungs-Cookie (HttpOnly, SameSite=Strict), Schutz gegen CSRF.
+46 -15
View File
@@ -97,31 +97,62 @@ public static class SaveStore
} }
/// <summary> /// <summary>
/// Spielstand aus einer ZIP lesen. Nur die bekannten Spielstand-Dateien werden genommen (egal in welchem Ordner /// Spielstand aus einem Archiv lesen (ZIP, RAR, 7z, tar ...). Nur die bekannten Spielstand-Dateien werden genommen
/// der ZIP) - Pfade aus der ZIP werden nie benutzt. Gibt einen Fehler zurück oder null. /// (egal in welchem Ordner im Archiv) - Pfade aus dem Archiv werden nie benutzt. Gibt einen Fehler zurück oder null.
/// </summary> /// </summary>
public static string ExtractZip(byte[] zip, out Dictionary<string, byte[]> found) public static string ExtractArchive(byte[] data, out Dictionary<string, byte[]> found)
{ {
found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase); found = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
try 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; long total = 0;
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<Stream> open)
{
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) return null;
if (entry.IsEncrypted) return "The archive is password protected";
using var s = open();
using var ms = new MemoryStream();
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) foreach (var entry in archive.Entries)
{ {
string file = Path.GetFileName(entry.FullName.Replace('\\', '/')); error = Take(entry, entry.OpenEntryStream);
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); if (error != null) break;
if (known == null) continue;
if (entry.Length > MaxZipBytes || (total += entry.Length) > MaxZipBytes) return "The save game is too large";
using var s = entry.Open();
using var ms = new MemoryStream();
s.CopyTo(ms);
found[known] = ms.ToArray();
} }
} }
catch (InvalidDataException) { return "This is not a valid ZIP file"; } 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 + ")";
}
if (!found.ContainsKey("defaultES2File.txt")) 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; return null;
} }
@@ -145,7 +176,7 @@ public static class SaveStore
/// <summary>Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer).</summary> /// <summary>Server aus: ZIP als neuen Spielstand übernehmen (Backup vorher, neuer Reset-Punkt, Weltzustand leer).</summary>
public static string ImportZipOffline(string dataDir, byte[] zip, string by) 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; if (error != null) return error;
try try
{ {
Binary file not shown.
+3
View File
@@ -5,6 +5,9 @@
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<None Include="Fixtures/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="C:\Users\nico\Desktop\MSCOMod\MSCO.Server\MSCO.Server.csproj" /> <ProjectReference Include="C:\Users\nico\Desktop\MSCOMod\MSCO.Server\MSCO.Server.csproj" />
</ItemGroup> </ItemGroup>
+15
View File
@@ -466,6 +466,21 @@ async Task PanelTests()
"A save game ZIP can be uploaded: " + await upRes.Content.ReadAsStringAsync()); "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.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"); 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"); var down = await owner.GetAsync("api/save/download");
byte[] downZip = await down.Content.ReadAsByteArrayAsync(); byte[] downZip = await down.Content.ReadAsByteArrayAsync();
using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip))) using (var da = new System.IO.Compression.ZipArchive(new MemoryStream(downZip)))