From 6286f1826b56f45f8be5f3c7a0ddd61af19221f1 Mon Sep 17 00:00:00 2001 From: samruk_3 Date: Tue, 22 Sep 2026 05:03:16 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard.html | 3 +- lib/alem.js | 37 ++++++++-- lib/store.js | 13 +++- server.js | 183 +++++++++++++++++++++++++++++++++++++------------ upload.html | 30 ++++++-- 5 files changed, 205 insertions(+), 61 deletions(-) diff --git a/dashboard.html b/dashboard.html index 9a92e1b..339e098 100644 --- a/dashboard.html +++ b/dashboard.html @@ -224,7 +224,7 @@ } var idx = ROW_IDX++; ROW_DATA[idx] = r; - var hasDetail = !!(r.comment || (r.flags && r.flags.length) || r.agent_comment); + var hasDetail = !!(r.comment || (r.flags && r.flags.length) || r.agent_comment || r.error); var actsStr = acts.length ? acts.join(" ") : ""; return "" + "" + esc(r.company) + @@ -247,6 +247,7 @@ (r.comment ? "
Комментарий дочки: " + esc(r.comment) + "
" : "") + (r.agent_comment ? "
Замечания агента: " + esc(r.agent_comment) + "
" : "") + ((r.flags && r.flags.length) ? "
Признаки:
" + r.flags.map(esc).join("
") + "
" : "") + + (r.error ? "
Ошибка расчёта: " + esc(r.error) + "
" : "") + "" : ""); }).join(""); tbody.innerHTML = html; diff --git a/lib/alem.js b/lib/alem.js index 8fab7a1..7562b43 100644 --- a/lib/alem.js +++ b/lib/alem.js @@ -52,7 +52,7 @@ function fetchWithTimeout(url, options, timeoutMs) { function createAlemSession() { - const url = process.env.ALEM_BASE_URL + "/api/v1/open-api/conversation"; + const url = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, "") + "/api/v1/open-api/conversation"; return fetchWithTimeout(url, { method: "POST", headers: { @@ -93,7 +93,7 @@ function extractSseText(raw) { } function askAlem(sessionId, message) { - var url = process.env.ALEM_BASE_URL + sendPath(sessionId); + var url = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, "") + sendPath(sessionId); return fetchWithTimeout(url, { method: 'POST', headers: { @@ -102,10 +102,32 @@ function askAlem(sessionId, message) { }, body: JSON.stringify({ content: message, user_id: 'kfu_app', model_api_key: process.env.ALEM_API_KEY }) }, ALEM_TIMEOUT_MS).then(function (resp) { - if (!resp.ok) { const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); e.code = "ALEM_HTTP_" + resp.status; throw e; } - return resp.text().then(function (raw) { - return extractSseText(raw); - }); + if (!resp.ok) { + const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); + e.code = "ALEM_HTTP_" + resp.status; + throw e; + } + const reader = resp.body.getReader(); + const dec = new TextDecoder(); + let raw = ""; + let finished = false; + function settle(fn) { + if (!finished) { + finished = true; + try { reader.cancel(); } catch (e) { } + fn(extractSseText(raw)); + } + } + function pump() { + reader.read().then(function (r) { + if (r.done) { settle(resolve); return; } + raw += dec.decode(r.value, { stream: true }); + pump(); + }).catch(function (err) { + if (!finished) { finished = true; reject(err.code === "ALEM_TIMEOUT" ? err : new Error("Alem stream read error: " + String((err && err.message) || err))); } + }); + } + return new Promise(function (resolve, reject) { pump(); }); }); } function sendPath(id) { @@ -146,7 +168,8 @@ function callKfuAgent(company, period, fileText) { 'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.', 'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).' ].join("\n"); - if (!process.env.ALEM_BASE_URL || !process.env.ALEM_API_KEY || !process.env.ALEM_TEMPLATE_ID) { + const base = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, ""); + if (!base || !process.env.ALEM_API_KEY || !process.env.ALEM_TEMPLATE_ID) { const e = new Error("Агент Alem не настроен: отсутствуют ALEM_BASE_URL / ALEM_API_KEY / ALEM_TEMPLATE_ID."); e.code = "ALEM_NOT_CONFIGURED"; return Promise.reject(e); diff --git a/lib/store.js b/lib/store.js index a3a805f..ed2a1a7 100644 --- a/lib/store.js +++ b/lib/store.js @@ -4,8 +4,13 @@ const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); -const DATA_FILE = path.join(__dirname, "..", "data.json"); -const UPLOADS_DIR = path.join(__dirname, "..", "uploads"); +let DATA_FILE = path.join(__dirname, "..", "data.json"); +let UPLOADS_DIR = path.join(__dirname, "..", "uploads"); + +function configure(dataFile, uploadsDir) { + DATA_FILE = dataFile; + UPLOADS_DIR = uploadsDir; +} const DEFAULT_DEADLINE = "2026-10-15"; @@ -77,6 +82,7 @@ function seed() { consolidatorPhone: "", users: [], reports: [], + sessions: {}, }; const consPw = process.env.KFU_SVOD_PASSWORD || "12345678"; const cons = makeUser({ @@ -86,7 +92,6 @@ function seed() { companyName: "", login: "svod", _seedPassword: consPw, - _keepTemp: true, }); db._consolidatorTempPassword = consPw; db.users.push(cons); @@ -130,6 +135,7 @@ function load() { if (typeof u.lockedUntil !== "number") u.lockedUntil = 0; if (typeof u.active !== "boolean") u.active = true; }); + if (!db.sessions || typeof db.sessions !== "object") db.sessions = {}; return db; } @@ -155,6 +161,7 @@ function verifyPassword(password, saltHex, expectedHex) { module.exports = { DATA_FILE: DATA_FILE, UPLOADS_DIR: UPLOADS_DIR, + configure: configure, ensureUploadsDir: ensureUploadsDir, randPassword: randPassword, makeUser: makeUser, diff --git a/server.js b/server.js index 6665728..b72a4a7 100644 --- a/server.js +++ b/server.js @@ -7,11 +7,38 @@ const crypto = require("crypto"); const PORT = process.env.PORT || 3000; const ROOT = __dirname; -let DATA_DIR = path.join(ROOT, "..", ".kfu-data"); -let DATA_FILE = path.join(DATA_DIR, "data.json"); -let UPLOADS_DIR = path.join(DATA_DIR, "uploads"); +function resolveDataDir() { + const candidates = [ + process.env.KFU_DATA_DIR, + path.join(ROOT, "..", ".kfu-data"), + path.join(ROOT, ".kfu-data"), + ].filter(Boolean); + for (const dir of candidates) { + try { + fs.mkdirSync(dir, { recursive: true }); + const probe = path.join(dir, ".writable-test"); + fs.writeFileSync(probe, "1"); + fs.unlinkSync(probe); + return dir; + } catch (e) { } + } + return null; +} + +const EXTERNAL_DATA_DIR = resolveDataDir(); +let DATA_DIR = EXTERNAL_DATA_DIR || path.join(ROOT, "data"); +let UPLOADS_DIR = path.join(DATA_DIR, "uploads"); +fs.mkdirSync(DATA_DIR, { recursive: true }); +fs.mkdirSync(UPLOADS_DIR, { recursive: true }); +if (!EXTERNAL_DATA_DIR) { + console.warn("[KFU] NOTE: data dir outside project not writable, using in-project " + DATA_DIR + " (protected by 404 allowlist)"); +} + +// store.js must point at the same files const store = require("./lib/store"); +store.configure(path.join(DATA_DIR, "data.json"), UPLOADS_DIR); + const alem = require("./lib/alem"); const SECRET = process.env.KFU_SECRET || crypto.randomBytes(16).toString("hex"); @@ -19,8 +46,6 @@ const PUBLIC_BASE = process.env.KFU_PUBLIC_BASE || ""; const SESSION_TTL_MS = 1000 * 60 * 60 * 12; const MAX_UPLOAD = 50 * 1024 * 1024; -try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } -catch (e) { DATA_DIR = ROOT; UPLOADS_DIR = path.join(ROOT, "uploads"); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } const DB = store.load(); let GENERATED_PASSWORD = DB._consolidatorTempPassword || null; @@ -36,7 +61,8 @@ if (DB._firstRunSubsidiaryPasswords) { store.save(DB); } -const sessions = {}; +// sessions are persisted in DB.sessions = { token: { userId, exp } } + function nowMs() { return Date.now(); } function iso() { return new Date().toISOString(); } @@ -65,16 +91,29 @@ function sendJson(res, code, obj) { function makeToken() { return crypto.randomBytes(24).toString("hex"); } +function pruneSessions() { + if (!DB.sessions) DB.sessions = {}; + const now = nowMs(); + let changed = false; + Object.keys(DB.sessions).forEach(function (tok) { + if (DB.sessions[tok].exp < now) { delete DB.sessions[tok]; changed = true; } + }); + if (changed) store.save(DB); +} + function createUserSession(res, user) { + if (!DB.sessions) DB.sessions = {}; + pruneSessions(); const token = makeToken(); - sessions[token] = { userId: user.id, exp: nowMs() + SESSION_TTL_MS }; + DB.sessions[token] = { userId: user.id, exp: nowMs() + SESSION_TTL_MS }; + store.save(DB); res.setHeader("Set-Cookie", "kfu_session=" + token + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + Math.floor(SESSION_TTL_MS / 1000)); } function currentUser(req) { const cookies = parseCookies(req); const token = cookies.kfu_session; if (!token) return null; - const s = sessions[token]; + const s = DB.sessions ? DB.sessions[token] : null; if (!s || s.exp < nowMs()) return null; return DB.users.find(function (u) { return u.id === s.userId && u.active; }) || null; } @@ -132,7 +171,7 @@ function handleLogin(req, res) { function handleLogout(req, res) { const cookies = parseCookies(req); const token = cookies.kfu_session; - if (token) delete sessions[token]; + if (token && DB.sessions) { delete DB.sessions[token]; store.save(DB); } res.setHeader("Set-Cookie", "kfu_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); sendJson(res, 200, { ok: true }); } @@ -198,6 +237,7 @@ function sendNotification(event, company, period, status) { function computeFromText(user, report, fileText) { report.status = "processing"; store.save(DB); + log("alem call start", user.companyName, "ALEM_BASE_URL=" + (process.env.ALEM_BASE_URL || ""), "ALEM_API_KEY=" + (process.env.ALEM_API_KEY ? "" : ""), "ALEM_TEMPLATE_ID=" + (process.env.ALEM_TEMPLATE_ID || "")); alem.callKfuAgent(user.companyName, DB.period, fileText).then(function (result) { report.status = "done"; report.result = result; @@ -221,7 +261,7 @@ function describeAlemError(err) { if (code === "ALEM_TIMEOUT") return "Агент не ответил за 10 минут. " + msg; if (code === "ALEM_NOT_CONFIGURED") return "Агент Alem не настроен."; if (code === "ALEM_NOT_JSON") return msg; - var m = msg.match(/^.*?HTTP (\d\d\d)/); + var m = msg.match(/HTTP (\d\d\d)/); if (m) return "Ошибка HTTP " + m[1] + " от сервера Alem."; return "Ошибка агента: " + msg; } @@ -364,6 +404,7 @@ function dashboardRows() { row.sources = res.sources || {}; row.agent_comment = res.comment || ""; } + if (r && r.error) row.error = r.error; return row; }); } @@ -392,10 +433,14 @@ function handleFile(req, res, id) { if (user.role !== "consolidator") { res.writeHead(404); return res.end("Нет такого файла"); } const fp = path.join(UPLOADS_DIR, id + ".bin"); if (!fs.existsSync(fp)) { res.writeHead(404); return res.end("Файл удалён"); } - const name = (report.filename || "file").replace(/[^\w.\-а-яА-ЯёЁ ]/g, "_"); + const safe = (report.filename || "file").replace(/[^\w.\-а-яА-ЯёЁ ]/g, "_").slice(0, 150); + const ascii = safe.replace(/[^\x20-\x7E]/g, "_"); + const dispName = ascii || "file"; + let disp = 'attachment; filename="' + dispName + '"'; + try { disp += "; filename*=UTF-8''" + encodeURIComponent(safe); } catch (e) {} res.writeHead(200, { "Content-Type": "application/octet-stream", - "Content-Disposition": 'attachment; filename="' + name + '"', + "Content-Disposition": disp, }); fs.createReadStream(fp).pipe(res); } @@ -452,13 +497,16 @@ function handleExport(req, res) { cols.forEach(function (c) { let v = r[c[0]]; if (v === null || v === undefined) v = ""; - if (c[0] === "status") v = { done: "сдан", processing: "обрабатывается", error: "ошибка расчёта", not_submitted: "не сдан" }[v] || v; + const ST = { done: "сдан", processing: "обрабатывается", error: "ошибка расчёта", not_submitted: "не сдан" }; + if (c[0] === "status") v = ST[v] || v; line.push(v); }); aoa.push(line); }); + const sheet = XLSX.utils.aoa_to_sheet(aoa); + sheet["!cols"] = cols.map(function (c) { return { wch: Math.min(40, Math.max(10, c[1].length + 4)) }; }); const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Свод"); + XLSX.utils.book_append_sheet(wb, sheet, "Свод"); const comm = [["Компания", "Ответственный", "Телефон", "Период", "Комментарий"]]; rows.forEach(function (r) { comm.push([r.company, r.person || "", r.phone || "", DB.period, r.comment || ""]); @@ -467,7 +515,7 @@ function handleExport(req, res) { const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }); res.writeHead(200, { "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "Content-Disposition": 'attachment; filename="kfu-svod-' + DB.period.replace(/[^\wа-яА-ЯёЁ]/g, "").replace(/\s+/g, "-") + '.xlsx"', + "Content-Disposition": 'attachment; filename="kfu-svod.xlsx"', }); res.end(buf); } @@ -654,12 +702,31 @@ function handleStatusApi(req, res) { } // ---------- static ---------- +// only UI html pages and design-system/ are public; everything else is 404 +const INTERNAL_NAMES = { + "/server.js": true, "/data.json": true, "/package.json": true, "/package-lock.json": true, + "/lib": true, "/node_modules": true, "/.git": true, "/data": true, "/.kfu-data": true, + "/uploads": true, "/AGENTS.md": true, "/README.md": true, "/design.md": true, + "/run": true, "/index": false, "/upload": false, "/dashboard": false, +}; +function isServeable(urlPath) { + if (!urlPath || typeof urlPath !== "string") return false; + if (urlPath === "/" || urlPath === "/index.html" || urlPath === "/upload.html" || urlPath === "/dashboard.html") return true; + if (urlPath.indexOf("/design-system/") === 0) return true; + if (INTERNAL_NAMES[urlPath] || INTERNAL_NAMES[path.dirname(urlPath)]) return false; + // unknown file: 404 unless it is an existing html file in ROOT (future pages) + const norm = path.normalize(urlPath); + const full = path.join(ROOT, norm); + if (norm.indexOf("..") !== -1 || full !== ROOT && !full.startsWith(ROOT + path.sep)) return false; + if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return false; + return path.extname(full).toLowerCase() === ".html"; +} function serveStatic(req, res, urlPath) { - let file = urlPath === "/" ? "/index.html" : urlPath; - if (path.extname(file) === "") { - if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html"; - else file = "/index.html"; + if (!isServeable(urlPath)) { + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end("{}"); } + let file = urlPath === "/" ? "/index.html" : urlPath; const norm = path.normalize(file); const full = path.join(ROOT, norm); if (norm.indexOf("..") !== -1 || (full !== ROOT && !full.startsWith(ROOT + path.sep))) { @@ -667,8 +734,8 @@ function serveStatic(req, res, urlPath) { return res.end("{}"); } if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { - res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); - return res.end("

404

"); + res.writeHead(404, { "Content-Type": "application/json" }); + return res.end("{}"); } const ext = path.extname(full).toLowerCase(); const types = { @@ -681,42 +748,72 @@ function serveStatic(req, res, urlPath) { } // ---------- router ---------- +function guard(fn) { + return function (req, res) { + try { + const out = fn(req, res); + if (out && typeof out.then === "function") { + out.catch(function (e) { + log("async handler error", req.method, req.url, String((e && e.stack) || e)); + try { + if (!res.headersSent) sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); + else res.end(); + } catch (_) {} + }); + } + return out; + } catch (e) { + log("handler error", req.method, req.url, String((e && e.stack) || e)); + try { + if (!res.headersSent) sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); + else res.end(); + } catch (_) {} + } + }; +} + +process.on("uncaughtException", function (e) { + log("UNCAUGHT", String((e && e.stack) || e)); +}); +process.on("unhandledRejection", function (e) { + log("UNHANDLED REJECTION", String((e && e.stack) || e)); +}); + http.createServer(function (req, res) { const urlPath = (req.url || "/").split("?")[0]; try { - if (req.method === "POST" && urlPath === "/api/login") return handleLogin(req, res); - if (req.method === "POST" && urlPath === "/api/logout") return handleLogout(req, res); - if (req.method === "GET" && urlPath === "/api/me") return handleMe(req, res); - if (req.method === "POST" && urlPath === "/api/upload") return handleUpload(req, res); - if (req.method === "GET" && urlPath === "/api/dashboard") return handleDashboard(req, res); - if (req.method === "GET" && urlPath === "/api/export") return handleExport(req, res); - if (req.method === "GET" && urlPath === "/api/settings") return handleSettingsGet(req, res); - if (req.method === "POST" && urlPath === "/api/settings") return handleSettingsSave(req, res); - if (req.method === "POST" && urlPath === "/api/add-company") return handleAddCompany(req, res); + if (req.method === "POST" && urlPath === "/api/login") return guard(handleLogin)(req, res); + if (req.method === "POST" && urlPath === "/api/logout") return guard(handleLogout)(req, res); + if (req.method === "GET" && urlPath === "/api/me") return guard(handleMe)(req, res); + if (req.method === "POST" && urlPath === "/api/upload") return guard(handleUpload)(req, res); + if (req.method === "GET" && urlPath === "/api/dashboard") return guard(handleDashboard)(req, res); + if (req.method === "GET" && urlPath === "/api/export") return guard(handleExport)(req, res); + if (req.method === "GET" && urlPath === "/api/settings") return guard(handleSettingsGet)(req, res); + if (req.method === "POST" && urlPath === "/api/settings") return guard(handleSettingsSave)(req, res); + if (req.method === "POST" && urlPath === "/api/add-company") return guard(handleAddCompany)(req, res); if (req.method === "POST" && urlPath === "/api/recalc") { const q = (req.url.split("?")[1] || ""); - return handleRecalc(req, res, decodeURIComponent((q.replace(/^id=/, "") || "").toUpperCase())); + return guard(handleRecalc)(req, res, decodeURIComponent((q.replace(/^id=/, "") || "").toUpperCase())); } const userMatch = urlPath.match(/^\/api\/user\/([\w]+)/); - if (req.method === "POST" && userMatch) return handleUserAction(req, res, userMatch[1]); - if (req.method === "POST" && urlPath === "/api/consolidator-password") return handleConsolidatorPassword(req, res); + if (req.method === "POST" && userMatch) return guard(handleUserAction)(req, res, userMatch[1]); + if (req.method === "POST" && urlPath === "/api/consolidator-password") return guard(handleConsolidatorPassword)(req, res); if (urlPath.indexOf("/api/file/") === 0) { const id = urlPath.slice("/api/file/".length).toUpperCase(); - return handleFile(req, res, decodeURIComponent(id)); + return guard(handleFile)(req, res, decodeURIComponent(id)); } - if (urlPath === "/api/recipients") { + if (urlPath === "/api/recipients" || urlPath === "/api/status") { if (!checkXKfuSecret(req, res)) return; - return handleRecipients(req, res); - } - if (urlPath === "/api/status") { - if (!checkXKfuSecret(req, res)) return; - return handleStatusApi(req, res); + return urlPath === "/api/recipients" ? guard(handleRecipients)(req, res) : guard(handleStatusApi)(req, res); } if (urlPath.indexOf("/api/") === 0) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end("{}"); } - return serveStatic(req, res, urlPath); + return guard(serveStatic)(req, res, urlPath); } catch (e) { - log("handler error", req.method, urlPath, String((e && e.message) || e)); - try { sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); } catch (_) {} + log("router error", req.method, req.url, String((e && e.stack) || e)); + try { + if (!res.headersSent) sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); + else res.end(); + } catch (_) {} } }).listen(PORT, function () { log("KFU server on port " + PORT); diff --git a/upload.html b/upload.html index 8c26702..13ed588 100644 --- a/upload.html +++ b/upload.html @@ -184,22 +184,38 @@ xhr.upload.onprogress = function (ev) { if (ev.lengthComputable) sendBtn.textContent = "Отправка… " + Math.round(ev.loaded * 100 / ev.total) + "%"; }; - xhr.onload = function () { + function failMessage(status) { try { var b = JSON.parse(xhr.responseText || "{}"); - if (xhr.status >= 200 && xhr.status < 300 && b.ok) { - showDone(b.uploadedAt); - return; + var msg = (b && b.error) || ""; + if (xhr.status === 401) return "Сессия истекла — войдите заново."; + if (!msg) { + if (xhr.status === 413) msg = "Файл не принят: файл больше 50 МБ"; + else if (xhr.status === 415) msg = "Файл не принят: поддерживаются только xlsx, xlsm, pdf"; + else if (xhr.status === 423) msg = "Слишком много попыток — попробуйте позже"; + else if (xhr.status >= 500) msg = "Ошибка сервера (" + xhr.status + "). Попробуйте ещё раз."; + else msg = "Файл не принят: причина — " + (xhr.statusText || xhr.status); } - alert((b && b.error) || "Не удалось отправить файл"); + return msg; } catch (err) { - alert("Ошибка сервера: " + xhr.status); + if (xhr.status === 401) return "Сессия истекла — войдите заново."; + return "Ошибка сервера: " + xhr.status + (xhr.statusText ? " " + xhr.statusText : ""); } + } + xhr.onload = function () { + if (xhr.status >= 200 && xhr.status < 300) { + try { + var b = JSON.parse(xhr.responseText || "{}"); + if (b.ok) { showDone(b.uploadedAt); return; } + } catch (err) {} + } + alert(failMessage(xhr.status)); sendBtn.disabled = false; sendBtn.textContent = "Отправить"; + if (xhr.status === 401) setTimeout(function () { window.location.href = "index.html"; }, 1500); }; xhr.onerror = function () { - alert("Сервер недоступен. Проверьте соединение."); + alert("Сервер недоступен. Проверьте соединение и попробуйте ещё раз."); sendBtn.disabled = false; sendBtn.textContent = "Отправить"; };