work-license-server 1.0.21 → 1.0.23
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 +701 -179
- package/assets/landing.css +158 -0
- package/bin/server.mjs +63 -4
- package/package.json +1 -1
- package/src/admin.js +228 -73
- package/src/index.js +7 -1
- package/src/server.js +199 -21
- package/src/store.js +1012 -30
- package/src/yookassa.js +9 -4
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,530 @@ 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
|
+
export function maskSecret(value = '') {
|
|
258
|
+
const s = String(value || '').trim();
|
|
259
|
+
if (!s) return '';
|
|
260
|
+
if (s.length >= 10)
|
|
261
|
+
return `${s.slice(0, 6)}•••••••${s.slice(-4)}`;
|
|
262
|
+
return '•••••••';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function isMaskedSecret(value = '') {
|
|
266
|
+
return String(value || '').includes('•••');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createDefaultPortalSettings() {
|
|
270
|
+
return {
|
|
271
|
+
smtp: {
|
|
272
|
+
host: process.env.SMTP_HOST || 'smtp.yandex.ru',
|
|
273
|
+
port: Number(process.env.SMTP_PORT || 465),
|
|
274
|
+
secure: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) === 465 : true,
|
|
275
|
+
user: process.env.SMTP_USER || 'proman1968.perepelkin@yandex.ru',
|
|
276
|
+
pass: process.env.SMTP_PASS || 'zifeomdqijldyece',
|
|
277
|
+
fromEmail: process.env.SMTP_FROM || process.env.SMTP_USER || 'proman1968.perepelkin@yandex.ru',
|
|
278
|
+
senderName: process.env.SMTP_SENDER_NAME || 'ODANT-WORK',
|
|
279
|
+
},
|
|
280
|
+
yookassa: {
|
|
281
|
+
shopId: process.env.YOOKASSA_SHOP_ID || '',
|
|
282
|
+
secretKey: process.env.YOOKASSA_SECRET_KEY || '',
|
|
283
|
+
sendReceipt: true,
|
|
284
|
+
},
|
|
285
|
+
bots: {
|
|
286
|
+
telegramToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
|
287
|
+
telegramUsername: process.env.TELEGRAM_BOT_USERNAME || '',
|
|
288
|
+
maxToken: process.env.MAX_BOT_TOKEN || '',
|
|
289
|
+
maxUsername: process.env.MAX_BOT_USERNAME || '',
|
|
290
|
+
},
|
|
291
|
+
legal: {
|
|
292
|
+
companyName: 'ООО «БИС» («Бизнес Интер Софт»)',
|
|
293
|
+
authorName: 'Перепелкин Роман Александрович',
|
|
294
|
+
legalStatus: 'Правообладатель и Оператор платформы · Проприетарное ПО (Все права защищены)',
|
|
295
|
+
inn: '6234049193',
|
|
296
|
+
ogrn: '',
|
|
297
|
+
supportEmail: 'support@odant.org',
|
|
298
|
+
phone: '+7 (4912) 77-70-21',
|
|
299
|
+
address: VENDOR_CONTACTS.address || '390000, г. Рязань, ул. Ленина, 47',
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Безопасное объединение (merge без потери записей) двух состояний store.json.
|
|
306
|
+
* Сохраняет всех пользователей, их реквизиты, токены привязки (owk1...), серверы/лицензии,
|
|
307
|
+
* заказы, франшизы, партнёров, тикеты и привязки ботов.
|
|
308
|
+
*/
|
|
309
|
+
export function mergeStoreStates(baseState = {}, incomingState = {}) {
|
|
310
|
+
const defSettings = createDefaultPortalSettings();
|
|
311
|
+
const baseSet = baseState.settings || {};
|
|
312
|
+
const incSet = incomingState.settings || {};
|
|
313
|
+
const out = {
|
|
314
|
+
licenses: { ...(baseState.licenses || {}) },
|
|
315
|
+
orders: { ...(baseState.orders || {}) },
|
|
316
|
+
tickets: { ...(baseState.tickets || {}) },
|
|
317
|
+
botBindings: { ...(baseState.botBindings || {}) },
|
|
318
|
+
partners: { ...(baseState.partners || {}) },
|
|
319
|
+
reservations: { ...(baseState.reservations || {}) },
|
|
320
|
+
certificates: { ...(baseState.certificates || {}) },
|
|
321
|
+
users: { ...(baseState.users || {}) },
|
|
322
|
+
settings: {
|
|
323
|
+
smtp: { ...defSettings.smtp, ...(baseSet.smtp || {}), ...(incSet.smtp || {}) },
|
|
324
|
+
yookassa: { ...defSettings.yookassa, ...(baseSet.yookassa || {}), ...(incSet.yookassa || {}) },
|
|
325
|
+
bots: { ...defSettings.bots, ...(baseSet.bots || {}), ...(incSet.bots || {}) },
|
|
326
|
+
legal: { ...defSettings.legal, ...(baseSet.legal || {}), ...(incSet.legal || {}) },
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// 1. Заказы (предпочитаем оплаченные или более свежие)
|
|
331
|
+
for (const [orderId, incOrd] of Object.entries(incomingState.orders || {})) {
|
|
332
|
+
if (!incOrd || !orderId) continue;
|
|
333
|
+
const curOrd = out.orders[orderId];
|
|
334
|
+
if (!curOrd) {
|
|
335
|
+
out.orders[orderId] = structuredClone(incOrd);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (incOrd.status === 'paid' && curOrd.status !== 'paid') {
|
|
339
|
+
out.orders[orderId] = { ...curOrd, ...structuredClone(incOrd) };
|
|
340
|
+
}
|
|
341
|
+
else if (curOrd.status === 'paid' && incOrd.status !== 'paid') {
|
|
342
|
+
out.orders[orderId] = { ...structuredClone(incOrd), ...curOrd };
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const incTs = Number(incOrd.paidAt || incOrd.updatedAt || incOrd.createdAt || 0);
|
|
346
|
+
const curTs = Number(curOrd.paidAt || curOrd.updatedAt || curOrd.createdAt || 0);
|
|
347
|
+
out.orders[orderId] = incTs >= curTs
|
|
348
|
+
? { ...curOrd, ...structuredClone(incOrd) }
|
|
349
|
+
: { ...structuredClone(incOrd), ...curOrd };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 2. Серверы / Лицензии
|
|
354
|
+
for (const [licId, incLic] of Object.entries(incomingState.licenses || {})) {
|
|
355
|
+
if (!incLic || !licId) continue;
|
|
356
|
+
const curLic = out.licenses[licId];
|
|
357
|
+
if (!curLic) {
|
|
358
|
+
out.licenses[licId] = structuredClone(incLic);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const incTs = Number(incLic.updatedAt || incLic.issuedAt || 0);
|
|
362
|
+
const curTs = Number(curLic.updatedAt || curLic.issuedAt || 0);
|
|
363
|
+
const [older, newer] = incTs >= curTs ? [curLic, incLic] : [incLic, curLic];
|
|
364
|
+
|
|
365
|
+
const mergedAddonsMap = new Map();
|
|
366
|
+
for (const sub of [...(older.addonSubscriptions || []), ...(newer.addonSubscriptions || [])]) {
|
|
367
|
+
if (!sub) continue;
|
|
368
|
+
const key = sub.id || sub.orderId || JSON.stringify(sub);
|
|
369
|
+
mergedAddonsMap.set(key, sub);
|
|
370
|
+
}
|
|
371
|
+
const mergedPaidOrders = Array.from(new Set([
|
|
372
|
+
...(older.paidOrderIds || []),
|
|
373
|
+
...(newer.paidOrderIds || []),
|
|
374
|
+
]));
|
|
375
|
+
|
|
376
|
+
const mergedCustomer = { ...(older.customer || {}), ...(newer.customer || {}) };
|
|
377
|
+
for (const k of ['name', 'inn', 'kpp', 'legalAddress', 'email', 'phone']) {
|
|
378
|
+
const vNew = String(newer.customer?.[k] || '').trim();
|
|
379
|
+
const vOld = String(older.customer?.[k] || '').trim();
|
|
380
|
+
mergedCustomer[k] = vNew || vOld;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const mergedHardware = (newer.hardware?.hwidBound || !older.hardware?.hwidBound)
|
|
384
|
+
? { ...(older.hardware || {}), ...(newer.hardware || {}) }
|
|
385
|
+
: { ...(newer.hardware || {}), ...(older.hardware || {}) };
|
|
386
|
+
|
|
387
|
+
const pickServerName = (newer.serverName && newer.serverName !== 'Сервер WORK #1' && newer.serverName !== 'Сервер WORK')
|
|
388
|
+
? newer.serverName
|
|
389
|
+
: (older.serverName || newer.serverName || 'Сервер WORK #1');
|
|
390
|
+
|
|
391
|
+
out.licenses[licId] = {
|
|
392
|
+
...older,
|
|
393
|
+
...newer,
|
|
394
|
+
serverName: pickServerName,
|
|
395
|
+
baseActivated: Boolean(older.baseActivated || newer.baseActivated),
|
|
396
|
+
perpetualBase: Boolean(older.perpetualBase || newer.perpetualBase || older.baseActivated || newer.baseActivated),
|
|
397
|
+
baseSeats: Math.max(Number(older.baseSeats || 0), Number(newer.baseSeats || 0), PAK_PRODUCT.baseSeats),
|
|
398
|
+
issuedAt: Math.min(Number(older.issuedAt || Date.now()), Number(newer.issuedAt || Date.now())),
|
|
399
|
+
updatedAt: Math.max(incTs, curTs),
|
|
400
|
+
hubToken: older.hubToken || newer.hubToken || '',
|
|
401
|
+
hubTokenId: older.hubTokenId || newer.hubTokenId || '',
|
|
402
|
+
hubTokenSecret: older.hubTokenSecret || newer.hubTokenSecret || '',
|
|
403
|
+
customer: mergedCustomer,
|
|
404
|
+
hardware: mergedHardware,
|
|
405
|
+
addonSubscriptions: Array.from(mergedAddonsMap.values()),
|
|
406
|
+
paidOrderIds: mergedPaidOrders,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 3. Пользователи Личного кабинета и администраторы
|
|
411
|
+
for (const [rawKey, incUser] of Object.entries(incomingState.users || {})) {
|
|
412
|
+
if (!incUser || typeof incUser !== 'object') continue;
|
|
413
|
+
const email = String(incUser.email || rawKey || '').trim().toLowerCase();
|
|
414
|
+
if (!email) continue;
|
|
415
|
+
const curUser = out.users[email];
|
|
416
|
+
if (!curUser) {
|
|
417
|
+
out.users[email] = { ...structuredClone(incUser), email };
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const emailPrefix = email.split('@')[0];
|
|
422
|
+
const isRealName = (val) => {
|
|
423
|
+
const s = String(val || '').trim();
|
|
424
|
+
return Boolean(s && s.toLowerCase() !== emailPrefix && s !== 'Организация ODANT-WORK' && !s.includes('@'));
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const curCreated = Number(curUser.createdAt || Date.now());
|
|
428
|
+
const incCreated = Number(incUser.createdAt || Date.now());
|
|
429
|
+
const [olderU, newerU] = incCreated >= curCreated ? [curUser, incUser] : [incUser, curUser];
|
|
430
|
+
|
|
431
|
+
const mergedUser = {
|
|
432
|
+
...olderU,
|
|
433
|
+
...newerU,
|
|
434
|
+
userId: olderU.userId || newerU.userId,
|
|
435
|
+
email,
|
|
436
|
+
isAdmin: Boolean(curUser.isAdmin || incUser.isAdmin),
|
|
437
|
+
role: (curUser.isAdmin || incUser.isAdmin) ? 'ADMIN' : 'CLIENT',
|
|
438
|
+
createdAt: Math.min(curCreated, incCreated),
|
|
439
|
+
lastLoginAt: Math.max(Number(curUser.lastLoginAt || 0), Number(incUser.lastLoginAt || 0)),
|
|
440
|
+
name: isRealName(newerU.name) ? newerU.name : (isRealName(olderU.name) ? olderU.name : (newerU.name || olderU.name || emailPrefix)),
|
|
441
|
+
companyName: isRealName(newerU.companyName) ? newerU.companyName : (isRealName(olderU.companyName) ? olderU.companyName : (newerU.companyName || olderU.companyName || '')),
|
|
442
|
+
hubToken: olderU.hubToken || newerU.hubToken || '',
|
|
443
|
+
hubTokenId: olderU.hubTokenId || newerU.hubTokenId || '',
|
|
444
|
+
hubTokenSecret: olderU.hubTokenSecret || newerU.hubTokenSecret || '',
|
|
445
|
+
hubUrl: newerU.hubUrl || olderU.hubUrl || 'https://work.odant.org',
|
|
446
|
+
sessionKey: newerU.sessionKey || olderU.sessionKey || '',
|
|
447
|
+
ofertaAccepted: Boolean(curUser.ofertaAccepted || incUser.ofertaAccepted),
|
|
448
|
+
ofertaAcceptedAt: olderU.ofertaAcceptedAt || newerU.ofertaAcceptedAt || null,
|
|
449
|
+
ofertaVersion: newerU.ofertaVersion || olderU.ofertaVersion || '',
|
|
450
|
+
ofertaSignerName: isRealName(newerU.ofertaSignerName) ? newerU.ofertaSignerName : (olderU.ofertaSignerName || newerU.ofertaSignerName || ''),
|
|
451
|
+
ofertaSignatureId: olderU.ofertaSignatureId || newerU.ofertaSignatureId || '',
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
for (const field of ['position', 'inn', 'kpp', 'legalAddress', 'signerName', 'signerPosition', 'bankName', 'bik', 'bankAccount', 'corrAccount', 'phone']) {
|
|
455
|
+
const vNew = String(newerU[field] || '').trim();
|
|
456
|
+
const vOld = String(olderU[field] || '').trim();
|
|
457
|
+
mergedUser[field] = vNew || vOld;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
out.users[email] = mergedUser;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// 4. Очистка пустых дублей «Сервер WORK #1», автоматически созданных при входе в пустую БД после перезапуска npx
|
|
464
|
+
for (const user of Object.values(out.users)) {
|
|
465
|
+
if (!user) continue;
|
|
466
|
+
const uEmail = String(user.email || '').toLowerCase();
|
|
467
|
+
const userLics = Object.values(out.licenses).filter(l => l && (
|
|
468
|
+
(user.userId && l.ownerAccountId === user.userId)
|
|
469
|
+
|| (uEmail && String(l.ownerEmail || l.customer?.email || '').toLowerCase() === uEmail)
|
|
470
|
+
)).sort((a, b) => (a.issuedAt || 0) - (b.issuedAt || 0));
|
|
471
|
+
|
|
472
|
+
if (userLics.length > 1) {
|
|
473
|
+
const nonPlaceholders = userLics.filter(l => !isAutoPlaceholderLicense(l, out.orders));
|
|
474
|
+
const keepList = nonPlaceholders.length > 0 ? nonPlaceholders : [userLics[0]];
|
|
475
|
+
const keepIds = new Set(keepList.map(l => l.licenseId));
|
|
476
|
+
for (const l of userLics) {
|
|
477
|
+
if (!keepIds.has(l.licenseId) && isAutoPlaceholderLicense(l, out.orders)) {
|
|
478
|
+
delete out.licenses[l.licenseId];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const remaining = userLics.filter(l => out.licenses[l.licenseId]);
|
|
482
|
+
if (remaining.length > 0 && (!user.licenseId || !out.licenses[user.licenseId])) {
|
|
483
|
+
const best = remaining.find(l => l.baseActivated || l.hardware?.hwidBound) || remaining[0];
|
|
484
|
+
user.licenseId = best.licenseId;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
else if (userLics.length === 1) {
|
|
488
|
+
user.licenseId = userLics[0].licenseId;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 5. Резервы франшиз и 12-месячные графики платежей
|
|
493
|
+
for (const [resId, incRes] of Object.entries(incomingState.reservations || {})) {
|
|
494
|
+
if (!incRes || !resId) continue;
|
|
495
|
+
const curRes = out.reservations[resId];
|
|
496
|
+
if (!curRes) {
|
|
497
|
+
out.reservations[resId] = structuredClone(incRes);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const mergedSchedule = buildFranchisePaymentSchedule({
|
|
501
|
+
remainingRub: incRes.remainingFranchiseFeeRub ?? curRes.remainingFranchiseFeeRub ?? 0,
|
|
502
|
+
startMs: Math.min(Number(curRes.createdAt || Date.now()), Number(incRes.createdAt || Date.now())),
|
|
503
|
+
existingSchedule: (curRes.paymentSchedule || []).map((item, idx) => {
|
|
504
|
+
const incItem = (incRes.paymentSchedule || [])[idx];
|
|
505
|
+
if (incItem?.status === 'paid' && item.status !== 'paid') return incItem;
|
|
506
|
+
return item;
|
|
507
|
+
}),
|
|
508
|
+
});
|
|
509
|
+
out.reservations[resId] = {
|
|
510
|
+
...curRes,
|
|
511
|
+
...incRes,
|
|
512
|
+
depositPaid: Boolean(curRes.depositPaid || incRes.depositPaid),
|
|
513
|
+
paidInstallmentsRub: Math.max(Number(curRes.paidInstallmentsRub || 0), Number(incRes.paidInstallmentsRub || 0)),
|
|
514
|
+
totalPaidRub: Math.max(Number(curRes.totalPaidRub || 0), Number(incRes.totalPaidRub || 0)),
|
|
515
|
+
paymentSchedule: mergedSchedule,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 6. Партнёры, тикеты, привязки ботов и сертификаты специалистов
|
|
520
|
+
for (const [pId, incP] of Object.entries(incomingState.partners || {})) {
|
|
521
|
+
if (!incP || !pId) continue;
|
|
522
|
+
const curP = out.partners[pId];
|
|
523
|
+
if (!curP) {
|
|
524
|
+
out.partners[pId] = structuredClone(incP);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const issuedLicenses = Array.from(new Set([...(curP.issuedLicenses || []), ...(incP.issuedLicenses || [])]));
|
|
528
|
+
const certMap = new Map();
|
|
529
|
+
for (const c of [...(curP.certifiedSpecialists || []), ...(incP.certifiedSpecialists || [])]) {
|
|
530
|
+
if (c) certMap.set(c.certificateId || `${c.email}:${c.trackId}`, c);
|
|
531
|
+
}
|
|
532
|
+
out.partners[pId] = {
|
|
533
|
+
...curP,
|
|
534
|
+
...incP,
|
|
535
|
+
annualSalesRub: Math.max(Number(curP.annualSalesRub || 0), Number(incP.annualSalesRub || 0)),
|
|
536
|
+
issuedLicenses,
|
|
537
|
+
certifiedSpecialists: Array.from(certMap.values()),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
for (const [tId, incT] of Object.entries(incomingState.tickets || {})) {
|
|
542
|
+
if (!incT || !tId) continue;
|
|
543
|
+
const curT = out.tickets[tId];
|
|
544
|
+
if (!curT) {
|
|
545
|
+
out.tickets[tId] = structuredClone(incT);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const msgMap = new Map();
|
|
549
|
+
for (const m of [...(curT.messages || []), ...(incT.messages || [])]) {
|
|
550
|
+
if (m) msgMap.set(`${m.time}:${m.author}:${m.text}`, m);
|
|
551
|
+
}
|
|
552
|
+
out.tickets[tId] = {
|
|
553
|
+
...curT,
|
|
554
|
+
...incT,
|
|
555
|
+
messages: Array.from(msgMap.values()).sort((a, b) => (a.time || 0) - (b.time || 0)),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
Object.assign(out.botBindings, incomingState.botBindings || {});
|
|
560
|
+
Object.assign(out.certificates, incomingState.certificates || {});
|
|
561
|
+
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
73
565
|
export class LicenseStore {
|
|
74
|
-
constructor({
|
|
75
|
-
|
|
566
|
+
constructor({
|
|
567
|
+
dataDir = '',
|
|
568
|
+
adminLogin = '',
|
|
569
|
+
adminPassword = '',
|
|
570
|
+
publicOrigin = 'https://work.odant.org',
|
|
571
|
+
autoRecover = undefined,
|
|
572
|
+
extraSearchRoots = [],
|
|
573
|
+
} = {}) {
|
|
574
|
+
const isExplicitDir = Boolean(
|
|
575
|
+
dataDir && dataDir !== './data' && (autoRecover === false || !isEphemeralPackagePath(dataDir))
|
|
576
|
+
);
|
|
577
|
+
this.dataDir = isExplicitDir
|
|
578
|
+
? path.resolve(dataDir)
|
|
579
|
+
: resolvePersistentDataDir({ requestedDir: dataDir });
|
|
580
|
+
this.autoRecover = autoRecover !== undefined
|
|
581
|
+
? Boolean(autoRecover)
|
|
582
|
+
: !isTestTempPath(this.dataDir);
|
|
583
|
+
this.extraSearchRoots = Array.isArray(extraSearchRoots) ? extraSearchRoots : [];
|
|
584
|
+
|
|
76
585
|
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
586
|
+
this.backupDir = path.join(this.dataDir, 'backups');
|
|
77
587
|
this.stateFile = path.join(this.dataDir, 'store.json');
|
|
588
|
+
this.prevStateFile = path.join(this.dataDir, 'store.prev.json');
|
|
78
589
|
this.keyFile = path.join(this.dataDir, 'authority-key.json');
|
|
590
|
+
this.mailFile = path.join(this.dataDir, 'mail.json');
|
|
591
|
+
|
|
592
|
+
// Если в новой постоянной папке ещё нет authority-key.json, восстанавливаем его из прежних папок / кэша npx
|
|
593
|
+
if (this.autoRecover && !fs.existsSync(this.keyFile)) {
|
|
594
|
+
this._recoverAuthorityKeyFromCandidates();
|
|
595
|
+
}
|
|
596
|
+
|
|
79
597
|
const isOfficialOrigin = !publicOrigin || String(publicOrigin).includes('work.odant.org') || String(publicOrigin).includes('localhost');
|
|
80
|
-
const isFakeTestDir = String(dataDir).includes('fake-hub-data');
|
|
598
|
+
const isFakeTestDir = String(this.dataDir).includes('fake-hub-data');
|
|
81
599
|
this.authorityKeys = loadOrCreateAuthorityKeys(this.keyFile, {
|
|
82
600
|
useCanonicalFallback: isOfficialOrigin && !isFakeTestDir,
|
|
83
601
|
});
|
|
@@ -85,6 +603,7 @@ export class LicenseStore {
|
|
|
85
603
|
this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
|
|
86
604
|
this.adminSessions = new Map();
|
|
87
605
|
this.revokedSessions = new Set();
|
|
606
|
+
this._readFailedGuard = false;
|
|
88
607
|
|
|
89
608
|
this.state = {
|
|
90
609
|
licenses: {},
|
|
@@ -95,8 +614,10 @@ export class LicenseStore {
|
|
|
95
614
|
reservations: {},
|
|
96
615
|
certificates: {},
|
|
97
616
|
users: {},
|
|
617
|
+
settings: createDefaultPortalSettings(),
|
|
98
618
|
};
|
|
99
619
|
this._load();
|
|
620
|
+
this._loadOrSyncMailFile();
|
|
100
621
|
}
|
|
101
622
|
|
|
102
623
|
/**
|
|
@@ -198,9 +719,13 @@ export class LicenseStore {
|
|
|
198
719
|
*/
|
|
199
720
|
setUserAdminRole(emailOrUserId = '', isAdmin = true) {
|
|
200
721
|
const key = String(emailOrUserId || '').trim();
|
|
201
|
-
|
|
722
|
+
let user = Object.values(this.state.users || {}).find(
|
|
202
723
|
u => u.userId === key || String(u.email || '').toLowerCase() === key.toLowerCase()
|
|
203
724
|
);
|
|
725
|
+
if (!user && key.includes('@')) {
|
|
726
|
+
this.registerOrLoginUser({ email: key.toLowerCase() });
|
|
727
|
+
user = this.state.users[key.toLowerCase()] || null;
|
|
728
|
+
}
|
|
204
729
|
if (!user)
|
|
205
730
|
throw new Error('Пользователь не найден');
|
|
206
731
|
const nextAdmin = Boolean(isAdmin);
|
|
@@ -216,25 +741,424 @@ export class LicenseStore {
|
|
|
216
741
|
return this._formatUserResponse(user);
|
|
217
742
|
}
|
|
218
743
|
|
|
744
|
+
_ensureSettingsObject() {
|
|
745
|
+
const def = createDefaultPortalSettings();
|
|
746
|
+
if (!this.state.settings || typeof this.state.settings !== 'object') {
|
|
747
|
+
this.state.settings = def;
|
|
748
|
+
}
|
|
749
|
+
this.state.settings.smtp = { ...def.smtp, ...(this.state.settings.smtp || {}) };
|
|
750
|
+
this.state.settings.yookassa = { ...def.yookassa, ...(this.state.settings.yookassa || {}) };
|
|
751
|
+
this.state.settings.bots = { ...def.bots, ...(this.state.settings.bots || {}) };
|
|
752
|
+
this.state.settings.legal = { ...def.legal, ...(this.state.settings.legal || {}) };
|
|
753
|
+
return this.state.settings;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
_loadOrSyncMailFile() {
|
|
757
|
+
const s = this._ensureSettingsObject();
|
|
758
|
+
try {
|
|
759
|
+
if (fs.existsSync(this.mailFile)) {
|
|
760
|
+
const j = JSON.parse(fs.readFileSync(this.mailFile, 'utf8'));
|
|
761
|
+
if (j?.host) s.smtp.host = String(j.host).trim();
|
|
762
|
+
if (j?.port) s.smtp.port = Number(j.port);
|
|
763
|
+
if (typeof j?.secure === 'boolean') s.smtp.secure = j.secure;
|
|
764
|
+
if (j?.auth?.user || j?.user) s.smtp.user = String(j.auth?.user || j.user).trim();
|
|
765
|
+
if (j?.auth?.pass || j?.pass) s.smtp.pass = String(j.auth?.pass || j.pass);
|
|
766
|
+
if (j?.fromEmail) s.smtp.fromEmail = String(j.fromEmail).trim();
|
|
767
|
+
if (j?.senderName) s.smtp.senderName = String(j.senderName).trim();
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
this._writeMailFile();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch { /* ignore */ }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
_writeMailFile() {
|
|
777
|
+
try {
|
|
778
|
+
const smtp = this._ensureSettingsObject().smtp;
|
|
779
|
+
const payload = {
|
|
780
|
+
host: smtp.host,
|
|
781
|
+
port: Number(smtp.port || 465),
|
|
782
|
+
secure: Boolean(smtp.secure),
|
|
783
|
+
fromEmail: smtp.fromEmail || smtp.user,
|
|
784
|
+
senderName: smtp.senderName || 'ODANT-WORK',
|
|
785
|
+
auth: {
|
|
786
|
+
user: smtp.user,
|
|
787
|
+
pass: smtp.pass,
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
fs.writeFileSync(this.mailFile, JSON.stringify(payload, null, 2), 'utf8');
|
|
791
|
+
}
|
|
792
|
+
catch { /* ignore */ }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
getSmtpConfig() {
|
|
796
|
+
const smtp = this._ensureSettingsObject().smtp;
|
|
797
|
+
return {
|
|
798
|
+
host: String(smtp.host || 'smtp.yandex.ru').trim(),
|
|
799
|
+
port: Number(smtp.port || 465),
|
|
800
|
+
secure: smtp.secure !== undefined ? Boolean(smtp.secure) : Number(smtp.port || 465) === 465,
|
|
801
|
+
user: String(smtp.user || '').trim(),
|
|
802
|
+
pass: String(smtp.pass || ''),
|
|
803
|
+
fromEmail: String(smtp.fromEmail || smtp.user || '').trim(),
|
|
804
|
+
senderName: String(smtp.senderName || 'ODANT-WORK').trim(),
|
|
805
|
+
mailFile: this.mailFile,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
getLegalSettings() {
|
|
810
|
+
return { ...this._ensureSettingsObject().legal };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
getAdminSettingsSummary() {
|
|
814
|
+
const s = this._ensureSettingsObject();
|
|
815
|
+
const smtpConnected = Boolean(s.smtp.host && s.smtp.user && s.smtp.pass);
|
|
816
|
+
const yookassaLive = Boolean(s.yookassa.shopId && s.yookassa.secretKey);
|
|
817
|
+
return {
|
|
818
|
+
smtp: {
|
|
819
|
+
host: s.smtp.host || '',
|
|
820
|
+
port: Number(s.smtp.port || 465),
|
|
821
|
+
secure: Boolean(s.smtp.secure),
|
|
822
|
+
user: s.smtp.user || '',
|
|
823
|
+
pass: maskSecret(s.smtp.pass),
|
|
824
|
+
fromEmail: s.smtp.fromEmail || s.smtp.user || '',
|
|
825
|
+
senderName: s.smtp.senderName || 'ODANT-WORK',
|
|
826
|
+
connected: smtpConnected,
|
|
827
|
+
mailFile: this.mailFile,
|
|
828
|
+
},
|
|
829
|
+
yookassa: {
|
|
830
|
+
shopId: s.yookassa.shopId || '',
|
|
831
|
+
secretKey: maskSecret(s.yookassa.secretKey),
|
|
832
|
+
sendReceipt: Boolean(s.yookassa.sendReceipt),
|
|
833
|
+
live: yookassaLive,
|
|
834
|
+
},
|
|
835
|
+
bots: {
|
|
836
|
+
telegramToken: maskSecret(s.bots.telegramToken),
|
|
837
|
+
telegramUsername: String(s.bots.telegramUsername || '').replace(/^@+/, ''),
|
|
838
|
+
maxToken: maskSecret(s.bots.maxToken),
|
|
839
|
+
maxUsername: String(s.bots.maxUsername || '').replace(/^@+/, ''),
|
|
840
|
+
},
|
|
841
|
+
legal: {
|
|
842
|
+
companyName: s.legal.companyName || '',
|
|
843
|
+
authorName: s.legal.authorName || '',
|
|
844
|
+
legalStatus: s.legal.legalStatus || '',
|
|
845
|
+
inn: s.legal.inn || '',
|
|
846
|
+
ogrn: s.legal.ogrn || '',
|
|
847
|
+
supportEmail: s.legal.supportEmail || '',
|
|
848
|
+
phone: s.legal.phone || '',
|
|
849
|
+
address: s.legal.address || '',
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
updateSmtpSettings(patch = {}) {
|
|
855
|
+
const s = this._ensureSettingsObject().smtp;
|
|
856
|
+
if (patch.host !== undefined) s.host = String(patch.host || '').trim();
|
|
857
|
+
if (patch.port !== undefined) s.port = Number(patch.port || 465);
|
|
858
|
+
if (patch.secure !== undefined) s.secure = Boolean(patch.secure);
|
|
859
|
+
if (patch.user !== undefined) s.user = String(patch.user || '').trim();
|
|
860
|
+
if (patch.pass !== undefined && !isMaskedSecret(patch.pass)) {
|
|
861
|
+
s.pass = String(patch.pass || '');
|
|
862
|
+
}
|
|
863
|
+
if (patch.fromEmail !== undefined) s.fromEmail = String(patch.fromEmail || '').trim();
|
|
864
|
+
if (patch.senderName !== undefined) s.senderName = String(patch.senderName || '').trim();
|
|
865
|
+
this._writeMailFile();
|
|
866
|
+
this._save();
|
|
867
|
+
return this.getAdminSettingsSummary();
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
updateYooKassaSettings(patch = {}) {
|
|
871
|
+
const y = this._ensureSettingsObject().yookassa;
|
|
872
|
+
if (patch.shopId !== undefined) y.shopId = String(patch.shopId || '').trim();
|
|
873
|
+
if (patch.secretKey !== undefined && !isMaskedSecret(patch.secretKey)) {
|
|
874
|
+
y.secretKey = String(patch.secretKey || '').trim();
|
|
875
|
+
}
|
|
876
|
+
if (patch.sendReceipt !== undefined) y.sendReceipt = Boolean(patch.sendReceipt);
|
|
877
|
+
this._save();
|
|
878
|
+
return this.getAdminSettingsSummary();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
updateBotsSettings(patch = {}) {
|
|
882
|
+
const b = this._ensureSettingsObject().bots;
|
|
883
|
+
if (patch.telegramToken !== undefined && !isMaskedSecret(patch.telegramToken)) {
|
|
884
|
+
b.telegramToken = String(patch.telegramToken || '').trim();
|
|
885
|
+
}
|
|
886
|
+
if (patch.telegramUsername !== undefined) {
|
|
887
|
+
b.telegramUsername = String(patch.telegramUsername || '').trim().replace(/^@+/, '');
|
|
888
|
+
}
|
|
889
|
+
if (patch.maxToken !== undefined && !isMaskedSecret(patch.maxToken)) {
|
|
890
|
+
b.maxToken = String(patch.maxToken || '').trim();
|
|
891
|
+
}
|
|
892
|
+
if (patch.maxUsername !== undefined) {
|
|
893
|
+
b.maxUsername = String(patch.maxUsername || '').trim().replace(/^@+/, '');
|
|
894
|
+
}
|
|
895
|
+
this._save();
|
|
896
|
+
return this.getAdminSettingsSummary();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
updateLegalSettings(patch = {}) {
|
|
900
|
+
const l = this._ensureSettingsObject().legal;
|
|
901
|
+
for (const k of ['companyName', 'authorName', 'legalStatus', 'inn', 'ogrn', 'supportEmail', 'phone', 'address']) {
|
|
902
|
+
if (patch[k] !== undefined) {
|
|
903
|
+
l[k] = String(patch[k] || '').trim();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
this._save();
|
|
907
|
+
return this.getAdminSettingsSummary();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
_recoverAuthorityKeyFromCandidates() {
|
|
911
|
+
const allRoots = [...this.extraSearchRoots];
|
|
912
|
+
const dirs = findCandidateRecoveryDirs({
|
|
913
|
+
primaryDir: this.dataDir,
|
|
914
|
+
extraSearchRoots: allRoots,
|
|
915
|
+
includeSystemDefaults: !(isTestTempPath(this.dataDir) && allRoots.length > 0),
|
|
916
|
+
});
|
|
917
|
+
let bestKeyFile = '';
|
|
918
|
+
let bestMtime = 0;
|
|
919
|
+
for (const d of dirs) {
|
|
920
|
+
const kf = path.join(d, 'authority-key.json');
|
|
921
|
+
if (path.resolve(kf) === path.resolve(this.keyFile)) continue;
|
|
922
|
+
try {
|
|
923
|
+
if (!fs.existsSync(kf)) continue;
|
|
924
|
+
const stat = fs.statSync(kf);
|
|
925
|
+
const parsed = JSON.parse(fs.readFileSync(kf, 'utf8'));
|
|
926
|
+
if (parsed?.publicKey && parsed?.privateKey && stat.mtimeMs >= bestMtime) {
|
|
927
|
+
bestKeyFile = kf;
|
|
928
|
+
bestMtime = stat.mtimeMs;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch { /* ignore */ }
|
|
932
|
+
}
|
|
933
|
+
if (bestKeyFile) {
|
|
934
|
+
try {
|
|
935
|
+
fs.mkdirSync(path.dirname(this.keyFile), { recursive: true });
|
|
936
|
+
fs.copyFileSync(bestKeyFile, this.keyFile);
|
|
937
|
+
console.log(`[work.odant.org] Восстановлен ключ ЦС authority-key.json из: ${bestKeyFile}`);
|
|
938
|
+
}
|
|
939
|
+
catch { /* ignore */ }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
_tryParseStateFile(filePath) {
|
|
944
|
+
try {
|
|
945
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
946
|
+
const text = fs.readFileSync(filePath, 'utf8').trim();
|
|
947
|
+
if (!text) return null;
|
|
948
|
+
const raw = JSON.parse(text);
|
|
949
|
+
const stateObj = (raw?.state && typeof raw.state === 'object' && (raw.state.users || raw.state.licenses))
|
|
950
|
+
? raw.state
|
|
951
|
+
: raw;
|
|
952
|
+
if (!stateObj || typeof stateObj !== 'object') return null;
|
|
953
|
+
return {
|
|
954
|
+
licenses: stateObj.licenses || {},
|
|
955
|
+
orders: stateObj.orders || {},
|
|
956
|
+
tickets: stateObj.tickets || {},
|
|
957
|
+
botBindings: stateObj.botBindings || {},
|
|
958
|
+
partners: stateObj.partners || {},
|
|
959
|
+
reservations: stateObj.reservations || {},
|
|
960
|
+
certificates: stateObj.certificates || {},
|
|
961
|
+
users: stateObj.users || {},
|
|
962
|
+
settings: stateObj.settings || {},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
getStoreCounts(state = this.state) {
|
|
971
|
+
return {
|
|
972
|
+
users: Object.keys(state?.users || {}).length,
|
|
973
|
+
licenses: Object.keys(state?.licenses || {}).length,
|
|
974
|
+
orders: Object.keys(state?.orders || {}).length,
|
|
975
|
+
reservations: Object.keys(state?.reservations || {}).length,
|
|
976
|
+
partners: Object.keys(state?.partners || {}).length,
|
|
977
|
+
tickets: Object.keys(state?.tickets || {}).length,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
219
981
|
_load() {
|
|
220
|
-
|
|
221
|
-
|
|
982
|
+
let loaded = this._tryParseStateFile(this.stateFile);
|
|
983
|
+
const mainExistsNonEmpty = fs.existsSync(this.stateFile) && fs.statSync(this.stateFile).size > 50;
|
|
984
|
+
|
|
985
|
+
if (!loaded && mainExistsNonEmpty) {
|
|
986
|
+
console.warn(`[work.odant.org] Внимание: не удалось прочитать ${this.stateFile}, проверяем резервные копии...`);
|
|
987
|
+
loaded = this._tryParseStateFile(this.prevStateFile);
|
|
988
|
+
if (!loaded && fs.existsSync(this.backupDir)) {
|
|
989
|
+
try {
|
|
990
|
+
const bFiles = fs.readdirSync(this.backupDir)
|
|
991
|
+
.filter(f => f.endsWith('.json'))
|
|
992
|
+
.sort()
|
|
993
|
+
.reverse();
|
|
994
|
+
for (const bf of bFiles) {
|
|
995
|
+
loaded = this._tryParseStateFile(path.join(this.backupDir, bf));
|
|
996
|
+
if (loaded) {
|
|
997
|
+
console.log(`[work.odant.org] Состояние восстановлено из резервной копии: ${bf}`);
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
catch { /* ignore */ }
|
|
1003
|
+
}
|
|
1004
|
+
if (!loaded) {
|
|
1005
|
+
this._readFailedGuard = true;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (loaded) {
|
|
1010
|
+
this.state = mergeStoreStates(this.state, loaded);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (this.autoRecover) {
|
|
1014
|
+
this.recoverFromExternalLocations({ silentIfZero: true });
|
|
1015
|
+
}
|
|
1016
|
+
else if (loaded) {
|
|
1017
|
+
this._ensureBootstrapAdmin();
|
|
1018
|
+
this._resignAllWithAuthority();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Просканировать кэш npx (~/.npm/_npx/*, %LOCALAPPDATA%/npm-cache/_npx/*), домашнее зеркало и рабочие папки,
|
|
1024
|
+
* и безопасно объединить все найденные записи store.json в текущую постоянную базу данных.
|
|
1025
|
+
*/
|
|
1026
|
+
recoverFromExternalLocations({ extraSearchRoots = [], silentIfZero = false } = {}) {
|
|
1027
|
+
const allRoots = [...this.extraSearchRoots, ...(extraSearchRoots || [])];
|
|
1028
|
+
const dirs = findCandidateRecoveryDirs({
|
|
1029
|
+
primaryDir: this.dataDir,
|
|
1030
|
+
extraSearchRoots: allRoots,
|
|
1031
|
+
includeSystemDefaults: !(isTestTempPath(this.dataDir) && allRoots.length > 0),
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
const candidates = [];
|
|
1035
|
+
for (const d of dirs) {
|
|
1036
|
+
const sf = path.join(d, 'store.json');
|
|
1037
|
+
if (path.resolve(sf) === path.resolve(this.stateFile)) continue;
|
|
1038
|
+
try {
|
|
1039
|
+
if (!fs.existsSync(sf)) continue;
|
|
1040
|
+
const stat = fs.statSync(sf);
|
|
1041
|
+
const parsed = this._tryParseStateFile(sf);
|
|
1042
|
+
if (!parsed) continue;
|
|
1043
|
+
const counts = this.getStoreCounts(parsed);
|
|
1044
|
+
const totalItems = counts.users + counts.licenses + counts.orders + counts.reservations + counts.partners + counts.tickets;
|
|
1045
|
+
if (totalItems > 0) {
|
|
1046
|
+
candidates.push({ file: sf, dir: d, mtimeMs: stat.mtimeMs, parsed, counts });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
catch { /* ignore */ }
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Сортируем от более старых к более новым, чтобы более свежие изменения дополняли старые
|
|
1053
|
+
candidates.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
1054
|
+
|
|
1055
|
+
const beforeCounts = this.getStoreCounts(this.state);
|
|
1056
|
+
const mergedSources = [];
|
|
1057
|
+
|
|
1058
|
+
for (const item of candidates) {
|
|
1059
|
+
this.state = mergeStoreStates(this.state, item.parsed);
|
|
1060
|
+
mergedSources.push({
|
|
1061
|
+
file: item.file,
|
|
1062
|
+
counts: item.counts,
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
this._ensureBootstrapAdmin();
|
|
1067
|
+
this._resignAllWithAuthority();
|
|
1068
|
+
|
|
1069
|
+
const afterCounts = this.getStoreCounts(this.state);
|
|
1070
|
+
const addedOrMerged = (afterCounts.users > beforeCounts.users)
|
|
1071
|
+
|| (afterCounts.licenses > beforeCounts.licenses)
|
|
1072
|
+
|| (afterCounts.orders > beforeCounts.orders)
|
|
1073
|
+
|| (afterCounts.reservations > beforeCounts.reservations)
|
|
1074
|
+
|| !fs.existsSync(this.stateFile);
|
|
1075
|
+
|
|
1076
|
+
if (mergedSources.length > 0 && addedOrMerged) {
|
|
1077
|
+
this._readFailedGuard = false;
|
|
1078
|
+
try { this._save(); } catch { /* ignore */ }
|
|
1079
|
+
if (!silentIfZero || afterCounts.users > beforeCounts.users || afterCounts.licenses > beforeCounts.licenses) {
|
|
1080
|
+
console.log(
|
|
1081
|
+
`[work.odant.org] Автовосстановление данных (источников: ${mergedSources.length}): `
|
|
1082
|
+
+ `пользователей=${afterCounts.users}, серверов/лицензий=${afterCounts.licenses}, `
|
|
1083
|
+
+ `заказов=${afterCounts.orders}, франшиз=${afterCounts.reservations}`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
ok: true,
|
|
1090
|
+
dataDir: this.dataDir,
|
|
1091
|
+
scannedDirs: dirs,
|
|
1092
|
+
mergedSources,
|
|
1093
|
+
beforeCounts,
|
|
1094
|
+
counts: afterCounts,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Сформировать полный пакет резервной копии базы данных для скачивания из /admin.
|
|
1100
|
+
*/
|
|
1101
|
+
exportBackupBundle() {
|
|
1102
|
+
let backups = [];
|
|
222
1103
|
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();
|
|
1104
|
+
if (fs.existsSync(this.backupDir)) {
|
|
1105
|
+
backups = fs.readdirSync(this.backupDir)
|
|
1106
|
+
.filter(f => f.endsWith('.json'))
|
|
1107
|
+
.sort()
|
|
1108
|
+
.reverse()
|
|
1109
|
+
.slice(0, 14);
|
|
235
1110
|
}
|
|
236
1111
|
}
|
|
237
|
-
catch { /*
|
|
1112
|
+
catch { /* ignore */ }
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
schema: 'ODANT-WORK-LICENSE-STORE-BACKUP-v1',
|
|
1116
|
+
exportedAt: new Date().toISOString(),
|
|
1117
|
+
dataDir: this.dataDir,
|
|
1118
|
+
authorityPublicKey: this.authorityKeys.publicKey,
|
|
1119
|
+
counts: this.getStoreCounts(this.state),
|
|
1120
|
+
backups,
|
|
1121
|
+
state: this.state,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Восстановить или безопасно объединить (merge) базу данных из резервной копии JSON.
|
|
1127
|
+
*/
|
|
1128
|
+
importBackupBundle(payload = {}, { mode = 'merge' } = {}) {
|
|
1129
|
+
const parsed = (payload?.state && typeof payload.state === 'object')
|
|
1130
|
+
? payload.state
|
|
1131
|
+
: payload;
|
|
1132
|
+
if (!parsed || typeof parsed !== 'object' || (!parsed.users && !parsed.licenses && !parsed.orders)) {
|
|
1133
|
+
throw new Error('Некорректный формат файла резервной копии (ожидается JSON с разделами users / licenses / orders)');
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Перед импортом обязательно сохраняем страховочный снапшот текущего состояния
|
|
1137
|
+
try {
|
|
1138
|
+
fs.mkdirSync(this.backupDir, { recursive: true });
|
|
1139
|
+
const preFile = path.join(this.backupDir, `store-pre-import-${Date.now()}.json`);
|
|
1140
|
+
fs.writeFileSync(preFile, JSON.stringify(this.state, null, 2), 'utf8');
|
|
1141
|
+
}
|
|
1142
|
+
catch { /* ignore */ }
|
|
1143
|
+
|
|
1144
|
+
if (mode === 'replace') {
|
|
1145
|
+
this.state = mergeStoreStates({}, parsed);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
this.state = mergeStoreStates(this.state, parsed);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
this._readFailedGuard = false;
|
|
1152
|
+
this._ensureBootstrapAdmin();
|
|
1153
|
+
this._resignAllWithAuthority();
|
|
1154
|
+
this._save();
|
|
1155
|
+
|
|
1156
|
+
return {
|
|
1157
|
+
ok: true,
|
|
1158
|
+
mode: mode === 'replace' ? 'replace' : 'merge',
|
|
1159
|
+
dataDir: this.dataDir,
|
|
1160
|
+
counts: this.getStoreCounts(this.state),
|
|
1161
|
+
};
|
|
238
1162
|
}
|
|
239
1163
|
|
|
240
1164
|
/**
|
|
@@ -294,9 +1218,60 @@ export class LicenseStore {
|
|
|
294
1218
|
}
|
|
295
1219
|
|
|
296
1220
|
_save() {
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
1221
|
+
const counts = this.getStoreCounts(this.state);
|
|
1222
|
+
const totalEntities = counts.users + counts.licenses + counts.orders + counts.reservations + counts.partners + counts.tickets;
|
|
1223
|
+
|
|
1224
|
+
// Защита от перезаписи непустого файла пустым состоянием при сбое чтения
|
|
1225
|
+
if (this._readFailedGuard && totalEntities === 0) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
1230
|
+
|
|
1231
|
+
// Сохраняем предыдущую версию (store.prev.json) и ежедневный снапшот (backups/store-YYYY-MM-DD.json)
|
|
1232
|
+
try {
|
|
1233
|
+
if (fs.existsSync(this.stateFile) && fs.statSync(this.stateFile).size > 50) {
|
|
1234
|
+
fs.copyFileSync(this.stateFile, this.prevStateFile);
|
|
1235
|
+
if (totalEntities > 0) {
|
|
1236
|
+
fs.mkdirSync(this.backupDir, { recursive: true });
|
|
1237
|
+
const dayKey = new Date().toISOString().slice(0, 10);
|
|
1238
|
+
const dailyBackup = path.join(this.backupDir, `store-${dayKey}.json`);
|
|
1239
|
+
fs.copyFileSync(this.stateFile, dailyBackup);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
catch { /* ignore */ }
|
|
1244
|
+
|
|
1245
|
+
const serialized = JSON.stringify(this.state, null, 2);
|
|
1246
|
+
const writeAtomically = (destPath) => {
|
|
1247
|
+
const tmp = `${destPath}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
1248
|
+
fs.writeFileSync(tmp, serialized, 'utf8');
|
|
1249
|
+
try {
|
|
1250
|
+
fs.renameSync(tmp, destPath);
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
fs.writeFileSync(destPath, serialized, 'utf8');
|
|
1254
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
writeAtomically(this.stateFile);
|
|
1259
|
+
|
|
1260
|
+
// Дублируем актуальную базу в системное зеркало профиля пользователя ОС (~/.odant-work-license/data),
|
|
1261
|
+
// чтобы даже при запуске npx из другой папки данные всегда оставались едиными.
|
|
1262
|
+
if (this.autoRecover && totalEntities > 0) {
|
|
1263
|
+
try {
|
|
1264
|
+
const mirrorDir = getHomeMirrorDataDir();
|
|
1265
|
+
if (path.resolve(mirrorDir) !== path.resolve(this.dataDir)) {
|
|
1266
|
+
fs.mkdirSync(mirrorDir, { recursive: true });
|
|
1267
|
+
writeAtomically(path.join(mirrorDir, 'store.json'));
|
|
1268
|
+
if (fs.existsSync(this.keyFile)) {
|
|
1269
|
+
fs.copyFileSync(this.keyFile, path.join(mirrorDir, 'authority-key.json'));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
catch { /* ignore */ }
|
|
1274
|
+
}
|
|
300
1275
|
}
|
|
301
1276
|
|
|
302
1277
|
/**
|
|
@@ -338,8 +1313,11 @@ export class LicenseStore {
|
|
|
338
1313
|
? Boolean(baseActivated)
|
|
339
1314
|
: (existing?.baseActivated !== undefined ? Boolean(existing.baseActivated) : true);
|
|
340
1315
|
|
|
341
|
-
const resolvedOwnerAccountId = String(ownerAccountId || existing?.ownerAccountId || '');
|
|
342
1316
|
const resolvedOwnerEmail = String(ownerEmail || customer.email || existing?.ownerEmail || existing?.customer?.email || '').toLowerCase();
|
|
1317
|
+
const matchedUser = resolvedOwnerEmail
|
|
1318
|
+
? (this.state.users?.[resolvedOwnerEmail] || Object.values(this.state.users || {}).find(u => String(u.email || '').toLowerCase() === resolvedOwnerEmail))
|
|
1319
|
+
: null;
|
|
1320
|
+
const resolvedOwnerAccountId = String(ownerAccountId || existing?.ownerAccountId || matchedUser?.userId || '');
|
|
343
1321
|
const resolvedServerName = String(serverName || existing?.serverName || 'Сервер WORK #1').trim();
|
|
344
1322
|
const resolvedHubUrl = String(hubUrl || existing?.hubUrl || 'https://work.odant.org').replace(/\/+$/, '');
|
|
345
1323
|
|
|
@@ -377,10 +1355,10 @@ export class LicenseStore {
|
|
|
377
1355
|
baseActivated: isBaseActivated,
|
|
378
1356
|
baseSku: String(baseSku || existing?.baseSku || PAK_PRODUCT.sku),
|
|
379
1357
|
customer: {
|
|
380
|
-
name: customer.name || existing?.customer?.name || '
|
|
381
|
-
inn: customer.inn || existing?.customer?.inn || '',
|
|
382
|
-
email: customer.email || existing?.customer?.email || '',
|
|
383
|
-
phone: customer.phone || existing?.customer?.phone || '',
|
|
1358
|
+
name: customer.name || existing?.customer?.name || matchedUser?.companyName || matchedUser?.name || resolvedOwnerEmail || 'Клиент WORK',
|
|
1359
|
+
inn: customer.inn || existing?.customer?.inn || matchedUser?.inn || '',
|
|
1360
|
+
email: customer.email || existing?.customer?.email || resolvedOwnerEmail || '',
|
|
1361
|
+
phone: customer.phone || existing?.customer?.phone || matchedUser?.phone || '',
|
|
384
1362
|
},
|
|
385
1363
|
hardware: {
|
|
386
1364
|
hwid: hwid || existing?.hardware?.hwid || ('PAK-' + crypto.randomBytes(6).toString('hex').toUpperCase()),
|
|
@@ -984,6 +1962,10 @@ export class LicenseStore {
|
|
|
984
1962
|
return null;
|
|
985
1963
|
const dateStr = new Date(ord.createdAt || Date.now()).toLocaleDateString('ru-RU');
|
|
986
1964
|
const c = ord.customer || {};
|
|
1965
|
+
const legal = this.getLegalSettings();
|
|
1966
|
+
const vendorTitle = legal.companyName || `ООО «${VENDOR_CONTACTS.company}»`;
|
|
1967
|
+
const vendorAddr = legal.address || VENDOR_CONTACTS.address;
|
|
1968
|
+
const vendorPhone = legal.phone || VENDOR_CONTACTS.phones.join(', ');
|
|
987
1969
|
const custName = (c.name && !String(c.name).includes('@')) ? c.name : 'Организация (не указана)';
|
|
988
1970
|
const rowsHtml = (ord.items || []).map((it, idx) => `
|
|
989
1971
|
<tr>
|
|
@@ -1032,7 +2014,7 @@ export class LicenseStore {
|
|
|
1032
2014
|
<span class="status">${ord.status === 'paid' ? 'ОПЛАЧЕН' : 'ОЖИДАЕТ ОПЛАТЫ ПО СЧЁТУ'}</span>
|
|
1033
2015
|
</div>
|
|
1034
2016
|
<div class="meta" style="margin-top:14px;">
|
|
1035
|
-
<div><strong>Поставщик (Лицензиар):</strong>
|
|
2017
|
+
<div><strong>Поставщик (Лицензиар):</strong> ${vendorTitle}${legal.inn ? `, ИНН: ${legal.inn}` : ''}${legal.ogrn ? `, ОГРН: ${legal.ogrn}` : ''}, адрес: ${vendorAddr}, тел.: ${vendorPhone}</div>
|
|
1036
2018
|
<div style="margin-top:8px;">
|
|
1037
2019
|
<strong>Покупатель (Лицензиат):</strong> ${custName}, ИНН: ${c.inn || '—'}${c.kpp ? ', КПП: ' + c.kpp : ''}
|
|
1038
2020
|
${c.legalAddress ? `<br><strong>Юридический адрес:</strong> ${c.legalAddress}` : ''}
|
|
@@ -1067,8 +2049,8 @@ export class LicenseStore {
|
|
|
1067
2049
|
<div class="sig-grid">
|
|
1068
2050
|
<div class="sig-box">
|
|
1069
2051
|
<strong>От Поставщика (Лицензиара):</strong><br>
|
|
1070
|
-
|
|
1071
|
-
______________________ / Руководитель /
|
|
2052
|
+
${vendorTitle}<br><br>
|
|
2053
|
+
______________________ / ${legal.authorName || 'Руководитель'} /
|
|
1072
2054
|
</div>
|
|
1073
2055
|
<div class="sig-box">
|
|
1074
2056
|
<strong>От Покупателя (Лицензиата):</strong><br>
|