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:
+42
-3
@@ -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
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+45
-14
@@ -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;
|
||||||
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<Stream> 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));
|
string known = SaveFiles.FirstOrDefault(f => string.Equals(f, file, StringComparison.OrdinalIgnoreCase));
|
||||||
if (known == null) continue;
|
if (known == null) return null;
|
||||||
if (entry.Length > MaxZipBytes || (total += entry.Length) > MaxZipBytes) return "The save game is too large";
|
if (entry.IsEncrypted) return "The archive is password protected";
|
||||||
using var s = entry.Open();
|
using var s = open();
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
s.CopyTo(ms);
|
var chunk = new byte[81920];
|
||||||
found[known] = ms.ToArray();
|
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"))
|
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.
@@ -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>
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
Reference in New Issue
Block a user