vimp-engine 0.38.0 → 0.38.1
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/components/controller/ClientReports.js +2 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +6 -2
- package/src/client/components/controller/Profile.js +13 -3
- package/src/client/components/model/LobbyAuth.js +56 -18
- package/src/client/components/model/Profile.js +6 -4
- package/src/client/components/view/LobbyAuth.js +7 -7
- package/src/client/components/view/Profile.js +0 -1
- package/src/client/lib/authApi.js +9 -0
- package/src/client/lib/hostCaps.js +3 -2
- package/src/client/lib/sessionSync.js +45 -0
- package/src/client/main.js +44 -3
- package/src/client/network/TokenRenewer.js +16 -7
- package/src/client/session/Membership.js +2 -1
- package/src/client/style.css +17 -8
- package/src/client/views/includes/lobbyAuth.pug +12 -12
- package/src/config/authClient.js +2 -1
- package/src/config/lobby.js +2 -3
package/package.json
CHANGED
|
@@ -25,7 +25,8 @@ export default class ClientReportsCtrl {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// роль решает только видимость кнопки «Errors»: доступ к данным проверяет
|
|
28
|
-
// мастер, а auth
|
|
28
|
+
// мастер, а права админа auth-сервис проверяет сам, по нику из
|
|
29
|
+
// VIMP_ADMIN_NICKS
|
|
29
30
|
setAdmin(isAdmin) {
|
|
30
31
|
this._view.setAdmin(isAdmin);
|
|
31
32
|
}
|
|
@@ -42,7 +42,7 @@ export default class GamesCtrl {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// роль решает только видимость кнопки модерации: доступ к данным
|
|
45
|
-
// проверяет мастер, а запись — auth
|
|
45
|
+
// проверяет мастер, а запись — auth-сервис, по нику из VIMP_ADMIN_NICKS
|
|
46
46
|
setAdmin(isAdmin) {
|
|
47
47
|
this._view.setAdmin(isAdmin);
|
|
48
48
|
|
|
@@ -101,8 +101,11 @@ export default class LobbyAuthCtrl {
|
|
|
101
101
|
return this.register(this._view.getNickValue());
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// флаг закрывает только построение URL (хэш verifier считается
|
|
105
|
+
// асинхронно): два построения подряд — лишняя работа. После navigate() он
|
|
106
|
+
// снят — отменённый переход или возврат кнопкой «Назад» не должен
|
|
107
|
+
// оставить экран без регистрации и входа
|
|
104
108
|
async restore(provider) {
|
|
105
|
-
// двойной клик перезаписал бы verifier попытки, уже ушедшей на auth
|
|
106
109
|
if (this._busy) {
|
|
107
110
|
return;
|
|
108
111
|
}
|
|
@@ -112,8 +115,9 @@ export default class LobbyAuthCtrl {
|
|
|
112
115
|
try {
|
|
113
116
|
this._navigate(await this._model.restoreUrl(provider));
|
|
114
117
|
} catch {
|
|
115
|
-
this._busy = false;
|
|
116
118
|
this._view.renderRestoreError('failed');
|
|
119
|
+
} finally {
|
|
120
|
+
this._busy = false;
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -32,6 +32,7 @@ export default class ProfileCtrl {
|
|
|
32
32
|
this._model = model;
|
|
33
33
|
this._view = view;
|
|
34
34
|
this._navigate = navigate;
|
|
35
|
+
this._linking = false;
|
|
35
36
|
|
|
36
37
|
this.publisher = new Publisher();
|
|
37
38
|
|
|
@@ -52,13 +53,22 @@ export default class ProfileCtrl {
|
|
|
52
53
|
this._view.show();
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
// кнопки вида неактивны без согласий; проверка здесь — второй
|
|
56
|
+
// кнопки вида неактивны без согласий; проверка здесь — второй рубеж.
|
|
57
|
+
// Флаг закрывает только построение URL — как у LobbyAuthCtrl.restore()
|
|
56
58
|
async link(provider) {
|
|
57
|
-
if (!this._view.consentGiven()) {
|
|
59
|
+
if (this._linking || !this._view.consentGiven()) {
|
|
58
60
|
return;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
this.
|
|
63
|
+
this._linking = true;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
this._navigate(await this._model.linkUrl(provider));
|
|
67
|
+
} catch {
|
|
68
|
+
this._view.renderError({ area: 'link', code: 'failed' });
|
|
69
|
+
} finally {
|
|
70
|
+
this._linking = false;
|
|
71
|
+
}
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
unlink(provider) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
2
|
import { decodeJwtPayload } from '../../../lib/jwt.js';
|
|
3
3
|
import { isValidNick } from '../../../lib/validators.js';
|
|
4
|
+
import { isSessionRejected } from '../../lib/authApi.js';
|
|
4
5
|
|
|
5
6
|
// Singleton LobbyAuthModel
|
|
6
7
|
|
|
@@ -25,6 +26,7 @@ export default class LobbyAuthModel {
|
|
|
25
26
|
* @param {Storage} [deps.sessionStore] - попытка OAuth, уведомление.
|
|
26
27
|
* @param {boolean} [deps.isDev] - принимать ?devSession= (dev-сборка).
|
|
27
28
|
* @param {Function} [deps.getLocation]
|
|
29
|
+
* @param {Function} [deps.now] - () → мс эпохи по часам устройства.
|
|
28
30
|
*/
|
|
29
31
|
constructor(
|
|
30
32
|
config,
|
|
@@ -35,6 +37,7 @@ export default class LobbyAuthModel {
|
|
|
35
37
|
sessionStore = sessionStorage,
|
|
36
38
|
isDev = false,
|
|
37
39
|
getLocation = () => window.location,
|
|
40
|
+
now = () => Date.now(),
|
|
38
41
|
} = {},
|
|
39
42
|
) {
|
|
40
43
|
if (lobbyAuthModel) {
|
|
@@ -50,9 +53,11 @@ export default class LobbyAuthModel {
|
|
|
50
53
|
this._sessionStore = sessionStore;
|
|
51
54
|
this._isDev = isDev;
|
|
52
55
|
this._getLocation = getLocation;
|
|
56
|
+
this._now = now;
|
|
53
57
|
|
|
54
58
|
this._session = null;
|
|
55
59
|
this._token = null;
|
|
60
|
+
this._tokenExpiresAt = null;
|
|
56
61
|
this._profile = null;
|
|
57
62
|
this._clientConfig = null;
|
|
58
63
|
this._urlParams = { code: null, error: null, devSession: null };
|
|
@@ -100,7 +105,15 @@ export default class LobbyAuthModel {
|
|
|
100
105
|
|
|
101
106
|
this.publisher.emit('loading');
|
|
102
107
|
|
|
103
|
-
|
|
108
|
+
// секрет читается до сети: настройки и токен уходят одновременно, лобби
|
|
109
|
+
// вернувшегося игрока открывается на круг запроса раньше
|
|
110
|
+
const stored = this._storage.getItem(sessionStorageKey);
|
|
111
|
+
// значение не того формата секретом не является: на auth не уходит
|
|
112
|
+
const session = stored && SESSION_RE.test(stored) ? stored : null;
|
|
113
|
+
const [cfg, res] = await Promise.all([
|
|
114
|
+
this._api.clientConfig(),
|
|
115
|
+
session ? this._api.token(session) : null,
|
|
116
|
+
]);
|
|
104
117
|
|
|
105
118
|
if (!cfg.ok) {
|
|
106
119
|
this.publisher.emit('service-error');
|
|
@@ -115,8 +128,6 @@ export default class LobbyAuthModel {
|
|
|
115
128
|
|
|
116
129
|
this._urlParams = { code: null, error: null, devSession: null };
|
|
117
130
|
|
|
118
|
-
const session = this._storage.getItem(sessionStorageKey);
|
|
119
|
-
|
|
120
131
|
if (!session) {
|
|
121
132
|
await this._bootWithoutSession(attempt, code, error);
|
|
122
133
|
return;
|
|
@@ -124,13 +135,14 @@ export default class LobbyAuthModel {
|
|
|
124
135
|
|
|
125
136
|
this._session = session;
|
|
126
137
|
|
|
127
|
-
const res = await this._api.token(session);
|
|
128
|
-
|
|
129
138
|
if (res.ok) {
|
|
130
139
|
this._adopt({ token: res.data.token, profile: res.data.profile });
|
|
140
|
+
// уведомление писала вкладка, потерявшая сессию в комнате; под принятой
|
|
141
|
+
// личностью оно ни к чему и не должно всплыть на Welcome позже
|
|
142
|
+
this._takeNotice();
|
|
131
143
|
await this._finishLink(attempt, code, error);
|
|
132
144
|
this._emitAuthenticated();
|
|
133
|
-
} else if (res
|
|
145
|
+
} else if (isSessionRejected(res)) {
|
|
134
146
|
this._forgetSession();
|
|
135
147
|
this.publisher.emit('welcome', { notice: 'profileGone' });
|
|
136
148
|
} else {
|
|
@@ -244,7 +256,7 @@ export default class LobbyAuthModel {
|
|
|
244
256
|
this._storage.setItem(this._config.sessionStorageKey, session);
|
|
245
257
|
}
|
|
246
258
|
|
|
247
|
-
this.
|
|
259
|
+
this._setToken(token);
|
|
248
260
|
this._profile = profile;
|
|
249
261
|
}
|
|
250
262
|
|
|
@@ -257,9 +269,18 @@ export default class LobbyAuthModel {
|
|
|
257
269
|
}
|
|
258
270
|
|
|
259
271
|
_forgetSession() {
|
|
260
|
-
this.
|
|
272
|
+
const key = this._config.sessionStorageKey;
|
|
273
|
+
|
|
274
|
+
// в хранилище может лежать секрет ДРУГОГО профиля: его записала вкладка,
|
|
275
|
+
// сменившая личность браузера, пока эта доигрывала матч под прежней.
|
|
276
|
+
// Стирается только свой — у профиля без привязки он единственный ключ
|
|
277
|
+
if (this._storage.getItem(key) === this._session) {
|
|
278
|
+
this._storage.removeItem(key);
|
|
279
|
+
}
|
|
280
|
+
|
|
261
281
|
this._session = null;
|
|
262
282
|
this._token = null;
|
|
283
|
+
this._tokenExpiresAt = null;
|
|
263
284
|
this._profile = null;
|
|
264
285
|
}
|
|
265
286
|
|
|
@@ -335,7 +356,7 @@ export default class LobbyAuthModel {
|
|
|
335
356
|
const res = await this._api.token(this._session);
|
|
336
357
|
|
|
337
358
|
if (res.ok) {
|
|
338
|
-
this.
|
|
359
|
+
this._setToken(res.data.token);
|
|
339
360
|
this._profile = res.data.profile;
|
|
340
361
|
this.publisher.emit('token-renewed', {
|
|
341
362
|
expiresAt: this.getTokenExpiresAt(),
|
|
@@ -343,7 +364,7 @@ export default class LobbyAuthModel {
|
|
|
343
364
|
return 'ok';
|
|
344
365
|
}
|
|
345
366
|
|
|
346
|
-
if (res
|
|
367
|
+
if (isSessionRejected(res)) {
|
|
347
368
|
this._forgetSession();
|
|
348
369
|
this.publisher.emit('session-lost');
|
|
349
370
|
return 'lost';
|
|
@@ -385,19 +406,36 @@ export default class LobbyAuthModel {
|
|
|
385
406
|
}
|
|
386
407
|
|
|
387
408
|
// роль из payload токена — ПОДСКАЗКА ИНТЕРФЕЙСУ (показывать ли кнопку
|
|
388
|
-
// модерации), не право:
|
|
389
|
-
//
|
|
409
|
+
// модерации), не право: права админа auth-сервис проверяет сам, по нику
|
|
410
|
+
// из VIMP_ADMIN_NICKS
|
|
390
411
|
getRole() {
|
|
391
412
|
return decodeJwtPayload(this._token)?.role ?? 'user';
|
|
392
413
|
}
|
|
393
414
|
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
const
|
|
415
|
+
// Срок токена в шкале часов УСТРОЙСТВА: момент получения плюс время жизни
|
|
416
|
+
// из самого токена (exp − iat — оба выставлены часами auth-сервиса).
|
|
417
|
+
// Сдвиг часов устройства на срок не влияет. Токен без iat — срок по exp
|
|
418
|
+
_setToken(token) {
|
|
419
|
+
const payload = decodeJwtPayload(token);
|
|
420
|
+
const exp = payload?.exp;
|
|
421
|
+
const iat = payload?.iat;
|
|
399
422
|
|
|
400
|
-
|
|
423
|
+
this._token = token;
|
|
424
|
+
|
|
425
|
+
if (typeof exp !== 'number') {
|
|
426
|
+
this._tokenExpiresAt = null;
|
|
427
|
+
} else if (typeof iat === 'number' && exp > iat) {
|
|
428
|
+
this._tokenExpiresAt = this._now() + (exp - iat) * 1000;
|
|
429
|
+
} else {
|
|
430
|
+
this._tokenExpiresAt = exp * 1000;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// срок входа (мс эпохи по часам устройства) или null — неизвестен.
|
|
435
|
+
// Вкладка с кончающимся токеном не принимает роль хоста:
|
|
436
|
+
// register_host/reclaim_host предъявят его мастеру посреди матча
|
|
437
|
+
getTokenExpiresAt() {
|
|
438
|
+
return this._tokenExpiresAt;
|
|
401
439
|
}
|
|
402
440
|
|
|
403
441
|
getProfile() {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
import { isSessionRejected } from '../../lib/authApi.js';
|
|
2
3
|
|
|
3
4
|
// Singleton ProfileModel
|
|
4
5
|
|
|
@@ -107,9 +108,10 @@ export default class ProfileModel {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
// один запрос за раз; успех — data тела ответа (null у 204); отказ —
|
|
110
|
-
// undefined и событие error
|
|
111
|
-
//
|
|
112
|
-
//
|
|
111
|
+
// undefined и событие error. Если auth-сервис не нашёл сессию
|
|
112
|
+
// (401 sessionUnknown: профиль удалён или сессия закрыта в другой вкладке) —
|
|
113
|
+
// вместо error событие left с уведомлением profileGone, уже после снятия
|
|
114
|
+
// busy; запрос во время другого не стартует
|
|
113
115
|
async _run(area, request) {
|
|
114
116
|
if (this._busy) {
|
|
115
117
|
return undefined;
|
|
@@ -131,7 +133,7 @@ export default class ProfileModel {
|
|
|
131
133
|
return res.data;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
if (res
|
|
136
|
+
if (isSessionRejected(res)) {
|
|
135
137
|
this._leave('profileGone');
|
|
136
138
|
} else {
|
|
137
139
|
this.publisher.emit('error', { area, code: res.error });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
import { ENGINE_VERSION } from '../../lib/engineVersion.js';
|
|
2
3
|
|
|
3
4
|
// Singleton LobbyAuthView
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ let lobbyAuthView;
|
|
|
6
7
|
|
|
7
8
|
const REGISTER_ERRORS = {
|
|
8
9
|
invalidNick:
|
|
9
|
-
'Invalid nick: 2–
|
|
10
|
+
'Invalid nick: 2–15 characters, starting with a letter. Letters, digits, spaces, "_" and "#" are allowed; the last character is a letter, digit or "_".',
|
|
10
11
|
nickTaken: 'This nick is already taken.',
|
|
11
12
|
turnstileBlocked:
|
|
12
13
|
'Security check blocked. Please disable your ad blocker (or Brave Shields) and click Retry.',
|
|
@@ -14,7 +15,7 @@ const REGISTER_ERRORS = {
|
|
|
14
15
|
turnstileFailed: 'Security check failed. Please try again.',
|
|
15
16
|
turnstileUnavailable:
|
|
16
17
|
'Registration is temporarily unavailable. Please try again later.',
|
|
17
|
-
rateLimited: 'Too many attempts. Please
|
|
18
|
+
rateLimited: 'Too many attempts. Please try again later.',
|
|
18
19
|
termsRequired: 'The terms have been updated. Reload the page and try again.',
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -56,6 +57,7 @@ export default class LobbyAuthView {
|
|
|
56
57
|
this._statusText = byId(elems.statusTextId);
|
|
57
58
|
this._statusRetry = byId(elems.statusRetryId);
|
|
58
59
|
this._welcome = byId(elems.welcomeId);
|
|
60
|
+
this._footer = byId(elems.footerId);
|
|
59
61
|
this._notice = byId(elems.noticeId);
|
|
60
62
|
this._nickInput = byId(elems.nickInputId);
|
|
61
63
|
this._nickError = byId(elems.nickErrorId);
|
|
@@ -64,7 +66,7 @@ export default class LobbyAuthView {
|
|
|
64
66
|
this._register = byId(elems.registerId);
|
|
65
67
|
this._terms = byId(elems.termsId);
|
|
66
68
|
this._privacy = byId(elems.privacyId);
|
|
67
|
-
|
|
69
|
+
byId(elems.versionId).textContent = ENGINE_VERSION;
|
|
68
70
|
this._restore = byId(elems.restoreId);
|
|
69
71
|
this._providers = byId(elems.providersId);
|
|
70
72
|
this._restoreError = byId(elems.restoreErrorId);
|
|
@@ -114,6 +116,7 @@ export default class LobbyAuthView {
|
|
|
114
116
|
this._enterAuth();
|
|
115
117
|
this._status.style.display = 'block';
|
|
116
118
|
this._welcome.style.display = 'none';
|
|
119
|
+
this._footer.style.display = 'none';
|
|
117
120
|
this._statusText.textContent = text;
|
|
118
121
|
this._statusRetry.style.display = retry ? '' : 'none';
|
|
119
122
|
}
|
|
@@ -125,14 +128,11 @@ export default class LobbyAuthView {
|
|
|
125
128
|
this._enterAuth();
|
|
126
129
|
this._status.style.display = 'none';
|
|
127
130
|
this._welcome.style.display = 'block';
|
|
131
|
+
this._footer.style.display = '';
|
|
128
132
|
|
|
129
133
|
this._notice.textContent = NOTICES[notice] ?? '';
|
|
130
134
|
this._terms.href = clientConfig.legal.termsUrl;
|
|
131
135
|
this._privacy.href = clientConfig.legal.privacyUrl;
|
|
132
|
-
this._lifetime.textContent =
|
|
133
|
-
'Your profile is kept in this browser and is deleted after ' +
|
|
134
|
-
`${clientConfig.accounts.inactiveDays} days of inactivity. ` +
|
|
135
|
-
'Link an account later to keep it longer.';
|
|
136
136
|
|
|
137
137
|
this._providerButtons.forEach(btn => {
|
|
138
138
|
btn.style.display = providers.includes(btn.dataset.provider)
|
|
@@ -65,7 +65,6 @@ export default class ProfileView {
|
|
|
65
65
|
this._close = byId(elems.closeBtnId);
|
|
66
66
|
this._nick = byId(elems.nickId);
|
|
67
67
|
this._status = byId(elems.statusId);
|
|
68
|
-
this._linkCard = byId(elems.linkCardId);
|
|
69
68
|
this._benefits = byId(elems.benefitsId);
|
|
70
69
|
this._providers = byId(elems.providersId);
|
|
71
70
|
this._consent = byId(elems.consentId);
|
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
|
9
9
|
|
|
10
|
+
// Единственный код, которым auth-сервис сообщает, что сессии нет.
|
|
11
|
+
// invalidSession («секрет не того формата») вердиктом не считается: клиент
|
|
12
|
+
// шлёт только секрет верного формата, и такой ответ значит, что заголовок
|
|
13
|
+
// Authorization не дошёл до сервиса (редирект, прокси, сбой сервиса). Как и
|
|
14
|
+
// 401 без кода, это сбой: секрет по нему не стирается — у профиля без
|
|
15
|
+
// привязки он единственный ключ
|
|
16
|
+
export const isSessionRejected = res =>
|
|
17
|
+
res.status === 401 && res.error === 'sessionUnknown';
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* @param {Object} options
|
|
12
21
|
* @param {string} options.serviceUrl - базовый URL auth-сервиса.
|
|
@@ -70,8 +70,9 @@ export function buildHostCaps(
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* Хватит ли срока токена, чтобы вкладка могла принять роль хоста: хост
|
|
73
|
-
* предъявляет токен мастеру посреди матча (register_host, reclaim_host)
|
|
74
|
-
*
|
|
73
|
+
* предъявляет токен мастеру посреди матча (register_host, reclaim_host).
|
|
74
|
+
* Токен обычно продлевается заранее; проверка — страховка на случай, когда
|
|
75
|
+
* продление не удаётся.
|
|
75
76
|
* @param {number|null} expiresAt - мс эпохи; null — неизвестно (не мешает).
|
|
76
77
|
* @param {number} minLifetimeMs
|
|
77
78
|
* @param {number} [now]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Что делать вкладке лобби, когда в localStorage лежит не тот секрет
|
|
3
|
+
* сессии, с которым она работает: личность вкладки меняется только
|
|
4
|
+
* перезагрузкой.
|
|
5
|
+
* @param {Object} ctx
|
|
6
|
+
* @param {string|null} ctx.storedSession - секрет в хранилище.
|
|
7
|
+
* @param {string|null} ctx.currentSession - секрет, с которым работает вкладка.
|
|
8
|
+
* @param {boolean} ctx.inRoom - вкладка в комнате.
|
|
9
|
+
* @returns {'reload'|'ignore'}
|
|
10
|
+
*/
|
|
11
|
+
export function decideStoredSession({ storedSession, currentSession, inRoom }) {
|
|
12
|
+
if (storedSession === currentSession) {
|
|
13
|
+
return 'ignore';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// матч доигрывается под прежней личностью; новую покажет следующая загрузка
|
|
17
|
+
return inRoom ? 'ignore' : 'reload';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* То же решение по событию storage: секрет изменил другой контекст.
|
|
22
|
+
* @param {Object} ctx
|
|
23
|
+
* @param {{key: string|null, newValue: string|null}} ctx.event - событие storage.
|
|
24
|
+
* @param {string} ctx.sessionKey - ключ секрета сессии.
|
|
25
|
+
* @param {string|null} ctx.currentSession - секрет, с которым работает вкладка.
|
|
26
|
+
* @param {boolean} ctx.inRoom - вкладка в комнате.
|
|
27
|
+
* @returns {'reload'|'ignore'}
|
|
28
|
+
*/
|
|
29
|
+
export function decideSessionStorageChange({
|
|
30
|
+
event,
|
|
31
|
+
sessionKey,
|
|
32
|
+
currentSession,
|
|
33
|
+
inRoom,
|
|
34
|
+
}) {
|
|
35
|
+
// key === null — хранилище очищено целиком
|
|
36
|
+
if (event.key !== null && event.key !== sessionKey) {
|
|
37
|
+
return 'ignore';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return decideStoredSession({
|
|
41
|
+
storedSession: event.key === null ? null : event.newValue,
|
|
42
|
+
currentSession,
|
|
43
|
+
inRoom,
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -70,6 +70,10 @@ import FpsMeter from './lib/FpsMeter.js';
|
|
|
70
70
|
import HostUnloadGuard from './lib/hostUnloadGuard.js';
|
|
71
71
|
import { createPageReload } from './lib/pageReload.js';
|
|
72
72
|
import { createAuthApi } from './lib/authApi.js';
|
|
73
|
+
import {
|
|
74
|
+
decideSessionStorageChange,
|
|
75
|
+
decideStoredSession,
|
|
76
|
+
} from './lib/sessionSync.js';
|
|
73
77
|
import { createVerifier, challengeOf } from './lib/pkce.js';
|
|
74
78
|
import { createTurnstile } from './lib/turnstile.js';
|
|
75
79
|
import TokenRenewer from './network/TokenRenewer.js';
|
|
@@ -2469,8 +2473,8 @@ function initLobby() {
|
|
|
2469
2473
|
const games = new GamesCtrl(gamesModel, gamesView);
|
|
2470
2474
|
|
|
2471
2475
|
// кнопку «Moderation» лобби показывает роли admin (master-game-registry,
|
|
2472
|
-
// этап 4). Это подсказка интерфейсу:
|
|
2473
|
-
//
|
|
2476
|
+
// этап 4). Это подсказка интерфейсу: права админа auth-сервис проверяет
|
|
2477
|
+
// сам, по нику из VIMP_ADMIN_NICKS
|
|
2474
2478
|
games.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2475
2479
|
|
|
2476
2480
|
// журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
|
|
@@ -2695,7 +2699,6 @@ if (isLobbyMode) {
|
|
|
2695
2699
|
const tokenRenewer = new TokenRenewer({
|
|
2696
2700
|
getExpiresAt: getTokenExpiresAt,
|
|
2697
2701
|
renew: () => lobbyAuthModel.refresh(),
|
|
2698
|
-
onLost: () => {},
|
|
2699
2702
|
leadMs: authClientConfig.renew.leadMs,
|
|
2700
2703
|
retryMs: authClientConfig.renew.retryMs,
|
|
2701
2704
|
});
|
|
@@ -2765,6 +2768,44 @@ if (isLobbyMode) {
|
|
|
2765
2768
|
diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
|
|
2766
2769
|
});
|
|
2767
2770
|
|
|
2771
|
+
// другая вкладка сменила личность браузера (регистрация, Restore, выход,
|
|
2772
|
+
// удаление): вкладка вне комнаты перечитывает её перезагрузкой
|
|
2773
|
+
window.addEventListener('storage', event => {
|
|
2774
|
+
if (event.storageArea !== localStorage) {
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
const action = decideSessionStorageChange({
|
|
2779
|
+
event,
|
|
2780
|
+
sessionKey: authClientConfig.sessionStorageKey,
|
|
2781
|
+
currentSession: lobbyAuthModel.getSession(),
|
|
2782
|
+
inRoom: Boolean(roomCtx.roomId),
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
if (action === 'reload') {
|
|
2786
|
+
reloadPage();
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
|
|
2790
|
+
// страница, восстановленная из bfcache (возврат кнопкой «Назад» от
|
|
2791
|
+
// провайдера OAuth), событий storage не получала: личность сверяется с
|
|
2792
|
+
// хранилищем заново
|
|
2793
|
+
window.addEventListener('pageshow', event => {
|
|
2794
|
+
if (!event.persisted) {
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
const action = decideStoredSession({
|
|
2799
|
+
storedSession: localStorage.getItem(authClientConfig.sessionStorageKey),
|
|
2800
|
+
currentSession: lobbyAuthModel.getSession(),
|
|
2801
|
+
inRoom: Boolean(roomCtx.roomId),
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
if (action === 'reload') {
|
|
2805
|
+
reloadPage();
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2768
2809
|
// возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
|
|
2769
2810
|
// (маршрут #/<gameId>[/<roomId>]) остаётся
|
|
2770
2811
|
if (lobbyAuthModel.takeUrlParams(window.location.search)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
|
|
2
2
|
// сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
|
|
3
|
-
// попыток: сеть вернётся — токен продлится.
|
|
3
|
+
// попыток: сеть вернётся — токен продлится. Запуски идут не чаще раза в
|
|
4
|
+
// retryMs, даже если срок остаётся в окне продления (цикла не будет).
|
|
4
5
|
//
|
|
5
6
|
// Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
|
|
6
7
|
|
|
@@ -9,7 +10,6 @@ export default class TokenRenewer {
|
|
|
9
10
|
* @param {Object} options
|
|
10
11
|
* @param {Function} options.getExpiresAt - () → мс эпохи | null.
|
|
11
12
|
* @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
|
|
12
|
-
* @param {Function} options.onLost - сессия потеряна, продлевать нечем.
|
|
13
13
|
* @param {number} options.leadMs
|
|
14
14
|
* @param {number} options.retryMs
|
|
15
15
|
* @param {Function} [options.now]
|
|
@@ -18,7 +18,6 @@ export default class TokenRenewer {
|
|
|
18
18
|
constructor({
|
|
19
19
|
getExpiresAt,
|
|
20
20
|
renew,
|
|
21
|
-
onLost,
|
|
22
21
|
leadMs,
|
|
23
22
|
retryMs,
|
|
24
23
|
now = () => Date.now(),
|
|
@@ -26,12 +25,12 @@ export default class TokenRenewer {
|
|
|
26
25
|
}) {
|
|
27
26
|
this._getExpiresAt = getExpiresAt;
|
|
28
27
|
this._renew = renew;
|
|
29
|
-
this._onLost = onLost;
|
|
30
28
|
this._leadMs = leadMs;
|
|
31
29
|
this._retryMs = retryMs;
|
|
32
30
|
this._now = now;
|
|
33
31
|
this._timers = timers;
|
|
34
32
|
this._timer = null;
|
|
33
|
+
this._lastFireAt = null;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
// таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
|
|
@@ -54,7 +53,17 @@ export default class TokenRenewer {
|
|
|
54
53
|
return null;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
const now = this._now();
|
|
57
|
+
// не раньше retryMs после предыдущего запуска: срок, который и со
|
|
58
|
+
// свежим токеном остаётся в окне продления, не должен завести цикл.
|
|
59
|
+
// Сверху порог ограничен тем же retryMs: часы устройства могли перевести
|
|
60
|
+
// назад, и разность с прошлым запуском уже ничего не значит
|
|
61
|
+
const floor =
|
|
62
|
+
this._lastFireAt === null
|
|
63
|
+
? 0
|
|
64
|
+
: Math.min(this._retryMs, this._lastFireAt + this._retryMs - now);
|
|
65
|
+
|
|
66
|
+
return Math.max(0, floor, expiresAt - this._leadMs - now);
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
_schedule(delay) {
|
|
@@ -69,6 +78,7 @@ export default class TokenRenewer {
|
|
|
69
78
|
|
|
70
79
|
async _fire() {
|
|
71
80
|
this._timer = null;
|
|
81
|
+
this._lastFireAt = this._now();
|
|
72
82
|
|
|
73
83
|
let result;
|
|
74
84
|
|
|
@@ -83,8 +93,7 @@ export default class TokenRenewer {
|
|
|
83
93
|
this.arm();
|
|
84
94
|
} else if (result === 'retry') {
|
|
85
95
|
this._schedule(this._retryMs);
|
|
86
|
-
} else {
|
|
87
|
-
this._onLost();
|
|
88
96
|
}
|
|
97
|
+
// 'lost': таймер не взводится, потерю объявляет модель
|
|
89
98
|
}
|
|
90
99
|
}
|
|
@@ -85,7 +85,8 @@ export default class Membership {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// хватит ли срока входа на роль хоста: register_host/reclaim_host
|
|
88
|
-
// предъявляют токен мастеру посреди
|
|
88
|
+
// предъявляют токен мастеру посреди матча. Страховка на случай, когда
|
|
89
|
+
// тихое продление токена не удаётся
|
|
89
90
|
tokenAllowsHostRole() {
|
|
90
91
|
return tokenAllowsHosting(
|
|
91
92
|
this._getTokenExpiresAt(),
|
package/src/client/style.css
CHANGED
|
@@ -589,6 +589,7 @@ body.hide-cursor {
|
|
|
589
589
|
держится в одном месте (#lobby-footer лежит в секции lobby ниже только
|
|
590
590
|
разметкой) */
|
|
591
591
|
#auth-link,
|
|
592
|
+
#lobby-auth-footer,
|
|
592
593
|
#lobby-footer {
|
|
593
594
|
height: 15px;
|
|
594
595
|
text-shadow: none;
|
|
@@ -601,15 +602,18 @@ body.hide-cursor {
|
|
|
601
602
|
padding: 0 5px;
|
|
602
603
|
}
|
|
603
604
|
|
|
605
|
+
#lobby-auth-footer,
|
|
604
606
|
#lobby-footer {
|
|
605
607
|
font-size: 10px;
|
|
606
608
|
padding: 0;
|
|
607
609
|
}
|
|
608
610
|
|
|
611
|
+
#lobby-auth-footer p,
|
|
609
612
|
#lobby-footer p {
|
|
610
613
|
margin: 0 5px;
|
|
611
614
|
}
|
|
612
615
|
|
|
616
|
+
#lobby-auth-footer p:not(:last-child)::after,
|
|
613
617
|
#lobby-footer p:not(:last-child)::after {
|
|
614
618
|
content: '|';
|
|
615
619
|
padding-left: 10px;
|
|
@@ -624,6 +628,7 @@ body.hide-cursor {
|
|
|
624
628
|
}
|
|
625
629
|
|
|
626
630
|
#auth-link p a,
|
|
631
|
+
#lobby-auth-footer p a,
|
|
627
632
|
#lobby-footer p a {
|
|
628
633
|
color: #ff0;
|
|
629
634
|
}
|
|
@@ -1117,16 +1122,20 @@ body.hide-cursor {
|
|
|
1117
1122
|
margin: 8px auto;
|
|
1118
1123
|
}
|
|
1119
1124
|
|
|
1120
|
-
|
|
1121
|
-
#lobby-auth-
|
|
1122
|
-
|
|
1123
|
-
font-size: 11px;
|
|
1124
|
-
line-height: 14px;
|
|
1125
|
-
margin: 8px 0;
|
|
1125
|
+
/* виджет Turnstile шириной 300 px не влезает в карточку 256 px */
|
|
1126
|
+
#lobby-auth-welcome {
|
|
1127
|
+
width: 300px;
|
|
1126
1128
|
}
|
|
1127
1129
|
|
|
1128
|
-
#lobby-auth-
|
|
1129
|
-
|
|
1130
|
+
#lobby-auth-turnstile {
|
|
1131
|
+
display: flex;
|
|
1132
|
+
justify-content: center;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/* футер вне рамки: по центру под карточкой */
|
|
1136
|
+
#lobby-auth-footer {
|
|
1137
|
+
justify-content: center;
|
|
1138
|
+
margin: 10px auto 0;
|
|
1130
1139
|
}
|
|
1131
1140
|
|
|
1132
1141
|
/* нейтральная плашка уведомления (профиль не найден, выход, удаление) */
|
|
@@ -9,24 +9,13 @@ div#lobby-auth
|
|
|
9
9
|
|
|
10
10
|
div#lobby-auth-welcome.card(style='display:none')
|
|
11
11
|
h2= 'Welcome to VIMP!'
|
|
12
|
-
p= 'Pick a nick and play. No e-mail or password needed.'
|
|
13
12
|
p#lobby-auth-notice.form-notice
|
|
14
|
-
input#lobby-auth-nick-input.field-text(type='text', maxlength='
|
|
13
|
+
input#lobby-auth-nick-input.field-text(type='text', maxlength='15', placeholder='Your nick', autocomplete='off')
|
|
15
14
|
div#lobby-auth-nick-error.form-error
|
|
16
15
|
input#lobby-auth-turnstile-retry(type='button', value='Retry', style='display:none')
|
|
17
16
|
//- контейнер виджета Turnstile: виден, только когда проверке нужен клик
|
|
18
17
|
div#lobby-auth-turnstile
|
|
19
18
|
input#lobby-auth-register(type='button', value='Create profile')
|
|
20
|
-
p#lobby-auth-legal
|
|
21
|
-
| By creating a profile you agree to the
|
|
22
|
-
|
|
|
23
|
-
a#lobby-auth-terms(target='_blank', rel='noopener') Terms of Service
|
|
24
|
-
|
|
|
25
|
-
| and the
|
|
26
|
-
|
|
|
27
|
-
a#lobby-auth-privacy(target='_blank', rel='noopener') Privacy Policy
|
|
28
|
-
| .
|
|
29
|
-
p#lobby-auth-lifetime
|
|
30
19
|
hr.lobby-divider
|
|
31
20
|
input#lobby-auth-restore(type='button', value='Restore profile')
|
|
32
21
|
div#lobby-auth-providers(style='display:none')
|
|
@@ -35,3 +24,14 @@ div#lobby-auth
|
|
|
35
24
|
input.lobby-auth-provider(type='button', data-provider='discord', value='Continue with Discord')
|
|
36
25
|
input.lobby-auth-provider(type='button', data-provider='github', value='Continue with GitHub')
|
|
37
26
|
div#lobby-auth-restore-error.form-error
|
|
27
|
+
|
|
28
|
+
//- футер под карточкой Welcome: ссылки заполняет LobbyAuthView,
|
|
29
|
+
//- версию движка — он же (engineVersion.js)
|
|
30
|
+
div#lobby-auth-footer(style='display:none')
|
|
31
|
+
p
|
|
32
|
+
a#lobby-auth-terms(target='_blank', rel='noopener') Terms of Service
|
|
33
|
+
| ·
|
|
34
|
+
|
|
|
35
|
+
a#lobby-auth-privacy(target='_blank', rel='noopener') Privacy Policy
|
|
36
|
+
p#lobby-auth-version
|
|
37
|
+
p © #{new Date().getFullYear()} VIMP
|
package/src/config/authClient.js
CHANGED
|
@@ -59,6 +59,7 @@ export default {
|
|
|
59
59
|
statusTextId: 'lobby-auth-status-text',
|
|
60
60
|
statusRetryId: 'lobby-auth-status-retry',
|
|
61
61
|
welcomeId: 'lobby-auth-welcome',
|
|
62
|
+
footerId: 'lobby-auth-footer',
|
|
62
63
|
noticeId: 'lobby-auth-notice',
|
|
63
64
|
nickInputId: 'lobby-auth-nick-input',
|
|
64
65
|
nickErrorId: 'lobby-auth-nick-error',
|
|
@@ -67,7 +68,7 @@ export default {
|
|
|
67
68
|
registerId: 'lobby-auth-register',
|
|
68
69
|
termsId: 'lobby-auth-terms',
|
|
69
70
|
privacyId: 'lobby-auth-privacy',
|
|
70
|
-
|
|
71
|
+
versionId: 'lobby-auth-version',
|
|
71
72
|
restoreId: 'lobby-auth-restore',
|
|
72
73
|
providersId: 'lobby-auth-providers',
|
|
73
74
|
restoreErrorId: 'lobby-auth-restore-error',
|
package/src/config/lobby.js
CHANGED
|
@@ -213,7 +213,8 @@ export default {
|
|
|
213
213
|
// подключён по WebRTC; при смене состава отчёт уходит сразу, с дебаунсом);
|
|
214
214
|
// minTokenLifetimeMs — вкладка, чей вход истечёт раньше, не объявляет
|
|
215
215
|
// canHost и отказывается от промоушена (токен предъявляется мастеру
|
|
216
|
-
// посреди
|
|
216
|
+
// посреди матча; обычно его заранее продлевает TokenRenewer, порог работает,
|
|
217
|
+
// пока продление не удаётся); tokenHandoffLeadMs — за столько до
|
|
217
218
|
// истечения входа хост с бетой сам передаёт роль (плановая передача на
|
|
218
219
|
// границе раунда); tokenHandoffRetryMs — через сколько повторить её, если
|
|
219
220
|
// беты ещё нет, идёт эстафета Worker'ов или передача сорвалась (повторы —
|
|
@@ -459,7 +460,6 @@ export default {
|
|
|
459
460
|
closeBtnId: 'profile-close',
|
|
460
461
|
nickId: 'profile-nick',
|
|
461
462
|
statusId: 'profile-status',
|
|
462
|
-
linkCardId: 'profile-link',
|
|
463
463
|
benefitsId: 'profile-link-benefits',
|
|
464
464
|
providersId: 'profile-providers',
|
|
465
465
|
consentId: 'profile-consent',
|
|
@@ -475,7 +475,6 @@ export default {
|
|
|
475
475
|
sessionErrorId: 'profile-session-error',
|
|
476
476
|
exportBtnId: 'profile-export',
|
|
477
477
|
exportErrorId: 'profile-export-error',
|
|
478
|
-
deleteCardId: 'profile-delete',
|
|
479
478
|
deleteAdminNoteId: 'profile-delete-admin',
|
|
480
479
|
deleteStartBtnId: 'profile-delete-start',
|
|
481
480
|
deleteConfirmId: 'profile-delete-confirm',
|