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/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, isMaskedSecret } 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
- const DEFAULT_DATA_DIR = fileURLToPath(new URL('../data', import.meta.url));
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) {
@@ -121,13 +119,21 @@ function sendHtml(res, status, html) {
121
119
  res.end(html);
122
120
  }
123
121
 
124
- function loadSmtpConfig() {
122
+ function loadSmtpConfig(store = null) {
123
+ if (store && typeof store.getSmtpConfig === 'function') {
124
+ const sc = store.getSmtpConfig();
125
+ if (sc?.host && sc?.user && sc?.pass)
126
+ return sc;
127
+ }
125
128
  if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
126
129
  return {
127
130
  host: process.env.SMTP_HOST,
128
131
  port: Number(process.env.SMTP_PORT || 465),
132
+ secure: Number(process.env.SMTP_PORT || 465) === 465,
129
133
  user: process.env.SMTP_USER,
130
134
  pass: process.env.SMTP_PASS,
135
+ fromEmail: process.env.SMTP_FROM || process.env.SMTP_USER,
136
+ senderName: process.env.SMTP_SENDER_NAME || 'ODANT-WORK',
131
137
  };
132
138
  }
133
139
  const candidates = [
@@ -143,8 +149,11 @@ function loadSmtpConfig() {
143
149
  return {
144
150
  host: j.host,
145
151
  port: Number(j.port || 465),
152
+ secure: j.secure !== undefined ? Boolean(j.secure) : Number(j.port || 465) === 465,
146
153
  user: j.auth.user,
147
154
  pass: j.auth.pass,
155
+ fromEmail: j.fromEmail || j.auth.user,
156
+ senderName: j.senderName || 'ODANT-WORK',
148
157
  };
149
158
  }
150
159
  }
@@ -154,8 +163,11 @@ function loadSmtpConfig() {
154
163
  return {
155
164
  host: 'smtp.yandex.ru',
156
165
  port: 465,
166
+ secure: true,
157
167
  user: 'proman1968.perepelkin@yandex.ru',
158
168
  pass: 'zifeomdqijldyece',
169
+ fromEmail: 'proman1968.perepelkin@yandex.ru',
170
+ senderName: 'ODANT-WORK',
159
171
  };
160
172
  }
161
173
 
@@ -164,11 +176,17 @@ function loadSmtpConfig() {
164
176
  * с полноценными RFC 5322 заголовками Date, Message-ID и text+html, чтобы Gmail доставлял письмо мгновенно).
165
177
  */
166
178
  let _cachedTransporter = null;
167
- async function sendOtpEmail({ to, code }) {
168
- const cfg = loadSmtpConfig();
169
- const subject = 'Код подтверждения ODANT-WORK';
170
- const textBody = `Ваш одноразовый код: ${code}\n\nДействует 10 минут.`;
171
- const htmlBody = `
179
+ async function sendOtpEmail({ to, code, store = null, isTest = false }) {
180
+ const cfg = loadSmtpConfig(store);
181
+ const senderName = cfg.senderName || 'ODANT-WORK';
182
+ const fromAddr = cfg.fromEmail || cfg.user;
183
+ const subject = isTest ? `Тестовое письмо SMTP · ${senderName}` : `Код подтверждения ${senderName}`;
184
+ const textBody = isTest
185
+ ? `Почтовый сервер SMTP (${cfg.host}:${cfg.port}) успешно настроен и отправляет письма от ${senderName} <${fromAddr}>.`
186
+ : `Ваш одноразовый код: ${code}\n\nДействует 10 минут.`;
187
+ const htmlBody = isTest
188
+ ? `<h2>Проверка SMTP прошла успешно</h2><p>Почтовый сервер <b>${cfg.host}:${cfg.port}</b> подключён и доставляет письма от <b>${senderName} &lt;${fromAddr}&gt;</b>.</p>`
189
+ : `
172
190
  <h2>Код подтверждения</h2>
173
191
  <p>Ваш код для входа/регистрации на work.odant.org:</p>
174
192
  <h1 style="font-size: 48px; letter-spacing: 10px; font-weight: bold; text-align: center;">
@@ -176,7 +194,7 @@ async function sendOtpEmail({ to, code }) {
176
194
  </h1>
177
195
  <p>Код действителен <strong>10 минут</strong>. Ни с кем не делитесь.</p>
178
196
  <p>Если это не вы — просто проигнорируйте письмо.</p>
179
- <p>С уважением,<br>ODANT-WORK</p>
197
+ <p>С уважением,<br>${senderName}</p>
180
198
  `;
181
199
 
182
200
  try {
@@ -192,7 +210,7 @@ async function sendOtpEmail({ to, code }) {
192
210
  _cachedTransporter = nodemailer.createTransport({
193
211
  host: cfg.host,
194
212
  port: cfg.port,
195
- secure: cfg.port === 465,
213
+ secure: cfg.secure !== undefined ? Boolean(cfg.secure) : cfg.port === 465,
196
214
  auth: {
197
215
  user: cfg.user,
198
216
  pass: cfg.pass,
@@ -200,7 +218,7 @@ async function sendOtpEmail({ to, code }) {
200
218
  });
201
219
  }
202
220
  const info = await _cachedTransporter.sendMail({
203
- from: `"ODANT-WORK" <${cfg.user}>`,
221
+ from: `"${senderName}" <${fromAddr}>`,
204
222
  to,
205
223
  subject,
206
224
  text: textBody,
@@ -217,7 +235,7 @@ async function sendOtpEmail({ to, code }) {
217
235
  }
218
236
 
219
237
  // Резервный нативный SMTPS-клиент с полными RFC 5322 заголовками (Date + Message-ID + multipart/alternative text+html)
220
- const domain = (cfg.user.split('@')[1] || 'yandex.ru').trim();
238
+ const domain = (fromAddr.split('@')[1] || 'yandex.ru').trim();
221
239
  const msgId = `<${crypto.randomUUID()}@${domain}>`;
222
240
  const dateHdr = new Date().toUTCString();
223
241
  const boundary = `----=_Part_${crypto.randomBytes(8).toString('hex')}`;
@@ -227,7 +245,7 @@ async function sendOtpEmail({ to, code }) {
227
245
  const mimeMsg = [
228
246
  `Date: ${dateHdr}`,
229
247
  `Message-ID: ${msgId}`,
230
- `From: "ODANT-WORK" <${cfg.user}>`,
248
+ `From: "${senderName}" <${fromAddr}>`,
231
249
  `To: <${to}>`,
232
250
  `Subject: =?UTF-8?B?${subjectB64}?=`,
233
251
  'MIME-Version: 1.0',
@@ -284,16 +302,20 @@ async function sendOtpEmail({ to, code }) {
284
302
  export function createLicenseServerApp(options = {}) {
285
303
  const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
286
304
  const store = options.store || new LicenseStore({
287
- dataDir: options.dataDir || DEFAULT_DATA_DIR,
305
+ dataDir: options.dataDir || resolvePersistentDataDir(),
288
306
  adminLogin: options.adminLogin,
289
307
  adminPassword: options.adminPassword,
290
308
  publicOrigin: options.publicOrigin || 'https://work.odant.org',
309
+ autoRecover: options.autoRecover,
310
+ extraSearchRoots: options.extraSearchRoots,
291
311
  });
312
+ const initSettings = store._ensureSettingsObject();
292
313
  let bots = null;
293
314
  const yookassa = options.yookassa || new YooKassaService({
294
315
  store,
295
- shopId: options.yookassaShopId,
296
- secretKey: options.yookassaSecretKey,
316
+ shopId: options.yookassaShopId ?? initSettings.yookassa.shopId,
317
+ secretKey: options.yookassaSecretKey ?? initSettings.yookassa.secretKey,
318
+ sendReceipt: initSettings.yookassa.sendReceipt,
297
319
  publicOrigin: options.publicOrigin || 'https://work.odant.org',
298
320
  fetchImpl: options.fetchImpl,
299
321
  onOrderPaid: async (order, license) => {
@@ -303,8 +325,8 @@ export function createLicenseServerApp(options = {}) {
303
325
  bots = options.bots || new SupportBotsHub({
304
326
  store,
305
327
  yookassa,
306
- maxBotSecret: options.maxBotSecret,
307
- telegramBotSecret: options.telegramBotSecret,
328
+ maxBotSecret: options.maxBotSecret ?? initSettings.bots.maxToken,
329
+ telegramBotSecret: options.telegramBotSecret ?? initSettings.bots.telegramToken,
308
330
  vkBotSecret: options.vkBotSecret,
309
331
  vkConfirmationCode: options.vkConfirmationCode,
310
332
  publicOrigin: options.publicOrigin || 'https://work.odant.org',
@@ -443,7 +465,7 @@ export function createLicenseServerApp(options = {}) {
443
465
  });
444
466
  console.log('[work.odant.org] Registration/Login code for', cleanEmail, ':', code);
445
467
  if (!options.skipEmailSend) {
446
- await sendOtpEmail({ to: cleanEmail, code });
468
+ await sendOtpEmail({ to: cleanEmail, code, store });
447
469
  }
448
470
  return sendJson(res, 200, {
449
471
  ok: true,
@@ -986,6 +1008,8 @@ export function createLicenseServerApp(options = {}) {
986
1008
  return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
987
1009
  const allFranchises = store.listAllFranchises();
988
1010
  return sendJson(res, 200, {
1011
+ dataDir: store.dataDir,
1012
+ counts: store.getStoreCounts(),
989
1013
  licenses: store.listLicenses(),
990
1014
  tickets: store.listTickets(),
991
1015
  orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
@@ -994,7 +1018,161 @@ export function createLicenseServerApp(options = {}) {
994
1018
  reservations: allFranchises,
995
1019
  regions: store.listRegionsWithQuotas(),
996
1020
  users: store.listUsers(),
1021
+ settings: store.getAdminSettingsSummary(),
1022
+ });
1023
+ }
1024
+
1025
+ if (req.method === 'POST' && pathname === '/api/admin/settings/smtp') {
1026
+ const body = await readJsonBody(req);
1027
+ const tok = extractBearerToken(req, body, url);
1028
+ if (!store.verifyAdminToken(tok))
1029
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1030
+ _cachedTransporter = null;
1031
+ const settings = store.updateSmtpSettings(body);
1032
+ return sendJson(res, 200, { ok: true, settings });
1033
+ }
1034
+
1035
+ if (req.method === 'POST' && pathname === '/api/admin/settings/smtp/test') {
1036
+ const body = await readJsonBody(req);
1037
+ const tok = extractBearerToken(req, body, url);
1038
+ const admSession = store.verifyAdminToken(tok);
1039
+ if (!admSession)
1040
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1041
+ const smtpCfg = store.getSmtpConfig();
1042
+ const targetEmail = String(body.to || admSession.email || smtpCfg.fromEmail || smtpCfg.user || '').trim();
1043
+ if (!targetEmail || !targetEmail.includes('@'))
1044
+ return sendJson(res, 400, { error: 'Укажите Email для отправки тестового письма' });
1045
+ if (!options.skipEmailSend) {
1046
+ _cachedTransporter = null;
1047
+ await sendOtpEmail({ to: targetEmail, code: '000000', store, isTest: true });
1048
+ }
1049
+ return sendJson(res, 200, {
1050
+ ok: true,
1051
+ to: targetEmail,
1052
+ message: `Тестовое письмо отправлено на ${targetEmail}`,
1053
+ });
1054
+ }
1055
+
1056
+ if (req.method === 'POST' && pathname === '/api/admin/settings/yookassa') {
1057
+ const body = await readJsonBody(req);
1058
+ const tok = extractBearerToken(req, body, url);
1059
+ if (!store.verifyAdminToken(tok))
1060
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1061
+ const settings = store.updateYooKassaSettings(body);
1062
+ const rawYk = store._ensureSettingsObject().yookassa;
1063
+ yookassa.shopId = rawYk.shopId;
1064
+ yookassa.secretKey = rawYk.secretKey;
1065
+ yookassa.sendReceipt = Boolean(rawYk.sendReceipt);
1066
+ return sendJson(res, 200, { ok: true, settings });
1067
+ }
1068
+
1069
+ if (req.method === 'POST' && pathname === '/api/admin/settings/bots') {
1070
+ const body = await readJsonBody(req);
1071
+ const tok = extractBearerToken(req, body, url);
1072
+ if (!store.verifyAdminToken(tok))
1073
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1074
+
1075
+ const rawBots = store._ensureSettingsObject().bots;
1076
+ const nextTgToken = (body.telegramToken !== undefined && !isMaskedSecret(body.telegramToken))
1077
+ ? String(body.telegramToken || '').trim()
1078
+ : rawBots.telegramToken;
1079
+ const nextMaxToken = (body.maxToken !== undefined && !isMaskedSecret(body.maxToken))
1080
+ ? String(body.maxToken || '').trim()
1081
+ : rawBots.maxToken;
1082
+
1083
+ let tgUsername = body.telegramUsername !== undefined
1084
+ ? String(body.telegramUsername || '').trim().replace(/^@+/, '')
1085
+ : rawBots.telegramUsername;
1086
+ let maxUsername = body.maxUsername !== undefined
1087
+ ? String(body.maxUsername || '').trim().replace(/^@+/, '')
1088
+ : rawBots.maxUsername;
1089
+
1090
+ const fetchFn = options.fetchImpl || globalThis.fetch;
1091
+ if (nextTgToken && typeof fetchFn === 'function') {
1092
+ try {
1093
+ const r = await fetchFn(`https://api.telegram.org/bot${encodeURIComponent(nextTgToken)}/getMe`, {
1094
+ signal: AbortSignal.timeout(5000),
1095
+ });
1096
+ if (r.ok) {
1097
+ const j = await r.json().catch(() => ({}));
1098
+ if (j?.result?.username) {
1099
+ tgUsername = String(j.result.username).replace(/^@+/, '');
1100
+ }
1101
+ }
1102
+ }
1103
+ catch { /* офлайн или тестовый токен */ }
1104
+ }
1105
+
1106
+ if (nextMaxToken && typeof fetchFn === 'function') {
1107
+ try {
1108
+ const r = await fetchFn(`https://botapi.max.ru/me?access_token=${encodeURIComponent(nextMaxToken)}`, {
1109
+ headers: { 'Authorization': nextMaxToken },
1110
+ signal: AbortSignal.timeout(5000),
1111
+ });
1112
+ if (r.ok) {
1113
+ const j = await r.json().catch(() => ({}));
1114
+ const uname = j?.username || j?.user?.username || (j?.user_id ? `id${j.user_id}_bot` : '');
1115
+ if (uname) {
1116
+ maxUsername = String(uname).replace(/^@+/, '');
1117
+ }
1118
+ }
1119
+ }
1120
+ catch { /* офлайн или тестовый токен */ }
1121
+ }
1122
+
1123
+ const settings = store.updateBotsSettings({
1124
+ telegramToken: body.telegramToken,
1125
+ telegramUsername: tgUsername,
1126
+ maxToken: body.maxToken,
1127
+ maxUsername,
1128
+ });
1129
+ const updatedRawBots = store._ensureSettingsObject().bots;
1130
+ bots.telegramBotSecret = updatedRawBots.telegramToken;
1131
+ bots.maxBotSecret = updatedRawBots.maxToken;
1132
+ return sendJson(res, 200, { ok: true, settings });
1133
+ }
1134
+
1135
+ if (req.method === 'POST' && pathname === '/api/admin/settings/legal') {
1136
+ const body = await readJsonBody(req);
1137
+ const tok = extractBearerToken(req, body, url);
1138
+ if (!store.verifyAdminToken(tok))
1139
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1140
+ const settings = store.updateLegalSettings(body);
1141
+ return sendJson(res, 200, { ok: true, settings });
1142
+ }
1143
+
1144
+ if (req.method === 'GET' && pathname === '/api/admin/backup/export') {
1145
+ const tok = extractBearerToken(req, null, url);
1146
+ if (!store.verifyAdminToken(tok))
1147
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1148
+ const bundle = store.exportBackupBundle();
1149
+ const dayStr = new Date().toISOString().slice(0, 10);
1150
+ const body = JSON.stringify(bundle, null, 2);
1151
+ res.writeHead(200, {
1152
+ 'Content-Type': 'application/json; charset=utf-8',
1153
+ 'Content-Disposition': `attachment; filename="odant-work-license-backup-${dayStr}.json"`,
1154
+ 'Access-Control-Allow-Origin': '*',
997
1155
  });
1156
+ res.end(body);
1157
+ return;
1158
+ }
1159
+
1160
+ if (req.method === 'POST' && pathname === '/api/admin/backup/import') {
1161
+ const body = await readJsonBody(req);
1162
+ const tok = extractBearerToken(req, body, url);
1163
+ if (!store.verifyAdminToken(tok))
1164
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1165
+ const result = store.importBackupBundle(body.backup || body, { mode: body.mode || 'merge' });
1166
+ return sendJson(res, 200, result);
1167
+ }
1168
+
1169
+ if (req.method === 'POST' && pathname === '/api/admin/backup/recover') {
1170
+ const body = await readJsonBody(req);
1171
+ const tok = extractBearerToken(req, body, url);
1172
+ if (!store.verifyAdminToken(tok))
1173
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
1174
+ const result = store.recoverFromExternalLocations({ extraSearchRoots: body.extraSearchRoots || [] });
1175
+ return sendJson(res, 200, result);
998
1176
  }
999
1177
 
1000
1178
  if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {