"use strict"; const http = require("http"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const PORT = process.env.PORT || 3000; const ROOT = __dirname; 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"); const PUBLIC_BASE = process.env.KFU_PUBLIC_BASE || ""; const SESSION_TTL_MS = 1000 * 60 * 60 * 12; const MAX_UPLOAD = 50 * 1024 * 1024; const DB = store.load(); let GENERATED_PASSWORD = DB._consolidatorTempPassword || null; if (GENERATED_PASSWORD) { log("FIRST RUN: consolidator login=svod password=" + GENERATED_PASSWORD); require("fs").writeFileSync(require("path").join(__dirname, ".kfu-svod-pw.txt"), GENERATED_PASSWORD); delete DB._consolidatorTempPassword; store.save(DB); } if (DB._firstRunSubsidiaryPasswords) { log("FIRST RUN: subsidiary logins/passwords:\n" + DB._firstRunSubsidiaryPasswords); delete DB._firstRunSubsidiaryPasswords; store.save(DB); } // sessions are persisted in DB.sessions = { token: { userId, exp } } function nowMs() { return Date.now(); } function iso() { return new Date().toISOString(); } function log() { const args = Array.prototype.slice.call(arguments); args.unshift("[KFU " + new Date().toISOString() + "]"); console.log.apply(null, args); } function readBody(req) { return new Promise(function (resolve) { let s = ""; req.on("data", function (c) { s += c; }); req.on("end", function () { try { resolve(JSON.parse(s || "{}")); } catch (e) { resolve({}); } }); }); } function sendJson(res, code, obj) { const body = JSON.stringify(obj); res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", }); res.end(body); } 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(); 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 = 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; } function parseCookies(req) { const out = {}; const raw = req.headers.cookie; if (!raw) return out; raw.split(";").forEach(function (pair) { const i = pair.indexOf("="); if (i === -1) return; out[pair.slice(0, i).trim()] = decodeURIComponent(pair.slice(i + 1).trim()); }); return out; } // ---------- auth ---------- function findUserByLogin(login) { return DB.users.find(function (u) { return u.login === login; }) || null; } function handleLogin(req, res) { readBody(req).then(function (b) { const user = findUserByLogin(String(b.login || "").trim()); if (!user || !user.active) { return sendJson(res, 404, { error: "Неверный логин или пароль" }); } if (user.lockedUntil && user.lockedUntil > nowMs()) { const waitSec = Math.ceil((user.lockedUntil - nowMs()) / 1000); return sendJson(res, 423, { error: "Слишком много попыток. Повторите через " + waitSec + " с" }); } let ok = false; try { ok = store.verifyPassword(String(b.password || ""), user.salt, user.passwordHash); } catch (e) {} console.error("LOGIN_FAIL", b.login, String(user && user.id), "ok=" + ok); if (!ok) { user.failedCount = (user.failedCount || 0) + 1; if (user.failedCount >= 5) { user.lockedUntil = nowMs() + 5 * 60 * 1000; user.failedCount = 0; store.save(DB); return sendJson(res, 423, { error: "Слишком много попыток. Повторите через 5 минут" }); } store.save(DB); return sendJson(res, 401, { error: "Неверный логин или пароль" }); } user.failedCount = 0; user.lockedUntil = 0; store.save(DB); createUserSession(res, user); const out = { ok: true, role: user.role }; if (user.role === "subsidiary") out.companyName = user.companyName; sendJson(res, 200, out); }); } function handleLogout(req, res) { const cookies = parseCookies(req); const token = cookies.kfu_session; 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 }); } // ---------- uploads ---------- function statusFromReport(r) { if (!r) return "not_submitted"; if (r.status === "processing") return "processing"; if (r.status === "error") return "error"; if (r.status === "done" && r.result) return "done"; return "not_submitted"; } function latestReportFor(company) { let best = null; DB.reports.forEach(function (r) { if (r.companyName !== company) return; if (!best || (r.uploadedAt || "") > (best.uploadedAt || "")) best = r; }); return best; } function saveUpload(req, destPath, cb) { const chunks = []; let total = 0; let aborted = false; req.on("data", function (ch) { if (aborted) return; total += ch.length; if (total > MAX_UPLOAD) { aborted = true; req.destroy(); return cb(new Error("FILE_TOO_LARGE")); } chunks.push(ch); }); req.on("end", function () { if (aborted) return; try { fs.writeFileSync(destPath, Buffer.concat(chunks)); cb(null, total); } catch (e) { cb(e); } }); req.on("error", function (e) { if (!aborted) { aborted = true; cb(e); } }); } function sendNotification(event, company, period, status) { const url = process.env.NOTIFY_URL; if (!url) return; const secret = process.env.KFU_NOTIFY_SECRET; const headers = { "Content-Type": "application/json" }; if (secret) headers["X-KFU-Secret"] = secret; fetch(url, { method: "POST", headers: headers, body: JSON.stringify({ event: event, company: company, period: period, status: status }), }).catch(function () { }); } 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; report.error = null; report.computedAt = iso(); store.save(DB); sendNotification("report_uploaded", user.companyName, DB.period, "done"); }).catch(function (err) { report.status = "error"; report.error = describeAlemError(err); report.computedAt = iso(); store.save(DB); sendNotification("report_uploaded", user.companyName, DB.period, "error"); }); } // ---------- helpers ---------- function describeAlemError(err) { const msg = String((err && err.message) || err); const code = err && err.code; 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)/); if (m) return "Ошибка HTTP " + m[1] + " от сервера Alem."; return "Ошибка агента: " + msg; } // ---------- API handlers ---------- function handleMe(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); const out = { role: user.role, login: user.login, companyName: user.companyName || null, person: user.person || null, period: DB.period, deadline: DB.deadline, }; if (user.role === "subsidiary") { const r = latestReportFor(user.companyName); const st = statusFromReport(r); out.lastStatus = st; if (r) out.lastUploadedAt = r.uploadedAt; } sendJson(res, 200, out); } function handleUpload(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "subsidiary") return sendJson(res, 403, { error: "Доступно только дочерним компаниям" }); const comment = String(req.headers["x-comment"] || "").slice(0, 2000); const reportId = store.genId("r"); const destPath = path.join(UPLOADS_DIR, reportId + ".bin"); let uploadName = String(req.headers["x-filename"] || ""); try { uploadName = decodeURIComponent(uploadName); } catch (e) {} if (!uploadName) uploadName = "file"; saveUpload(req, destPath, function (err, size) { if (err) { if (err.message === "FILE_TOO_LARGE") return sendJson(res, 413, { error: "Файл больше 50 МБ" }); return sendJson(res, 400, { error: "Не удалось прочитать файл" }); } const fileExt = path.extname(uploadName).toLowerCase(); const isPdf = fileExt === ".pdf"; const isXl = fileExt === ".xlsx" || fileExt === ".xlsm"; if (!isPdf && !isXl) { fs.unlinkSync(destPath); return sendJson(res, 415, { error: "Поддерживаются только xlsx, xlsm, pdf" }); } const report = { id: reportId, companyName: user.companyName, userId: user.id, period: DB.period, filename: uploadName, ext: fileExt, size: size, comment: comment, uploadedAt: iso(), status: "processing", result: null, error: null, computedAt: null, file: path.join("uploads", reportId + ".bin"), }; DB.reports.push(report); store.save(DB); log("upload", user.companyName, uploadName, size, "bytes"); sendJson(res, 200, { ok: true, uploadedAt: report.uploadedAt }); // background: convert to text then call agent fileToText(destPath, isPdf).then(function (text) { computeFromText(user, report, text); }).catch(function (e) { report.status = "error"; report.error = "Не удалось разобрать файл: " + String((e && e.message) || e).slice(0, 300); report.computedAt = iso(); store.save(DB); }); }); } function fileToText(filePath, isPdf) { if (isPdf) { return new Promise(function (resolve, reject) { const pdfParse = require("pdf-parse"); pdfParse(fs.readFileSync(filePath)).then(function (data) { resolve(String(data.text || "").slice(0, 200000)); }).catch(reject); }); } return new Promise(function (resolve, reject) { const XLSX = require("xlsx"); const wb = XLSX.readFile(filePath, { type: "file" }); const parts = []; wb.SheetNames.forEach(function (name) { const ws = wb.Sheets[name]; const csv = XLSX.utils.sheet_to_csv(ws, { blankrows: false,FS: "\t",RS: "\n" }); // filter empty rows const lines = csv.split("\n").map(function (l) { return l.trim(); }).filter(function (l) { return l.length > 0; }); if (lines.length) parts.push("=== ЛИСТ: " + name + " ===\n" + lines.join("\n")); }); resolve(parts.join("\n\n").slice(0, 200000)); }); } function dashboardRows() { return DB.users.filter(function (u) { return u.role === "subsidiary"; }).map(function (u) { const r = latestReportFor(u.companyName); const st = statusFromReport(r); const row = { company: u.companyName, status: st, uploadedAt: r ? r.uploadedAt : null, person: u.person || "", phone: u.phone || "", submitted: st === "done" || st === "error", }; if (r) { row.comment = r.comment || ""; row.filename = r.filename; row.fileId = r.id; } if (r && r.result) { const res = r.result; row.debt = res.debt; row.ebitda_ltm = res.ebitda_ltm; row.interest_ltm = res.interest_ltm; row.equity = res.equity; row.units = res.units; row.k1 = res.k1; row.k2 = res.k2; row.k3 = res.k3; row.zone_k1 = res.zone_k1; row.zone_k2 = res.zone_k2; row.zone_k3 = res.zone_k3; row.zone_company = res.zone_company; row.flags = res.flags || []; row.sources = res.sources || {}; row.agent_comment = res.comment || ""; } if (r && r.error) row.error = r.error; return row; }); } function handleDashboard(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Доступно только сводящему" }); const rows = dashboardRows(); const submitted = rows.filter(function (r) { return r.submitted; }).length; sendJson(res, 200, { period: DB.period, deadline: DB.deadline, consolidatorPhone: DB.consolidatorPhone, total: rows.length, submitted: submitted, rows: rows, }); } function handleFile(req, res, id) { const user = currentUser(req); if (!user) { res.writeHead(401); return res.end(); } const report = DB.reports.find(function (r) { return r.id === id; }); if (!report) { res.writeHead(404); return res.end("Нет такого файла"); } 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 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": disp, }); fs.createReadStream(fp).pipe(res); } function handleRecalc(req, res, id) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); const report = DB.reports.find(function (r) { return r.id === id; }); if (!report) return sendJson(res, 404, { error: "Нет такого отчёта" }); const companyUser = DB.users.find(function (u) { return u.companyName === report.companyName; }); const fp = path.join(UPLOADS_DIR, report.id + ".bin"); if (!fs.existsSync(fp)) return sendJson(res, 410, { error: "Файл не найден" }); report.status = "processing"; report.error = null; store.save(DB); const isPdf = report.ext === ".pdf"; fileToText(fp, isPdf).then(function (text) { computeFromText(companyUser || { companyName: report.companyName }, report, text); }).catch(function (e) { report.status = "error"; report.error = "Не удалось разобрать файл: " + String((e && e.message) || e).slice(0, 300); report.computedAt = iso(); store.save(DB); }); sendJson(res, 200, { ok: true, status: "processing" }); } function handleExport(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); const XLSX = require("xlsx"); const rows = dashboardRows(); const cols = [ ["company", "Компания"], ["status", "Статус"], ["uploadedAt", "Когда загружен"], ["comment", "Комментарий дочки"], ["debt", "Долг"], ["ebitda_ltm", "EBITDA 12М"], ["interest_ltm", "Процентные расходы 12М"], ["equity", "Капитал (млрд тенге)"], ["k1", "К1 Долг/EBITDA"], ["k2", "К2 EBITDA/Проценты"], ["k3", "К3 Долг/Капитал"], ["zone_company", "Зона компании"], ["agent_comment", "Замечания агента"], ]; const aoa = [[]]; cols.forEach(function (c) { aoa[0].push(c[1]); }); rows.forEach(function (r) { const line = []; cols.forEach(function (c) { let v = r[c[0]]; if (v === null || v === undefined) 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, sheet, "Свод"); const comm = [["Компания", "Ответственный", "Телефон", "Период", "Комментарий"]]; rows.forEach(function (r) { comm.push([r.company, r.person || "", r.phone || "", DB.period, r.comment || ""]); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(comm), "Комментарии"); 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.xlsx"', }); res.end(buf); } function handleSettingsGet(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); const companies = DB.users.filter(function (u) { return u.role === "subsidiary"; }).map(function (u) { return { id: u.id, name: u.companyName, person: u.person || "", phone: u.phone || "", login: u.login, active: u.active, }; }); sendJson(res, 200, { period: DB.period, deadline: DB.deadline, consolidatorPhone: DB.consolidatorPhone, consolidatorLogin: user.login, companies: companies, }); } function handleSettingsSave(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); readBody(req).then(function (b) { if (b.period) DB.period = String(b.period).slice(0, 100); if (b.deadline) DB.deadline = String(b.deadline).slice(0, 20); if (typeof b.consolidatorPhone === "string") DB.consolidatorPhone = b.consolidatorPhone.slice(0, 30); store.save(DB); const companies = Array.isArray(b.companies) ? b.companies : null; if (companies) { const wantLogins = {}; companies.forEach(function (c) { const u = DB.users.find(function (x) { return x.id === c.id; }); if (u) { u.companyName = String(c.name || u.companyName).slice(0, 200); u.person = String(c.person || "").slice(0, 100); u.phone = String(c.phone || "").slice(0, 30); u.active = c.active !== false; if (c.login && c.login !== u.login) { const taken = DB.users.some(function (other) { return other.id !== u.id && other.login === c.login; }); if (!taken) u.login = String(c.login).slice(0, 50); } } wantLogins[u ? u.login : (c.login || "")] = true; }); // delete companies not in list DB.users = DB.users.filter(function (u) { if (u.role !== "subsidiary") return true; return companies.some(function (c) { return c.id === u.id; }); }); store.save(DB); } sendJson(res, 200, { ok: true }); }); } function handleAddCompany(req, res) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); readBody(req).then(function (b) { const name = String(b.name || "").trim(); if (name.length < 3) return sendJson(res, 400, { error: "Укажите название компании" }); const exists = DB.users.some(function (u) { return u.role === "subsidiary" && u.companyName === name; }); if (exists) return sendJson(res, 409, { error: "Компания уже есть в списке" }); let login = String(b.login || "").trim() || generateLogin(); if (DB.users.some(function (u) { return u.login === login; })) { return sendJson(res, 409, { error: "Логин уже занят" }); } const u = store.makeUser({ id: store.genId("u"), role: "subsidiary", name: "", companyName: name, person: String(b.person || "").slice(0, 100), phone: String(b.phone || "").slice(0, 30), login: login, }); const tempPw = store.randPassword(10); const salt = crypto.randomBytes(32); u.salt = salt.toString("hex"); u.passwordHash = crypto.scryptSync(tempPw, salt, 32).toString("hex"); DB.users.push(u); store.save(DB); sendJson(res, 200, { ok: true, login: u.login, tempPassword: tempPw }); }); } function generateLogin() { return "c" + crypto.randomBytes(3).toString("hex"); } function handleUserAction(req, res, userId) { const user = currentUser(req); if (!user) return sendJson(res, 401, { error: "Требуется вход" }); if (user.role !== "consolidator") return sendJson(res, 403, { error: "Нет доступа" }); const target = DB.users.find(function (u) { return u.id === userId; }); if (!target || target.role !== "subsidiary") return sendJson(res, 404, { error: "Запись не найдена" }); readBody(req).then(function (b) { if (b.action === "toggle") { target.active = !target.active; store.save(DB); sendJson(res, 200, { ok: true, active: target.active }); } else if (b.action === "set_password") { const pw = b.password && String(b.password).length >= 4 ? String(b.password) : store.randPassword(10); const salt = crypto.randomBytes(32); target.salt = salt.toString("hex"); target.passwordHash = crypto.scryptSync(pw, salt, 32).toString("hex"); target.lockedUntil = 0; target.failedCount = 0; store.save(DB); sendJson(res, 200, { ok: true, tempPassword: pw }); } else { sendJson(res, 400, { error: "Неизвестное действие" }); } }); } function handleConsolidatorPassword(req, res) { const user = currentUser(req); if (!user || user.role !== "consolidator") return sendJson(res, 401, { error: "Требуется вход" }); readBody(req).then(function (b) { const cur = String(b.current || ""); if (!store.verifyPassword(cur, user.salt, user.passwordHash)) { if (!user.tempPassword || cur !== user.tempPassword) { return sendJson(res, 401, { error: "Текущий пароль неверен" }); } } const nw = String(b.next || ""); if (nw.length < 4) return sendJson(res, 400, { error: "Новый пароль — минимум 4 символа" }); const salt = crypto.randomBytes(32); user.salt = salt.toString("hex"); user.passwordHash = crypto.scryptSync(nw, salt, 32).toString("hex"); store.save(DB); sendJson(res, 200, { ok: true }); }); } // ---------- external service APIs (X-KFU-Secret) ---------- function checkXKfuSecret(req, res) { const key = req.headers["x-kfu-secret"]; if (key !== SECRET) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "bad secret" })); return false; } return true; } function handleRecipients(req, res) { const rows = dashboardRows(); sendJson(res, 200, { period: DB.period, deadline: DB.deadline, upload_link: PUBLIC_BASE ? PUBLIC_BASE.replace(/\/$/, "") + "/" : "", consolidator: { phone: DB.consolidatorPhone || "", dashboard_link: PUBLIC_BASE ? PUBLIC_BASE.replace(/\/$/, "") + "/dashboard" : "", }, companies: rows.map(function (r) { return { company: r.company, person: r.person || "", phone: r.phone || "", submitted: r.submitted }; }), }); } function handleStatusApi(req, res) { const rows = dashboardRows(); sendJson(res, 200, { period: DB.period, deadline: DB.deadline, total: rows.length, submitted: rows.filter(function (r) { return r.submitted; }).length, rows: rows, }); } // ---------- 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) { 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))) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end("{}"); } if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404, { "Content-Type": "application/json" }); return res.end("{}"); } const ext = path.extname(full).toLowerCase(); const types = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".woff": "font/woff", ".ico": "image/x-icon", }; res.writeHead(200, { "Content-Type": (types[ext] || "application/octet-stream") + "; charset=utf-8" }); fs.createReadStream(full).pipe(res); } // ---------- 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 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 guard(handleRecalc)(req, res, decodeURIComponent((q.replace(/^id=/, "") || "").toUpperCase())); } const userMatch = urlPath.match(/^\/api\/user\/([\w]+)/); 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 guard(handleFile)(req, res, decodeURIComponent(id)); } if (urlPath === "/api/recipients" || urlPath === "/api/status") { if (!checkXKfuSecret(req, res)) return; 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 guard(serveStatic)(req, res, urlPath); } catch (e) { 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); });