neuroalpha 0.2.0 → 0.3.12
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/LICENSE +21 -0
- package/README.md +7 -3
- package/bin/cli.js +69 -69
- package/config/seed-run.json +1 -0
- package/config/strategy.json +4 -10
- package/deploy/install.sh +128 -0
- package/docs//320/224/320/225/320/237/320/233/320/236/320/231.md +103 -0
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +1 -1
- package/docs//320/234/320/225/320/245/320/220/320/235/320/230/320/232/320/220_/320/230_/320/237/320/220/320/240/320/220/320/234/320/225/320/242/320/240/320/253.md +171 -0
- package/package.json +15 -5
- package/public/admin.html +426 -0
- package/public/app.js +741 -251
- package/public/index.html +45 -20
- package/public/landing.html +1004 -870
- package/public/legal.html +22 -0
- package/public/styles.css +49 -15
- package/server/api/routes.js +176 -23
- package/server/auth/index.js +544 -340
- package/server/billing/yookassa.js +1 -0
- package/server/brokers/index.js +1224 -645
- package/server/config.js +6 -4
- package/server/data/moex.js +1 -0
- package/server/db.js +53 -2
- package/server/hub.js +1 -0
- package/server/index.js +10 -2
- package/server/llm/client.js +1 -0
- package/server/llm/providers.js +50 -3
- package/server/model/dataset.js +362 -352
- package/server/model/forecast.js +15 -21
- package/server/model/linalg.js +1 -0
- package/server/model/live.js +489 -486
- package/server/model/metrics.js +1 -0
- package/server/model/models.js +31 -21
- package/server/model/profiles.js +1 -0
- package/server/model/research.js +119 -172
- package/server/model/vocab.js +1 -0
- package/server/news/index.js +1 -0
- package/server/notify/index.js +304 -168
- package/server/params-meta.js +20 -25
- package/server/settings.js +1 -0
- package/server/strategist/analytics.js +1 -0
- package/server/strategist/index.js +6 -6
- package/server/strategist/tools.js +2 -1
- package/server/util.js +1 -0
- package/server/model/gpu-worker.html +0 -11
- package/server/model/gpu-worker.js +0 -60
- package/server/model/tf-node.js +0 -26
- package/server/model/transformer-core.js +0 -330
- package/server/model/transformer-gpu.js +0 -221
- package/server/model/transformer-pool.js +0 -200
- package/server/model/transformer-worker.js +0 -28
- package/server/model/transformer.js +0 -57
package/server/auth/index.js
CHANGED
|
@@ -1,340 +1,544 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
fs.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (s
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (!s
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
decipher.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (u
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
const
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
db.prepare('
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. Licensed under the MIT License.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
import { ROOT, DATA_DIR, DEFAULT_CLOUD_URL } from '../config.js';
|
|
7
|
+
import { db, kvGet, kvSet, registerUidResolver } from '../db.js';
|
|
8
|
+
|
|
9
|
+
const ctx = new AsyncLocalStorage();
|
|
10
|
+
registerUidResolver(() => ctx.getStore()?.user?.id || 1);
|
|
11
|
+
|
|
12
|
+
// ---------- шифрование секретов (AES-256-GCM) ----------
|
|
13
|
+
let masterKey = null;
|
|
14
|
+
function getMasterKey() {
|
|
15
|
+
if (masterKey) return masterKey;
|
|
16
|
+
const envKey = (process.env.MASTER_KEY || '').trim();
|
|
17
|
+
if (envKey) {
|
|
18
|
+
masterKey = crypto.createHash('sha256').update(envKey).digest();
|
|
19
|
+
return masterKey;
|
|
20
|
+
}
|
|
21
|
+
const keyPath = path.join(DATA_DIR, '.master.key');
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(keyPath)) {
|
|
24
|
+
const hex = fs.readFileSync(keyPath, 'utf8').trim();
|
|
25
|
+
if (hex.length >= 64) { masterKey = Buffer.from(hex.slice(0, 64), 'hex'); return masterKey; }
|
|
26
|
+
}
|
|
27
|
+
const rnd = crypto.randomBytes(32);
|
|
28
|
+
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(keyPath, rnd.toString('hex'), { mode: 0o600 });
|
|
30
|
+
masterKey = rnd;
|
|
31
|
+
} catch {
|
|
32
|
+
masterKey = crypto.createHash('sha256').update(ROOT).digest();
|
|
33
|
+
}
|
|
34
|
+
return masterKey;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function encryptSecret(plain) {
|
|
38
|
+
const s = String(plain ?? '').trim();
|
|
39
|
+
if (!s) return '';
|
|
40
|
+
if (s.startsWith('enc:v1:')) return s;
|
|
41
|
+
const iv = crypto.randomBytes(12);
|
|
42
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', getMasterKey(), iv);
|
|
43
|
+
const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()]);
|
|
44
|
+
const tag = cipher.getAuthTag();
|
|
45
|
+
return `enc:v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decryptSecret(val) {
|
|
49
|
+
const s = String(val ?? '').trim();
|
|
50
|
+
if (!s) return '';
|
|
51
|
+
if (!s.startsWith('enc:v1:')) return s;
|
|
52
|
+
try {
|
|
53
|
+
const [, , ivHex, tagHex, encHex] = s.split(':');
|
|
54
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', getMasterKey(), Buffer.from(ivHex, 'hex'));
|
|
55
|
+
decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
|
|
56
|
+
return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString('utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- хэширование паролей (scrypt) ----------
|
|
63
|
+
export function hashPassword(password) {
|
|
64
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
65
|
+
const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
66
|
+
return `${salt}:${hash}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function verifyPassword(password, stored) {
|
|
70
|
+
if (!stored || !stored.includes(':')) return false;
|
|
71
|
+
const [salt, hex] = stored.split(':');
|
|
72
|
+
const hash = crypto.scryptSync(String(password), salt, 64);
|
|
73
|
+
const target = Buffer.from(hex, 'hex');
|
|
74
|
+
return hash.length === target.length && crypto.timingSafeEqual(hash, target);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- пользователи и сессии ----------
|
|
78
|
+
export const PLANS = {
|
|
79
|
+
free: { id: 'free', name: 'Старт (Витрина)', priceRub: 0, maxRunsPerDay: 2, maxVirtualAccounts: 1, maxBrokerAccounts: 0, maxChatPerDay: 10, allowTransformer: false },
|
|
80
|
+
investor: { id: 'investor', name: 'Инвестор (Автопилот)', priceRub: 3900, maxRunsPerDay: 30, maxVirtualAccounts: 3, maxBrokerAccounts: 2, maxChatPerDay: 150, allowTransformer: false },
|
|
81
|
+
pro: { id: 'pro', name: 'Профи / Лаборатория', priceRub: 9900, maxRunsPerDay: 500, maxVirtualAccounts: 5, maxBrokerAccounts: 5, maxChatPerDay: 1000, allowTransformer: true },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function generateApiKey() {
|
|
85
|
+
return `na_live_${crypto.randomBytes(16).toString('hex')}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ensureApiKey(userId, regenerate = false) {
|
|
89
|
+
const u = getUserById(userId);
|
|
90
|
+
if (!u) throw new Error('Пользователь не найден');
|
|
91
|
+
if (u.api_key && !regenerate) return u.api_key;
|
|
92
|
+
const key = generateApiKey();
|
|
93
|
+
db.prepare('UPDATE users SET api_key = ? WHERE id = ?').run(key, u.id);
|
|
94
|
+
return key;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getUserByApiKey(apiKey) {
|
|
98
|
+
const clean = String(apiKey || '').trim();
|
|
99
|
+
if (!clean.startsWith('na_live_')) return null;
|
|
100
|
+
return db.prepare('SELECT * FROM users WHERE api_key = ?').get(clean) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------- синхронизация лицензии для локального запуска (npx neuroalpha --key ...) ----------
|
|
104
|
+
export function getLocalLicense() {
|
|
105
|
+
return kvGet('cloudLicense') || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function activateLocalLicense(apiKey, cloudUrl = '') {
|
|
109
|
+
const cleanKey = String(apiKey || '').trim();
|
|
110
|
+
if (!cleanKey.startsWith('na_live_')) throw new Error('Ключ должен начинаться с na_live_…');
|
|
111
|
+
const baseUrl = String(cloudUrl || process.env.CLOUD_URL || DEFAULT_CLOUD_URL).trim().replace(/\/+$/, '');
|
|
112
|
+
return patchUserLicense(cleanKey, baseUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function patchUserLicense(cleanKey, baseUrl) {
|
|
116
|
+
// 1) если ключ есть в этой же БД — проверяем напрямую
|
|
117
|
+
const localUser = getUserByApiKey(cleanKey);
|
|
118
|
+
if (localUser) {
|
|
119
|
+
const p = effectivePlan(localUser);
|
|
120
|
+
const lic = {
|
|
121
|
+
apiKey: cleanKey,
|
|
122
|
+
cloudUrl: baseUrl || 'local',
|
|
123
|
+
email: localUser.email,
|
|
124
|
+
name: localUser.name,
|
|
125
|
+
plan: p.id,
|
|
126
|
+
planName: p.name,
|
|
127
|
+
planUntil: localUser.plan_until || 0,
|
|
128
|
+
verifiedAt: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
kvSet('cloudLicense', lic);
|
|
131
|
+
return lic;
|
|
132
|
+
}
|
|
133
|
+
// 2) иначе проверяем об облачный сервер
|
|
134
|
+
if (!baseUrl) throw new Error('Ключ не найден локально. Укажите адрес облачного сервера или активируйте ключ на сайте');
|
|
135
|
+
const r = await fetch(`${baseUrl}/api/license/verify`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cleanKey}` },
|
|
138
|
+
body: JSON.stringify({ apiKey: cleanKey }),
|
|
139
|
+
signal: AbortSignal.timeout(15000),
|
|
140
|
+
});
|
|
141
|
+
const j = await r.json().catch(() => ({}));
|
|
142
|
+
if (!r.ok || !j.valid) throw new Error(j.error || `Не удалось проверить ключ (HTTP ${r.status})`);
|
|
143
|
+
const lic = {
|
|
144
|
+
apiKey: cleanKey,
|
|
145
|
+
cloudUrl: baseUrl,
|
|
146
|
+
email: j.email,
|
|
147
|
+
name: j.name,
|
|
148
|
+
plan: j.plan || 'free',
|
|
149
|
+
planName: j.planName || PLANS[j.plan]?.name || 'Старт',
|
|
150
|
+
planUntil: j.planUntil || 0,
|
|
151
|
+
verifiedAt: Date.now(),
|
|
152
|
+
};
|
|
153
|
+
kvSet('cloudLicense', lic);
|
|
154
|
+
// применяем тариф к локальному пользователю #1
|
|
155
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = 1').run(lic.plan, lic.planUntil);
|
|
156
|
+
return lic;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function effectivePlan(user) {
|
|
160
|
+
if (!user) return PLANS.free;
|
|
161
|
+
// В локальном режиме (npx): если активирован лицензионный ключ — действует тариф подписки;
|
|
162
|
+
// если ADMIN_ALL_PRO=1 или это явно админ облака — действует его тариф
|
|
163
|
+
if (process.env.CLOUD_MODE !== '1' && user.id === 1) {
|
|
164
|
+
const lic = getLocalLicense();
|
|
165
|
+
if (lic?.plan && PLANS[lic.plan]) {
|
|
166
|
+
if (lic.plan !== 'free' && lic.planUntil > 0 && lic.planUntil < Date.now()) return PLANS.free;
|
|
167
|
+
return PLANS[lic.plan];
|
|
168
|
+
}
|
|
169
|
+
if (process.env.LOCAL_STRICT_LICENSE === '1') return PLANS.free;
|
|
170
|
+
return PLANS.pro;
|
|
171
|
+
}
|
|
172
|
+
if (user.role === 'admin') return PLANS.pro;
|
|
173
|
+
const p = PLANS[user.plan] || PLANS.free;
|
|
174
|
+
if (p.id !== 'free' && user.plan_until > 0 && user.plan_until < Date.now()) return PLANS.free;
|
|
175
|
+
return p;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function publicUser(u) {
|
|
179
|
+
if (!u) return null;
|
|
180
|
+
if (!u.api_key) {
|
|
181
|
+
try { u.api_key = ensureApiKey(u.id); } catch { /* ignore */ }
|
|
182
|
+
}
|
|
183
|
+
const plan = effectivePlan(u);
|
|
184
|
+
return {
|
|
185
|
+
id: u.id,
|
|
186
|
+
email: u.email,
|
|
187
|
+
name: u.name || 'Инвестор',
|
|
188
|
+
role: u.role || 'user',
|
|
189
|
+
plan: plan.id,
|
|
190
|
+
planName: plan.name,
|
|
191
|
+
planUntil: u.plan_until || 0,
|
|
192
|
+
limits: plan,
|
|
193
|
+
apiKey: u.api_key || null,
|
|
194
|
+
tgConnected: Boolean(u.tg_chat_id),
|
|
195
|
+
maxConnected: Boolean(u.max_chat_id),
|
|
196
|
+
notifyCode: u.notify_code || null,
|
|
197
|
+
localLicense: process.env.CLOUD_MODE !== '1' ? getLocalLicense() : null,
|
|
198
|
+
createdAt: u.created_at,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function getUserById(id) {
|
|
203
|
+
return db.prepare('SELECT * FROM users WHERE id = ?').get(Number(id));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function currentUser() {
|
|
207
|
+
const store = ctx.getStore();
|
|
208
|
+
if (store?.user) return store.user;
|
|
209
|
+
return getUserById(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function currentUserId() {
|
|
213
|
+
const store = ctx.getStore();
|
|
214
|
+
return store?.user?.id || 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function runWithUser(user, fn) {
|
|
218
|
+
return ctx.run({ user }, fn);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function parseCookies(cookieHeader = '') {
|
|
222
|
+
const out = {};
|
|
223
|
+
for (const part of String(cookieHeader || '').split(';')) {
|
|
224
|
+
const i = part.indexOf('=');
|
|
225
|
+
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function userFromRequest(req) {
|
|
231
|
+
const cookies = parseCookies(req.headers?.cookie);
|
|
232
|
+
const authHeader = String(req.headers?.authorization || '');
|
|
233
|
+
const apiKeyHeader = String(req.headers?.['x-api-key'] || '').trim();
|
|
234
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
235
|
+
const apiCandidate = apiKeyHeader || (bearer.startsWith('na_live_') ? bearer : '');
|
|
236
|
+
if (apiCandidate) {
|
|
237
|
+
const byKey = getUserByApiKey(apiCandidate);
|
|
238
|
+
if (byKey) return byKey;
|
|
239
|
+
}
|
|
240
|
+
const token = cookies.sid || bearer;
|
|
241
|
+
if (token) {
|
|
242
|
+
const sess = db.prepare('SELECT * FROM sessions WHERE token = ? AND expires_at > ?').get(token, Date.now());
|
|
243
|
+
if (sess) {
|
|
244
|
+
const u = getUserById(sess.user_id);
|
|
245
|
+
if (u) return u;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// В локальном режиме (без CLOUD_MODE=1) по умолчанию открывается владелец (id = 1)
|
|
249
|
+
if (process.env.CLOUD_MODE !== '1' && cookies.sid !== 'logged_out') {
|
|
250
|
+
return getUserById(1);
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function createSession(userId, days = 30) {
|
|
256
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
|
|
259
|
+
.run(token, Number(userId), now, now + days * 86400000);
|
|
260
|
+
return token;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deleteSession(token) {
|
|
264
|
+
if (!token) return;
|
|
265
|
+
db.prepare('DELETE FROM sessions WHERE token = ?').run(String(token));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function registerUser({ email, password, name }) {
|
|
269
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
270
|
+
if (!cleanEmail || !cleanEmail.includes('@')) throw new Error('Укажите корректный email');
|
|
271
|
+
if (!password || String(password).length < 6) throw new Error('Пароль должен быть не короче 6 символов');
|
|
272
|
+
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(cleanEmail);
|
|
273
|
+
if (existing) throw new Error('Пользователь с таким email уже зарегистрирован');
|
|
274
|
+
const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
275
|
+
const apiKey = generateApiKey();
|
|
276
|
+
const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, created_at)
|
|
277
|
+
VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?)`).run(cleanEmail, String(name || cleanEmail.split('@')[0]).trim(), hashPassword(password), code, apiKey, Date.now());
|
|
278
|
+
return getUserById(Number(r.lastInsertRowid));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function loginUser({ email, password }) {
|
|
282
|
+
const cleanEmail = String(email || '').trim().toLowerCase();
|
|
283
|
+
const u = db.prepare('SELECT * FROM users WHERE email = ?').get(cleanEmail);
|
|
284
|
+
if (!u || !verifyPassword(password, u.pass_hash)) throw new Error('Неверный email или пароль');
|
|
285
|
+
return u;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function ensureNotifyCode(userId) {
|
|
289
|
+
const u = getUserById(userId);
|
|
290
|
+
if (!u) throw new Error('Пользователь не найден');
|
|
291
|
+
if (u.notify_code) return u.notify_code;
|
|
292
|
+
const code = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
293
|
+
db.prepare('UPDATE users SET notify_code = ? WHERE id = ?').run(code, u.id);
|
|
294
|
+
return code;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function listAllUsers() {
|
|
298
|
+
return db.prepare('SELECT * FROM users ORDER BY id ASC').all().map(publicUser);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function setUserPlan(userId, plan, days = 30) {
|
|
302
|
+
if (!(plan in PLANS)) throw new Error(`Неизвестный тариф: ${plan}`);
|
|
303
|
+
const until = plan === 'free' ? 0 : Date.now() + Math.max(1, Number(days) || 30) * 86400000;
|
|
304
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(plan, until, Number(userId));
|
|
305
|
+
return publicUser(getUserById(userId));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------- промокоды ----------
|
|
309
|
+
export function createPromoCode({ code, plan = 'investor', days = 14, maxUses = 50 }) {
|
|
310
|
+
const clean = String(code || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '');
|
|
311
|
+
if (clean.length < 3) throw new Error('Код должен содержать минимум 3 символа (латиница/цифры)');
|
|
312
|
+
if (!(plan in PLANS) || plan === 'free') throw new Error('Выберите тариф Инвестор или Профи');
|
|
313
|
+
const d = Math.max(1, Math.min(365, Math.round(Number(days) || 14)));
|
|
314
|
+
const m = Math.max(1, Math.min(10000, Math.round(Number(maxUses) || 50)));
|
|
315
|
+
db.prepare(`INSERT OR REPLACE INTO promocodes (code, plan, days, max_uses, used_count, created_at)
|
|
316
|
+
VALUES (?, ?, ?, ?, COALESCE((SELECT used_count FROM promocodes WHERE code = ?), 0), ?)`).run(clean, plan, d, m, clean, Date.now());
|
|
317
|
+
return listPromoCodes();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function listPromoCodes() {
|
|
321
|
+
return db.prepare('SELECT * FROM promocodes ORDER BY created_at DESC').all();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function redeemPromoCode(userId, rawCode) {
|
|
325
|
+
if (!userId) throw new Error('Войдите в аккаунт для активации промокода');
|
|
326
|
+
const clean = String(rawCode || '').trim().toUpperCase();
|
|
327
|
+
const promo = db.prepare('SELECT * FROM promocodes WHERE UPPER(code) = ?').get(clean);
|
|
328
|
+
if (!promo) throw new Error('Промокод не найден');
|
|
329
|
+
if (promo.used_count >= promo.max_uses) throw new Error('Лимит активаций этого промокода исчерпан');
|
|
330
|
+
const already = db.prepare('SELECT 1 FROM promocode_uses WHERE code = ? AND user_id = ?').get(promo.code, Number(userId));
|
|
331
|
+
if (already) throw new Error('Вы уже активировали этот промокод');
|
|
332
|
+
const u = getUserById(userId);
|
|
333
|
+
const baseTs = u.plan === promo.plan && u.plan_until > Date.now() ? u.plan_until : Date.now();
|
|
334
|
+
const nextUntil = baseTs + promo.days * 86400000;
|
|
335
|
+
db.prepare('UPDATE users SET plan = ?, plan_until = ? WHERE id = ?').run(promo.plan, nextUntil, u.id);
|
|
336
|
+
db.prepare('UPDATE promocodes SET used_count = used_count + 1 WHERE code = ?').run(promo.code);
|
|
337
|
+
db.prepare('INSERT INTO promocode_uses (code, user_id, used_at) VALUES (?, ?, ?)').run(promo.code, u.id, Date.now());
|
|
338
|
+
return publicUser(getUserById(userId));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function authMiddleware(req, res, next) {
|
|
342
|
+
const u = userFromRequest(req);
|
|
343
|
+
req.user = u;
|
|
344
|
+
return runWithUser(u, () => next());
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------- OAuth 2.0 / Социальный вход: Яндекс, Google, Сбер ID, Т-Банк ID, VK ID ----------
|
|
348
|
+
export const OAUTH_PROVIDERS = [
|
|
349
|
+
{ 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' },
|
|
350
|
+
{ 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' },
|
|
351
|
+
{ 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' },
|
|
352
|
+
{ 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' },
|
|
353
|
+
{ 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' },
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
export function getOAuthConfig() {
|
|
357
|
+
const saved = kvGet('oauthConfig') || {};
|
|
358
|
+
const out = {};
|
|
359
|
+
for (const p of OAUTH_PROVIDERS) {
|
|
360
|
+
const envPrefix = `OAUTH_${p.id.toUpperCase()}_`;
|
|
361
|
+
const cur = saved[p.id] || {};
|
|
362
|
+
const clientId = String(cur.clientId || process.env[`${envPrefix}CLIENT_ID`] || '').trim();
|
|
363
|
+
const clientSecret = decryptSecret(cur.clientSecret || '') || String(process.env[`${envPrefix}CLIENT_SECRET`] || '').trim();
|
|
364
|
+
out[p.id] = { id: p.id, name: p.name, clientId, clientSecret, enabled: Boolean(clientId && clientSecret) };
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function publicOAuthConfig() {
|
|
370
|
+
const cfg = getOAuthConfig();
|
|
371
|
+
return Object.fromEntries(OAUTH_PROVIDERS.map((p) => [p.id, {
|
|
372
|
+
id: p.id,
|
|
373
|
+
name: p.name,
|
|
374
|
+
clientId: cfg[p.id].clientId,
|
|
375
|
+
hasSecret: Boolean(cfg[p.id].clientSecret),
|
|
376
|
+
enabled: cfg[p.id].enabled,
|
|
377
|
+
}]));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function saveOAuthConfig(input = {}) {
|
|
381
|
+
const prev = kvGet('oauthConfig') || {};
|
|
382
|
+
const next = { ...prev };
|
|
383
|
+
for (const p of OAUTH_PROVIDERS) {
|
|
384
|
+
const b = input[p.id];
|
|
385
|
+
if (!b) continue;
|
|
386
|
+
const cur = prev[p.id] || {};
|
|
387
|
+
next[p.id] = {
|
|
388
|
+
clientId: String(b.clientId ?? cur.clientId ?? '').trim(),
|
|
389
|
+
clientSecret: b.clearSecret ? '' : (b.clientSecret && String(b.clientSecret).trim() ? encryptSecret(b.clientSecret) : (cur.clientSecret || '')),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
kvSet('oauthConfig', next);
|
|
393
|
+
return publicOAuthConfig();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const oauthStates = new Map(); // state -> { provider, plan, createdAt, origin }
|
|
397
|
+
|
|
398
|
+
export function startOAuthRedirect(providerId, { origin = 'http://localhost:3000', plan = '' } = {}) {
|
|
399
|
+
const meta = OAUTH_PROVIDERS.find((p) => p.id === providerId);
|
|
400
|
+
if (!meta) throw new Error(`Неизвестный провайдер: ${providerId}`);
|
|
401
|
+
const cfg = getOAuthConfig()[providerId];
|
|
402
|
+
if (!cfg?.enabled) {
|
|
403
|
+
throw new Error(`Вход через «${meta.name}» ещё не подключён администратором (укажите Client ID и Secret в Настройках → Профиль, тариф и боты)`);
|
|
404
|
+
}
|
|
405
|
+
// очистка старых state
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
for (const [k, v] of oauthStates) if (now - v.createdAt > 15 * 60000) oauthStates.delete(k);
|
|
408
|
+
|
|
409
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
410
|
+
const redirectUri = `${origin.replace(/\/+$/, '')}/api/auth/oauth/${providerId}/callback`;
|
|
411
|
+
oauthStates.set(state, { provider: providerId, plan: String(plan || ''), createdAt: now, redirectUri });
|
|
412
|
+
|
|
413
|
+
const params = new URLSearchParams({
|
|
414
|
+
client_id: cfg.clientId,
|
|
415
|
+
redirect_uri: redirectUri,
|
|
416
|
+
response_type: 'code',
|
|
417
|
+
scope: meta.scope,
|
|
418
|
+
state,
|
|
419
|
+
});
|
|
420
|
+
if (providerId === 'sber') params.set('nonce', crypto.randomUUID());
|
|
421
|
+
return `${meta.authUrl}?${params.toString()}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function decodeJwtPayload(jwt) {
|
|
425
|
+
try {
|
|
426
|
+
const part = String(jwt || '').split('.')[1];
|
|
427
|
+
if (!part) return null;
|
|
428
|
+
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function completeOAuthCallback(providerId, { code, state }) {
|
|
435
|
+
const st = oauthStates.get(String(state || ''));
|
|
436
|
+
oauthStates.delete(String(state || ''));
|
|
437
|
+
if (!st || st.provider !== providerId) throw new Error('Сессия авторизации истекла — попробуйте войти ещё раз');
|
|
438
|
+
if (!code) throw new Error('Провайдер не передал код авторизации');
|
|
439
|
+
|
|
440
|
+
const meta = OAUTH_PROVIDERS.find((p) => p.id === providerId);
|
|
441
|
+
const cfg = getOAuthConfig()[providerId];
|
|
442
|
+
if (!meta || !cfg?.enabled) throw new Error('Провайдер не настроен');
|
|
443
|
+
|
|
444
|
+
const body = new URLSearchParams({
|
|
445
|
+
grant_type: 'authorization_code',
|
|
446
|
+
code: String(code),
|
|
447
|
+
client_id: cfg.clientId,
|
|
448
|
+
client_secret: cfg.clientSecret,
|
|
449
|
+
redirect_uri: st.redirectUri,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const tokRes = await fetch(meta.tokenUrl, {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
455
|
+
body: body.toString(),
|
|
456
|
+
signal: AbortSignal.timeout(15000),
|
|
457
|
+
});
|
|
458
|
+
const tokJson = await tokRes.json().catch(() => ({}));
|
|
459
|
+
if (!tokRes.ok || (!tokJson.access_token && !tokJson.id_token)) {
|
|
460
|
+
throw new Error(tokJson.error_description || tokJson.error || `Ошибка обмена токена ${meta.name} (HTTP ${tokRes.status})`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let email = '';
|
|
464
|
+
let name = '';
|
|
465
|
+
let extId = '';
|
|
466
|
+
|
|
467
|
+
if (providerId === 'vk') {
|
|
468
|
+
email = String(tokJson.email || '').trim().toLowerCase();
|
|
469
|
+
extId = String(tokJson.user_id || '');
|
|
470
|
+
if (tokJson.access_token && extId) {
|
|
471
|
+
const uRes = await fetch(`${meta.userUrl}&access_token=${encodeURIComponent(tokJson.access_token)}&user_ids=${encodeURIComponent(extId)}`).then((r) => r.json()).catch(() => null);
|
|
472
|
+
const u0 = uRes?.response?.[0];
|
|
473
|
+
if (u0) name = `${u0.first_name || ''} ${u0.last_name || ''}`.trim();
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
if (tokJson.id_token) {
|
|
477
|
+
const claims = decodeJwtPayload(tokJson.id_token);
|
|
478
|
+
if (claims) {
|
|
479
|
+
email = String(claims.email || '').trim().toLowerCase();
|
|
480
|
+
name = String(claims.name || claims.given_name || '').trim();
|
|
481
|
+
extId = String(claims.sub || '');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if ((!email || !name) && tokJson.access_token && meta.userUrl) {
|
|
485
|
+
const uRes = await fetch(meta.userUrl, {
|
|
486
|
+
headers: { Authorization: `Bearer ${tokJson.access_token}`, Accept: 'application/json' },
|
|
487
|
+
signal: AbortSignal.timeout(12000),
|
|
488
|
+
}).then((r) => r.json()).catch(() => null);
|
|
489
|
+
if (uRes) {
|
|
490
|
+
email = email || String(uRes.default_email || uRes.email || uRes.emails?.[0] || '').trim().toLowerCase();
|
|
491
|
+
name = name || String(uRes.real_name || uRes.display_name || uRes.name || `${uRes.first_name || ''} ${uRes.last_name || ''}`).trim();
|
|
492
|
+
extId = extId || String(uRes.id || uRes.sub || uRes.psuid || '');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (!email) {
|
|
498
|
+
email = `${providerId}_${extId || crypto.randomBytes(4).toString('hex')}@oauth.neuroalpha.local`;
|
|
499
|
+
}
|
|
500
|
+
if (!name) {
|
|
501
|
+
name = email.split('@')[0];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
|
505
|
+
if (!user) {
|
|
506
|
+
const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
507
|
+
const apiKey = generateApiKey();
|
|
508
|
+
const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
|
|
509
|
+
const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, notify_code, api_key, created_at)
|
|
510
|
+
VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?)`).run(email, name, rndPass, notifyCode, apiKey, Date.now());
|
|
511
|
+
user = getUserById(Number(r.lastInsertRowid));
|
|
512
|
+
} else if (!user.api_key) {
|
|
513
|
+
ensureApiKey(user.id);
|
|
514
|
+
user = getUserById(user.id);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { user, plan: st.plan };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function verifyTelegramLoginWidget(query = {}, botToken = '') {
|
|
521
|
+
const { hash, ...data } = query;
|
|
522
|
+
if (!hash || !botToken) throw new Error('Некорректные данные авторизации Telegram');
|
|
523
|
+
const checkString = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n');
|
|
524
|
+
const secretKey = crypto.createHash('sha256').update(botToken).digest();
|
|
525
|
+
const hmac = crypto.createHmac('sha256', secretKey).update(checkString).digest('hex');
|
|
526
|
+
if (hmac !== String(hash)) throw new Error('Подпись Telegram не прошла проверку');
|
|
527
|
+
const tgId = String(data.id || '');
|
|
528
|
+
const name = `${data.first_name || ''} ${data.last_name || ''}`.trim() || data.username || `Telegram ${tgId}`;
|
|
529
|
+
const email = data.username ? `${String(data.username).toLowerCase()}@tg.neuroalpha.local` : `tg_${tgId}@tg.neuroalpha.local`;
|
|
530
|
+
|
|
531
|
+
let user = db.prepare('SELECT * FROM users WHERE tg_chat_id = ? OR email = ?').get(tgId, email);
|
|
532
|
+
if (!user) {
|
|
533
|
+
const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
|
|
534
|
+
const apiKey = generateApiKey();
|
|
535
|
+
const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
|
|
536
|
+
const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, tg_chat_id, notify_code, api_key, created_at)
|
|
537
|
+
VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?, ?)`).run(email, name, rndPass, tgId, notifyCode, apiKey, Date.now());
|
|
538
|
+
user = getUserById(Number(r.lastInsertRowid));
|
|
539
|
+
} else if (!user.tg_chat_id) {
|
|
540
|
+
db.prepare('UPDATE users SET tg_chat_id = ? WHERE id = ?').run(tgId, user.id);
|
|
541
|
+
user = getUserById(user.id);
|
|
542
|
+
}
|
|
543
|
+
return user;
|
|
544
|
+
}
|