@abtnode/blocklet-services 1.17.3-beta-20251119-034511-f26047c0 → 1.17.3-beta-20251119-102907-28b69b76
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/api/services/auth/session.js +181 -161
- package/package.json +23 -23
|
@@ -30,6 +30,7 @@ const { createOrgValidators } = require('@abtnode/core/lib/util/org');
|
|
|
30
30
|
const { isAllowedURL } = require('@abtnode/util/lib/ssrf-protector');
|
|
31
31
|
|
|
32
32
|
const { getApplicationWallet } = require('@blocklet/meta/lib/wallet');
|
|
33
|
+
const dayjs = require('@abtnode/util/lib/dayjs');
|
|
33
34
|
const { createTokenFn, getDidConnectVersion } = require('../../util');
|
|
34
35
|
const checkUser = require('../../middlewares/check-user');
|
|
35
36
|
const cache = require('../../cache');
|
|
@@ -337,194 +338,213 @@ module.exports = {
|
|
|
337
338
|
res.json({ success: true });
|
|
338
339
|
});
|
|
339
340
|
|
|
340
|
-
router.post(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
341
|
+
router.post(
|
|
342
|
+
`${WELLKNOWN_SERVICE_PATH_PREFIX}/api/did/refreshSession`,
|
|
343
|
+
refreshBearerToken,
|
|
344
|
+
/**
|
|
345
|
+
* @description 刷新 session token
|
|
346
|
+
* @param {import('express').Request} req
|
|
347
|
+
* @param {import('express').Response} res
|
|
348
|
+
* @returns {Promise<void>}
|
|
349
|
+
*
|
|
350
|
+
*/
|
|
351
|
+
async (req, res) => {
|
|
352
|
+
const token = req.refreshToken;
|
|
353
|
+
if (token) {
|
|
354
|
+
const teamDid = req.getBlockletDid();
|
|
355
|
+
const blocklet = await req.getBlocklet();
|
|
356
|
+
if (blocklet.settings?.session?.enableBlacklist) {
|
|
357
|
+
const cacheData = await cache.refreshToken.get(md5(token));
|
|
358
|
+
if (cacheData?.block) {
|
|
359
|
+
res.status(400).send(t('userSessionLogout', req.blockletLocale));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
350
362
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
const visitorId = req.get('x-blocklet-visitor-id');
|
|
364
|
+
const ua = req.get('user-agent');
|
|
365
|
+
// FIXME: @zhanghan BlockletSDK 和 Aistro 来的请求暂时不需要检查 visitorId,需要在 aistro 适配新的逻辑
|
|
366
|
+
if (!(isAistroUserAgent(ua) || isBlockletSDKUserAgent(ua))) {
|
|
367
|
+
if (!visitorId) {
|
|
368
|
+
res.status(400).send(t('userSessionLogout', req.blockletLocale));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const { appPid = teamDid } = req.query;
|
|
373
|
+
// FIXME: @zhanghan BlockletSDK 和 Aistro 来的请求暂时不需要检查 visitorId,需要在 aistro 适配新的逻辑
|
|
374
|
+
if (isAistroUserAgent(ua) || isBlockletSDKUserAgent(ua)) {
|
|
375
|
+
await req.ensureUser({ token, appPid });
|
|
376
|
+
} else {
|
|
377
|
+
await req.ensureUser({ token, visitorId, appPid });
|
|
378
|
+
}
|
|
379
|
+
if (!req.user) {
|
|
357
380
|
res.status(400).send(t('userSessionLogout', req.blockletLocale));
|
|
358
381
|
return;
|
|
359
382
|
}
|
|
360
|
-
}
|
|
361
|
-
const { appPid = teamDid } = req.query;
|
|
362
|
-
// FIXME: @zhanghan BlockletSDK 和 Aistro 来的请求暂时不需要检查 visitorId,需要在 aistro 适配新的逻辑
|
|
363
|
-
if (isAistroUserAgent(ua) || isBlockletSDKUserAgent(ua)) {
|
|
364
|
-
await req.ensureUser({ token, appPid });
|
|
365
|
-
} else {
|
|
366
|
-
await req.ensureUser({ token, visitorId, appPid });
|
|
367
|
-
}
|
|
368
|
-
if (!req.user) {
|
|
369
|
-
res.status(400).send(t('userSessionLogout', req.blockletLocale));
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
383
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
384
|
+
try {
|
|
385
|
+
const blockletInfo = await req.getBlockletInfo();
|
|
386
|
+
|
|
387
|
+
const refreshTokenType = 'refresh';
|
|
388
|
+
const {
|
|
389
|
+
did: userPid, // 从 token 拿到的 did 就是 userPid
|
|
390
|
+
role,
|
|
391
|
+
passport,
|
|
392
|
+
provider = LOGIN_PROVIDER.WALLET,
|
|
393
|
+
walletOS,
|
|
394
|
+
org, // 从 token 拿到的 orgId
|
|
395
|
+
} = await verifySessionToken(token, blockletInfo.secret, {
|
|
396
|
+
checkFromDb: true,
|
|
397
|
+
teamDid,
|
|
398
|
+
checkToken: (_token) => {
|
|
399
|
+
if (_token.tokenType !== refreshTokenType) {
|
|
400
|
+
throw new Error(`invalid token type ${_token.tokenType}`);
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
locale: req.blockletLocale,
|
|
404
|
+
});
|
|
394
405
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
406
|
+
const user = await node.getUser({ teamDid, user: { did: userPid } });
|
|
407
|
+
|
|
408
|
+
if (role) {
|
|
409
|
+
const rbac = await node.getRBAC(teamDid);
|
|
410
|
+
user.permissions = await rbac.getScope(role);
|
|
411
|
+
user.role = role;
|
|
412
|
+
// 根据 role 获取所在的 org 信息
|
|
413
|
+
const { getOrgSettings } = createOrgValidators(blocklet);
|
|
414
|
+
const orgSettings = getOrgSettings();
|
|
415
|
+
if (orgSettings?.enabled) {
|
|
416
|
+
try {
|
|
417
|
+
const roleInfo = await node.getRole({ teamDid, role: { name: user.role } });
|
|
418
|
+
if (roleInfo?.orgId) {
|
|
419
|
+
const orgInfo = await node.getOrg({ teamDid, id: roleInfo?.orgId }, { user: req.user });
|
|
420
|
+
user.org = orgInfo || null;
|
|
421
|
+
}
|
|
422
|
+
} catch (err) {
|
|
423
|
+
logger.error('get role error', err);
|
|
410
424
|
}
|
|
411
|
-
} catch (err) {
|
|
412
|
-
logger.error('get role error', err);
|
|
413
425
|
}
|
|
414
426
|
}
|
|
415
|
-
}
|
|
416
427
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
428
|
+
if (user.avatar && user.avatar.startsWith(USER_AVATAR_URL_PREFIX)) {
|
|
429
|
+
user.avatar = `${WELLKNOWN_SERVICE_PATH_PREFIX}${USER_AVATAR_PATH_PREFIX}/${
|
|
430
|
+
user.avatar.split('/').slice(-1)[0]
|
|
431
|
+
}`;
|
|
421
432
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
433
|
+
if (req.get('x-avatar-host') === '1') {
|
|
434
|
+
const appUrl = blocklet.environmentObj.BLOCKLET_APP_URL;
|
|
435
|
+
user.avatar = joinURL(appUrl, user.avatar);
|
|
436
|
+
}
|
|
425
437
|
}
|
|
426
|
-
}
|
|
427
438
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
teamDid,
|
|
453
|
-
userDid: userPid,
|
|
454
|
-
appPid: teamDid,
|
|
455
|
-
status: USER_SESSION_STATUS.ONLINE,
|
|
456
|
-
// refreshSession 时更新 ua 可以解决浏览器升级导致 ua 变化产生的问题
|
|
457
|
-
ua,
|
|
458
|
-
lastLoginIp,
|
|
459
|
-
extra: {
|
|
460
|
-
walletOS,
|
|
461
|
-
device: deviceData,
|
|
462
|
-
},
|
|
463
|
-
origin: await getOrigin({ req }),
|
|
464
|
-
});
|
|
465
|
-
if (user?.sourceAppPid) {
|
|
466
|
-
await node.syncUserSession({
|
|
439
|
+
const sessionConfig = blocklet.settings?.session || {};
|
|
440
|
+
let userOrg = org;
|
|
441
|
+
if (!userOrg) {
|
|
442
|
+
userOrg = await req.getUserOrg(role);
|
|
443
|
+
}
|
|
444
|
+
const { sessionToken, refreshToken } = createToken(
|
|
445
|
+
userPid,
|
|
446
|
+
{
|
|
447
|
+
secret: blockletInfo.secret,
|
|
448
|
+
passport,
|
|
449
|
+
role,
|
|
450
|
+
fullName: user.fullName,
|
|
451
|
+
provider,
|
|
452
|
+
walletOS,
|
|
453
|
+
emailVerified: !!user?.emailVerified,
|
|
454
|
+
phoneVerified: !!user?.phoneVerified,
|
|
455
|
+
org: userOrg,
|
|
456
|
+
},
|
|
457
|
+
{ ...sessionConfig, didConnectVersion: getDidConnectVersion(req) }
|
|
458
|
+
);
|
|
459
|
+
const lastLoginIp = getRequestIP(req);
|
|
460
|
+
const deviceData = getDeviceData({ req });
|
|
461
|
+
const userSession = await node.upsertUserSession({
|
|
462
|
+
visitorId,
|
|
467
463
|
teamDid,
|
|
468
|
-
userDid:
|
|
469
|
-
|
|
470
|
-
|
|
464
|
+
userDid: userPid,
|
|
465
|
+
appPid: teamDid,
|
|
466
|
+
status: USER_SESSION_STATUS.ONLINE,
|
|
467
|
+
// refreshSession 时更新 ua 可以解决浏览器升级导致 ua 变化产生的问题
|
|
471
468
|
ua,
|
|
472
469
|
lastLoginIp,
|
|
473
470
|
extra: {
|
|
474
471
|
walletOS,
|
|
475
472
|
device: deviceData,
|
|
476
473
|
},
|
|
474
|
+
origin: await getOrigin({ req }),
|
|
477
475
|
});
|
|
478
|
-
|
|
476
|
+
if (user?.sourceAppPid) {
|
|
477
|
+
await node.syncUserSession({
|
|
478
|
+
teamDid,
|
|
479
|
+
userDid: userSession.userDid,
|
|
480
|
+
visitorId: userSession.visitorId,
|
|
481
|
+
targetAppPid: user.sourceAppPid,
|
|
482
|
+
ua,
|
|
483
|
+
lastLoginIp,
|
|
484
|
+
extra: {
|
|
485
|
+
walletOS,
|
|
486
|
+
device: deviceData,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}
|
|
479
490
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
491
|
+
// 颁发新的 token 前,将原有的 token 和 refreshToken 拉黑
|
|
492
|
+
// req.token 无法正确获得 login_token,所以需要通过 getTokenFromReq 获取
|
|
493
|
+
const { token: requestSessionToken } = getTokenFromReq(req, {
|
|
494
|
+
cookie: {
|
|
495
|
+
key: 'login_token',
|
|
496
|
+
},
|
|
497
|
+
headerKey: false,
|
|
498
|
+
});
|
|
499
|
+
if (requestSessionToken) {
|
|
500
|
+
const sessionTtl = jwtDecode(requestSessionToken).exp * 1000 - Date.now();
|
|
501
|
+
if (sessionTtl > 0) {
|
|
502
|
+
await cache.sessionToken.set(md5(requestSessionToken), { block: true }, { ttl: sessionTtl });
|
|
503
|
+
}
|
|
492
504
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
505
|
+
if (req.refreshToken) {
|
|
506
|
+
const refreshTtl = jwtDecode(req.refreshToken).exp * 1000 - Date.now();
|
|
507
|
+
if (refreshTtl > 0) {
|
|
508
|
+
await cache.refreshToken.set(md5(req.refreshToken), { block: true }, { ttl: refreshTtl });
|
|
509
|
+
}
|
|
498
510
|
}
|
|
499
|
-
}
|
|
500
511
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
512
|
+
// 刷新 csrf token
|
|
513
|
+
const nodeInfo = await node.getNodeInfo();
|
|
514
|
+
const accessWallet = getApplicationWallet(blocklet.appDid || blocklet.meta.did, nodeInfo.sk, undefined, 2);
|
|
515
|
+
const csrfToken = await sign(accessWallet.secretKey, sessionToken);
|
|
516
|
+
// @note: 同时设置 token
|
|
517
|
+
res.cookie('x-csrf-token', csrfToken, {
|
|
518
|
+
sameSite: 'strict',
|
|
519
|
+
secure: true,
|
|
520
|
+
});
|
|
521
|
+
res.cookie('login_token', sessionToken, {
|
|
522
|
+
sameSite: 'lax',
|
|
523
|
+
secure: true,
|
|
524
|
+
expires: dayjs()
|
|
525
|
+
.add(blocklet?.settings?.session?.cacheTtl || 3600, 's')
|
|
526
|
+
.toDate(),
|
|
527
|
+
});
|
|
509
528
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
529
|
+
res.json(
|
|
530
|
+
await signResponse(
|
|
531
|
+
{
|
|
532
|
+
user: omit(user, ['extra']),
|
|
533
|
+
nextToken: sessionToken,
|
|
534
|
+
nextRefreshToken: refreshToken,
|
|
535
|
+
provider,
|
|
536
|
+
walletOS,
|
|
537
|
+
},
|
|
538
|
+
blockletInfo.wallet
|
|
539
|
+
)
|
|
540
|
+
);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
res.status(400).send(err.message);
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
res.status(400).send(t('emptyRefreshToken', req.blockletLocale));
|
|
524
546
|
}
|
|
525
|
-
} else {
|
|
526
|
-
res.status(400).send(t('emptyRefreshToken', req.blockletLocale));
|
|
527
547
|
}
|
|
528
|
-
|
|
548
|
+
);
|
|
529
549
|
},
|
|
530
550
|
};
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "1.17.3-beta-20251119-
|
|
6
|
+
"version": "1.17.3-beta-20251119-102907-28b69b76",
|
|
7
7
|
"description": "Provide unified services for every blocklet",
|
|
8
8
|
"main": "api/index.js",
|
|
9
9
|
"files": [
|
|
@@ -32,17 +32,17 @@
|
|
|
32
32
|
"author": "wangshijun <wangshijun2010@gmail.com> (http://github.com/wangshijun)",
|
|
33
33
|
"license": "Apache-2.0",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@abtnode/analytics": "1.17.3-beta-20251119-
|
|
36
|
-
"@abtnode/auth": "1.17.3-beta-20251119-
|
|
37
|
-
"@abtnode/connect-storage": "1.17.3-beta-20251119-
|
|
38
|
-
"@abtnode/constant": "1.17.3-beta-20251119-
|
|
39
|
-
"@abtnode/core": "1.17.3-beta-20251119-
|
|
40
|
-
"@abtnode/cron": "1.17.3-beta-20251119-
|
|
41
|
-
"@abtnode/db-cache": "1.17.3-beta-20251119-
|
|
42
|
-
"@abtnode/logger": "1.17.3-beta-20251119-
|
|
43
|
-
"@abtnode/models": "1.17.3-beta-20251119-
|
|
44
|
-
"@abtnode/router-templates": "1.17.3-beta-20251119-
|
|
45
|
-
"@abtnode/util": "1.17.3-beta-20251119-
|
|
35
|
+
"@abtnode/analytics": "1.17.3-beta-20251119-102907-28b69b76",
|
|
36
|
+
"@abtnode/auth": "1.17.3-beta-20251119-102907-28b69b76",
|
|
37
|
+
"@abtnode/connect-storage": "1.17.3-beta-20251119-102907-28b69b76",
|
|
38
|
+
"@abtnode/constant": "1.17.3-beta-20251119-102907-28b69b76",
|
|
39
|
+
"@abtnode/core": "1.17.3-beta-20251119-102907-28b69b76",
|
|
40
|
+
"@abtnode/cron": "1.17.3-beta-20251119-102907-28b69b76",
|
|
41
|
+
"@abtnode/db-cache": "1.17.3-beta-20251119-102907-28b69b76",
|
|
42
|
+
"@abtnode/logger": "1.17.3-beta-20251119-102907-28b69b76",
|
|
43
|
+
"@abtnode/models": "1.17.3-beta-20251119-102907-28b69b76",
|
|
44
|
+
"@abtnode/router-templates": "1.17.3-beta-20251119-102907-28b69b76",
|
|
45
|
+
"@abtnode/util": "1.17.3-beta-20251119-102907-28b69b76",
|
|
46
46
|
"@arcblock/did": "^1.27.7",
|
|
47
47
|
"@arcblock/did-connect-js": "^1.27.7",
|
|
48
48
|
"@arcblock/did-ext": "^1.27.7",
|
|
@@ -52,18 +52,18 @@
|
|
|
52
52
|
"@arcblock/jwt": "^1.27.7",
|
|
53
53
|
"@arcblock/validator": "^1.27.7",
|
|
54
54
|
"@arcblock/ws": "^1.27.7",
|
|
55
|
-
"@blocklet/constant": "1.17.3-beta-20251119-
|
|
55
|
+
"@blocklet/constant": "1.17.3-beta-20251119-102907-28b69b76",
|
|
56
56
|
"@blocklet/dbhub": "^0.2.9",
|
|
57
|
-
"@blocklet/env": "1.17.3-beta-20251119-
|
|
57
|
+
"@blocklet/env": "1.17.3-beta-20251119-102907-28b69b76",
|
|
58
58
|
"@blocklet/error": "^0.3.3",
|
|
59
59
|
"@blocklet/form-builder": "^0.1.12",
|
|
60
60
|
"@blocklet/form-collector": "^0.1.8",
|
|
61
|
-
"@blocklet/images": "1.17.3-beta-20251119-
|
|
62
|
-
"@blocklet/js-sdk": "1.17.3-beta-20251119-
|
|
63
|
-
"@blocklet/meta": "1.17.3-beta-20251119-
|
|
64
|
-
"@blocklet/rate-limit": "1.17.3-beta-20251119-
|
|
65
|
-
"@blocklet/sdk": "1.17.3-beta-20251119-
|
|
66
|
-
"@blocklet/server-js": "1.17.3-beta-20251119-
|
|
61
|
+
"@blocklet/images": "1.17.3-beta-20251119-102907-28b69b76",
|
|
62
|
+
"@blocklet/js-sdk": "1.17.3-beta-20251119-102907-28b69b76",
|
|
63
|
+
"@blocklet/meta": "1.17.3-beta-20251119-102907-28b69b76",
|
|
64
|
+
"@blocklet/rate-limit": "1.17.3-beta-20251119-102907-28b69b76",
|
|
65
|
+
"@blocklet/sdk": "1.17.3-beta-20251119-102907-28b69b76",
|
|
66
|
+
"@blocklet/server-js": "1.17.3-beta-20251119-102907-28b69b76",
|
|
67
67
|
"@blocklet/theme": "^3.2.6",
|
|
68
68
|
"@blocklet/theme-builder": "0.4.8",
|
|
69
69
|
"@blocklet/uploader-server": "^0.3.11",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"whatwg-url": "14.0.0"
|
|
124
124
|
},
|
|
125
125
|
"devDependencies": {
|
|
126
|
-
"@abtnode/ux": "1.17.3-beta-20251119-
|
|
126
|
+
"@abtnode/ux": "1.17.3-beta-20251119-102907-28b69b76",
|
|
127
127
|
"@arcblock/bridge": "^3.2.6",
|
|
128
128
|
"@arcblock/did-connect-react": "^3.2.6",
|
|
129
129
|
"@arcblock/icons": "^3.2.6",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
"@blocklet/did-space-react": "^1.2.4",
|
|
134
134
|
"@blocklet/launcher-layout": "^3.2.6",
|
|
135
135
|
"@blocklet/payment-react": "^1.22.14",
|
|
136
|
-
"@blocklet/tracker": "1.17.3-beta-20251119-
|
|
136
|
+
"@blocklet/tracker": "1.17.3-beta-20251119-102907-28b69b76",
|
|
137
137
|
"@blocklet/ui-react": "^3.2.6",
|
|
138
138
|
"@blocklet/uploader": "^0.3.11",
|
|
139
139
|
"@emotion/react": "^11.14.0",
|
|
@@ -213,5 +213,5 @@
|
|
|
213
213
|
"url": "https://github.com/ArcBlock/blocklet-server/issues",
|
|
214
214
|
"email": "shijun@arcblock.io"
|
|
215
215
|
},
|
|
216
|
-
"gitHead": "
|
|
216
|
+
"gitHead": "6ae74784386f183ac410a226b6b9334c0f722fcc"
|
|
217
217
|
}
|