697 lines
26 KiB
JavaScript
697 lines
26 KiB
JavaScript
"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;
|
||
const DATA_FILE = path.join(ROOT, "data.json");
|
||
const UPLOADS_DIR = path.join(ROOT, "uploads");
|
||
|
||
const store = require("./lib/store");
|
||
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;
|
||
|
||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||
|
||
const DB = store.load();
|
||
let GENERATED_PASSWORD = DB._consolidatorTempPassword || null;
|
||
if (GENERATED_PASSWORD) {
|
||
log("FIRST RUN: consolidator login=svod password=" + GENERATED_PASSWORD);
|
||
delete DB._consolidatorTempPassword;
|
||
store.save(DB);
|
||
}
|
||
|
||
const sessions = {};
|
||
|
||
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 createUserSession(res, user) {
|
||
const token = makeToken();
|
||
sessions[token] = { userId: user.id, exp: nowMs() + SESSION_TTL_MS };
|
||
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];
|
||
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) {}
|
||
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) delete sessions[token];
|
||
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);
|
||
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 = String((err && err.message) || err).slice(0, 500);
|
||
report.computedAt = iso();
|
||
store.save(DB);
|
||
sendNotification("report_uploaded", user.companyName, DB.period, "error");
|
||
});
|
||
}
|
||
|
||
// ---------- 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",
|
||
};
|
||
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 || "";
|
||
}
|
||
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 name = (report.filename || "file").replace(/[^\w.\-а-яА-ЯёЁ ]/g, "_");
|
||
res.writeHead(200, {
|
||
"Content-Type": "application/octet-stream",
|
||
"Content-Disposition": 'attachment; filename="' + name + '"',
|
||
});
|
||
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 = "";
|
||
if (c[0] === "status") v = { done: "сдан", processing: "обрабатывается", error: "ошибка расчёта", not_submitted: "не сдан" }[v] || v;
|
||
line.push(v);
|
||
});
|
||
aoa.push(line);
|
||
});
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Свод");
|
||
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-' + DB.period.replace(/[^\wа-яА-ЯёЁ]/g, "").replace(/\s+/g, "-") + '.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)) {
|
||
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 ----------
|
||
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";
|
||
}
|
||
const full = path.join(ROOT, file.replace(/\//g, path.sep));
|
||
if (!full.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
||
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
|
||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||
return res.end("<h1>404</h1>");
|
||
}
|
||
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 ----------
|
||
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/recalc") {
|
||
const q = (req.url.split("?")[1] || "");
|
||
return 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 (urlPath.indexOf("/api/file/") === 0) {
|
||
const id = urlPath.slice("/api/file/".length).toUpperCase();
|
||
return handleFile(req, res, decodeURIComponent(id));
|
||
}
|
||
if (urlPath === "/api/recipients") {
|
||
if (!checkXKfuSecret(req, res)) return;
|
||
return handleRecipients(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("{}"); }
|
||
return serveStatic(req, res, urlPath);
|
||
} catch (e) {
|
||
log("handler error", req.method, urlPath, String((e && e.message) || e));
|
||
try { sendJson(res, 500, { error: "Внутренняя ошибка сервера" }); } catch (_) {}
|
||
}
|
||
}).listen(PORT, function () {
|
||
log("KFU server on port " + PORT);
|
||
});
|