Files

556 lines
16 KiB
JavaScript

(() => {
"use strict";
const API = {
list: "/api/keys",
register: "/api/keys/register",
delete: (id) => `/api/keys/${encodeURIComponent(id)}`,
reveal: (id) => `/api/keys/${encodeURIComponent(id)}/access-key`,
};
let bound = false;
function qs(sel, root = document) {
return root.querySelector(sel);
}
function esc(v) {
return String(v ?? "").replace(/[&<>"']/g, ch => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[ch]));
}
async function readJson(res) {
const text = await res.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch {
return { error: text };
}
}
function formEl() {
const version = qs("#key-version");
return version ? version.closest("form") : null;
}
function listEl() {
return qs(".key-list");
}
function setMessage(text, kind = "") {
const form = formEl();
if (!form) return;
let box = qs("#key-profile-status");
if (!box) {
box = document.createElement("p");
box.id = "key-profile-status";
box.className = "key-status-message";
form.insertAdjacentElement("afterend", box);
}
box.textContent = text || "";
box.dataset.kind = kind;
}
function normalizeSerial(raw, gameVersion) {
const value = String(raw || "").trim();
if (gameVersion === "dc_v2") {
let v = value.toUpperCase();
if (v.startsWith("0X")) v = v.slice(2);
if (/^[0-9A-F]{1,8}$/.test(v)) {
return v.padStart(8, "0");
}
return "";
}
if (gameVersion === "pc_v2") {
if (/^[0-9]{1,10}$/.test(value)) {
const n = Number(value);
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
return n.toString(16).toUpperCase().padStart(8, "0");
}
}
return "";
}
if (gameVersion === "gc_v3") {
if (/^[0-9]{2}-[0-9]{4}-[0-9]{4}$/.test(value)) {
const n = Number(value.replaceAll("-", ""));
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
return n.toString(16).toUpperCase().padStart(8, "0");
}
}
return "";
}
return "";
}
function setupForm(form) {
const version = qs("#key-version", form);
if (version) {
version.innerHTML = [
'<option value="dc_v2">DC V2</option>',
'<option value="pc_v2">PC V2</option>',
'<option value="gc_v3">GC V3</option>',
].join("");
}
updateSerialHint(form);
const versionSelect = qs("#key-version", form);
if (versionSelect && !versionSelect.dataset.hintBound) {
versionSelect.dataset.hintBound = "1";
versionSelect.addEventListener("change", () => updateSerialHint(form));
}
const keyPassword = qs("#key-password", form);
if (keyPassword) {
const label = qs('label[for="key-password"]', form);
if (label) label.remove();
keyPassword.remove();
}
const serial = qs("#key-serial", form);
if (serial && !qs("#key-display-serial", form)) {
const displayLabel = document.createElement("label");
displayLabel.id = "key-display-serial-label";
displayLabel.setAttribute("for", "key-display-serial");
displayLabel.textContent = "Confirm Serial Number";
const displayInput = document.createElement("input");
displayInput.id = "key-display-serial";
displayInput.name = "display_serial";
displayInput.type = "text";
displayInput.autocomplete = "off";
displayInput.placeholder = "confirm serial number";
serial.insertAdjacentElement("afterend", displayInput);
serial.insertAdjacentElement("afterend", displayLabel);
}
const access = qs("#key-access", form);
if (access && !qs("#key-password", form)) {
const passwordLabel = document.createElement("label");
passwordLabel.id = "key-password-label";
passwordLabel.setAttribute("for", "key-password");
passwordLabel.textContent = "GC Password";
passwordLabel.style.display = "none";
const passwordInput = document.createElement("input");
passwordInput.id = "key-password";
passwordInput.name = "password";
passwordInput.type = "text";
passwordInput.autocomplete = "off";
passwordInput.placeholder = "GC password";
passwordInput.style.display = "none";
access.insertAdjacentElement("afterend", passwordInput);
access.insertAdjacentElement("afterend", passwordLabel);
}
const button = qs('button[type="submit"]', form) || qs("button", form);
if (button) {
button.type = "button";
}
}
function syncDotClass(status) {
const value = String(status || "").toLowerCase();
return (value === "synced" || value === "current") ? "is-synced" : "is-syncing";
}
function renderSyncStatus(label, status) {
const safeStatus = status || "unknown";
return `
<span class="key-sync-status">
<span class="key-sync-dot ${syncDotClass(safeStatus)}" style="color: ${syncDotClass(safeStatus) === 'is-synced' ? '#38d66b' : '#e05252'};" aria-hidden="true">●</span>
<span>${esc(label)}: <strong>${esc(safeStatus)}</strong></span>
</span>
`;
}
function renderKeySyncSummary(data) {
const box = qs("#key-sync-summary");
if (!box) return;
const sync = data.sync_status || "unknown";
const us = data.regions?.us?.status || "unknown";
const eu = data.regions?.eu?.status || "unknown";
box.innerHTML = `
${renderSyncStatus("Key sync", sync)}
${renderSyncStatus("US", us)}
${renderSyncStatus("EU", eu)}
`;
}
async function loadKeys() {
const res = await fetch("/api/keys", {
credentials: "same-origin",
});
const data = await readJson(res);
if (!res.ok || data.ok === false) {
throw new Error(data.error || data.detail || `HTTP ${res.status}`);
}
renderKeys(data);
return data;
}
function renderKeys(data) {
const list = listEl();
if (!list) return;
const keys = Array.isArray(data.keys) ? data.keys : [];
const sync = data.sync_status || "unknown";
const us = data.regions?.us?.status || "unknown";
const eu = data.regions?.eu?.status || "unknown";
renderKeySyncSummary(data);
const summary = "";
if (!keys.length) {
list.innerHTML = summary + `
<article class="key-row key-row--empty" role="listitem">
<div>
<h3>No V2 / V3 keys registered</h3>
<p>Add a DC V2, PC V2, or GC V3 key profile above.</p>
</div>
</article>
`;
return;
}
list.innerHTML = summary + keys.map(key => {
const version = key.game_version_label || key.game_version || "Key";
const label = key.label || "";
const serial = key.serial_number_hex || "";
let displaySerial = key.display_serial || serial;
if (!key.display_serial && serial) {
const n = Number.parseInt(serial, 16);
if (Number.isFinite(n)) {
if (key.game_version === "pc_v2") {
displaySerial = String(n);
} else if (key.game_version === "gc_v3") {
const dec = String(n).padStart(10, "0");
displaySerial = `${dec.slice(0, 2)}-${dec.slice(2, 6)}-${dec.slice(6, 10)}`;
}
}
}
const isGC = key.game_version === "gc_v3";
return `
<article class="key-row" role="listitem" data-key-id="${esc(key.id)}" data-key-version="${esc(key.game_version || "")}">
<div>
<h3>${esc(version)}</h3>
<p>Registered profile <strong>${esc(serial)}</strong></p>
<p class="key-meta">Serial: ${esc(displaySerial)}</p>
${label ? `<p class="key-meta">Label: ${esc(label)}</p>` : ""}
<p class="key-secret-line">
<span>Access key:</span>
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="access_key">••••••••••••</code>
<button class="inline-link key-reveal-button" type="button" data-key-id="${esc(key.id)}">
show
</button>
</p>
${isGC ? `<p class="key-secret-line">
<span>Password:</span>
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="password">••••••••••••</code>
</p>` : ""}
</div>
<button class="button-danger key-delete-button" type="button" data-key-id="${esc(key.id)}">
Delete Key
</button>
</article>
`;
}).join("");
}
function updateSerialHint(form) {
const gameVersion = qs("#key-version", form)?.value || "";
const serial = qs("#key-serial", form);
const displaySerial = qs("#key-display-serial", form);
const access = qs("#key-access", form);
const password = qs("#key-password", form);
const passwordLabel = qs("#key-password-label", form);
if (!serial) return;
if (gameVersion === "dc_v2") {
serial.placeholder = "DC V2 serial number";
if (displaySerial) displaySerial.placeholder = "confirm DC V2 serial number";
if (access) access.placeholder = "access key";
} else if (gameVersion === "pc_v2") {
serial.placeholder = "PC V2 decimal serial number";
if (displaySerial) displaySerial.placeholder = "confirm PC V2 decimal serial number";
if (access) access.placeholder = "access key";
} else if (gameVersion === "gc_v3") {
serial.placeholder = "GC V3 serial number";
if (displaySerial) displaySerial.placeholder = "confirm GC V3 serial number";
if (access) access.placeholder = "access key";
}
if (passwordLabel) {
passwordLabel.style.display = gameVersion === "gc_v3" ? "" : "none";
}
if (password) {
password.style.display = gameVersion === "gc_v3" ? "" : "none";
if (gameVersion !== "gc_v3") {
password.value = "";
}
}
}
async function registerKey() {
const form = formEl();
if (!form) return;
const button = qs("button", form);
const gameVersion = qs("#key-version", form)?.value || "";
const label = qs("#key-label", form)?.value.trim() || "";
const rawSerial = qs("#key-serial", form)?.value.trim() || "";
const displaySerial = qs("#key-display-serial", form)?.value.trim() || "";
const accessKey = qs("#key-access", form)?.value.trim() || "";
const password = qs("#key-password", form)?.value.trim() || "";
if (!["dc_v2", "pc_v2", "gc_v3"].includes(gameVersion)) {
setMessage("Choose DC V2, PC V2, or GC V3.", "warn");
return;
}
const serialChecks = {
dc_v2: /^(?:0x)?[0-9A-Fa-f]{1,8}$/,
pc_v2: /^[0-9]{1,10}$/,
gc_v3: /^[0-9]{2}-[0-9]{4}-[0-9]{4}$/,
};
if (!serialChecks[gameVersion]?.test(rawSerial)) {
const examples = {
dc_v2: "DC V2 serial must be hex, like 4E62F237.",
pc_v2: "PC V2 serial must be digits only.",
gc_v3: "GC V3 serial must use the dashed format: NN-NNNN-NNNN.",
};
setMessage(examples[gameVersion] || "Enter a valid serial number.", "warn");
return;
}
const serial = rawSerial;
if (!serial) {
setMessage("Enter a valid serial number.", "warn");
return;
}
if (!displaySerial) {
setMessage("Confirm serial number is required.", "warn");
return;
}
if (displaySerial !== rawSerial) {
setMessage("Serial number confirmation does not match.", "warn");
return;
}
if (!accessKey) {
setMessage("Enter the key.", "warn");
return;
}
if (gameVersion === "gc_v3" && !password) {
setMessage("Enter the GC password.", "warn");
return;
}
if (button) button.disabled = true;
setMessage("Registering key profile...", "pending");
try {
const res = await fetch("/api/keys/register", {
method: "POST",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
game_version: gameVersion,
label,
serial_number: serial,
display_serial: displaySerial,
access_key: accessKey,
password,
}),
});
const data = await readJson(res);
if (!res.ok) {
throw new Error(data.error || `Register failed: ${res.status}`);
}
form.reset();
setupForm(form);
setMessage(`Saved. Sync status: ${data.sync_status}. Refresh status in a few seconds.`, "ok");
await loadKeys();
} catch (err) {
setMessage(err.message || String(err), "warn");
} finally {
if (button) button.disabled = false;
}
}
async function revealAccessKey(id, button) {
if (!id || !button) return;
const card = button.closest("article, .key-card, .key-row, .registered-key");
const values = Array.from(card?.querySelectorAll(`[data-key-secret="${String(id).replace(/"/g, "\\\"")}"]`) || []);
if (!values.length) {
setMessage("Could not find this key on the page. Refresh and try again.", "warn");
return;
}
if (button.dataset.visible === "1") {
values.forEach(value => value.textContent = "••••••••••••");
button.textContent = "show";
button.dataset.visible = "0";
return;
}
button.disabled = true;
try {
const res = await fetch(API.reveal(id), {
credentials: "same-origin",
headers: { "Accept": "application/json" },
});
const data = await readJson(res);
if (!res.ok) {
throw new Error(data.error || `Reveal failed: ${res.status}`);
}
values.forEach(value => {
const kind = value.dataset.keySecretKind || "access_key";
value.textContent = data.key?.[kind] || "";
});
button.textContent = "hide";
button.dataset.visible = "1";
} catch (err) {
setMessage(err.message || String(err), "warn");
} finally {
button.disabled = false;
}
}
async function deleteKey(id, button) {
if (!id) return;
const card = button?.closest("article, .key-card, .key-row, .registered-key");
const gameVersion = card?.dataset?.keyVersion || "";
let confirmMessage = "Delete this V2 key profile from this website account?";
if (gameVersion === "pc_v2") {
confirmMessage += "\n\nPC V2 serial/key is forever tied to your local character saves. Please be sure you have your keys backed up before removing this key from your profile.";
}
if (!window.confirm(confirmMessage)) return;
button.disabled = true;
setMessage("Deleting key profile...", "pending");
try {
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "same-origin",
headers: { "Accept": "application/json" },
});
const data = await readJson(res);
if (!res.ok) {
throw new Error(data.error || `Delete failed: ${res.status}`);
}
setMessage(`Deleted. Sync status: ${data.sync_status}.`, "ok");
await loadKeys();
} catch (err) {
setMessage(err.message || String(err), "warn");
button.disabled = false;
}
}
function bind() {
if (bound) return true;
const form = formEl();
const list = listEl();
if (!form || !list) return false;
setupForm(form);
const button = qs("button", form);
if (button) {
button.addEventListener("click", registerKey);
}
form.addEventListener("submit", (ev) => {
ev.preventDefault();
registerKey();
});
list.addEventListener("click", (ev) => {
const revealButton = ev.target.closest(".key-reveal-button");
if (revealButton) {
revealAccessKey(revealButton.dataset.keyId, revealButton);
return;
}
const button = ev.target.closest(".key-delete-button");
if (!button) return;
deleteKey(button.dataset.keyId, button);
});
bound = true;
loadKeys().catch(err => {
list.innerHTML = `
<article class="key-row key-row--empty" role="listitem">
<div>
<h3>Could not load keys</h3>
<p>${esc(err.message || err)}</p>
</div>
</article>
`;
});
return true;
}
function start() {
[0, 500, 1500, 3000].forEach(ms => {
window.setTimeout(bind, ms);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();