Опубликовано через кнопку

This commit is contained in:
samruk_3 2026-09-22 05:03:16 +00:00
parent 09e179107c
commit 6286f1826b
5 changed files with 205 additions and 61 deletions

View File

@ -224,7 +224,7 @@
} }
var idx = ROW_IDX++; var idx = ROW_IDX++;
ROW_DATA[idx] = r; 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(" ") : ""; var actsStr = acts.length ? acts.join(" ") : "";
return "<tr>" + return "<tr>" +
"<td class='company company-cell' title='" + esc(r.company) + "'>" + esc(r.company) + "<td class='company company-cell' title='" + esc(r.company) + "'>" + esc(r.company) +
@ -247,6 +247,7 @@
(r.comment ? "<div class='kfu-cell'><span class='lbl'>Комментарий дочки:</span> " + esc(r.comment) + "</div>" : "") + (r.comment ? "<div class='kfu-cell'><span class='lbl'>Комментарий дочки:</span> " + esc(r.comment) + "</div>" : "") +
(r.agent_comment ? "<div class='kfu-cell'><span class='lbl'>Замечания агента:</span> " + esc(r.agent_comment) + "</div>" : "") + (r.agent_comment ? "<div class='kfu-cell'><span class='lbl'>Замечания агента:</span> " + esc(r.agent_comment) + "</div>" : "") +
((r.flags && r.flags.length) ? "<div class='kfu-cell'><span class='lbl'>Признаки:</span><br>" + r.flags.map(esc).join("<br>") + "</div>" : "") + ((r.flags && r.flags.length) ? "<div class='kfu-cell'><span class='lbl'>Признаки:</span><br>" + r.flags.map(esc).join("<br>") + "</div>" : "") +
(r.error ? "<div class='kfu-cell' style='color:var(--kt-ai-danger)'><span class='lbl'>Ошибка расчёта:</span> " + esc(r.error) + "</div>" : "") +
"</td></tr>" : ""); "</td></tr>" : "");
}).join(""); }).join("");
tbody.innerHTML = html; tbody.innerHTML = html;

View File

@ -52,7 +52,7 @@ function fetchWithTimeout(url, options, timeoutMs) {
function createAlemSession() { 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, { return fetchWithTimeout(url, {
method: "POST", method: "POST",
headers: { headers: {
@ -93,7 +93,7 @@ function extractSseText(raw) {
} }
function askAlem(sessionId, message) { 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, { return fetchWithTimeout(url, {
method: 'POST', method: 'POST',
headers: { 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 }) body: JSON.stringify({ content: message, user_id: 'kfu_app', model_api_key: process.env.ALEM_API_KEY })
}, ALEM_TIMEOUT_MS).then(function (resp) { }, 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; } if (!resp.ok) {
return resp.text().then(function (raw) { const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : ""));
return extractSseText(raw); 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) { function sendPath(id) {
@ -146,7 +168,8 @@ function callKfuAgent(company, period, fileText) {
'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.', 'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.',
'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).' 'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).'
].join("\n"); ].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."); const e = new Error("Агент Alem не настроен: отсутствуют ALEM_BASE_URL / ALEM_API_KEY / ALEM_TEMPLATE_ID.");
e.code = "ALEM_NOT_CONFIGURED"; e.code = "ALEM_NOT_CONFIGURED";
return Promise.reject(e); return Promise.reject(e);

View File

@ -4,8 +4,13 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const DATA_FILE = path.join(__dirname, "..", "data.json"); let DATA_FILE = path.join(__dirname, "..", "data.json");
const UPLOADS_DIR = path.join(__dirname, "..", "uploads"); let UPLOADS_DIR = path.join(__dirname, "..", "uploads");
function configure(dataFile, uploadsDir) {
DATA_FILE = dataFile;
UPLOADS_DIR = uploadsDir;
}
const DEFAULT_DEADLINE = "2026-10-15"; const DEFAULT_DEADLINE = "2026-10-15";
@ -77,6 +82,7 @@ function seed() {
consolidatorPhone: "", consolidatorPhone: "",
users: [], users: [],
reports: [], reports: [],
sessions: {},
}; };
const consPw = process.env.KFU_SVOD_PASSWORD || "12345678"; const consPw = process.env.KFU_SVOD_PASSWORD || "12345678";
const cons = makeUser({ const cons = makeUser({
@ -86,7 +92,6 @@ function seed() {
companyName: "", companyName: "",
login: "svod", login: "svod",
_seedPassword: consPw, _seedPassword: consPw,
_keepTemp: true,
}); });
db._consolidatorTempPassword = consPw; db._consolidatorTempPassword = consPw;
db.users.push(cons); db.users.push(cons);
@ -130,6 +135,7 @@ function load() {
if (typeof u.lockedUntil !== "number") u.lockedUntil = 0; if (typeof u.lockedUntil !== "number") u.lockedUntil = 0;
if (typeof u.active !== "boolean") u.active = true; if (typeof u.active !== "boolean") u.active = true;
}); });
if (!db.sessions || typeof db.sessions !== "object") db.sessions = {};
return db; return db;
} }
@ -155,6 +161,7 @@ function verifyPassword(password, saltHex, expectedHex) {
module.exports = { module.exports = {
DATA_FILE: DATA_FILE, DATA_FILE: DATA_FILE,
UPLOADS_DIR: UPLOADS_DIR, UPLOADS_DIR: UPLOADS_DIR,
configure: configure,
ensureUploadsDir: ensureUploadsDir, ensureUploadsDir: ensureUploadsDir,
randPassword: randPassword, randPassword: randPassword,
makeUser: makeUser, makeUser: makeUser,

183
server.js
View File

@ -7,11 +7,38 @@ const crypto = require("crypto");
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const ROOT = __dirname; 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"); const store = require("./lib/store");
store.configure(path.join(DATA_DIR, "data.json"), UPLOADS_DIR);
const alem = require("./lib/alem"); const alem = require("./lib/alem");
const SECRET = process.env.KFU_SECRET || crypto.randomBytes(16).toString("hex"); 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 SESSION_TTL_MS = 1000 * 60 * 60 * 12;
const MAX_UPLOAD = 50 * 1024 * 1024; 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(); const DB = store.load();
let GENERATED_PASSWORD = DB._consolidatorTempPassword || null; let GENERATED_PASSWORD = DB._consolidatorTempPassword || null;
@ -36,7 +61,8 @@ if (DB._firstRunSubsidiaryPasswords) {
store.save(DB); store.save(DB);
} }
const sessions = {}; // sessions are persisted in DB.sessions = { token: { userId, exp } }
function nowMs() { return Date.now(); } function nowMs() { return Date.now(); }
function iso() { return new Date().toISOString(); } function iso() { return new Date().toISOString(); }
@ -65,16 +91,29 @@ function sendJson(res, code, obj) {
function makeToken() { function makeToken() {
return crypto.randomBytes(24).toString("hex"); 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) { function createUserSession(res, user) {
if (!DB.sessions) DB.sessions = {};
pruneSessions();
const token = makeToken(); 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)); res.setHeader("Set-Cookie", "kfu_session=" + token + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + Math.floor(SESSION_TTL_MS / 1000));
} }
function currentUser(req) { function currentUser(req) {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const token = cookies.kfu_session; const token = cookies.kfu_session;
if (!token) return null; if (!token) return null;
const s = sessions[token]; const s = DB.sessions ? DB.sessions[token] : null;
if (!s || s.exp < nowMs()) return null; if (!s || s.exp < nowMs()) return null;
return DB.users.find(function (u) { return u.id === s.userId && u.active; }) || 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) { function handleLogout(req, res) {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const token = cookies.kfu_session; 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"); res.setHeader("Set-Cookie", "kfu_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
sendJson(res, 200, { ok: true }); sendJson(res, 200, { ok: true });
} }
@ -198,6 +237,7 @@ function sendNotification(event, company, period, status) {
function computeFromText(user, report, fileText) { function computeFromText(user, report, fileText) {
report.status = "processing"; report.status = "processing";
store.save(DB); store.save(DB);
log("alem call start", user.companyName, "ALEM_BASE_URL=" + (process.env.ALEM_BASE_URL || "<not set>"), "ALEM_API_KEY=" + (process.env.ALEM_API_KEY ? "<set>" : "<not set>"), "ALEM_TEMPLATE_ID=" + (process.env.ALEM_TEMPLATE_ID || "<not set>"));
alem.callKfuAgent(user.companyName, DB.period, fileText).then(function (result) { alem.callKfuAgent(user.companyName, DB.period, fileText).then(function (result) {
report.status = "done"; report.status = "done";
report.result = result; report.result = result;
@ -221,7 +261,7 @@ function describeAlemError(err) {
if (code === "ALEM_TIMEOUT") return "Агент не ответил за 10 минут. " + msg; if (code === "ALEM_TIMEOUT") return "Агент не ответил за 10 минут. " + msg;
if (code === "ALEM_NOT_CONFIGURED") return "Агент Alem не настроен."; if (code === "ALEM_NOT_CONFIGURED") return "Агент Alem не настроен.";
if (code === "ALEM_NOT_JSON") return msg; 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."; if (m) return "Ошибка HTTP " + m[1] + " от сервера Alem.";
return "Ошибка агента: " + msg; return "Ошибка агента: " + msg;
} }
@ -364,6 +404,7 @@ function dashboardRows() {
row.sources = res.sources || {}; row.sources = res.sources || {};
row.agent_comment = res.comment || ""; row.agent_comment = res.comment || "";
} }
if (r && r.error) row.error = r.error;
return row; return row;
}); });
} }
@ -392,10 +433,14 @@ function handleFile(req, res, id) {
if (user.role !== "consolidator") { res.writeHead(404); return res.end("Нет такого файла"); } if (user.role !== "consolidator") { res.writeHead(404); return res.end("Нет такого файла"); }
const fp = path.join(UPLOADS_DIR, id + ".bin"); const fp = path.join(UPLOADS_DIR, id + ".bin");
if (!fs.existsSync(fp)) { res.writeHead(404); return res.end("Файл удалён"); } 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, { res.writeHead(200, {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Disposition": 'attachment; filename="' + name + '"', "Content-Disposition": disp,
}); });
fs.createReadStream(fp).pipe(res); fs.createReadStream(fp).pipe(res);
} }
@ -452,13 +497,16 @@ function handleExport(req, res) {
cols.forEach(function (c) { cols.forEach(function (c) {
let v = r[c[0]]; let v = r[c[0]];
if (v === null || v === undefined) v = ""; 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); line.push(v);
}); });
aoa.push(line); 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(); 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 = [["Компания", "Ответственный", "Телефон", "Период", "Комментарий"]]; const comm = [["Компания", "Ответственный", "Телефон", "Период", "Комментарий"]];
rows.forEach(function (r) { rows.forEach(function (r) {
comm.push([r.company, r.person || "", r.phone || "", DB.period, r.comment || ""]); 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" }); const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "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); res.end(buf);
} }
@ -654,12 +702,31 @@ function handleStatusApi(req, res) {
} }
// ---------- static ---------- // ---------- 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) { function serveStatic(req, res, urlPath) {
let file = urlPath === "/" ? "/index.html" : urlPath; if (!isServeable(urlPath)) {
if (path.extname(file) === "") { res.writeHead(404, { "Content-Type": "application/json" });
if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html"; return res.end("{}");
else file = "/index.html";
} }
let file = urlPath === "/" ? "/index.html" : urlPath;
const norm = path.normalize(file); const norm = path.normalize(file);
const full = path.join(ROOT, norm); const full = path.join(ROOT, norm);
if (norm.indexOf("..") !== -1 || (full !== ROOT && !full.startsWith(ROOT + path.sep))) { if (norm.indexOf("..") !== -1 || (full !== ROOT && !full.startsWith(ROOT + path.sep))) {
@ -667,8 +734,8 @@ function serveStatic(req, res, urlPath) {
return res.end("{}"); return res.end("{}");
} }
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); res.writeHead(404, { "Content-Type": "application/json" });
return res.end("<h1>404</h1>"); return res.end("{}");
} }
const ext = path.extname(full).toLowerCase(); const ext = path.extname(full).toLowerCase();
const types = { const types = {
@ -681,42 +748,72 @@ function serveStatic(req, res, urlPath) {
} }
// ---------- router ---------- // ---------- 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) { http.createServer(function (req, res) {
const urlPath = (req.url || "/").split("?")[0]; const urlPath = (req.url || "/").split("?")[0];
try { try {
if (req.method === "POST" && urlPath === "/api/login") return handleLogin(req, res); if (req.method === "POST" && urlPath === "/api/login") return guard(handleLogin)(req, res);
if (req.method === "POST" && urlPath === "/api/logout") return handleLogout(req, res); if (req.method === "POST" && urlPath === "/api/logout") return guard(handleLogout)(req, res);
if (req.method === "GET" && urlPath === "/api/me") return handleMe(req, res); if (req.method === "GET" && urlPath === "/api/me") return guard(handleMe)(req, res);
if (req.method === "POST" && urlPath === "/api/upload") return handleUpload(req, res); if (req.method === "POST" && urlPath === "/api/upload") return guard(handleUpload)(req, res);
if (req.method === "GET" && urlPath === "/api/dashboard") return handleDashboard(req, res); if (req.method === "GET" && urlPath === "/api/dashboard") return guard(handleDashboard)(req, res);
if (req.method === "GET" && urlPath === "/api/export") return handleExport(req, res); if (req.method === "GET" && urlPath === "/api/export") return guard(handleExport)(req, res);
if (req.method === "GET" && urlPath === "/api/settings") return handleSettingsGet(req, res); if (req.method === "GET" && urlPath === "/api/settings") return guard(handleSettingsGet)(req, res);
if (req.method === "POST" && urlPath === "/api/settings") return handleSettingsSave(req, res); if (req.method === "POST" && urlPath === "/api/settings") return guard(handleSettingsSave)(req, res);
if (req.method === "POST" && urlPath === "/api/add-company") return handleAddCompany(req, res); if (req.method === "POST" && urlPath === "/api/add-company") return guard(handleAddCompany)(req, res);
if (req.method === "POST" && urlPath === "/api/recalc") { if (req.method === "POST" && urlPath === "/api/recalc") {
const q = (req.url.split("?")[1] || ""); 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]+)/); const userMatch = urlPath.match(/^\/api\/user\/([\w]+)/);
if (req.method === "POST" && userMatch) return handleUserAction(req, res, userMatch[1]); if (req.method === "POST" && userMatch) return guard(handleUserAction)(req, res, userMatch[1]);
if (req.method === "POST" && urlPath === "/api/consolidator-password") return handleConsolidatorPassword(req, res); if (req.method === "POST" && urlPath === "/api/consolidator-password") return guard(handleConsolidatorPassword)(req, res);
if (urlPath.indexOf("/api/file/") === 0) { if (urlPath.indexOf("/api/file/") === 0) {
const id = urlPath.slice("/api/file/".length).toUpperCase(); 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; if (!checkXKfuSecret(req, res)) return;
return handleRecipients(req, res); return urlPath === "/api/recipients" ? guard(handleRecipients)(req, res) : guard(handleStatusApi)(req, res);
}
if (urlPath === "/api/status") {
if (!checkXKfuSecret(req, res)) return;
return handleStatusApi(req, res);
} }
if (urlPath.indexOf("/api/") === 0) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end("{}"); } 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) { } catch (e) {
log("handler error", req.method, urlPath, String((e && e.message) || e)); log("router error", req.method, req.url, String((e && e.stack) || e));
try { sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); } catch (_) {} try {
if (!res.headersSent) sendJson(res, 500, { error: "Внутренняя ошибка сервера" });
else res.end();
} catch (_) {}
} }
}).listen(PORT, function () { }).listen(PORT, function () {
log("KFU server on port " + PORT); log("KFU server on port " + PORT);

View File

@ -184,22 +184,38 @@
xhr.upload.onprogress = function (ev) { xhr.upload.onprogress = function (ev) {
if (ev.lengthComputable) sendBtn.textContent = "Отправка… " + Math.round(ev.loaded * 100 / ev.total) + "%"; if (ev.lengthComputable) sendBtn.textContent = "Отправка… " + Math.round(ev.loaded * 100 / ev.total) + "%";
}; };
xhr.onload = function () { function failMessage(status) {
try { try {
var b = JSON.parse(xhr.responseText || "{}"); var b = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300 && b.ok) { var msg = (b && b.error) || "";
showDone(b.uploadedAt); if (xhr.status === 401) return "Сессия истекла — войдите заново.";
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) { } 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.disabled = false;
sendBtn.textContent = "Отправить"; sendBtn.textContent = "Отправить";
if (xhr.status === 401) setTimeout(function () { window.location.href = "index.html"; }, 1500);
}; };
xhr.onerror = function () { xhr.onerror = function () {
alert("Сервер недоступен. Проверьте соединение."); alert("Сервер недоступен. Проверьте соединение и попробуйте ещё раз.");
sendBtn.disabled = false; sendBtn.disabled = false;
sendBtn.textContent = "Отправить"; sendBtn.textContent = "Отправить";
}; };