vimp-engine 0.37.1 → 0.38.0
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 +1 -1
- package/src/client/boot.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +97 -15
- package/src/client/components/controller/Profile.js +85 -0
- package/src/client/components/model/Games.js +1 -1
- package/src/client/components/model/LobbyAuth.js +344 -115
- package/src/client/components/model/Profile.js +142 -0
- package/src/client/components/view/LobbyAuth.js +150 -62
- package/src/client/components/view/Profile.js +373 -0
- package/src/client/lib/authApi.js +97 -0
- package/src/client/lib/pkce.js +23 -0
- package/src/client/lib/roomLink.js +1 -1
- package/src/client/lib/signalingErrors.js +3 -3
- package/src/client/lib/turnstile.js +130 -0
- package/src/client/main.js +89 -24
- package/src/client/network/TokenRenewer.js +90 -0
- package/src/client/session/GuestSession.js +63 -17
- package/src/client/style.css +119 -16
- package/src/client/views/includes/lobby.pug +11 -6
- package/src/client/views/includes/lobbyAuth.pug +34 -16
- package/src/client/views/includes/profile.pug +73 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/authClient.js +54 -30
- package/src/config/chatMessages.js +1 -2
- package/src/config/env.js +2 -2
- package/src/config/lobby.js +45 -0
- package/src/config/master.js +8 -5
- package/src/config/wsports.js +4 -1
- package/src/devtools/builtinScenario.js +1 -1
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +1 -1
- package/src/host/GameCoreAdapter.js +0 -3
- package/src/host/HostGame.js +28 -0
- package/src/host/PortMachine.js +50 -1
- package/src/host/meta/SocketManager.js +0 -13
- package/src/host/meta/core/RoundManager.js +0 -19
- package/src/host/meta/modules/PlayerDataSync.js +29 -3
- package/src/host/meta/modules/chat/systemMessages.js +1 -4
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/validators.js +8 -0
- package/src/standalone/index.js +1 -1
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
2
|
import { decodeJwtPayload } from '../../../lib/jwt.js';
|
|
3
|
+
import { isValidNick } from '../../../lib/validators.js';
|
|
3
4
|
|
|
4
5
|
// Singleton LobbyAuthModel
|
|
5
6
|
|
|
6
7
|
let lobbyAuthModel;
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
const SESSION_RE = /^s_[A-Za-z0-9_-]{43}$/;
|
|
10
|
+
|
|
11
|
+
// Модель профиля лобби: регистрация ника, сессия устройства, identity-токен
|
|
12
|
+
// (в памяти вкладки), возврат с OAuth (Restore и привязка). Сетевые вызовы —
|
|
13
|
+
// через инъектированный authApi, PKCE — через pkce.
|
|
14
|
+
//
|
|
15
|
+
// События: loading, welcome { notice }, authenticated { nick, role, profile },
|
|
16
|
+
// register-error <код>, restore-error <код>, service-error,
|
|
17
|
+
// token-renewed { expiresAt }, session-lost, profile-changed <profile>.
|
|
13
18
|
export default class LobbyAuthModel {
|
|
14
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} config - config/authClient.js.
|
|
21
|
+
* @param {Object} deps
|
|
22
|
+
* @param {Object} deps.api - client/lib/authApi.js.
|
|
23
|
+
* @param {Object} deps.pkce - { createVerifier, challengeOf }.
|
|
24
|
+
* @param {Storage} [deps.storage] - секрет сессии.
|
|
25
|
+
* @param {Storage} [deps.sessionStore] - попытка OAuth, уведомление.
|
|
26
|
+
* @param {boolean} [deps.isDev] - принимать ?devSession= (dev-сборка).
|
|
27
|
+
* @param {Function} [deps.getLocation]
|
|
28
|
+
*/
|
|
29
|
+
constructor(
|
|
30
|
+
config,
|
|
31
|
+
{
|
|
32
|
+
api,
|
|
33
|
+
pkce,
|
|
34
|
+
storage = localStorage,
|
|
35
|
+
sessionStore = sessionStorage,
|
|
36
|
+
isDev = false,
|
|
37
|
+
getLocation = () => window.location,
|
|
38
|
+
} = {},
|
|
39
|
+
) {
|
|
15
40
|
if (lobbyAuthModel) {
|
|
16
41
|
return lobbyAuthModel;
|
|
17
42
|
}
|
|
@@ -19,167 +44,371 @@ export default class LobbyAuthModel {
|
|
|
19
44
|
lobbyAuthModel = this;
|
|
20
45
|
|
|
21
46
|
this._config = config;
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
24
|
-
this.
|
|
47
|
+
this._api = api;
|
|
48
|
+
this._pkce = pkce;
|
|
49
|
+
this._storage = storage;
|
|
50
|
+
this._sessionStore = sessionStore;
|
|
51
|
+
this._isDev = isDev;
|
|
52
|
+
this._getLocation = getLocation;
|
|
53
|
+
|
|
54
|
+
this._session = null;
|
|
55
|
+
this._token = null;
|
|
56
|
+
this._profile = null;
|
|
57
|
+
this._clientConfig = null;
|
|
58
|
+
this._urlParams = { code: null, error: null, devSession: null };
|
|
59
|
+
this._linkResult = null;
|
|
60
|
+
this._refreshing = null;
|
|
25
61
|
|
|
26
62
|
this.publisher = new Publisher();
|
|
27
63
|
}
|
|
28
64
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const {
|
|
65
|
+
// читает параметры возврата с OAuth и (в dev-сборке) dev-сессию; true —
|
|
66
|
+
// что-то найдено, вызывающий чистит адресную строку. Ищет только в search:
|
|
67
|
+
// колбэк auth собирает адрес через URL.searchParams, поэтому параметры
|
|
68
|
+
// стоят до #, а hash — маршрут #/<gameId>[/<roomId>] (его разбирает RouteBoot)
|
|
69
|
+
takeUrlParams(search) {
|
|
70
|
+
const { oauth, devSessionParam } = this._config;
|
|
35
71
|
const params = new URLSearchParams(search);
|
|
36
72
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
73
|
+
this._urlParams = {
|
|
74
|
+
code: params.get(oauth.codeParam),
|
|
75
|
+
error: params.get(oauth.errorParam),
|
|
76
|
+
devSession: this._isDev ? params.get(devSessionParam) : null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const { code, error, devSession } = this._urlParams;
|
|
80
|
+
|
|
81
|
+
return Boolean(code || error || devSession);
|
|
82
|
+
}
|
|
40
83
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.
|
|
44
|
-
|
|
84
|
+
async boot() {
|
|
85
|
+
try {
|
|
86
|
+
await this._boot();
|
|
87
|
+
} catch {
|
|
88
|
+
// иначе экран навсегда остался бы на «Loading…» без Retry
|
|
89
|
+
this.publisher.emit('service-error');
|
|
45
90
|
}
|
|
91
|
+
}
|
|
46
92
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
async _boot() {
|
|
94
|
+
const { sessionStorageKey } = this._config;
|
|
95
|
+
const { devSession } = this._urlParams;
|
|
96
|
+
|
|
97
|
+
if (devSession && SESSION_RE.test(devSession)) {
|
|
98
|
+
this._storage.setItem(sessionStorageKey, devSession);
|
|
50
99
|
}
|
|
51
100
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
101
|
+
this.publisher.emit('loading');
|
|
102
|
+
|
|
103
|
+
const cfg = await this._api.clientConfig();
|
|
104
|
+
|
|
105
|
+
if (!cfg.ok) {
|
|
106
|
+
this.publisher.emit('service-error');
|
|
107
|
+
return;
|
|
56
108
|
}
|
|
57
109
|
|
|
58
|
-
this.
|
|
59
|
-
|
|
110
|
+
this._clientConfig = cfg.data;
|
|
111
|
+
|
|
112
|
+
// попытка живёт один возврат: любой исход её закрывает
|
|
113
|
+
const attempt = this._takeAttempt();
|
|
114
|
+
const { code, error } = this._urlParams;
|
|
115
|
+
|
|
116
|
+
this._urlParams = { code: null, error: null, devSession: null };
|
|
117
|
+
|
|
118
|
+
const session = this._storage.getItem(sessionStorageKey);
|
|
119
|
+
|
|
120
|
+
if (!session) {
|
|
121
|
+
await this._bootWithoutSession(attempt, code, error);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this._session = session;
|
|
126
|
+
|
|
127
|
+
const res = await this._api.token(session);
|
|
128
|
+
|
|
129
|
+
if (res.ok) {
|
|
130
|
+
this._adopt({ token: res.data.token, profile: res.data.profile });
|
|
131
|
+
await this._finishLink(attempt, code, error);
|
|
132
|
+
this._emitAuthenticated();
|
|
133
|
+
} else if (res.status === 401) {
|
|
134
|
+
this._forgetSession();
|
|
135
|
+
this.publisher.emit('welcome', { notice: 'profileGone' });
|
|
136
|
+
} else {
|
|
137
|
+
// код OAuth одноразовый: Retry доведёт привязку, а не потеряет её
|
|
138
|
+
this._restoreAttempt(attempt, code, error);
|
|
139
|
+
this.publisher.emit('service-error');
|
|
140
|
+
}
|
|
60
141
|
}
|
|
61
142
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
143
|
+
async _bootWithoutSession(attempt, code, error) {
|
|
144
|
+
const notice = this._takeNotice();
|
|
145
|
+
const restoring = attempt?.mode === 'restore';
|
|
146
|
+
|
|
147
|
+
if (restoring && code) {
|
|
148
|
+
const res = await this._api.restore({
|
|
149
|
+
code,
|
|
150
|
+
verifier: attempt.verifier,
|
|
151
|
+
termsVersion: this._clientConfig.legal.version,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (res.ok) {
|
|
155
|
+
this._adopt(res.data);
|
|
156
|
+
this._emitAuthenticated();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.publisher.emit('welcome', { notice });
|
|
161
|
+
this.publisher.emit('restore-error', res.error);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
69
164
|
|
|
70
|
-
|
|
165
|
+
this.publisher.emit('welcome', { notice });
|
|
166
|
+
|
|
167
|
+
if (restoring && error) {
|
|
168
|
+
this.publisher.emit(
|
|
169
|
+
'restore-error',
|
|
170
|
+
error === 'denied' ? 'denied' : 'failed',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
71
173
|
}
|
|
72
174
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
175
|
+
// привязка завершается до authenticated, а не в панели профиля: код живёт
|
|
176
|
+
// 120 с и не должен ждать, пока откроется лобби
|
|
177
|
+
async _finishLink(attempt, code, error) {
|
|
178
|
+
if (attempt?.mode !== 'link') {
|
|
76
179
|
return;
|
|
77
180
|
}
|
|
78
181
|
|
|
79
|
-
|
|
80
|
-
let data;
|
|
182
|
+
const { provider } = attempt;
|
|
81
183
|
|
|
82
|
-
|
|
83
|
-
res = await
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Authorization: `Bearer ${this._pendingToken}`,
|
|
88
|
-
},
|
|
89
|
-
body: JSON.stringify({ nick }),
|
|
184
|
+
if (code) {
|
|
185
|
+
const res = await this._api.linkOauth(this._session, {
|
|
186
|
+
code,
|
|
187
|
+
verifier: attempt.verifier,
|
|
188
|
+
consentVersion: attempt.consentVersion,
|
|
90
189
|
});
|
|
91
|
-
|
|
190
|
+
|
|
191
|
+
if (res.ok) {
|
|
192
|
+
this._profile = res.data.profile;
|
|
193
|
+
this._linkResult = { ok: true, provider };
|
|
194
|
+
} else {
|
|
195
|
+
this._linkResult = { ok: false, provider, code: res.error };
|
|
196
|
+
}
|
|
197
|
+
} else if (error) {
|
|
198
|
+
this._linkResult = {
|
|
199
|
+
ok: false,
|
|
200
|
+
provider,
|
|
201
|
+
code: error === 'denied' ? 'denied' : 'failed',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_restoreAttempt(attempt, code, error) {
|
|
207
|
+
if (attempt) {
|
|
208
|
+
this._sessionStore.setItem(
|
|
209
|
+
this._config.oauth.pendingKey,
|
|
210
|
+
JSON.stringify(attempt),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this._urlParams = { code, error, devSession: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_takeAttempt() {
|
|
218
|
+
const key = this._config.oauth.pendingKey;
|
|
219
|
+
const raw = this._sessionStore.getItem(key);
|
|
220
|
+
|
|
221
|
+
this._sessionStore.removeItem(key);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
return raw ? JSON.parse(raw) : null;
|
|
92
225
|
} catch {
|
|
93
|
-
|
|
94
|
-
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_takeNotice() {
|
|
231
|
+
const key = this._config.noticeKey;
|
|
232
|
+
const notice = this._sessionStore.getItem(key);
|
|
233
|
+
|
|
234
|
+
this._sessionStore.removeItem(key);
|
|
235
|
+
|
|
236
|
+
return notice || null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// принять личность: секрет сессии (если пришёл) — в хранилище, токен и
|
|
240
|
+
// профиль — в память
|
|
241
|
+
_adopt({ session, token, profile }) {
|
|
242
|
+
if (session) {
|
|
243
|
+
this._session = session;
|
|
244
|
+
this._storage.setItem(this._config.sessionStorageKey, session);
|
|
95
245
|
}
|
|
96
246
|
|
|
247
|
+
this._token = token;
|
|
248
|
+
this._profile = profile;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_emitAuthenticated() {
|
|
252
|
+
this.publisher.emit('authenticated', {
|
|
253
|
+
nick: this.getNick(),
|
|
254
|
+
role: this.getRole(),
|
|
255
|
+
profile: this._profile,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_forgetSession() {
|
|
260
|
+
this._storage.removeItem(this._config.sessionStorageKey);
|
|
261
|
+
this._session = null;
|
|
262
|
+
this._token = null;
|
|
263
|
+
this._profile = null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
validateNick(nick) {
|
|
267
|
+
return isValidNick(nick);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async register(nick, turnstileToken) {
|
|
271
|
+
const res = await this._api.register({
|
|
272
|
+
nick,
|
|
273
|
+
turnstileToken,
|
|
274
|
+
termsVersion: this._clientConfig.legal.version,
|
|
275
|
+
});
|
|
276
|
+
|
|
97
277
|
if (!res.ok) {
|
|
98
|
-
this.publisher.emit('
|
|
99
|
-
return;
|
|
278
|
+
this.publisher.emit('register-error', res.error);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this._adopt(res.data);
|
|
283
|
+
this._emitAuthenticated();
|
|
284
|
+
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// URL старта OAuth для Restore; попытка (verifier) остаётся в
|
|
289
|
+
// sessionStorage вкладки, на auth уходит только challenge
|
|
290
|
+
async restoreUrl(provider) {
|
|
291
|
+
return this._oauthUrl('restore', provider);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// то же для привязки из панели профиля; consentVersion — версия
|
|
295
|
+
// согласий, принятых чекбоксами перед переходом
|
|
296
|
+
async linkUrl(provider, consentVersion) {
|
|
297
|
+
return this._oauthUrl('link', provider, { consentVersion });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async _oauthUrl(mode, provider, extra = {}) {
|
|
301
|
+
const verifier = this._pkce.createVerifier();
|
|
302
|
+
const challenge = await this._pkce.challengeOf(verifier);
|
|
303
|
+
const { origin, pathname, hash } = this._getLocation();
|
|
304
|
+
|
|
305
|
+
this._sessionStore.setItem(
|
|
306
|
+
this._config.oauth.pendingKey,
|
|
307
|
+
JSON.stringify({ mode, provider, verifier, ...extra }),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// hash (#/<gameId>[/<roomId>]) едет в returnUrl: после возврата игрок
|
|
311
|
+
// попадает туда, куда вёл открытый адрес
|
|
312
|
+
return this._api.oauthStartUrl(provider, {
|
|
313
|
+
mode,
|
|
314
|
+
returnUrl: `${origin}${pathname}${hash}`,
|
|
315
|
+
challenge,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// продление identity-токена по сессии; параллельные вызовы делят запрос
|
|
320
|
+
refresh() {
|
|
321
|
+
if (!this._refreshing) {
|
|
322
|
+
this._refreshing = this._refresh().finally(() => {
|
|
323
|
+
this._refreshing = null;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return this._refreshing;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async _refresh() {
|
|
331
|
+
if (!this._session) {
|
|
332
|
+
return 'lost';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const res = await this._api.token(this._session);
|
|
336
|
+
|
|
337
|
+
if (res.ok) {
|
|
338
|
+
this._token = res.data.token;
|
|
339
|
+
this._profile = res.data.profile;
|
|
340
|
+
this.publisher.emit('token-renewed', {
|
|
341
|
+
expiresAt: this.getTokenExpiresAt(),
|
|
342
|
+
});
|
|
343
|
+
return 'ok';
|
|
100
344
|
}
|
|
101
345
|
|
|
102
|
-
|
|
103
|
-
|
|
346
|
+
if (res.status === 401) {
|
|
347
|
+
this._forgetSession();
|
|
348
|
+
this.publisher.emit('session-lost');
|
|
349
|
+
return 'lost';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return 'retry';
|
|
104
353
|
}
|
|
105
354
|
|
|
106
|
-
|
|
107
|
-
|
|
355
|
+
// итог привязки, завершённой в boot(); отдаётся один раз
|
|
356
|
+
takeLinkResult() {
|
|
357
|
+
const result = this._linkResult;
|
|
358
|
+
|
|
359
|
+
this._linkResult = null;
|
|
108
360
|
|
|
109
|
-
|
|
110
|
-
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
setProfile(profile) {
|
|
365
|
+
this._profile = profile;
|
|
366
|
+
this.publisher.emit('profile-changed', profile);
|
|
367
|
+
}
|
|
111
368
|
|
|
112
|
-
|
|
369
|
+
// выход и удаление профиля (панель профиля)
|
|
370
|
+
clearSession() {
|
|
371
|
+
this._forgetSession();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// уведомление для экрана Welcome после перезагрузки страницы
|
|
375
|
+
setNotice(code) {
|
|
376
|
+
this._sessionStore.setItem(this._config.noticeKey, code);
|
|
113
377
|
}
|
|
114
378
|
|
|
115
379
|
getToken() {
|
|
116
|
-
return this.
|
|
380
|
+
return this._token;
|
|
117
381
|
}
|
|
118
382
|
|
|
119
383
|
getNick() {
|
|
120
|
-
return this.
|
|
384
|
+
return this._profile?.nick ?? null;
|
|
121
385
|
}
|
|
122
386
|
|
|
123
|
-
// роль из payload
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// auth-сервис, перечитывая роль из БД
|
|
387
|
+
// роль из payload токена — ПОДСКАЗКА ИНТЕРФЕЙСУ (показывать ли кнопку
|
|
388
|
+
// модерации), не право: доступ к данным проверяет мастер по подписи, а
|
|
389
|
+
// запись — auth-сервис, перечитывая роль из БД
|
|
127
390
|
getRole() {
|
|
128
|
-
return decodeJwtPayload(this.
|
|
391
|
+
return decodeJwtPayload(this._token)?.role ?? 'user';
|
|
129
392
|
}
|
|
130
393
|
|
|
131
394
|
// срок входа (мс эпохи) или null — неизвестен. Вкладка с кончающимся
|
|
132
395
|
// токеном не принимает роль хоста: register_host/reclaim_host предъявят
|
|
133
|
-
// его мастеру посреди матча
|
|
396
|
+
// его мастеру посреди матча
|
|
134
397
|
getTokenExpiresAt() {
|
|
135
|
-
const exp = decodeJwtPayload(this.
|
|
398
|
+
const exp = decodeJwtPayload(this._token)?.exp;
|
|
136
399
|
|
|
137
400
|
return typeof exp === 'number' ? exp * 1000 : null;
|
|
138
401
|
}
|
|
139
402
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (token) {
|
|
144
|
-
this._setIdentity(token, tokenStorageKey, false);
|
|
145
|
-
} else {
|
|
146
|
-
this.publisher.emit('login-required', this._config.providers);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// payload JWT не проверяется подписью на клиенте (только для отображения
|
|
151
|
-
// ника) — авторитетная проверка по /jwks на хосте (Этап B3). exp
|
|
152
|
-
// проверяется (F5 кодревью) — иначе восстановленная из localStorage сессия
|
|
153
|
-
// показывает «залогинен» токеном, который хост уже отклонит при входе в игру
|
|
154
|
-
_setIdentity(token, tokenStorageKey, persist = true) {
|
|
155
|
-
const payload = decodeJwtPayload(token);
|
|
156
|
-
const isExpired =
|
|
157
|
-
typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
|
|
158
|
-
|
|
159
|
-
if (!payload || !payload.nick || isExpired) {
|
|
160
|
-
delete localStorage[tokenStorageKey];
|
|
161
|
-
|
|
162
|
-
// Тихое восстановление из localStorage (persist === false): просроченный/
|
|
163
|
-
// битый токен — штатная ситуация возврата на сайт, не ошибка. login-error
|
|
164
|
-
// — только на интерактивном пути.
|
|
165
|
-
if (persist) {
|
|
166
|
-
this.publisher.emit(
|
|
167
|
-
'login-error',
|
|
168
|
-
isExpired ? 'tokenExpired' : 'invalidToken',
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
this.publisher.emit('login-required', this._config.providers);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
this._identityToken = token;
|
|
177
|
-
this._nick = payload.nick;
|
|
403
|
+
getProfile() {
|
|
404
|
+
return this._profile;
|
|
405
|
+
}
|
|
178
406
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
407
|
+
getClientConfig() {
|
|
408
|
+
return this._clientConfig;
|
|
409
|
+
}
|
|
182
410
|
|
|
183
|
-
|
|
411
|
+
getSession() {
|
|
412
|
+
return this._session;
|
|
184
413
|
}
|
|
185
414
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
|
|
3
|
+
// Singleton ProfileModel
|
|
4
|
+
|
|
5
|
+
let profileModel;
|
|
6
|
+
|
|
7
|
+
// Модель панели профиля: привязка и отвязка OAuth, выход, выгрузка данных,
|
|
8
|
+
// удаление. Личность и сессию держит LobbyAuthModel, сеть — authApi; сама
|
|
9
|
+
// модель только оркестрирует и сообщает исход событиями.
|
|
10
|
+
//
|
|
11
|
+
// События: profile <profile>, busy <boolean>, export { data, fileName },
|
|
12
|
+
// left (выход или удаление — вкладка меняет личность),
|
|
13
|
+
// error { area: 'link' | 'session' | 'export' | 'delete', code }.
|
|
14
|
+
export default class ProfileModel {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} config - Блок `profile` конфига лобби.
|
|
17
|
+
* @param {Object} deps
|
|
18
|
+
* @param {Object} deps.auth - LobbyAuthModel.
|
|
19
|
+
* @param {Object} deps.api - client/lib/authApi.js.
|
|
20
|
+
*/
|
|
21
|
+
constructor(config, { auth, api }) {
|
|
22
|
+
if (profileModel) {
|
|
23
|
+
return profileModel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
profileModel = this;
|
|
27
|
+
|
|
28
|
+
this._config = config;
|
|
29
|
+
this._auth = auth;
|
|
30
|
+
this._api = api;
|
|
31
|
+
this._busy = false;
|
|
32
|
+
|
|
33
|
+
this.publisher = new Publisher();
|
|
34
|
+
|
|
35
|
+
auth.publisher.on('profile-changed', profile =>
|
|
36
|
+
this.publisher.emit('profile', profile),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getProfile() {
|
|
41
|
+
return this._auth.getProfile();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getClientConfig() {
|
|
45
|
+
return this._auth.getClientConfig();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// версия согласий — версия условий из /client-config: чекбоксы панели
|
|
49
|
+
// принимают именно их
|
|
50
|
+
async linkUrl(provider) {
|
|
51
|
+
return this._auth.linkUrl(provider, this.getClientConfig().legal.version);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
takeLinkResult() {
|
|
55
|
+
return this._auth.takeLinkResult();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async unlink(provider) {
|
|
59
|
+
const data = await this._run('link', () =>
|
|
60
|
+
this._api.unlinkOauth(this._auth.getSession(), provider),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (data) {
|
|
64
|
+
this._auth.setProfile(data.profile);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async signOut() {
|
|
69
|
+
const data = await this._run('session', () =>
|
|
70
|
+
this._api.logout(this._auth.getSession()),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (data !== undefined) {
|
|
74
|
+
this._leave('signedOut');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteProfile(confirmNick) {
|
|
79
|
+
const data = await this._run('delete', () =>
|
|
80
|
+
this._api.deleteAccount(this._auth.getSession(), confirmNick),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (data !== undefined) {
|
|
84
|
+
this._leave('deleted');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async exportData() {
|
|
89
|
+
const data = await this._run('export', () =>
|
|
90
|
+
this._api.exportData(this._auth.getSession()),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (data) {
|
|
94
|
+
this.publisher.emit('export', {
|
|
95
|
+
data,
|
|
96
|
+
fileName: this._config.exportFileName(this.getProfile().nick),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// сессия стёрта, уведомление ждёт экран Welcome: дальше вкладка
|
|
102
|
+
// перезагружается (решение ◆13), состояние по частям не чистится
|
|
103
|
+
_leave(notice) {
|
|
104
|
+
this._auth.clearSession();
|
|
105
|
+
this._auth.setNotice(notice);
|
|
106
|
+
this.publisher.emit('left');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// один запрос за раз; успех — data тела ответа (null у 204); отказ —
|
|
110
|
+
// undefined и событие error, а при 401 (сессия мертва: профиль удалён в
|
|
111
|
+
// другой вкладке, срок вышел) — вместо error событие left с уведомлением
|
|
112
|
+
// profileGone, уже после снятия busy; запрос во время другого не стартует
|
|
113
|
+
async _run(area, request) {
|
|
114
|
+
if (this._busy) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this._busy = true;
|
|
119
|
+
this.publisher.emit('busy', true);
|
|
120
|
+
|
|
121
|
+
let res;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
res = await request();
|
|
125
|
+
} finally {
|
|
126
|
+
this._busy = false;
|
|
127
|
+
this.publisher.emit('busy', false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (res.ok) {
|
|
131
|
+
return res.data;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (res.status === 401) {
|
|
135
|
+
this._leave('profileGone');
|
|
136
|
+
} else {
|
|
137
|
+
this.publisher.emit('error', { area, code: res.error });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|