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 = "Отправить";
};