Add password protection to server saves (SHA-256 client hash, lock icons)

This commit is contained in:
Johannes
2026-03-13 00:31:33 +01:00
parent 728f9d46cc
commit a55daf4ef3

View File

@@ -1056,6 +1056,10 @@
!e.hasOwnProperty("primaryMapData") || !e.hasOwnProperty("subMapData") !e.hasOwnProperty("primaryMapData") || !e.hasOwnProperty("subMapData")
); );
} }
static async sha256_hex(str) {
const buf = await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");
}
static compressJSObjectToString(e) { static compressJSObjectToString(e) {
return btoa( return btoa(
n.arraybufferToString( n.arraybufferToString(
@@ -5625,7 +5629,7 @@
() => { () => {
E.exportSavegamePrivateBin(); E.exportSavegamePrivateBin();
}, },
)}"><i class="fas fa-file-export"></i> ${e.T("settings-share-save-button", "Export save without file")}</button>\n <div id="savegameQR"></div>\n --\x3e\n </div>\n <div class="form-group">\n <h5 class="py-2 text-warning">Server Saves</h5>\n <div class="input-group mb-2">\n <input type="text" class="form-control bg-dark text-white" id="named-save-input" placeholder="Save name...">\n <div class="input-group-append">\n <button class="btn btn-outline-success" onclick="${wi.register(() => { E.exportNamedSave(); })}"><i class="fas fa-cloud-upload-alt"></i> Save on server</button>\n </div>\n </div>\n <small class="form-text text-muted mb-3">Upload a complete export to the server under a custom name.</small>\n <div class="input-group mt-2">\n <select class="form-control bg-dark text-white" id="server-saves-select"><option value="">Loading saves...</option></select>\n <div class="input-group-append">\n <button class="btn btn-outline-warning" onclick="${wi.register(() => { E.importNamedSave(); })}"><i class="fas fa-cloud-download-alt"></i> Load from server</button>\n </div>\n </div>\n <small class="form-text text-muted">Load a previously saved game from the server. This will overwrite your current progress.</small>\n </div>\n </div>\n </div>`; )}"><i class="fas fa-file-export"></i> ${e.T("settings-share-save-button", "Export save without file")}</button>\n <div id="savegameQR"></div>\n --\x3e\n </div>\n <div class="form-group">\n <h5 class="py-2 text-warning">Server Saves</h5>\n <div class="input-group mb-1">\n <input type="text" class="form-control bg-dark text-white" id="named-save-input" placeholder="Save name...">\n <div class="input-group-append">\n <button class="btn btn-outline-success" onclick="${wi.register(() => { E.exportNamedSave(); })}"><i class="fas fa-cloud-upload-alt"></i> Save on server</button>\n </div>\n </div>\n <input type="password" class="form-control bg-dark text-white mb-2" id="named-save-password" placeholder="Password (optional)">\n <small class="form-text text-muted mb-3">Upload a complete export to the server under a custom name.</small>\n <div class="input-group mt-2 mb-1">\n <select class="form-control bg-dark text-white" id="server-saves-select"><option value="">Loading saves...</option></select>\n <div class="input-group-append">\n <button class="btn btn-outline-warning" onclick="${wi.register(() => { E.importNamedSave(); })}"><i class="fas fa-cloud-download-alt"></i> Load from server</button>\n </div>\n </div>\n <input type="password" class="form-control bg-dark text-white mb-1" id="named-load-password" placeholder="Password (if protected)">\n <small class="form-text text-muted">Load a previously saved game from the server. This will overwrite your current progress.</small>\n </div>\n </div>\n </div>`;
} }
static mapManagementFormView() { static mapManagementFormView() {
const e = s.getInstance(); const e = s.getInstance();
@@ -5875,16 +5879,21 @@
const orig_compress = n.getSetting("compressExports"); const orig_compress = n.getSetting("compressExports");
n.setSetting("completeExports", true); n.setSetting("completeExports", true);
n.setSetting("compressExports", true); n.setSetting("compressExports", true);
const password = $("#named-save-password").val();
try { try {
const data = await _.getSavegameData(error_obj); const data = await _.getSavegameData(error_obj);
const compressed = n.compressJSObjectToString(data); const compressed = n.compressJSObjectToString(data);
const blob = new Blob([compressed], { type: "application/octet-stream" }); const blob = new Blob([compressed], { type: "application/octet-stream" });
const headers = { "Content-Type": "application/octet-stream" };
if (password) headers["X-Password-Hash"] = await n.sha256_hex(password);
await fetch(`https://pu-saves.burrson.de/saves/${encodeURIComponent(save_name)}`, { await fetch(`https://pu-saves.burrson.de/saves/${encodeURIComponent(save_name)}`, {
method: "POST", method: "POST",
mode: "cors", mode: "cors",
headers,
body: blob, body: blob,
}); });
n.showToast("Save stored", `"${save_name}" was saved to the server.`, "success"); const label = password ? " (protected)" : "";
n.showToast("Save stored", `"${save_name}"${label} was saved to the server.`, "success");
} catch (e) { } catch (e) {
n.showToast("Save failed", "Could not store the save on the server.", "danger"); n.showToast("Save failed", "Could not store the save on the server.", "danger");
} }
@@ -5894,14 +5903,17 @@
static async populateServerSaves() { static async populateServerSaves() {
try { try {
const res = await fetch("https://pu-saves.burrson.de/saves", { mode: "cors" }); const res = await fetch("https://pu-saves.burrson.de/saves", { mode: "cors" });
const saves = await res.json(); const data = await res.json();
const select = $("#server-saves-select"); const select = $("#server-saves-select");
if (!select.length) return; if (!select.length) return;
select.empty(); select.empty();
if (saves.length === 0) { if (data.saves.length === 0) {
select.append('<option value="">No saves found</option>'); select.append('<option value="">No saves found</option>');
} else { } else {
saves.forEach(name => select.append(`<option value="${name}">${name}</option>`)); data.saves.forEach(name => {
const is_protected = data.protected.includes(name);
select.append(`<option value="${name}">${is_protected ? "🔒 " : ""}${name}</option>`);
});
} }
} catch (e) { } catch (e) {
$("#server-saves-select").empty().append('<option value="">Could not load saves</option>'); $("#server-saves-select").empty().append('<option value="">Could not load saves</option>');
@@ -5910,13 +5922,21 @@
static async importNamedSave() { static async importNamedSave() {
const save_name = $("#server-saves-select").val(); const save_name = $("#server-saves-select").val();
if (!save_name) return; if (!save_name) return;
const password = $("#named-load-password").val();
try { try {
const res = await fetch(`https://pu-saves.burrson.de/saves/${encodeURIComponent(save_name)}`, { mode: "cors" }); const headers = {};
if (password) headers["X-Password-Hash"] = await n.sha256_hex(password);
const res = await fetch(`https://pu-saves.burrson.de/saves/${encodeURIComponent(save_name)}`, { mode: "cors", headers });
if (res.status === 401) {
const msg = await res.text();
n.showToast("Access denied", msg.includes("required") ? "This save is password protected." : "Wrong password.", "danger");
return;
}
if (!res.ok) throw "Not found"; if (!res.ok) throw "Not found";
const text = await res.text(); const text = await res.text();
if (n.hasScript(text)) throw "Script found!"; if (n.hasScript(text)) throw "Script found!";
const data = JSON.parse(n.decompressJsonFromString(text)); const parsed = JSON.parse(n.decompressJsonFromString(text));
E.importSaveGame(data); E.importSaveGame(parsed);
} catch (e) { } catch (e) {
n.showToast("Load failed", "Could not load the save from the server.", "danger"); n.showToast("Load failed", "Could not load the save from the server.", "danger");
} }