Опубликовано через кнопку
This commit is contained in:
parent
09e179107c
commit
6286f1826b
@ -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;
|
||||||
|
|||||||
35
lib/alem.js
35
lib/alem.js
@ -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);
|
||||||
|
|||||||
13
lib/store.js
13
lib/store.js
@ -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,
|
||||||
|
|||||||
185
server.js
185
server.js
@ -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 ----------
|
||||||
function serveStatic(req, res, urlPath) {
|
// only UI html pages and design-system/ are public; everything else is 404
|
||||||
let file = urlPath === "/" ? "/index.html" : urlPath;
|
const INTERNAL_NAMES = {
|
||||||
if (path.extname(file) === "") {
|
"/server.js": true, "/data.json": true, "/package.json": true, "/package-lock.json": true,
|
||||||
if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html";
|
"/lib": true, "/node_modules": true, "/.git": true, "/data": true, "/.kfu-data": true,
|
||||||
else file = "/index.html";
|
"/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 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);
|
||||||
|
|||||||
30
upload.html
30
upload.html
@ -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 = "Отправить";
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user