Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
// World backup management -- list, create, restore, delete.
//
// Backups are server-online (no downtime) -- the daemon issues `save-all flush`
// + `save-off`, archives the world, then `save-on`. Restore *does* stop the
// server (it has to), and snapshots the current world to a `-prerestore-*` dir
// before extracting so a wrong restore is recoverable.
"use strict";
import { api } from "./api.js";
const els = {};
let lastSchedule = null;
let lastKeep = null;
function fmtSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
function fmtRelativeFuture(iso) {
if (!iso) return "--";
const target = new Date(iso).getTime();
const ms = target - Date.now();
if (ms <= 0) return "imminent";
const sec = Math.round(ms / 1000);
if (sec < 60) return `in ${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `in ${min}m`;
const hr = Math.floor(min / 60);
const rem = min % 60;
if (hr < 24) return rem ? `in ${hr}h ${rem}m` : `in ${hr}h`;
const days = Math.floor(hr / 24);
return `in ${days}d ${hr % 24}h`;
}
async function refresh() {
let data;
try { data = await api("/api/backup/list"); }
catch { return; }
els.dir.textContent = data.dir || "--";
// Server returns a human description ("Daily at 04:00", "Every 6 hours", "Disabled").
els.schedule.textContent = data.description || (data.schedule ? `Daily at ${data.schedule}` : "Disabled");
els.next.textContent = data.nextRun ? fmtRelativeFuture(data.nextRun) : "--";
els.keep.textContent = data.keep != null ? `${data.keep} latest` : "--";
lastSchedule = data.schedule || "";
lastKeep = data.keep ?? 14;
// Right-sidebar badge: count of backups
const badge = document.getElementById("bkpBadge");
if (badge) badge.textContent = data.backups?.length ? `${data.backups.length}` : "0";
if (!data.backups || data.backups.length === 0) {
els.list.innerHTML = '<li class="empty-state">No backups yet</li>';
return;
}
els.list.innerHTML = data.backups.map(b => `
<li class="backup-item">
<div class="backup-meta">
<div class="backup-name">${escape(b.name)}</div>
<div class="backup-sub">${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}</div>
</div>
<div class="backup-actions">
<button class="ghost-btn bkp-restore" data-name="${escape(b.name)}">Restore</button>
<button class="ghost-btn bkp-delete" data-name="${escape(b.name)}">Delete</button>
</div>
</li>
`).join("");
}
function escape(s) {
return String(s).replace(/[&<>"']/g, c =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function showMsg(text, ok = false) {
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
els.msg.textContent = text;
}
async function postJson(path, body) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return { ok: res.ok, status: res.status, body: await res.json().catch(() => ({})) };
}
export function setupBackup() {
els.dir = document.getElementById("backupDir");
els.list = document.getElementById("bkpList");
els.create = document.getElementById("bkpCreate");
els.msg = document.getElementById("bkpMsg");
els.schedule = document.getElementById("backupSchedule");
els.next = document.getElementById("backupNext");
els.keep = document.getElementById("backupKeep");
els.editBtn = document.getElementById("bkpEditSchedule");
els.form = document.getElementById("bkpScheduleForm");
els.input = document.getElementById("bkpScheduleInput");
els.keepInput = document.getElementById("bkpKeepInput");
els.saveBtn = document.getElementById("bkpScheduleSave");
els.cancelBtn = document.getElementById("bkpScheduleCancel");
if (!els.create) return;
els.editBtn?.addEventListener("click", () => {
els.form.hidden = !els.form.hidden;
if (!els.form.hidden) {
els.input.value = lastSchedule || "";
els.keepInput.value = lastKeep ?? 14;
els.input.focus();
}
});
els.cancelBtn?.addEventListener("click", () => {
els.form.hidden = true;
showMsg("");
});
els.saveBtn?.addEventListener("click", async () => {
const sched = els.input.value.trim();
const keep = parseInt(els.keepInput.value, 10);
const r = await postJson("/api/backup/schedule", {
schedule: sched,
keep: Number.isFinite(keep) ? keep : undefined,
});
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Error ${r.status}`);
} else {
showMsg(sched ? `Schedule saved: daily at ${sched}` : "Schedule disabled.", true);
els.form.hidden = true;
refresh();
}
});
els.create.addEventListener("click", async () => {
const reason = prompt("Optional reason / label for this backup (e.g. 'pre-update'). Leave blank for none:");
if (reason === null) return; // user cancelled
showMsg("Creating backup -- this may take a minute on a large world...");
els.create.disabled = true;
const r = await postJson("/api/backup/create", { reason: reason.trim() || null });
els.create.disabled = false;
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Error ${r.status}`);
} else {
showMsg(`Backup created: ${r.body.name} (${fmtSize(r.body.sizeBytes)})`, true);
refresh();
}
});
els.list.addEventListener("click", async e => {
const restore = e.target.closest(".bkp-restore");
const del = e.target.closest(".bkp-delete");
if (restore) {
const name = restore.dataset.name;
if (!confirm(`Restore from ${name}?\n\nServer will stop, current world is moved to a "-prerestore" folder for safety, then the backup is extracted and server starts again.`))
return;
showMsg("Restoring -- this stops the server...");
const r = await postJson("/api/backup/restore", { name });
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
else showMsg("Restore complete. Server is starting.", true);
}
if (del) {
const name = del.dataset.name;
if (!confirm(`Delete backup ${name}? This cannot be undone.`)) return;
const r = await postJson("/api/backup/delete", { name });
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
else { showMsg("Deleted.", true); refresh(); }
}
});
refresh();
// Backups don't change often; light poll to pick up new ones if scheduled
// backups are added later, or just to refresh after an external mv/rm.
setInterval(refresh, 30000);
}