work-license-server 1.0.21 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/admin.js +63 -0
- package/bin/server.mjs +63 -4
- package/package.json +1 -1
- package/src/admin.js +9 -0
- package/src/index.js +7 -1
- package/src/server.js +41 -5
- package/src/store.js +766 -21
package/assets/admin.js
CHANGED
|
@@ -311,6 +311,9 @@
|
|
|
311
311
|
if ($('#navAdmOrders')) $('#navAdmOrders').textContent = `Заказы и счета (${orders.length})`;
|
|
312
312
|
if ($('#navAdmUsers')) $('#navAdmUsers').textContent = `Клиенты и администраторы (${users.length})`;
|
|
313
313
|
if ($('#navAdmTickets')) $('#navAdmTickets').textContent = `Тикеты поддержки (${openTickets.length})`;
|
|
314
|
+
if ($('#admDataDirLabel') && data.dataDir) {
|
|
315
|
+
$('#admDataDirLabel').textContent = `Папка БД: ${data.dataDir}`;
|
|
316
|
+
}
|
|
314
317
|
|
|
315
318
|
renderServersTable(licenses);
|
|
316
319
|
renderFranchisesTable(franchises);
|
|
@@ -870,6 +873,57 @@
|
|
|
870
873
|
URL.revokeObjectURL(url);
|
|
871
874
|
}
|
|
872
875
|
|
|
876
|
+
async function exportAdminBackup() {
|
|
877
|
+
try {
|
|
878
|
+
const bundle = await api('/api/admin/backup/export');
|
|
879
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
880
|
+
const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
|
|
881
|
+
const url = URL.createObjectURL(blob);
|
|
882
|
+
const a = document.createElement('a');
|
|
883
|
+
const dayStr = new Date().toISOString().slice(0, 10);
|
|
884
|
+
a.href = url;
|
|
885
|
+
a.download = `odant-work-license-backup-${dayStr}.json`;
|
|
886
|
+
document.body.appendChild(a);
|
|
887
|
+
a.click();
|
|
888
|
+
a.remove();
|
|
889
|
+
URL.revokeObjectURL(url);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
alert(err.message);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function importAdminBackupFile(file) {
|
|
897
|
+
if (!file) return;
|
|
898
|
+
try {
|
|
899
|
+
const text = await file.text();
|
|
900
|
+
const parsed = JSON.parse(text);
|
|
901
|
+
const res = await api('/api/admin/backup/import', {
|
|
902
|
+
backup: parsed,
|
|
903
|
+
mode: 'merge',
|
|
904
|
+
});
|
|
905
|
+
await refreshRegistry();
|
|
906
|
+
const c = res.counts || {};
|
|
907
|
+
alert(`Резервная копия успешно объединена с базой!\nПользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
alert('Ошибка импорта резервной копии: ' + (err.message || err));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function recoverAdminFromNpx() {
|
|
915
|
+
try {
|
|
916
|
+
const res = await api('/api/admin/backup/recover', {});
|
|
917
|
+
await refreshRegistry();
|
|
918
|
+
const c = res.counts || {};
|
|
919
|
+
const srcCount = Array.isArray(res.mergedSources) ? res.mergedSources.length : 0;
|
|
920
|
+
alert(`Поиск по кэшу npx и системным папкам завершён (найдено источников: ${srcCount}).\nВ базе сейчас — пользователей: ${c.users || 0}, серверов/лицензий: ${c.licenses || 0}, заказов: ${c.orders || 0}, франшиз: ${c.reservations || 0}.`);
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
alert(err.message);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
873
927
|
/* ================= Привязка событий ================= */
|
|
874
928
|
function bind() {
|
|
875
929
|
document.addEventListener('click', (e) => {
|
|
@@ -944,9 +998,18 @@
|
|
|
944
998
|
navigator.clipboard?.writeText($('#certJsonBox')?.textContent || '');
|
|
945
999
|
break;
|
|
946
1000
|
case 'cert-download': downloadCurrentCert(); break;
|
|
1001
|
+
case 'admin-export-backup': exportAdminBackup(); break;
|
|
1002
|
+
case 'admin-import-backup': $('#inpAdmBackupFile')?.click(); break;
|
|
1003
|
+
case 'admin-recover-npx': recoverAdminFromNpx(); break;
|
|
947
1004
|
}
|
|
948
1005
|
});
|
|
949
1006
|
|
|
1007
|
+
$('#inpAdmBackupFile')?.addEventListener('change', (e) => {
|
|
1008
|
+
const file = e.target.files?.[0];
|
|
1009
|
+
if (file) importAdminBackupFile(file);
|
|
1010
|
+
e.target.value = '';
|
|
1011
|
+
});
|
|
1012
|
+
|
|
950
1013
|
$('#btnTheme')?.addEventListener('click', toggleTheme);
|
|
951
1014
|
$('#adminLoginScreen')?.addEventListener('keydown', (e) => {
|
|
952
1015
|
if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
|
package/bin/server.mjs
CHANGED
|
@@ -2,16 +2,75 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Точка запуска лицензионного сервера и лендинга ПАК WORK (work.odant.org).
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import * as childProcess from 'node:child_process';
|
|
6
9
|
import { createLicenseServerApp } from '../src/server.js';
|
|
10
|
+
import { resolvePersistentDataDir } from '../src/store.js';
|
|
7
11
|
|
|
8
12
|
const port = Number(process.env.PORT || process.env.WORK_LICENSE_PORT || 8020);
|
|
9
|
-
const
|
|
10
|
-
const dataDir = process.env.WORK_LICENSE_DATA_DIR || defaultDataDir;
|
|
13
|
+
const dataDir = resolvePersistentDataDir();
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
async function releasePreviousInstance() {
|
|
16
|
+
const pidFile = path.join(dataDir, 'run.pid');
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(pidFile)) {
|
|
19
|
+
const oldPid = Number(fs.readFileSync(pidFile, 'utf8').trim());
|
|
20
|
+
if (Number.isInteger(oldPid) && oldPid > 0 && oldPid !== process.pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(oldPid);
|
|
23
|
+
for (let i = 0; i < 15; i++) {
|
|
24
|
+
await new Promise(r => setTimeout(r, 100));
|
|
25
|
+
try { process.kill(oldPid, 0); }
|
|
26
|
+
catch { break; }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* процесс уже завершён */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* ignore */ }
|
|
34
|
+
|
|
35
|
+
const isPortBusy = () => new Promise((resolve) => {
|
|
36
|
+
const sock = net.createConnection({ host: '127.0.0.1', port });
|
|
37
|
+
sock.once('connect', () => { sock.destroy(); resolve(true); });
|
|
38
|
+
sock.once('error', () => { sock.destroy(); resolve(false); });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (await isPortBusy() && process.platform === 'win32') {
|
|
42
|
+
try {
|
|
43
|
+
const out = childProcess.execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
44
|
+
for (const line of out.split(/\r?\n/)) {
|
|
45
|
+
const m = line.trim().match(/LISTENING\s+(\d+)$/i);
|
|
46
|
+
const pid = m ? Number(m[1]) : 0;
|
|
47
|
+
if (pid && pid !== process.pid) {
|
|
48
|
+
try { process.kill(pid); } catch { /* ignore */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < 15; i++) {
|
|
52
|
+
await new Promise(r => setTimeout(r, 100));
|
|
53
|
+
if (!(await isPortBusy()))
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
62
|
+
fs.writeFileSync(pidFile, String(process.pid), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await releasePreviousInstance();
|
|
68
|
+
|
|
69
|
+
const app = createLicenseServerApp({ dataDir, autoRecover: true });
|
|
13
70
|
const server = await app.listen(port);
|
|
14
71
|
const addr = server.address();
|
|
15
72
|
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
73
|
+
const counts = app.store.getStoreCounts();
|
|
16
74
|
console.log(`[work.odant.org] Витрина ODANT-WORK: http://localhost:${actualPort}/`);
|
|
17
75
|
console.log(`[work.odant.org] Панель администратора: http://localhost:${actualPort}/admin`);
|
|
76
|
+
console.log(`[work.odant.org] Постоянная база данных: ${app.store.stateFile} (пользователей: ${counts.users}, серверов/лицензий: ${counts.licenses}, заказов: ${counts.orders})`);
|
package/package.json
CHANGED
package/src/admin.js
CHANGED
|
@@ -129,6 +129,15 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
129
129
|
<button type="button" class="sec-nav-item" data-adm-tab="orders" id="navAdmOrders">Заказы и счета (0)</button>
|
|
130
130
|
<button type="button" class="sec-nav-item" data-adm-tab="users" id="navAdmUsers">Клиенты и администраторы (0)</button>
|
|
131
131
|
<button type="button" class="sec-nav-item" data-adm-tab="tickets" id="navAdmTickets">Тикеты поддержки (0)</button>
|
|
132
|
+
<div class="sec-nav-sep"></div>
|
|
133
|
+
<div style="padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;gap:6px">
|
|
134
|
+
<div class="small muted" style="font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.04em">Резервная копия базы</div>
|
|
135
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-export-backup">📥 Скачать бэкап (.json)</button>
|
|
136
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-import-backup">📤 Восстановить из файла</button>
|
|
137
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%;justify-content:flex-start;font-size:12px;padding:6px 10px" data-action="admin-recover-npx" title="Найти и объединить данные из предыдущих запусков npx">🔄 Найти данные из npx</button>
|
|
138
|
+
<input type="file" id="inpAdmBackupFile" accept=".json,application/json" style="display:none">
|
|
139
|
+
<div class="small muted" id="admDataDirLabel" style="font-size:11px;word-break:break-all;line-height:1.3;margin-top:2px"></div>
|
|
140
|
+
</div>
|
|
132
141
|
</aside>
|
|
133
142
|
|
|
134
143
|
<!-- Правая область выбранного раздела -->
|
package/src/index.js
CHANGED
|
@@ -37,7 +37,13 @@ export {
|
|
|
37
37
|
parseOwnerAccessToken,
|
|
38
38
|
verifyOwnerAccessToken,
|
|
39
39
|
} from './crypto.js';
|
|
40
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
LicenseStore,
|
|
42
|
+
isEphemeralPackagePath,
|
|
43
|
+
resolvePersistentDataDir,
|
|
44
|
+
findCandidateRecoveryDirs,
|
|
45
|
+
mergeStoreStates,
|
|
46
|
+
} from './store.js';
|
|
41
47
|
export { YooKassaService } from './yookassa.js';
|
|
42
48
|
export { SupportBotsHub } from './bots.js';
|
|
43
49
|
export { LicenseServerAiAgent } from './agent.js';
|
package/src/server.js
CHANGED
|
@@ -39,16 +39,14 @@ import {
|
|
|
39
39
|
calculateRegionPartnerQuota,
|
|
40
40
|
PUBLIC_OFERTA,
|
|
41
41
|
} from './catalog.js';
|
|
42
|
-
import { LicenseStore } from './store.js';
|
|
42
|
+
import { LicenseStore, resolvePersistentDataDir } from './store.js';
|
|
43
43
|
import { YooKassaService } from './yookassa.js';
|
|
44
44
|
import { SupportBotsHub } from './bots.js';
|
|
45
45
|
import { LicenseServerAiAgent } from './agent.js';
|
|
46
46
|
import { renderLandingPage, renderSandboxPaymentPage } from './landing.js';
|
|
47
47
|
import { renderAdminPage } from './admin.js';
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
async function readJsonBody(req, maxBytes = 1024 * 1024) {
|
|
49
|
+
async function readJsonBody(req, maxBytes = 16 * 1024 * 1024) {
|
|
52
50
|
const chunks = [];
|
|
53
51
|
let size = 0;
|
|
54
52
|
for await (const ch of req) {
|
|
@@ -284,10 +282,12 @@ async function sendOtpEmail({ to, code }) {
|
|
|
284
282
|
export function createLicenseServerApp(options = {}) {
|
|
285
283
|
const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
|
|
286
284
|
const store = options.store || new LicenseStore({
|
|
287
|
-
dataDir: options.dataDir ||
|
|
285
|
+
dataDir: options.dataDir || resolvePersistentDataDir(),
|
|
288
286
|
adminLogin: options.adminLogin,
|
|
289
287
|
adminPassword: options.adminPassword,
|
|
290
288
|
publicOrigin: options.publicOrigin || 'https://work.odant.org',
|
|
289
|
+
autoRecover: options.autoRecover,
|
|
290
|
+
extraSearchRoots: options.extraSearchRoots,
|
|
291
291
|
});
|
|
292
292
|
let bots = null;
|
|
293
293
|
const yookassa = options.yookassa || new YooKassaService({
|
|
@@ -986,6 +986,8 @@ export function createLicenseServerApp(options = {}) {
|
|
|
986
986
|
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
987
987
|
const allFranchises = store.listAllFranchises();
|
|
988
988
|
return sendJson(res, 200, {
|
|
989
|
+
dataDir: store.dataDir,
|
|
990
|
+
counts: store.getStoreCounts(),
|
|
989
991
|
licenses: store.listLicenses(),
|
|
990
992
|
tickets: store.listTickets(),
|
|
991
993
|
orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
|
|
@@ -997,6 +999,40 @@ export function createLicenseServerApp(options = {}) {
|
|
|
997
999
|
});
|
|
998
1000
|
}
|
|
999
1001
|
|
|
1002
|
+
if (req.method === 'GET' && pathname === '/api/admin/backup/export') {
|
|
1003
|
+
const tok = extractBearerToken(req, null, url);
|
|
1004
|
+
if (!store.verifyAdminToken(tok))
|
|
1005
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1006
|
+
const bundle = store.exportBackupBundle();
|
|
1007
|
+
const dayStr = new Date().toISOString().slice(0, 10);
|
|
1008
|
+
const body = JSON.stringify(bundle, null, 2);
|
|
1009
|
+
res.writeHead(200, {
|
|
1010
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1011
|
+
'Content-Disposition': `attachment; filename="odant-work-license-backup-${dayStr}.json"`,
|
|
1012
|
+
'Access-Control-Allow-Origin': '*',
|
|
1013
|
+
});
|
|
1014
|
+
res.end(body);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (req.method === 'POST' && pathname === '/api/admin/backup/import') {
|
|
1019
|
+
const body = await readJsonBody(req);
|
|
1020
|
+
const tok = extractBearerToken(req, body, url);
|
|
1021
|
+
if (!store.verifyAdminToken(tok))
|
|
1022
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1023
|
+
const result = store.importBackupBundle(body.backup || body, { mode: body.mode || 'merge' });
|
|
1024
|
+
return sendJson(res, 200, result);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (req.method === 'POST' && pathname === '/api/admin/backup/recover') {
|
|
1028
|
+
const body = await readJsonBody(req);
|
|
1029
|
+
const tok = extractBearerToken(req, body, url);
|
|
1030
|
+
if (!store.verifyAdminToken(tok))
|
|
1031
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
|
|
1032
|
+
const result = store.recoverFromExternalLocations({ extraSearchRoots: body.extraSearchRoots || [] });
|
|
1033
|
+
return sendJson(res, 200, result);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1000
1036
|
if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {
|
|
1001
1037
|
const body = await readJsonBody(req);
|
|
1002
1038
|
const tok = extractBearerToken(req, body, url);
|
package/src/store.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* - Тикеты техподдержки с расчётом SLA времени реакции и лимитов обращений по уровням Start / Business / Enterprise.
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import crypto from 'node:crypto';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
11
13
|
import {
|
|
12
14
|
PAK_PRODUCT,
|
|
13
15
|
BASE_LICENSE_PRODUCT,
|
|
@@ -70,14 +72,473 @@ export function buildFranchisePaymentSchedule({
|
|
|
70
72
|
return schedule;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Проверить, указывает ли путь внутрь временного кэша npx / npm или папки node_modules.
|
|
77
|
+
* Хранить базу данных внутри таких папок недопустимо, так как при обновлении версии npx создаёт новый хэш-каталог.
|
|
78
|
+
*/
|
|
79
|
+
export function isEphemeralPackagePath(dirPath = '') {
|
|
80
|
+
const norm = String(dirPath || '').replace(/\\/g, '/').toLowerCase();
|
|
81
|
+
return norm.includes('/_npx/') || norm.includes('/node_modules/') || norm.includes('/npm-cache/');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Проверить, является ли директория изолированной временной папкой автотестов.
|
|
86
|
+
*/
|
|
87
|
+
export function isTestTempPath(dirPath = '') {
|
|
88
|
+
const full = path.resolve(String(dirPath || ''));
|
|
89
|
+
const norm = full.replace(/\\/g, '/').toLowerCase();
|
|
90
|
+
const tmpNorm = os.tmpdir().replace(/\\/g, '/').toLowerCase();
|
|
91
|
+
if (tmpNorm && norm.startsWith(tmpNorm))
|
|
92
|
+
return true;
|
|
93
|
+
return norm.includes('fake-hub-data')
|
|
94
|
+
|| norm.includes('work-lic-')
|
|
95
|
+
|| norm.includes('work-npx-test')
|
|
96
|
+
|| norm.includes('/temp/opencode/');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Постоянное зеркало базы данных в профиле пользователя ОС (~/.odant-work-license/data).
|
|
101
|
+
*/
|
|
102
|
+
export function getHomeMirrorDataDir() {
|
|
103
|
+
if (process.env.WORK_LICENSE_HOME_MIRROR)
|
|
104
|
+
return path.resolve(process.env.WORK_LICENSE_HOME_MIRROR);
|
|
105
|
+
return path.join(os.homedir(), '.odant-work-license', 'data');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Определить постоянную папку хранения базы данных лицензионного сервера:
|
|
110
|
+
* 1) Явная переменная окружения WORK_LICENSE_DATA_DIR;
|
|
111
|
+
* 2) Явно переданный requestedDir (если он не указывает внутрь временного кэша _npx / node_modules);
|
|
112
|
+
* 3) Постоянная папка внутри рабочей директории запуска (например, <WORK>/data или <WORK>/license-server/data);
|
|
113
|
+
* 4) Защищённое системное хранилище в домашнем профиле ОС (~/.odant-work-license/data).
|
|
114
|
+
*/
|
|
115
|
+
export function resolvePersistentDataDir({
|
|
116
|
+
requestedDir = '',
|
|
117
|
+
cwd = process.cwd(),
|
|
118
|
+
env = process.env,
|
|
119
|
+
} = {}) {
|
|
120
|
+
if (env?.WORK_LICENSE_DATA_DIR && String(env.WORK_LICENSE_DATA_DIR).trim()) {
|
|
121
|
+
return path.resolve(String(env.WORK_LICENSE_DATA_DIR).trim());
|
|
122
|
+
}
|
|
123
|
+
if (requestedDir && requestedDir !== './data' && !isEphemeralPackagePath(requestedDir)) {
|
|
124
|
+
return path.resolve(requestedDir);
|
|
125
|
+
}
|
|
126
|
+
const cleanCwd = path.resolve(cwd || process.cwd());
|
|
127
|
+
if (!isEphemeralPackagePath(cleanCwd)) {
|
|
128
|
+
const lsData = path.join(cleanCwd, 'license-server', 'data');
|
|
129
|
+
if (fs.existsSync(path.join(lsData, 'store.json')))
|
|
130
|
+
return lsData;
|
|
131
|
+
const sysData = path.join(cleanCwd, '#system', 'license-server');
|
|
132
|
+
if (fs.existsSync(path.join(sysData, 'store.json')))
|
|
133
|
+
return sysData;
|
|
134
|
+
return path.join(cleanCwd, 'data');
|
|
135
|
+
}
|
|
136
|
+
return getHomeMirrorDataDir();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Найти все возможные папки на диске (включая старые хэш-каталоги ~/.npm/_npx/* и %LOCALAPPDATA%/npm-cache/_npx/*),
|
|
141
|
+
* где могли остаться данные от предыдущих запусков через npx.
|
|
142
|
+
*/
|
|
143
|
+
export function findCandidateRecoveryDirs({
|
|
144
|
+
primaryDir = '',
|
|
145
|
+
cwd = process.cwd(),
|
|
146
|
+
extraSearchRoots = [],
|
|
147
|
+
includeSystemDefaults = true,
|
|
148
|
+
} = {}) {
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const dirs = [];
|
|
151
|
+
const addDir = (d) => {
|
|
152
|
+
if (!d) return;
|
|
153
|
+
try {
|
|
154
|
+
const full = path.resolve(d);
|
|
155
|
+
const key = process.platform === 'win32' ? full.toLowerCase() : full;
|
|
156
|
+
if (seen.has(key)) return;
|
|
157
|
+
seen.add(key);
|
|
158
|
+
if (fs.existsSync(full))
|
|
159
|
+
dirs.push(full);
|
|
160
|
+
}
|
|
161
|
+
catch { /* ignore */ }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
addDir(primaryDir);
|
|
165
|
+
addDir(getHomeMirrorDataDir());
|
|
166
|
+
|
|
167
|
+
const npxRoots = new Set();
|
|
168
|
+
const addNpxRoot = (r) => {
|
|
169
|
+
if (!r) return;
|
|
170
|
+
try {
|
|
171
|
+
const full = path.resolve(r);
|
|
172
|
+
if (fs.existsSync(full))
|
|
173
|
+
npxRoots.add(full);
|
|
174
|
+
}
|
|
175
|
+
catch { /* ignore */ }
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const root of (extraSearchRoots || [])) {
|
|
179
|
+
addDir(root);
|
|
180
|
+
addNpxRoot(root);
|
|
181
|
+
addNpxRoot(path.join(root, '_npx'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (includeSystemDefaults) {
|
|
185
|
+
const cleanCwd = path.resolve(cwd || process.cwd());
|
|
186
|
+
if (!isEphemeralPackagePath(cleanCwd)) {
|
|
187
|
+
addDir(path.join(cleanCwd, 'data'));
|
|
188
|
+
addDir(path.join(cleanCwd, '#system', 'license-server'));
|
|
189
|
+
addDir(path.join(cleanCwd, 'license-server', 'data'));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
addDir(fileURLToPath(new URL('../data', import.meta.url)));
|
|
194
|
+
}
|
|
195
|
+
catch { /* ignore */ }
|
|
196
|
+
|
|
197
|
+
if (process.env.npm_config_cache)
|
|
198
|
+
addNpxRoot(path.join(process.env.npm_config_cache, '_npx'));
|
|
199
|
+
addNpxRoot(path.join(os.homedir(), '.npm', '_npx'));
|
|
200
|
+
addNpxRoot(path.join(os.homedir(), '.npm-cache', '_npx'));
|
|
201
|
+
addNpxRoot('/root/.npm/_npx');
|
|
202
|
+
|
|
203
|
+
if (process.platform !== 'win32' && fs.existsSync('/home')) {
|
|
204
|
+
try {
|
|
205
|
+
for (const entry of fs.readdirSync('/home', { withFileTypes: true })) {
|
|
206
|
+
if (entry.isDirectory()) {
|
|
207
|
+
addNpxRoot(path.join('/home', entry.name, '.npm', '_npx'));
|
|
208
|
+
addDir(path.join('/home', entry.name, '.odant-work-license', 'data'));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (process.env.LOCALAPPDATA)
|
|
216
|
+
addNpxRoot(path.join(process.env.LOCALAPPDATA, 'npm-cache', '_npx'));
|
|
217
|
+
if (process.env.APPDATA) {
|
|
218
|
+
addNpxRoot(path.join(process.env.APPDATA, 'npm-cache', '_npx'));
|
|
219
|
+
addDir(path.join(process.env.APPDATA, 'npm', 'node_modules', 'work-license-server', 'data'));
|
|
220
|
+
}
|
|
221
|
+
addNpxRoot(path.join(os.homedir(), 'AppData', 'Local', 'npm-cache', '_npx'));
|
|
222
|
+
addNpxRoot(path.join(os.homedir(), 'AppData', 'Roaming', 'npm-cache', '_npx'));
|
|
223
|
+
|
|
224
|
+
addDir('/usr/local/lib/node_modules/work-license-server/data');
|
|
225
|
+
addDir('/usr/lib/node_modules/work-license-server/data');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const npxRoot of npxRoots) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = fs.readdirSync(npxRoot, { withFileTypes: true });
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (!entry.isDirectory()) continue;
|
|
233
|
+
const hashDir = path.join(npxRoot, entry.name);
|
|
234
|
+
addDir(path.join(hashDir, 'node_modules', 'work-license-server', 'data'));
|
|
235
|
+
addDir(path.join(hashDir, 'node_modules', 'odant-work', 'license-server', 'data'));
|
|
236
|
+
addDir(path.join(hashDir, 'node_modules', 'odant-work', 'data'));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch { /* ignore */ }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return dirs;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isAutoPlaceholderLicense(lic, allOrders = {}) {
|
|
246
|
+
if (!lic || typeof lic !== 'object') return false;
|
|
247
|
+
if (lic.baseActivated) return false;
|
|
248
|
+
if (lic.hardware?.hwidBound || lic.hardware?.nodeFingerprint) return false;
|
|
249
|
+
if (Array.isArray(lic.addonSubscriptions) && lic.addonSubscriptions.length > 0) return false;
|
|
250
|
+
if (Array.isArray(lic.paidOrderIds) && lic.paidOrderIds.length > 0) return false;
|
|
251
|
+
const name = String(lic.serverName || '').trim();
|
|
252
|
+
if (name && name !== 'Сервер WORK #1' && name !== 'Сервер WORK') return false;
|
|
253
|
+
const hasOrders = Object.values(allOrders || {}).some(o => o && o.licenseId === lic.licenseId && o.status !== 'canceled');
|
|
254
|
+
return !hasOrders;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Безопасное объединение (merge без потери записей) двух состояний store.json.
|
|
259
|
+
* Сохраняет всех пользователей, их реквизиты, токены привязки (owk1...), серверы/лицензии,
|
|
260
|
+
* заказы, франшизы, партнёров, тикеты и привязки ботов.
|
|
261
|
+
*/
|
|
262
|
+
export function mergeStoreStates(baseState = {}, incomingState = {}) {
|
|
263
|
+
const out = {
|
|
264
|
+
licenses: { ...(baseState.licenses || {}) },
|
|
265
|
+
orders: { ...(baseState.orders || {}) },
|
|
266
|
+
tickets: { ...(baseState.tickets || {}) },
|
|
267
|
+
botBindings: { ...(baseState.botBindings || {}) },
|
|
268
|
+
partners: { ...(baseState.partners || {}) },
|
|
269
|
+
reservations: { ...(baseState.reservations || {}) },
|
|
270
|
+
certificates: { ...(baseState.certificates || {}) },
|
|
271
|
+
users: { ...(baseState.users || {}) },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// 1. Заказы (предпочитаем оплаченные или более свежие)
|
|
275
|
+
for (const [orderId, incOrd] of Object.entries(incomingState.orders || {})) {
|
|
276
|
+
if (!incOrd || !orderId) continue;
|
|
277
|
+
const curOrd = out.orders[orderId];
|
|
278
|
+
if (!curOrd) {
|
|
279
|
+
out.orders[orderId] = structuredClone(incOrd);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (incOrd.status === 'paid' && curOrd.status !== 'paid') {
|
|
283
|
+
out.orders[orderId] = { ...curOrd, ...structuredClone(incOrd) };
|
|
284
|
+
}
|
|
285
|
+
else if (curOrd.status === 'paid' && incOrd.status !== 'paid') {
|
|
286
|
+
out.orders[orderId] = { ...structuredClone(incOrd), ...curOrd };
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const incTs = Number(incOrd.paidAt || incOrd.updatedAt || incOrd.createdAt || 0);
|
|
290
|
+
const curTs = Number(curOrd.paidAt || curOrd.updatedAt || curOrd.createdAt || 0);
|
|
291
|
+
out.orders[orderId] = incTs >= curTs
|
|
292
|
+
? { ...curOrd, ...structuredClone(incOrd) }
|
|
293
|
+
: { ...structuredClone(incOrd), ...curOrd };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 2. Серверы / Лицензии
|
|
298
|
+
for (const [licId, incLic] of Object.entries(incomingState.licenses || {})) {
|
|
299
|
+
if (!incLic || !licId) continue;
|
|
300
|
+
const curLic = out.licenses[licId];
|
|
301
|
+
if (!curLic) {
|
|
302
|
+
out.licenses[licId] = structuredClone(incLic);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const incTs = Number(incLic.updatedAt || incLic.issuedAt || 0);
|
|
306
|
+
const curTs = Number(curLic.updatedAt || curLic.issuedAt || 0);
|
|
307
|
+
const [older, newer] = incTs >= curTs ? [curLic, incLic] : [incLic, curLic];
|
|
308
|
+
|
|
309
|
+
const mergedAddonsMap = new Map();
|
|
310
|
+
for (const sub of [...(older.addonSubscriptions || []), ...(newer.addonSubscriptions || [])]) {
|
|
311
|
+
if (!sub) continue;
|
|
312
|
+
const key = sub.id || sub.orderId || JSON.stringify(sub);
|
|
313
|
+
mergedAddonsMap.set(key, sub);
|
|
314
|
+
}
|
|
315
|
+
const mergedPaidOrders = Array.from(new Set([
|
|
316
|
+
...(older.paidOrderIds || []),
|
|
317
|
+
...(newer.paidOrderIds || []),
|
|
318
|
+
]));
|
|
319
|
+
|
|
320
|
+
const mergedCustomer = { ...(older.customer || {}), ...(newer.customer || {}) };
|
|
321
|
+
for (const k of ['name', 'inn', 'kpp', 'legalAddress', 'email', 'phone']) {
|
|
322
|
+
const vNew = String(newer.customer?.[k] || '').trim();
|
|
323
|
+
const vOld = String(older.customer?.[k] || '').trim();
|
|
324
|
+
mergedCustomer[k] = vNew || vOld;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const mergedHardware = (newer.hardware?.hwidBound || !older.hardware?.hwidBound)
|
|
328
|
+
? { ...(older.hardware || {}), ...(newer.hardware || {}) }
|
|
329
|
+
: { ...(newer.hardware || {}), ...(older.hardware || {}) };
|
|
330
|
+
|
|
331
|
+
const pickServerName = (newer.serverName && newer.serverName !== 'Сервер WORK #1' && newer.serverName !== 'Сервер WORK')
|
|
332
|
+
? newer.serverName
|
|
333
|
+
: (older.serverName || newer.serverName || 'Сервер WORK #1');
|
|
334
|
+
|
|
335
|
+
out.licenses[licId] = {
|
|
336
|
+
...older,
|
|
337
|
+
...newer,
|
|
338
|
+
serverName: pickServerName,
|
|
339
|
+
baseActivated: Boolean(older.baseActivated || newer.baseActivated),
|
|
340
|
+
perpetualBase: Boolean(older.perpetualBase || newer.perpetualBase || older.baseActivated || newer.baseActivated),
|
|
341
|
+
baseSeats: Math.max(Number(older.baseSeats || 0), Number(newer.baseSeats || 0), PAK_PRODUCT.baseSeats),
|
|
342
|
+
issuedAt: Math.min(Number(older.issuedAt || Date.now()), Number(newer.issuedAt || Date.now())),
|
|
343
|
+
updatedAt: Math.max(incTs, curTs),
|
|
344
|
+
hubToken: older.hubToken || newer.hubToken || '',
|
|
345
|
+
hubTokenId: older.hubTokenId || newer.hubTokenId || '',
|
|
346
|
+
hubTokenSecret: older.hubTokenSecret || newer.hubTokenSecret || '',
|
|
347
|
+
customer: mergedCustomer,
|
|
348
|
+
hardware: mergedHardware,
|
|
349
|
+
addonSubscriptions: Array.from(mergedAddonsMap.values()),
|
|
350
|
+
paidOrderIds: mergedPaidOrders,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 3. Пользователи Личного кабинета и администраторы
|
|
355
|
+
for (const [rawKey, incUser] of Object.entries(incomingState.users || {})) {
|
|
356
|
+
if (!incUser || typeof incUser !== 'object') continue;
|
|
357
|
+
const email = String(incUser.email || rawKey || '').trim().toLowerCase();
|
|
358
|
+
if (!email) continue;
|
|
359
|
+
const curUser = out.users[email];
|
|
360
|
+
if (!curUser) {
|
|
361
|
+
out.users[email] = { ...structuredClone(incUser), email };
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const emailPrefix = email.split('@')[0];
|
|
366
|
+
const isRealName = (val) => {
|
|
367
|
+
const s = String(val || '').trim();
|
|
368
|
+
return Boolean(s && s.toLowerCase() !== emailPrefix && s !== 'Организация ODANT-WORK' && !s.includes('@'));
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const curCreated = Number(curUser.createdAt || Date.now());
|
|
372
|
+
const incCreated = Number(incUser.createdAt || Date.now());
|
|
373
|
+
const [olderU, newerU] = incCreated >= curCreated ? [curUser, incUser] : [incUser, curUser];
|
|
374
|
+
|
|
375
|
+
const mergedUser = {
|
|
376
|
+
...olderU,
|
|
377
|
+
...newerU,
|
|
378
|
+
userId: olderU.userId || newerU.userId,
|
|
379
|
+
email,
|
|
380
|
+
isAdmin: Boolean(curUser.isAdmin || incUser.isAdmin),
|
|
381
|
+
role: (curUser.isAdmin || incUser.isAdmin) ? 'ADMIN' : 'CLIENT',
|
|
382
|
+
createdAt: Math.min(curCreated, incCreated),
|
|
383
|
+
lastLoginAt: Math.max(Number(curUser.lastLoginAt || 0), Number(incUser.lastLoginAt || 0)),
|
|
384
|
+
name: isRealName(newerU.name) ? newerU.name : (isRealName(olderU.name) ? olderU.name : (newerU.name || olderU.name || emailPrefix)),
|
|
385
|
+
companyName: isRealName(newerU.companyName) ? newerU.companyName : (isRealName(olderU.companyName) ? olderU.companyName : (newerU.companyName || olderU.companyName || '')),
|
|
386
|
+
hubToken: olderU.hubToken || newerU.hubToken || '',
|
|
387
|
+
hubTokenId: olderU.hubTokenId || newerU.hubTokenId || '',
|
|
388
|
+
hubTokenSecret: olderU.hubTokenSecret || newerU.hubTokenSecret || '',
|
|
389
|
+
hubUrl: newerU.hubUrl || olderU.hubUrl || 'https://work.odant.org',
|
|
390
|
+
sessionKey: newerU.sessionKey || olderU.sessionKey || '',
|
|
391
|
+
ofertaAccepted: Boolean(curUser.ofertaAccepted || incUser.ofertaAccepted),
|
|
392
|
+
ofertaAcceptedAt: olderU.ofertaAcceptedAt || newerU.ofertaAcceptedAt || null,
|
|
393
|
+
ofertaVersion: newerU.ofertaVersion || olderU.ofertaVersion || '',
|
|
394
|
+
ofertaSignerName: isRealName(newerU.ofertaSignerName) ? newerU.ofertaSignerName : (olderU.ofertaSignerName || newerU.ofertaSignerName || ''),
|
|
395
|
+
ofertaSignatureId: olderU.ofertaSignatureId || newerU.ofertaSignatureId || '',
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
for (const field of ['position', 'inn', 'kpp', 'legalAddress', 'signerName', 'signerPosition', 'bankName', 'bik', 'bankAccount', 'corrAccount', 'phone']) {
|
|
399
|
+
const vNew = String(newerU[field] || '').trim();
|
|
400
|
+
const vOld = String(olderU[field] || '').trim();
|
|
401
|
+
mergedUser[field] = vNew || vOld;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
out.users[email] = mergedUser;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 4. Очистка пустых дублей «Сервер WORK #1», автоматически созданных при входе в пустую БД после перезапуска npx
|
|
408
|
+
for (const user of Object.values(out.users)) {
|
|
409
|
+
if (!user) continue;
|
|
410
|
+
const uEmail = String(user.email || '').toLowerCase();
|
|
411
|
+
const userLics = Object.values(out.licenses).filter(l => l && (
|
|
412
|
+
(user.userId && l.ownerAccountId === user.userId)
|
|
413
|
+
|| (uEmail && String(l.ownerEmail || l.customer?.email || '').toLowerCase() === uEmail)
|
|
414
|
+
)).sort((a, b) => (a.issuedAt || 0) - (b.issuedAt || 0));
|
|
415
|
+
|
|
416
|
+
if (userLics.length > 1) {
|
|
417
|
+
const nonPlaceholders = userLics.filter(l => !isAutoPlaceholderLicense(l, out.orders));
|
|
418
|
+
const keepList = nonPlaceholders.length > 0 ? nonPlaceholders : [userLics[0]];
|
|
419
|
+
const keepIds = new Set(keepList.map(l => l.licenseId));
|
|
420
|
+
for (const l of userLics) {
|
|
421
|
+
if (!keepIds.has(l.licenseId) && isAutoPlaceholderLicense(l, out.orders)) {
|
|
422
|
+
delete out.licenses[l.licenseId];
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const remaining = userLics.filter(l => out.licenses[l.licenseId]);
|
|
426
|
+
if (remaining.length > 0 && (!user.licenseId || !out.licenses[user.licenseId])) {
|
|
427
|
+
const best = remaining.find(l => l.baseActivated || l.hardware?.hwidBound) || remaining[0];
|
|
428
|
+
user.licenseId = best.licenseId;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
else if (userLics.length === 1) {
|
|
432
|
+
user.licenseId = userLics[0].licenseId;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 5. Резервы франшиз и 12-месячные графики платежей
|
|
437
|
+
for (const [resId, incRes] of Object.entries(incomingState.reservations || {})) {
|
|
438
|
+
if (!incRes || !resId) continue;
|
|
439
|
+
const curRes = out.reservations[resId];
|
|
440
|
+
if (!curRes) {
|
|
441
|
+
out.reservations[resId] = structuredClone(incRes);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const mergedSchedule = buildFranchisePaymentSchedule({
|
|
445
|
+
remainingRub: incRes.remainingFranchiseFeeRub ?? curRes.remainingFranchiseFeeRub ?? 0,
|
|
446
|
+
startMs: Math.min(Number(curRes.createdAt || Date.now()), Number(incRes.createdAt || Date.now())),
|
|
447
|
+
existingSchedule: (curRes.paymentSchedule || []).map((item, idx) => {
|
|
448
|
+
const incItem = (incRes.paymentSchedule || [])[idx];
|
|
449
|
+
if (incItem?.status === 'paid' && item.status !== 'paid') return incItem;
|
|
450
|
+
return item;
|
|
451
|
+
}),
|
|
452
|
+
});
|
|
453
|
+
out.reservations[resId] = {
|
|
454
|
+
...curRes,
|
|
455
|
+
...incRes,
|
|
456
|
+
depositPaid: Boolean(curRes.depositPaid || incRes.depositPaid),
|
|
457
|
+
paidInstallmentsRub: Math.max(Number(curRes.paidInstallmentsRub || 0), Number(incRes.paidInstallmentsRub || 0)),
|
|
458
|
+
totalPaidRub: Math.max(Number(curRes.totalPaidRub || 0), Number(incRes.totalPaidRub || 0)),
|
|
459
|
+
paymentSchedule: mergedSchedule,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 6. Партнёры, тикеты, привязки ботов и сертификаты специалистов
|
|
464
|
+
for (const [pId, incP] of Object.entries(incomingState.partners || {})) {
|
|
465
|
+
if (!incP || !pId) continue;
|
|
466
|
+
const curP = out.partners[pId];
|
|
467
|
+
if (!curP) {
|
|
468
|
+
out.partners[pId] = structuredClone(incP);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const issuedLicenses = Array.from(new Set([...(curP.issuedLicenses || []), ...(incP.issuedLicenses || [])]));
|
|
472
|
+
const certMap = new Map();
|
|
473
|
+
for (const c of [...(curP.certifiedSpecialists || []), ...(incP.certifiedSpecialists || [])]) {
|
|
474
|
+
if (c) certMap.set(c.certificateId || `${c.email}:${c.trackId}`, c);
|
|
475
|
+
}
|
|
476
|
+
out.partners[pId] = {
|
|
477
|
+
...curP,
|
|
478
|
+
...incP,
|
|
479
|
+
annualSalesRub: Math.max(Number(curP.annualSalesRub || 0), Number(incP.annualSalesRub || 0)),
|
|
480
|
+
issuedLicenses,
|
|
481
|
+
certifiedSpecialists: Array.from(certMap.values()),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for (const [tId, incT] of Object.entries(incomingState.tickets || {})) {
|
|
486
|
+
if (!incT || !tId) continue;
|
|
487
|
+
const curT = out.tickets[tId];
|
|
488
|
+
if (!curT) {
|
|
489
|
+
out.tickets[tId] = structuredClone(incT);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
const msgMap = new Map();
|
|
493
|
+
for (const m of [...(curT.messages || []), ...(incT.messages || [])]) {
|
|
494
|
+
if (m) msgMap.set(`${m.time}:${m.author}:${m.text}`, m);
|
|
495
|
+
}
|
|
496
|
+
out.tickets[tId] = {
|
|
497
|
+
...curT,
|
|
498
|
+
...incT,
|
|
499
|
+
messages: Array.from(msgMap.values()).sort((a, b) => (a.time || 0) - (b.time || 0)),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
Object.assign(out.botBindings, incomingState.botBindings || {});
|
|
504
|
+
Object.assign(out.certificates, incomingState.certificates || {});
|
|
505
|
+
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
73
509
|
export class LicenseStore {
|
|
74
|
-
constructor({
|
|
75
|
-
|
|
510
|
+
constructor({
|
|
511
|
+
dataDir = '',
|
|
512
|
+
adminLogin = '',
|
|
513
|
+
adminPassword = '',
|
|
514
|
+
publicOrigin = 'https://work.odant.org',
|
|
515
|
+
autoRecover = undefined,
|
|
516
|
+
extraSearchRoots = [],
|
|
517
|
+
} = {}) {
|
|
518
|
+
const isExplicitDir = Boolean(
|
|
519
|
+
dataDir && dataDir !== './data' && (autoRecover === false || !isEphemeralPackagePath(dataDir))
|
|
520
|
+
);
|
|
521
|
+
this.dataDir = isExplicitDir
|
|
522
|
+
? path.resolve(dataDir)
|
|
523
|
+
: resolvePersistentDataDir({ requestedDir: dataDir });
|
|
524
|
+
this.autoRecover = autoRecover !== undefined
|
|
525
|
+
? Boolean(autoRecover)
|
|
526
|
+
: !isTestTempPath(this.dataDir);
|
|
527
|
+
this.extraSearchRoots = Array.isArray(extraSearchRoots) ? extraSearchRoots : [];
|
|
528
|
+
|
|
76
529
|
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
530
|
+
this.backupDir = path.join(this.dataDir, 'backups');
|
|
77
531
|
this.stateFile = path.join(this.dataDir, 'store.json');
|
|
532
|
+
this.prevStateFile = path.join(this.dataDir, 'store.prev.json');
|
|
78
533
|
this.keyFile = path.join(this.dataDir, 'authority-key.json');
|
|
534
|
+
|
|
535
|
+
// Если в новой постоянной папке ещё нет authority-key.json, восстанавливаем его из прежних папок / кэша npx
|
|
536
|
+
if (this.autoRecover && !fs.existsSync(this.keyFile)) {
|
|
537
|
+
this._recoverAuthorityKeyFromCandidates();
|
|
538
|
+
}
|
|
539
|
+
|
|
79
540
|
const isOfficialOrigin = !publicOrigin || String(publicOrigin).includes('work.odant.org') || String(publicOrigin).includes('localhost');
|
|
80
|
-
const isFakeTestDir = String(dataDir).includes('fake-hub-data');
|
|
541
|
+
const isFakeTestDir = String(this.dataDir).includes('fake-hub-data');
|
|
81
542
|
this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile, {
|
|
82
543
|
useCanonicalFallback: isOfficialOrigin && !isFakeTestDir,
|
|
83
544
|
});
|
|
@@ -85,6 +546,7 @@ export class LicenseStore {
|
|
|
85
546
|
this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
|
|
86
547
|
this.adminSessions = new Map();
|
|
87
548
|
this.revokedSessions = new Set();
|
|
549
|
+
this._readFailedGuard = false;
|
|
88
550
|
|
|
89
551
|
this.state = {
|
|
90
552
|
licenses: {},
|
|
@@ -216,25 +678,257 @@ export class LicenseStore {
|
|
|
216
678
|
return this._formatUserResponse(user);
|
|
217
679
|
}
|
|
218
680
|
|
|
681
|
+
_recoverAuthorityKeyFromCandidates() {
|
|
682
|
+
const allRoots = [...this.extraSearchRoots];
|
|
683
|
+
const dirs = findCandidateRecoveryDirs({
|
|
684
|
+
primaryDir: this.dataDir,
|
|
685
|
+
extraSearchRoots: allRoots,
|
|
686
|
+
includeSystemDefaults: !(isTestTempPath(this.dataDir) && allRoots.length > 0),
|
|
687
|
+
});
|
|
688
|
+
let bestKeyFile = '';
|
|
689
|
+
let bestMtime = 0;
|
|
690
|
+
for (const d of dirs) {
|
|
691
|
+
const kf = path.join(d, 'authority-key.json');
|
|
692
|
+
if (path.resolve(kf) === path.resolve(this.keyFile)) continue;
|
|
693
|
+
try {
|
|
694
|
+
if (!fs.existsSync(kf)) continue;
|
|
695
|
+
const stat = fs.statSync(kf);
|
|
696
|
+
const parsed = JSON.parse(fs.readFileSync(kf, 'utf8'));
|
|
697
|
+
if (parsed?.publicKey && parsed?.privateKey && stat.mtimeMs >= bestMtime) {
|
|
698
|
+
bestKeyFile = kf;
|
|
699
|
+
bestMtime = stat.mtimeMs;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
catch { /* ignore */ }
|
|
703
|
+
}
|
|
704
|
+
if (bestKeyFile) {
|
|
705
|
+
try {
|
|
706
|
+
fs.mkdirSync(path.dirname(this.keyFile), { recursive: true });
|
|
707
|
+
fs.copyFileSync(bestKeyFile, this.keyFile);
|
|
708
|
+
console.log(`[work.odant.org] Восстановлен ключ ЦС authority-key.json из: ${bestKeyFile}`);
|
|
709
|
+
}
|
|
710
|
+
catch { /* ignore */ }
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
_tryParseStateFile(filePath) {
|
|
715
|
+
try {
|
|
716
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
717
|
+
const text = fs.readFileSync(filePath, 'utf8').trim();
|
|
718
|
+
if (!text) return null;
|
|
719
|
+
const raw = JSON.parse(text);
|
|
720
|
+
const stateObj = (raw?.state && typeof raw.state === 'object' && (raw.state.users || raw.state.licenses))
|
|
721
|
+
? raw.state
|
|
722
|
+
: raw;
|
|
723
|
+
if (!stateObj || typeof stateObj !== 'object') return null;
|
|
724
|
+
return {
|
|
725
|
+
licenses: stateObj.licenses || {},
|
|
726
|
+
orders: stateObj.orders || {},
|
|
727
|
+
tickets: stateObj.tickets || {},
|
|
728
|
+
botBindings: stateObj.botBindings || {},
|
|
729
|
+
partners: stateObj.partners || {},
|
|
730
|
+
reservations: stateObj.reservations || {},
|
|
731
|
+
certificates: stateObj.certificates || {},
|
|
732
|
+
users: stateObj.users || {},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
getStoreCounts(state = this.state) {
|
|
741
|
+
return {
|
|
742
|
+
users: Object.keys(state?.users || {}).length,
|
|
743
|
+
licenses: Object.keys(state?.licenses || {}).length,
|
|
744
|
+
orders: Object.keys(state?.orders || {}).length,
|
|
745
|
+
reservations: Object.keys(state?.reservations || {}).length,
|
|
746
|
+
partners: Object.keys(state?.partners || {}).length,
|
|
747
|
+
tickets: Object.keys(state?.tickets || {}).length,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
219
751
|
_load() {
|
|
220
|
-
|
|
221
|
-
|
|
752
|
+
let loaded = this._tryParseStateFile(this.stateFile);
|
|
753
|
+
const mainExistsNonEmpty = fs.existsSync(this.stateFile) && fs.statSync(this.stateFile).size > 50;
|
|
754
|
+
|
|
755
|
+
if (!loaded && mainExistsNonEmpty) {
|
|
756
|
+
console.warn(`[work.odant.org] Внимание: не удалось прочитать ${this.stateFile}, проверяем резервные копии...`);
|
|
757
|
+
loaded = this._tryParseStateFile(this.prevStateFile);
|
|
758
|
+
if (!loaded && fs.existsSync(this.backupDir)) {
|
|
759
|
+
try {
|
|
760
|
+
const bFiles = fs.readdirSync(this.backupDir)
|
|
761
|
+
.filter(f => f.endsWith('.json'))
|
|
762
|
+
.sort()
|
|
763
|
+
.reverse();
|
|
764
|
+
for (const bf of bFiles) {
|
|
765
|
+
loaded = this._tryParseStateFile(path.join(this.backupDir, bf));
|
|
766
|
+
if (loaded) {
|
|
767
|
+
console.log(`[work.odant.org] Состояние восстановлено из резервной копии: ${bf}`);
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch { /* ignore */ }
|
|
773
|
+
}
|
|
774
|
+
if (!loaded) {
|
|
775
|
+
this._readFailedGuard = true;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (loaded) {
|
|
780
|
+
this.state = mergeStoreStates(this.state, loaded);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (this.autoRecover) {
|
|
784
|
+
this.recoverFromExternalLocations({ silentIfZero: true });
|
|
785
|
+
}
|
|
786
|
+
else if (loaded) {
|
|
787
|
+
this._ensureBootstrapAdmin();
|
|
788
|
+
this._resignAllWithAuthority();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Просканировать кэш npx (~/.npm/_npx/*, %LOCALAPPDATA%/npm-cache/_npx/*), домашнее зеркало и рабочие папки,
|
|
794
|
+
* и безопасно объединить все найденные записи store.json в текущую постоянную базу данных.
|
|
795
|
+
*/
|
|
796
|
+
recoverFromExternalLocations({ extraSearchRoots = [], silentIfZero = false } = {}) {
|
|
797
|
+
const allRoots = [...this.extraSearchRoots, ...(extraSearchRoots || [])];
|
|
798
|
+
const dirs = findCandidateRecoveryDirs({
|
|
799
|
+
primaryDir: this.dataDir,
|
|
800
|
+
extraSearchRoots: allRoots,
|
|
801
|
+
includeSystemDefaults: !(isTestTempPath(this.dataDir) && allRoots.length > 0),
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const candidates = [];
|
|
805
|
+
for (const d of dirs) {
|
|
806
|
+
const sf = path.join(d, 'store.json');
|
|
807
|
+
if (path.resolve(sf) === path.resolve(this.stateFile)) continue;
|
|
808
|
+
try {
|
|
809
|
+
if (!fs.existsSync(sf)) continue;
|
|
810
|
+
const stat = fs.statSync(sf);
|
|
811
|
+
const parsed = this._tryParseStateFile(sf);
|
|
812
|
+
if (!parsed) continue;
|
|
813
|
+
const counts = this.getStoreCounts(parsed);
|
|
814
|
+
const totalItems = counts.users + counts.licenses + counts.orders + counts.reservations + counts.partners + counts.tickets;
|
|
815
|
+
if (totalItems > 0) {
|
|
816
|
+
candidates.push({ file: sf, dir: d, mtimeMs: stat.mtimeMs, parsed, counts });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch { /* ignore */ }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Сортируем от более старых к более новым, чтобы более свежие изменения дополняли старые
|
|
823
|
+
candidates.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
824
|
+
|
|
825
|
+
const beforeCounts = this.getStoreCounts(this.state);
|
|
826
|
+
const mergedSources = [];
|
|
827
|
+
|
|
828
|
+
for (const item of candidates) {
|
|
829
|
+
this.state = mergeStoreStates(this.state, item.parsed);
|
|
830
|
+
mergedSources.push({
|
|
831
|
+
file: item.file,
|
|
832
|
+
counts: item.counts,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
this._ensureBootstrapAdmin();
|
|
837
|
+
this._resignAllWithAuthority();
|
|
838
|
+
|
|
839
|
+
const afterCounts = this.getStoreCounts(this.state);
|
|
840
|
+
const addedOrMerged = (afterCounts.users > beforeCounts.users)
|
|
841
|
+
|| (afterCounts.licenses > beforeCounts.licenses)
|
|
842
|
+
|| (afterCounts.orders > beforeCounts.orders)
|
|
843
|
+
|| (afterCounts.reservations > beforeCounts.reservations)
|
|
844
|
+
|| !fs.existsSync(this.stateFile);
|
|
845
|
+
|
|
846
|
+
if (mergedSources.length > 0 && addedOrMerged) {
|
|
847
|
+
this._readFailedGuard = false;
|
|
848
|
+
try { this._save(); } catch { /* ignore */ }
|
|
849
|
+
if (!silentIfZero || afterCounts.users > beforeCounts.users || afterCounts.licenses > beforeCounts.licenses) {
|
|
850
|
+
console.log(
|
|
851
|
+
`[work.odant.org] Автовосстановление данных (источников: ${mergedSources.length}): `
|
|
852
|
+
+ `пользователей=${afterCounts.users}, серверов/лицензий=${afterCounts.licenses}, `
|
|
853
|
+
+ `заказов=${afterCounts.orders}, франшиз=${afterCounts.reservations}`
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
ok: true,
|
|
860
|
+
dataDir: this.dataDir,
|
|
861
|
+
scannedDirs: dirs,
|
|
862
|
+
mergedSources,
|
|
863
|
+
beforeCounts,
|
|
864
|
+
counts: afterCounts,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Сформировать полный пакет резервной копии базы данных для скачивания из /admin.
|
|
870
|
+
*/
|
|
871
|
+
exportBackupBundle() {
|
|
872
|
+
let backups = [];
|
|
222
873
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this.state.partners = raw.partners || {};
|
|
230
|
-
this.state.reservations = raw.reservations || {};
|
|
231
|
-
this.state.certificates = raw.certificates || {};
|
|
232
|
-
this.state.users = raw.users || {};
|
|
233
|
-
this._ensureBootstrapAdmin();
|
|
234
|
-
this._resignAllWithAuthority();
|
|
874
|
+
if (fs.existsSync(this.backupDir)) {
|
|
875
|
+
backups = fs.readdirSync(this.backupDir)
|
|
876
|
+
.filter(f => f.endsWith('.json'))
|
|
877
|
+
.sort()
|
|
878
|
+
.reverse()
|
|
879
|
+
.slice(0, 14);
|
|
235
880
|
}
|
|
236
881
|
}
|
|
237
|
-
catch { /*
|
|
882
|
+
catch { /* ignore */ }
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
schema: 'ODANT-WORK-LICENSE-STORE-BACKUP-v1',
|
|
886
|
+
exportedAt: new Date().toISOString(),
|
|
887
|
+
dataDir: this.dataDir,
|
|
888
|
+
authorityPublicKey: this.authorityKeys.publicKey,
|
|
889
|
+
counts: this.getStoreCounts(this.state),
|
|
890
|
+
backups,
|
|
891
|
+
state: this.state,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Восстановить или безопасно объединить (merge) базу данных из резервной копии JSON.
|
|
897
|
+
*/
|
|
898
|
+
importBackupBundle(payload = {}, { mode = 'merge' } = {}) {
|
|
899
|
+
const parsed = (payload?.state && typeof payload.state === 'object')
|
|
900
|
+
? payload.state
|
|
901
|
+
: payload;
|
|
902
|
+
if (!parsed || typeof parsed !== 'object' || (!parsed.users && !parsed.licenses && !parsed.orders)) {
|
|
903
|
+
throw new Error('Некорректный формат файла резервной копии (ожидается JSON с разделами users / licenses / orders)');
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Перед импортом обязательно сохраняем страховочный снапшот текущего состояния
|
|
907
|
+
try {
|
|
908
|
+
fs.mkdirSync(this.backupDir, { recursive: true });
|
|
909
|
+
const preFile = path.join(this.backupDir, `store-pre-import-${Date.now()}.json`);
|
|
910
|
+
fs.writeFileSync(preFile, JSON.stringify(this.state, null, 2), 'utf8');
|
|
911
|
+
}
|
|
912
|
+
catch { /* ignore */ }
|
|
913
|
+
|
|
914
|
+
if (mode === 'replace') {
|
|
915
|
+
this.state = mergeStoreStates({}, parsed);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
this.state = mergeStoreStates(this.state, parsed);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
this._readFailedGuard = false;
|
|
922
|
+
this._ensureBootstrapAdmin();
|
|
923
|
+
this._resignAllWithAuthority();
|
|
924
|
+
this._save();
|
|
925
|
+
|
|
926
|
+
return {
|
|
927
|
+
ok: true,
|
|
928
|
+
mode: mode === 'replace' ? 'replace' : 'merge',
|
|
929
|
+
dataDir: this.dataDir,
|
|
930
|
+
counts: this.getStoreCounts(this.state),
|
|
931
|
+
};
|
|
238
932
|
}
|
|
239
933
|
|
|
240
934
|
/**
|
|
@@ -294,9 +988,60 @@ export class LicenseStore {
|
|
|
294
988
|
}
|
|
295
989
|
|
|
296
990
|
_save() {
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
991
|
+
const counts = this.getStoreCounts(this.state);
|
|
992
|
+
const totalEntities = counts.users + counts.licenses + counts.orders + counts.reservations + counts.partners + counts.tickets;
|
|
993
|
+
|
|
994
|
+
// Защита от перезаписи непустого файла пустым состоянием при сбое чтения
|
|
995
|
+
if (this._readFailedGuard && totalEntities === 0) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
1000
|
+
|
|
1001
|
+
// Сохраняем предыдущую версию (store.prev.json) и ежедневный снапшот (backups/store-YYYY-MM-DD.json)
|
|
1002
|
+
try {
|
|
1003
|
+
if (fs.existsSync(this.stateFile) && fs.statSync(this.stateFile).size > 50) {
|
|
1004
|
+
fs.copyFileSync(this.stateFile, this.prevStateFile);
|
|
1005
|
+
if (totalEntities > 0) {
|
|
1006
|
+
fs.mkdirSync(this.backupDir, { recursive: true });
|
|
1007
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
1008
|
+
const dailyBackup = path.join(this.backupDir, `store-${dayKey}.json`);
|
|
1009
|
+
fs.copyFileSync(this.stateFile, dailyBackup);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch { /* ignore */ }
|
|
1014
|
+
|
|
1015
|
+
const serialized = JSON.stringify(this.state, null, 2);
|
|
1016
|
+
const writeAtomically = (destPath) => {
|
|
1017
|
+
const tmp = `${destPath}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
1018
|
+
fs.writeFileSync(tmp, serialized, 'utf8');
|
|
1019
|
+
try {
|
|
1020
|
+
fs.renameSync(tmp, destPath);
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
fs.writeFileSync(destPath, serialized, 'utf8');
|
|
1024
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
writeAtomically(this.stateFile);
|
|
1029
|
+
|
|
1030
|
+
// Дублируем актуальную базу в системное зеркало профиля пользователя ОС (~/.odant-work-license/data),
|
|
1031
|
+
// чтобы даже при запуске npx из другой папки данные всегда оставались едиными.
|
|
1032
|
+
if (this.autoRecover && totalEntities > 0) {
|
|
1033
|
+
try {
|
|
1034
|
+
const mirrorDir = getHomeMirrorDataDir();
|
|
1035
|
+
if (path.resolve(mirrorDir) !== path.resolve(this.dataDir)) {
|
|
1036
|
+
fs.mkdirSync(mirrorDir, { recursive: true });
|
|
1037
|
+
writeAtomically(path.join(mirrorDir, 'store.json'));
|
|
1038
|
+
if (fs.existsSync(this.keyFile)) {
|
|
1039
|
+
fs.copyFileSync(this.keyFile, path.join(mirrorDir, 'authority-key.json'));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch { /* ignore */ }
|
|
1044
|
+
}
|
|
300
1045
|
}
|
|
301
1046
|
|
|
302
1047
|
/**
|