neuroalpha 0.5.17 → 0.5.20
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/package.json +61 -60
- package/public/admin.html +604 -571
- package/public/app.js +101 -22
- package/public/landing.html +1207 -1273
- package/public/legal.html +6 -5
- package/public/splat3d.html +671 -0
- package/public/styles.css +12 -3
- package/scripts/set-admin-password.mjs +30 -0
- package/server/api/routes.js +55 -16
- package/server/auth/index.js +58 -13
- package/server/billing/yookassa.js +142 -133
- package/server/brokers/index.js +84 -52
- package/server/data/moex.js +6 -5
- package/server/db.js +1 -1
- package/server/index.js +3 -3
- package/server/instance/central.js +514 -505
- package/server/model/dataset.js +8 -9
- package/server/model/edge.js +74 -19
- package/server/model/forecast.js +117 -20
- package/server/model/live.js +16 -18
- package/server/model/research.js +48 -17
- package/server/security.js +4 -1
- package/server/strategist/index.js +758 -757
- package/server/strategist/tools.js +707 -675
package/public/styles.css
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
--yellow: #d29922;
|
|
16
16
|
--violet: #a371f7;
|
|
17
17
|
--best-bg: #13241a;
|
|
18
|
+
--blocked-bg: rgba(244, 114, 182, 0.14);
|
|
19
|
+
--blocked-fg: #f472b6;
|
|
20
|
+
--blocked-border: rgba(244, 114, 182, 0.48);
|
|
18
21
|
--flash-bg: #2a1f45;
|
|
19
22
|
--chat-w: 38%;
|
|
20
23
|
--accent-bg: rgba(88, 149, 255, 0.15);
|
|
@@ -36,6 +39,9 @@ body.light {
|
|
|
36
39
|
--yellow: #9a6700;
|
|
37
40
|
--violet: #8250df;
|
|
38
41
|
--best-bg: #dcfce7;
|
|
42
|
+
--blocked-bg: #fce7f3;
|
|
43
|
+
--blocked-fg: #be185d;
|
|
44
|
+
--blocked-border: #f9a8d4;
|
|
39
45
|
--flash-bg: #ede9fe;
|
|
40
46
|
--accent-bg: rgba(36, 87, 214, 0.09);
|
|
41
47
|
--hover-bg: #f0f3f6;
|
|
@@ -297,10 +303,13 @@ th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line
|
|
|
297
303
|
th { color: var(--muted); font-weight: 500; }
|
|
298
304
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
299
305
|
tr.best td { background: var(--best-bg); }
|
|
306
|
+
tr.fc-blocked td { background: var(--blocked-bg); }
|
|
307
|
+
tr.fc-blocked td:first-child { box-shadow: inset 3px 0 0 var(--blocked-fg); }
|
|
300
308
|
.tag { font-size: 11px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); }
|
|
301
309
|
.tag.ok { border-color: var(--green); color: var(--green); }
|
|
302
310
|
.tag.no { border-color: var(--red); color: var(--red); }
|
|
303
311
|
.tag.mid { border-color: var(--yellow); color: var(--yellow); }
|
|
312
|
+
.tag.blocked { border-color: var(--blocked-border); color: var(--blocked-fg); background: var(--blocked-bg); font-weight: 500; }
|
|
304
313
|
.stab-box { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; margin: 8px 0 10px; font-size: 13px; }
|
|
305
314
|
.stab-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
306
315
|
.stab-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
@@ -583,7 +592,7 @@ details.hist-year-box[open] > summary { border-bottom: 1px solid var(--line); }
|
|
|
583
592
|
background: var(--accent);
|
|
584
593
|
border-radius: 4px;
|
|
585
594
|
}
|
|
586
|
-
|
|
587
|
-
/* Прогноз: две таблицы с общей сеткой колонок */
|
|
588
|
-
.fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
595
|
+
|
|
596
|
+
/* Прогноз: две таблицы с общей сеткой колонок */
|
|
597
|
+
.fc-table td, .fc-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
589
598
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Задать email и пароль главного администратора (пользователь №1) или добавить/обновить второго (запасного) администратора (--add).
|
|
3
|
+
// ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@neuroalpha.odant.org
|
|
4
|
+
// ADMIN_PASS='…' node scripts/set-admin-password.mjs --add --email backup@neuroalpha.odant.org --name "Запасной админ"
|
|
5
|
+
// kubectl exec -it deploy/neuroalpha-central -- env ADMIN_PASS='…' node scripts/set-admin-password.mjs --email admin@…
|
|
6
|
+
// Пароль берётся из переменной ADMIN_PASS (чтобы не попадал в историю команд как аргумент).
|
|
7
|
+
import { db } from '../server/db.js';
|
|
8
|
+
import { hashPassword, ensureApiKey, createOrUpdateAdminUser } from '../server/auth/index.js';
|
|
9
|
+
|
|
10
|
+
const i = process.argv.indexOf('--email');
|
|
11
|
+
const iName = process.argv.indexOf('--name');
|
|
12
|
+
const isAdd = process.argv.includes('--add') || process.argv.includes('--second');
|
|
13
|
+
const email = String(i >= 0 ? process.argv[i + 1] : '').trim().toLowerCase();
|
|
14
|
+
const name = iName >= 0 ? String(process.argv[iName + 1] || '').trim() : '';
|
|
15
|
+
const pass = String(process.env.ADMIN_PASS || '');
|
|
16
|
+
if (!email.includes('@') || pass.length < 10) {
|
|
17
|
+
console.error('Укажите --email и пароль не короче 10 символов в переменной ADMIN_PASS');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
if (isAdd) {
|
|
21
|
+
const u = createOrUpdateAdminUser({ email, password: pass, name: name || 'Администратор' });
|
|
22
|
+
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
|
|
23
|
+
console.log(`✓ Запасной администратор (#${u.id}): ${u.email}. Прежние сессии этого аккаунта завершены.`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
db.prepare("UPDATE users SET email = ?, name = COALESCE(NULLIF(?, ''), name), pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = 1").run(email, name, hashPassword(pass));
|
|
27
|
+
ensureApiKey(1);
|
|
28
|
+
db.prepare('DELETE FROM sessions WHERE user_id = 1').run();
|
|
29
|
+
console.log(`✓ Главный администратор (#1): ${email}. Все прежние сессии администратора завершены.`);
|
|
30
|
+
process.exit(0);
|
package/server/api/routes.js
CHANGED
|
@@ -6,13 +6,13 @@ import { ROOT, strategyDefaults } from '../config.js';
|
|
|
6
6
|
import { activeModelInfo } from '../llm/client.js';
|
|
7
7
|
import { listProviders, getSelected, addProvider, updateProvider, deleteProvider, fetchRemoteModels, testProvider, selectModel, resolveUpstreamForProxy, providerFetch } from '../llm/providers.js';
|
|
8
8
|
import { listRuns, allRuns, getRun, getTrades, deleteRun, updateRunTitle, setRunShowcase, listShowcaseRuns, countUserRunsToday, countUserChatToday, listJournal, deleteJournal, addJournal, listSettingsHistory, kvGet, kvSet, db, loadCandles } from '../db.js';
|
|
9
|
-
import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
|
|
10
|
-
import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments } from '../billing/yookassa.js';
|
|
9
|
+
import { PLANS, effectivePlan, publicUser, getUserById, createSession, deleteSession, registerUser, loginUser, ensureNotifyCode, ensureApiKey, getUserByApiKey, activateLocalLicense, listAllUsers, setUserPlan, createOrUpdateAdminUser, createPromoCode, listPromoCodes, redeemPromoCode, acceptUserOffer, createVipRequest, listVipRequests, parseCookies, parseAllCookieValues, publicOAuthConfig, saveOAuthConfig, startOAuthRedirect, completeOAuthCallback, verifyTelegramLoginWidget } from '../auth/index.js';
|
|
10
|
+
import { publicBillingConfig, saveBillingConfig, createYookassaPayment, verifyYookassaPayment, verifyUserLatestPayment, listAllPayments, listUserPayments } from '../billing/yookassa.js';
|
|
11
11
|
import { publicNotifyConfig, getNotifyConfig, saveNotifyConfig, notifyUser, createTelegramLoginChallenge, checkTelegramLoginChallenge, handleMaxWebhook } from '../notify/index.js';
|
|
12
12
|
import { PARAMS } from '../params-meta.js';
|
|
13
13
|
import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
|
|
14
14
|
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
15
|
-
import { forecastNow, TIER1_LIQUID_SYMBOLS } from '../model/forecast.js';
|
|
15
|
+
import { forecastNow, TIER1_LIQUID_SYMBOLS, symbolEdgeMult } from '../model/forecast.js';
|
|
16
16
|
import { profilesInfo, startProfilesRefresh, TRAITS, loadProfiles } from '../model/profiles.js';
|
|
17
17
|
import { ensureDividends } from '../data/moex.js';
|
|
18
18
|
import { live } from '../model/live.js';
|
|
@@ -25,7 +25,7 @@ import { isCrossOrigin } from '../security.js';
|
|
|
25
25
|
import { IS_CENTRAL, IS_INSTANCE, CENTRAL_URL, clientIp, isHttpsRequest } from '../role.js';
|
|
26
26
|
import { consumeSsoToken } from '../instance/agent.js';
|
|
27
27
|
import { IS_DESKTOP } from '../role.js';
|
|
28
|
-
import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
|
|
28
|
+
import { listInstances, createInstance, rotateInstanceKey, setInstanceTrading, setInstanceRevoked, deleteInstance, instanceByKey, instanceHeartbeat, cabinetInstances, ensureUserCloudInstance, ssoRedirectUrl, relayInstanceNotify, centralPublicKeyPem, createDesktopActivationCode, activateDesktopInstall, unbindInstall } from '../instance/central.js';
|
|
29
29
|
import { activateDesktop, instanceInfo } from '../instance/agent.js';
|
|
30
30
|
import { isActivated } from '../instance/identity.js';
|
|
31
31
|
|
|
@@ -73,8 +73,16 @@ export function createRouter() {
|
|
|
73
73
|
const secure = isHttpsRequest(res.req) ? '; Secure' : '';
|
|
74
74
|
res.setHeader('Set-Cookie', `sid=${encodeURIComponent(sid)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`);
|
|
75
75
|
};
|
|
76
|
+
const isRuSegmentRequest = (req) => {
|
|
77
|
+
if (process.env.ALLOW_FOREIGN_AUTH === '1') return false;
|
|
78
|
+
const country = String(req.headers?.['cf-ipcountry'] || req.headers?.['x-country-code'] || req.headers?.['x-geo-country'] || '').trim().toUpperCase();
|
|
79
|
+
if (country) return country === 'RU';
|
|
80
|
+
const lang = String(req.headers?.['accept-language'] || '').toLowerCase();
|
|
81
|
+
if (lang && !lang.includes('ru')) return false;
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
76
84
|
r.get('/auth/me', (req, res) => res.json({
|
|
77
|
-
user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [] } : null,
|
|
85
|
+
user: req.user ? { ...publicUser(req.user), instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
|
|
78
86
|
downloads: IS_CENTRAL ? {
|
|
79
87
|
windows: process.env.DESKTOP_DOWNLOAD_WINDOWS || '',
|
|
80
88
|
linux: process.env.DESKTOP_DOWNLOAD_LINUX || '',
|
|
@@ -83,6 +91,7 @@ export function createRouter() {
|
|
|
83
91
|
role: IS_INSTANCE ? 'instance' : IS_CENTRAL ? 'central' : 'dev',
|
|
84
92
|
centralUrl: IS_INSTANCE ? CENTRAL_URL : '',
|
|
85
93
|
cloudMode: process.env.CLOUD_MODE === '1',
|
|
94
|
+
isRuSegment: isRuSegmentRequest(req),
|
|
86
95
|
plans: PLANS,
|
|
87
96
|
notify: publicNotifyConfig(),
|
|
88
97
|
oauth: publicOAuthConfig(),
|
|
@@ -154,8 +163,9 @@ export function createRouter() {
|
|
|
154
163
|
res.json({ user: publicUser(u), token });
|
|
155
164
|
}));
|
|
156
165
|
r.post('/auth/logout', (req, res) => {
|
|
157
|
-
const
|
|
158
|
-
|
|
166
|
+
for (const tok of parseAllCookieValues(req.headers?.cookie, 'sid')) {
|
|
167
|
+
if (tok && tok !== 'logged_out') deleteSession(tok);
|
|
168
|
+
}
|
|
159
169
|
setCookie(res, 'logged_out', 365 * 86400);
|
|
160
170
|
res.json({ ok: true, ...(IS_INSTANCE ? { redirect: CENTRAL_URL } : {}) });
|
|
161
171
|
});
|
|
@@ -221,10 +231,11 @@ export function createRouter() {
|
|
|
221
231
|
if (!sent.tg && !sent.max) throw new Error('Сначала привяжите Telegram или MAX по вашему коду привязки');
|
|
222
232
|
res.json({ ok: true, ...sent });
|
|
223
233
|
}));
|
|
224
|
-
r.post('/auth/redeem-promo', wrap((req, res) => {
|
|
234
|
+
r.post('/auth/redeem-promo', wrap(async (req, res) => {
|
|
225
235
|
if (!req.user) throw new Error('Войдите в аккаунт для активации кода или VIP-токена');
|
|
226
236
|
const user = redeemPromoCode(req.user.id, req.body?.code);
|
|
227
|
-
|
|
237
|
+
if (IS_CENTRAL) await ensureUserCloudInstance(req.user.id).catch(() => {});
|
|
238
|
+
res.json({ user: { ...user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } });
|
|
228
239
|
}));
|
|
229
240
|
r.post('/auth/accept-offer', wrap((req, res) => {
|
|
230
241
|
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
@@ -277,12 +288,25 @@ export function createRouter() {
|
|
|
277
288
|
}));
|
|
278
289
|
r.post('/billing/verify', wrap(async (req, res) => {
|
|
279
290
|
if (!req.user) throw new Error('Войдите в аккаунт');
|
|
280
|
-
|
|
281
|
-
|
|
291
|
+
const out = req.body?.paymentId
|
|
292
|
+
? await verifyYookassaPayment(req.body.paymentId)
|
|
293
|
+
: await verifyUserLatestPayment(req.user.id);
|
|
294
|
+
if (IS_CENTRAL && out?.status === 'succeeded') {
|
|
295
|
+
await ensureUserCloudInstance(req.user.id).catch(() => {});
|
|
296
|
+
}
|
|
297
|
+
res.json({
|
|
298
|
+
...out,
|
|
299
|
+
user: out?.user ? { ...out.user, instances: IS_CENTRAL ? cabinetInstances(req.user.id) : [], payments: listUserPayments(req.user.id, 15) } : null,
|
|
300
|
+
});
|
|
282
301
|
}));
|
|
283
302
|
r.post('/billing/yookassa/webhook', wrap(async (req, res) => {
|
|
284
303
|
const pid = req.body?.object?.id;
|
|
285
|
-
if (pid)
|
|
304
|
+
if (pid) {
|
|
305
|
+
const out = await verifyYookassaPayment(pid).catch(() => null);
|
|
306
|
+
if (IS_CENTRAL && out?.status === 'succeeded' && out?.user?.id) {
|
|
307
|
+
await ensureUserCloudInstance(out.user.id).catch(() => {});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
286
310
|
res.json({ ok: true });
|
|
287
311
|
}));
|
|
288
312
|
r.post('/notify/max/webhook', wrap(async (req, res) => {
|
|
@@ -393,9 +417,15 @@ export function createRouter() {
|
|
|
393
417
|
requireAdmin(req);
|
|
394
418
|
res.json({ promos: createPromoCode(req.body || {}) });
|
|
395
419
|
}));
|
|
396
|
-
r.post('/admin/user-plan', wrap((req, res) => {
|
|
420
|
+
r.post('/admin/user-plan', wrap(async (req, res) => {
|
|
421
|
+
requireAdmin(req);
|
|
422
|
+
const user = setUserPlan(req.body?.userId, req.body?.plan, req.body?.days || 30);
|
|
423
|
+
if (IS_CENTRAL) await ensureUserCloudInstance(req.body?.userId).catch(() => {});
|
|
424
|
+
res.json({ user, users: listAllUsers() });
|
|
425
|
+
}));
|
|
426
|
+
r.post('/admin/create-admin', wrap((req, res) => {
|
|
397
427
|
requireAdmin(req);
|
|
398
|
-
res.json({ user:
|
|
428
|
+
res.json({ user: createOrUpdateAdminUser(req.body || {}), users: listAllUsers() });
|
|
399
429
|
}));
|
|
400
430
|
r.post('/admin/billing', wrap((req, res) => {
|
|
401
431
|
requireAdmin(req);
|
|
@@ -636,6 +666,7 @@ export function createRouter() {
|
|
|
636
666
|
const vocabHit = vocabList.find((v) => v.symbol === sym);
|
|
637
667
|
const fcRow = fc?.rows?.find((x) => x.symbol === sym) || null;
|
|
638
668
|
const isTier1 = TIER1_LIQUID_SYMBOLS.has(sym);
|
|
669
|
+
const tierMult = symbolEdgeMult(sym);
|
|
639
670
|
|
|
640
671
|
// 10-минутные свечи за последние 30 дней (для детального просмотра 1Д / 1Н / 1М) + дневной ряд за всё время (1Г / Все)
|
|
641
672
|
const nowTs = Date.now();
|
|
@@ -727,8 +758,10 @@ export function createRouter() {
|
|
|
727
758
|
yearChangePct,
|
|
728
759
|
tier: isTier1 ? 1 : 2,
|
|
729
760
|
tierLabel: isTier1 ? '1-й эшелон (высокая ликвидность)' : '2-й эшелон',
|
|
730
|
-
|
|
731
|
-
|
|
761
|
+
edgeMult: fcRow?.edgeMult ?? round(tierMult, 3),
|
|
762
|
+
sigmaPct: fcRow?.sigmaPct ?? null,
|
|
763
|
+
buyThresholdPct: fcRow?.buyThresholdPct ?? round(Math.max((fc?.buyThresholdPct || 0.413) * tierMult, 0.30), 3),
|
|
764
|
+
shortThresholdPct: fcRow?.shortThresholdPct ?? round(Math.max((fc?.shortThresholdPct || 0.516) * tierMult, 0.31), 3),
|
|
732
765
|
shortAllowedByBroker: shortableMap[sym] !== undefined ? Boolean(shortableMap[sym]) : null,
|
|
733
766
|
specs: {
|
|
734
767
|
lotSize: sp.lotSize,
|
|
@@ -748,6 +781,12 @@ export function createRouter() {
|
|
|
748
781
|
signal: Boolean(fcRow.signal),
|
|
749
782
|
shortSignal: Boolean(fcRow.shortSignal),
|
|
750
783
|
shortBlockedByBroker: Boolean(fcRow.shortBlockedByBroker),
|
|
784
|
+
shortBlockedBySqueeze: Boolean(fcRow.shortBlockedBySqueeze),
|
|
785
|
+
buyBlockedByDip: Boolean(fcRow.buyBlockedByDip),
|
|
786
|
+
buyBlockedByDiv: fcRow.buyBlockedByDiv || null,
|
|
787
|
+
shortBlockedByDiv: fcRow.shortBlockedByDiv || null,
|
|
788
|
+
buyBlockedByNews: fcRow.buyBlockedByNews || null,
|
|
789
|
+
shortBlockedByNews: fcRow.shortBlockedByNews || null,
|
|
751
790
|
selfPct: fcRow.selfPct ?? 0,
|
|
752
791
|
influences: (fcRow.influences || []).map((inf) => {
|
|
753
792
|
const infRow = fc?.rows?.find((x) => x.symbol === inf.symbol);
|
package/server/auth/index.js
CHANGED
|
@@ -281,7 +281,7 @@ export function effectivePlan(user) {
|
|
|
281
281
|
if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.none;
|
|
282
282
|
return PLANS.owner;
|
|
283
283
|
}
|
|
284
|
-
if (user.role === 'admin' || user.plan === 'owner') return PLANS
|
|
284
|
+
if (user.id === 1 || user.role === 'admin' || user.plan === 'owner') return PLANS.owner;
|
|
285
285
|
const p = PLANS[user.plan] || PLANS.none;
|
|
286
286
|
if (p.id !== 'none' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.none;
|
|
287
287
|
return p;
|
|
@@ -360,8 +360,20 @@ export function parseCookies(cookieHeader = '') {
|
|
|
360
360
|
return out;
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
export function parseAllCookieValues(cookieHeader = '', key = 'sid') {
|
|
364
|
+
const vals = [];
|
|
365
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
366
|
+
const i = part.indexOf('=');
|
|
367
|
+
if (i > 0 && part.slice(0, i).trim() === key) {
|
|
368
|
+
vals.push(decodeURIComponent(part.slice(i + 1).trim()));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return vals;
|
|
372
|
+
}
|
|
373
|
+
|
|
363
374
|
export function userFromRequest(req) {
|
|
364
375
|
const cookies = parseCookies(req.headers?.cookie);
|
|
376
|
+
const allSids = parseAllCookieValues(req.headers?.cookie, 'sid');
|
|
365
377
|
const authHeader = String(req.headers?.authorization || '');
|
|
366
378
|
const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
|
|
367
379
|
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
@@ -370,8 +382,8 @@ export function userFromRequest(req) {
|
|
|
370
382
|
const byKey = getUserByApiKey(apiCandidate);
|
|
371
383
|
if (byKey) return byKey;
|
|
372
384
|
}
|
|
373
|
-
const
|
|
374
|
-
|
|
385
|
+
const candidates = [...allSids.filter((s) => s && s !== 'logged_out'), ...(bearer ? [bearer] : [])];
|
|
386
|
+
for (const token of candidates) {
|
|
375
387
|
const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
376
388
|
if (sess) {
|
|
377
389
|
const u = getUserById(sess.user_id);
|
|
@@ -381,7 +393,8 @@ export function userFromRequest(req) {
|
|
|
381
393
|
// В локальном режиме (без CLOUD_MODE=1) владелец (id = 1) открывается без входа только с этого компьютера
|
|
382
394
|
// Установка на компьютер клиента после активации — так же, без пароля только с этого компьютера
|
|
383
395
|
const localTrusted = !IS_INSTANCE || (IS_DESKTOP && isActivated());
|
|
384
|
-
|
|
396
|
+
const explicitlyLoggedOut = cookies.sid === 'logged_out' || allSids.includes('logged_out');
|
|
397
|
+
if (process.env.CLOUD_MODE !== '1' && localTrusted && !explicitlyLoggedOut && isLoopbackRequest(req)) {
|
|
385
398
|
return getUserById(1);
|
|
386
399
|
}
|
|
387
400
|
return null;
|
|
@@ -445,9 +458,36 @@ export function listAllUsers() {
|
|
|
445
458
|
|
|
446
459
|
export function setUserPlan(userId, plan, days = 30) {
|
|
447
460
|
if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
|
|
461
|
+
const uid = Number(userId);
|
|
462
|
+
if (!uid) throw new Error('Некорректный ID пользователя');
|
|
448
463
|
const until = (plan === 'none' || plan === 'owner') ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
|
|
449
|
-
|
|
450
|
-
|
|
464
|
+
// Тариф «Хозяин» (owner) даёт права администратора (role = 'admin'); главный владелец (id = 1) всегда сохраняет роль admin
|
|
465
|
+
const role = (plan === 'owner' || uid === 1) ? 'admin' : 'user';
|
|
466
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ?, role = ? WHERE id = ?').run(plan, until, role, uid);
|
|
467
|
+
return publicUser(getUserById(uid));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function createOrUpdateAdminUser({ email, password, name }) {
|
|
471
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
472
|
+
if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email администратора');
|
|
473
|
+
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
|
|
474
|
+
const cleanPass = String(password || '').trim();
|
|
475
|
+
if (existing) {
|
|
476
|
+
if (cleanPass && cleanPass.length < 6) throw new Error('Пароль должен быть не короче 6 символов');
|
|
477
|
+
const nextHash = cleanPass ? hashPassword(cleanPass) : existing.pass_hash;
|
|
478
|
+
const nextName = String(name || '').trim() || existing.name || 'Администратор';
|
|
479
|
+
db.prepare("UPDATE users SET name = ?, pass_hash = ?, role = 'admin', plan = 'owner', plan_until = 0 WHERE id = ?")
|
|
480
|
+
.run(nextName, nextHash, existing.id);
|
|
481
|
+
ensureApiKey(existing.id);
|
|
482
|
+
return publicUser(getUserById(existing.id));
|
|
483
|
+
}
|
|
484
|
+
if (cleanPass.length < 6) throw new Error('Для нового администратора укажите пароль не короче 6 символов');
|
|
485
|
+
const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
486
|
+
const apiKey = generateApiKey();
|
|
487
|
+
const cleanName = String(name || '').trim() || 'Администратор';
|
|
488
|
+
const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, offer_accepted_at, created_at)
|
|
489
|
+
VALUES (?, ?, ?, 'admin', 'owner', 0, ?, ?, ?, ?)`).run(cleanEmail, cleanName, hashPassword(cleanPass), code, apiKey, Date.now(), Date.now());
|
|
490
|
+
return publicUser(getUserById(Number(r.lastInsertRowid)));
|
|
451
491
|
}
|
|
452
492
|
|
|
453
493
|
// ---------- промокоды и персональные VIP-токены ----------
|
|
@@ -480,7 +520,11 @@ export function redeemPromoCode(userId, rawCode) {
|
|
|
480
520
|
const u = getUserById(userId);
|
|
481
521
|
const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
|
|
482
522
|
const nextUntil = baseTs + promo.days * 86400000;
|
|
483
|
-
|
|
523
|
+
if (u.id === 1 || u.role === 'admin') {
|
|
524
|
+
db.prepare('UPDATE users SET offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(Date.now(), u.id);
|
|
525
|
+
} else {
|
|
526
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ?, offer_accepted_at = COALESCE(NULLIF(offer_accepted_at, 0), ?) WHERE id = ?').run(promo.plan, nextUntil, Date.now(), u.id);
|
|
527
|
+
}
|
|
484
528
|
db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
|
|
485
529
|
db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
|
|
486
530
|
return publicUser(getUserById(userId));
|
|
@@ -518,11 +562,11 @@ export function authMiddleware(req, res, next) {
|
|
|
518
562
|
|
|
519
563
|
// ---------- OAuth 2.0 / Социальный вход: Яндекс, Google, Сбер ID, Т-Банк ID, VK ID ----------
|
|
520
564
|
export const OAUTH_PROVIDERS = [
|
|
521
|
-
{ id: 'yandex', name: 'Яндекс ID', authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
|
|
522
|
-
{ id: 'google', name: 'Google', authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
|
|
523
|
-
{ id: 'sber', name: 'Сбер ID', authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
|
|
524
|
-
{ id: 'tbank', name: 'T-ID (Т-Банк)', authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
|
|
525
|
-
{ id: 'vk', name: 'VK ID', authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
|
|
565
|
+
{ id: 'yandex', name: 'Яндекс ID', foreign: false, authUrl: 'https://oauth.yandex.ru/authorize', tokenUrl: 'https://oauth.yandex.ru/token', userUrl: 'https://login.yandex.ru/info?format=json', scope: 'login:email login:info' },
|
|
566
|
+
{ id: 'google', name: 'Google', foreign: true, authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', userUrl: 'https://www.googleapis.com/oauth2/v3/userinfo', scope: 'openid email profile' },
|
|
567
|
+
{ id: 'sber', name: 'Сбер ID', foreign: false, authUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oauth', tokenUrl: 'https://oauth.sber.ru/ru/prod/tokens/v2/oidc', userUrl: 'https://oauth.sber.ru/ru/prod/sberbankid/v2.1/userinfo', scope: 'openid name email' },
|
|
568
|
+
{ id: 'tbank', name: 'T-ID (Т-Банк)', foreign: false, authUrl: 'https://id.tbank.ru/auth/authorize', tokenUrl: 'https://id.tbank.ru/auth/token', userUrl: 'https://id.tbank.ru/userinfo/userinfo', scope: 'openid profile email' },
|
|
569
|
+
{ id: 'vk', name: 'VK ID', foreign: false, authUrl: 'https://oauth.vk.com/authorize', tokenUrl: 'https://oauth.vk.com/access_token', userUrl: 'https://api.vk.com/method/users.get?v=5.199', scope: 'email' },
|
|
526
570
|
];
|
|
527
571
|
|
|
528
572
|
export function getOAuthConfig() {
|
|
@@ -533,7 +577,7 @@ export function getOAuthConfig() {
|
|
|
533
577
|
const cur = saved[p.id] || {};
|
|
534
578
|
const clientId = String(cur.clientId || process.env[`${envPrefix}CLIENT_ID`] || '').trim();
|
|
535
579
|
const clientSecret = decryptSecret(cur.clientSecret || '') || String(process.env[`${envPrefix}CLIENT_SECRET`] || '').trim();
|
|
536
|
-
out[p.id] = { id: p.id, name: p.name, clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
|
|
580
|
+
out[p.id] = { id: p.id, name: p.name, foreign: Boolean(p.foreign), clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
|
|
537
581
|
}
|
|
538
582
|
return out;
|
|
539
583
|
}
|
|
@@ -543,6 +587,7 @@ export function publicOAuthConfig() {
|
|
|
543
587
|
return Object.fromEntries(OAUTH_PROVIDERS.map((p) => [p.id, {
|
|
544
588
|
id: p.id,
|
|
545
589
|
name: p.name,
|
|
590
|
+
foreign: Boolean(p.foreign),
|
|
546
591
|
clientId: cfg[p.id].clientId,
|
|
547
592
|
hasSecret: Boolean(cfg[p.id].clientSecret),
|
|
548
593
|
enabled: cfg[p.id].enabled,
|