From 676172adc5ff967b6cdb48e871cefb3d8770576e Mon Sep 17 00:00:00 2001 From: Arseniy Romenskiy Date: Sat, 27 Dec 2025 05:24:02 +0300 Subject: [PATCH] fix --- frontend/js/app.js | 165 ++++++++++++++++++--------------------------- 1 file changed, 65 insertions(+), 100 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index ccf4d45..274049a 100755 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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 = ` ${escapeHtml(r.id)} ${escapeHtml(r.name)} @@ -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("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - 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 }; -}