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/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
|
-
|
|
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
|
|
170
|
-
const
|
|
171
|
-
const
|
|
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} <${fromAddr}></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
|
|
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: `"
|
|
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 = (
|
|
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: "
|
|
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 ||
|
|
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') {
|