This commit is contained in:
Arseniy Romenskiy 2025-12-27 05:24:02 +03:00
parent 22d9f275f2
commit 676172adc5

View file

@ -1,11 +1,9 @@
import { requireAuthOrRedirect, logout } from "./auth.js";
import { apiFetch } from "./api.js";
import { apiFetch, apiFetchBlob } from "./api.js";
import { CONFIG } from "./config.js";
import { loadSession, saveSession, clearSession } from "./storage.js";
import { loadSession } from "./storage.js";
if (!requireAuthOrRedirect()) {
// редирект уже сделан
}
if (!requireAuthOrRedirect()) {}
const userNameEl = document.getElementById("userName");
const logoutBtn = document.getElementById("logoutBtn");
@ -17,42 +15,71 @@ const searchEl = document.getElementById("search");
let rows = [];
function showError(text) {
errorBox.textContent = text;
errorBox.classList.remove("hidden");
}
function clearError() {
errorBox.textContent = "";
errorBox.classList.add("hidden");
}
function setStatus(text) {
statusEl.textContent = text;
statusEl.textContent = text || "";
}
function normalizeCell(v) {
function showError(text) {
errorBox.textContent = text || "";
errorBox.classList.toggle("hidden", !text);
}
function escapeHtml(s) {
return String(s ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function norm(v) {
if (v === null || v === undefined) return "";
return String(v);
}
function isWireguardLike(text) {
const t = String(text || "");
return t.includes("[Interface]") || t.includes("[Peer]") || t.includes("PrivateKey") || t.includes("AllowedIPs");
// Поддержка двух форматов:
// 1) {"ls":[ [id,name,status,ip_local,ip_server], ... ]}
// 2) {"ls":[ {id,name,status,ip_local,ip_server}, ... ]}
function mapLsItem(x) {
if (Array.isArray(x)) {
return {
id: norm(x[0]),
name: norm(x[1]),
status: norm(x[2]),
ipLocal: norm(x[3]),
ipServer: norm(x[4]),
};
}
if (x && typeof x === "object") {
return {
id: norm(x.id),
name: norm(x.name),
status: norm(x.status),
ipLocal: norm(x.ip_local),
ipServer: norm(x.ip_server),
};
}
return null;
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
} catch {
// fallback
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
async function downloadConf(id) {
const path = CONFIG.ENDPOINTS.download_conf_prefix + encodeURIComponent(id);
setStatus(`Скачивание #${id}`);
const { blob, filename } = await apiFetchBlob(path, { method: "GET", auth: true });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename || `wg_${id}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
setStatus("Готово.");
setTimeout(() => setStatus(""), 900);
}
function renderTable(filter = "") {
@ -60,9 +87,9 @@ function renderTable(filter = "") {
const filtered = !q ? rows : rows.filter(r => r.searchBlob.includes(q));
tbody.innerHTML = "";
for (const r of filtered) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(r.id)}</td>
<td>${escapeHtml(r.name)}</td>
@ -77,9 +104,9 @@ function renderTable(filter = "") {
btn.className = "btn btn-ghost btn-sm";
btn.textContent = "Скачать";
btn.addEventListener("click", async () => {
clearError();
showError("");
try {
await triggerDownloadById(r.id);
await downloadConf(r.id);
} catch (err) {
if (err?.status === CONFIG.AUTH_ERROR_STATUS) {
await logout();
@ -96,22 +123,14 @@ function renderTable(filter = "") {
}
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function loadData() {
clearError();
showError("");
setStatus("Загрузка…");
try {
const data = await apiFetch(CONFIG.ENDPOINTS.list, { method: "GET", auth: true });
const list = Array.isArray(data?.ls) ? data.ls : Array.isArray(data) ? data : [];
const list = Array.isArray(data?.ls) ? data.ls : [];
rows = list
.map(mapLsItem)
.filter(Boolean)
@ -124,7 +143,6 @@ async function loadData() {
setStatus(`Готово. Строк: ${rows.length}`);
} catch (err) {
if (err?.status === CONFIG.AUTH_ERROR_STATUS) {
// если даже renew не помог — сессия умерла
await logout();
location.replace("./login.html");
return;
@ -144,60 +162,7 @@ async function loadData() {
});
reloadBtn.addEventListener("click", loadData);
searchEl.addEventListener("input", () => renderTable(searchEl.value));
loadData();
})();
function filenameFromDisposition(cd) {
if (!cd) return null;
// filename*=UTF-8''...
let m = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (m?.[1]) return decodeURIComponent(m[1].replace(/"/g, "").trim());
// filename="..."
m = cd.match(/filename\s*=\s*"?([^";]+)"?/i);
if (m?.[1]) return m[1].trim();
return null;
}
export async function apiFetchBlob(path, { method = "GET", headers = {}, auth = true } = {}) {
const s = loadSession();
const reqHeaders = { ...headers };
if (auth) {
if (!s?.short_token) throw new Error("NO_AUTH");
reqHeaders["short_token"] = String(s.short_token);
}
const doRequest = async () => {
return await fetch(path.startsWith("http") ? path : (CONFIG.API_BASE_URL ? CONFIG.API_BASE_URL.replace(/\/+$/, "") + path : path), {
method,
headers: reqHeaders
});
};
let res = await doRequest();
if (auth && res.status === CONFIG.AUTH_ERROR_STATUS) {
// renew + retry один раз
await (await import("./api.js")).renewTokens?.(); // на случай циклического импорта (если будет) — безопасно
const s2 = loadSession();
reqHeaders["short_token"] = String(s2.short_token);
res = await doRequest();
}
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error("API_BLOB_ERROR");
err.status = res.status;
err.payload = text;
throw err;
}
const blob = await res.blob();
const filename = filenameFromDisposition(res.headers.get("content-disposition"));
return { blob, filename };
}