2023-07-27 07:31:52 +02:00
|
|
|
|
/*
|
2024-02-13 16:59:27 +01:00
|
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-07-27 07:31:52 +02:00
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
*/
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
import { Inject, Injectable } from '@nestjs/common';
|
2023-04-14 06:50:05 +02:00
|
|
|
|
import * as Redis from 'ioredis';
|
2023-01-12 13:02:26 +01:00
|
|
|
|
import { In } from 'typeorm';
|
2023-12-21 02:39:11 +01:00
|
|
|
|
import { ModuleRef } from '@nestjs/core';
|
|
|
|
|
import type {
|
|
|
|
|
MiRole,
|
|
|
|
|
MiRoleAssignment,
|
|
|
|
|
RoleAssignmentsRepository,
|
|
|
|
|
RolesRepository,
|
|
|
|
|
UsersRepository,
|
|
|
|
|
} from '@/models/_.js';
|
2023-04-06 04:14:43 +02:00
|
|
|
|
import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js';
|
2023-09-20 04:33:36 +02:00
|
|
|
|
import type { MiUser } from '@/models/User.js';
|
2023-01-12 13:02:26 +01:00
|
|
|
|
import { DI } from '@/di-symbols.js';
|
|
|
|
|
import { bindThis } from '@/decorators.js';
|
|
|
|
|
import { MetaService } from '@/core/MetaService.js';
|
2023-04-04 10:32:09 +02:00
|
|
|
|
import { CacheService } from '@/core/CacheService.js';
|
2023-09-20 04:33:36 +02:00
|
|
|
|
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
2023-01-13 03:03:54 +01:00
|
|
|
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
2023-09-29 04:29:54 +02:00
|
|
|
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
2023-03-01 02:20:03 +01:00
|
|
|
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
2023-12-21 02:39:11 +01:00
|
|
|
|
import { IdService } from '@/core/IdService.js';
|
2023-09-23 11:28:16 +02:00
|
|
|
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
2023-06-25 14:13:15 +02:00
|
|
|
|
import type { Packed } from '@/misc/json-schema.js';
|
2023-11-26 02:02:22 +01:00
|
|
|
|
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
2023-12-21 02:39:11 +01:00
|
|
|
|
import { NotificationService } from '@/core/NotificationService.js';
|
|
|
|
|
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
export type RolePolicies = {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
gtlAvailable: boolean;
|
|
|
|
|
ltlAvailable: boolean;
|
|
|
|
|
canPublicNote: boolean;
|
2024-02-29 12:48:02 +01:00
|
|
|
|
mentionLimit: number;
|
2023-01-13 06:22:53 +01:00
|
|
|
|
canInvite: boolean;
|
2023-07-15 02:57:58 +02:00
|
|
|
|
inviteLimit: number;
|
|
|
|
|
inviteLimitCycle: number;
|
|
|
|
|
inviteExpirationTime: number;
|
2023-01-13 06:58:27 +01:00
|
|
|
|
canManageCustomEmojis: boolean;
|
2023-10-30 07:33:15 +01:00
|
|
|
|
canManageAvatarDecorations: boolean;
|
2023-03-13 09:52:24 +01:00
|
|
|
|
canSearchNotes: boolean;
|
2023-09-30 00:54:11 +02:00
|
|
|
|
canUseTranslator: boolean;
|
2023-01-16 03:21:04 +01:00
|
|
|
|
canHideAds: boolean;
|
2023-01-12 13:02:26 +01:00
|
|
|
|
driveCapacityMb: number;
|
2023-05-05 07:18:06 +02:00
|
|
|
|
alwaysMarkNsfw: boolean;
|
2024-07-14 02:31:05 +02:00
|
|
|
|
canUpdateBioMedia: boolean;
|
2023-01-14 10:04:56 +01:00
|
|
|
|
pinLimit: number;
|
2023-01-12 13:02:26 +01:00
|
|
|
|
antennaLimit: number;
|
2023-01-14 00:04:38 +01:00
|
|
|
|
wordMuteLimit: number;
|
2023-01-14 02:48:11 +01:00
|
|
|
|
webhookLimit: number;
|
2023-01-14 08:14:24 +01:00
|
|
|
|
clipLimit: number;
|
|
|
|
|
noteEachClipsLimit: number;
|
2023-01-14 09:38:16 +01:00
|
|
|
|
userListLimit: number;
|
|
|
|
|
userEachUserListsLimit: number;
|
2023-01-15 08:52:12 +01:00
|
|
|
|
rateLimitFactor: number;
|
2023-12-13 08:56:19 +01:00
|
|
|
|
avatarDecorationLimit: number;
|
2023-01-12 13:02:26 +01:00
|
|
|
|
};
|
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
export const DEFAULT_POLICIES: RolePolicies = {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
gtlAvailable: true,
|
|
|
|
|
ltlAvailable: true,
|
|
|
|
|
canPublicNote: true,
|
2024-02-29 12:48:02 +01:00
|
|
|
|
mentionLimit: 20,
|
2023-01-13 06:22:53 +01:00
|
|
|
|
canInvite: false,
|
2023-07-15 02:57:58 +02:00
|
|
|
|
inviteLimit: 0,
|
|
|
|
|
inviteLimitCycle: 60 * 24 * 7,
|
|
|
|
|
inviteExpirationTime: 0,
|
2023-01-13 06:58:27 +01:00
|
|
|
|
canManageCustomEmojis: false,
|
2023-10-30 07:33:15 +01:00
|
|
|
|
canManageAvatarDecorations: false,
|
2023-03-13 09:52:24 +01:00
|
|
|
|
canSearchNotes: false,
|
2023-09-30 00:54:11 +02:00
|
|
|
|
canUseTranslator: true,
|
2023-01-16 03:21:04 +01:00
|
|
|
|
canHideAds: false,
|
2023-01-12 13:02:26 +01:00
|
|
|
|
driveCapacityMb: 100,
|
2023-05-05 07:18:06 +02:00
|
|
|
|
alwaysMarkNsfw: false,
|
2024-07-14 02:31:05 +02:00
|
|
|
|
canUpdateBioMedia: true,
|
2023-01-14 10:04:56 +01:00
|
|
|
|
pinLimit: 5,
|
2023-01-12 13:02:26 +01:00
|
|
|
|
antennaLimit: 5,
|
2023-01-14 00:04:38 +01:00
|
|
|
|
wordMuteLimit: 200,
|
2023-01-14 02:48:11 +01:00
|
|
|
|
webhookLimit: 3,
|
2023-01-14 08:14:24 +01:00
|
|
|
|
clipLimit: 10,
|
|
|
|
|
noteEachClipsLimit: 200,
|
2023-01-14 09:38:16 +01:00
|
|
|
|
userListLimit: 10,
|
|
|
|
|
userEachUserListsLimit: 50,
|
2023-01-15 08:52:12 +01:00
|
|
|
|
rateLimitFactor: 1,
|
2023-12-13 08:56:19 +01:00
|
|
|
|
avatarDecorationLimit: 1,
|
2023-01-12 13:02:26 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
2023-12-21 02:39:11 +01:00
|
|
|
|
export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
2023-08-16 10:51:28 +02:00
|
|
|
|
private rolesCache: MemorySingleCache<MiRole[]>;
|
|
|
|
|
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
|
2023-12-21 02:39:11 +01:00
|
|
|
|
private notificationService: NotificationService;
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
2023-03-01 02:20:03 +01:00
|
|
|
|
public static AlreadyAssignedError = class extends Error {};
|
|
|
|
|
public static NotAssignedError = class extends Error {};
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
constructor(
|
2023-12-21 02:39:11 +01:00
|
|
|
|
private moduleRef: ModuleRef,
|
|
|
|
|
|
2023-04-12 04:40:08 +02:00
|
|
|
|
@Inject(DI.redis)
|
|
|
|
|
private redisClient: Redis.Redis,
|
|
|
|
|
|
2023-11-21 01:55:49 +01:00
|
|
|
|
@Inject(DI.redisForTimelines)
|
|
|
|
|
private redisForTimelines: Redis.Redis,
|
|
|
|
|
|
2023-04-09 10:09:27 +02:00
|
|
|
|
@Inject(DI.redisForSub)
|
|
|
|
|
private redisForSub: Redis.Redis,
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
|
|
|
|
@Inject(DI.usersRepository)
|
|
|
|
|
private usersRepository: UsersRepository,
|
|
|
|
|
|
|
|
|
|
@Inject(DI.rolesRepository)
|
|
|
|
|
private rolesRepository: RolesRepository,
|
|
|
|
|
|
|
|
|
|
@Inject(DI.roleAssignmentsRepository)
|
|
|
|
|
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
|
|
|
|
|
|
|
|
|
private metaService: MetaService,
|
2023-04-04 10:32:09 +02:00
|
|
|
|
private cacheService: CacheService,
|
2023-01-13 03:03:54 +01:00
|
|
|
|
private userEntityService: UserEntityService,
|
2023-03-01 02:20:03 +01:00
|
|
|
|
private globalEventService: GlobalEventService,
|
|
|
|
|
private idService: IdService,
|
2023-09-23 11:28:16 +02:00
|
|
|
|
private moderationLogService: ModerationLogService,
|
2023-11-26 02:02:22 +01:00
|
|
|
|
private fanoutTimelineService: FanoutTimelineService,
|
2023-01-12 13:02:26 +01:00
|
|
|
|
) {
|
|
|
|
|
//this.onMessage = this.onMessage.bind(this);
|
|
|
|
|
|
2023-08-16 10:51:28 +02:00
|
|
|
|
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
|
|
|
|
|
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
2023-04-09 10:09:27 +02:00
|
|
|
|
this.redisForSub.on('message', this.onMessage);
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-12-21 02:39:11 +01:00
|
|
|
|
async onModuleInit() {
|
|
|
|
|
this.notificationService = this.moduleRef.get(NotificationService.name);
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
@bindThis
|
|
|
|
|
private async onMessage(_: string, data: string): Promise<void> {
|
|
|
|
|
const obj = JSON.parse(data);
|
|
|
|
|
|
|
|
|
|
if (obj.channel === 'internal') {
|
2023-09-29 04:29:54 +02:00
|
|
|
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
2023-01-12 13:02:26 +01:00
|
|
|
|
switch (type) {
|
|
|
|
|
case 'roleCreated': {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const cached = this.rolesCache.get();
|
2023-01-12 13:02:26 +01:00
|
|
|
|
if (cached) {
|
2023-01-25 03:18:16 +01:00
|
|
|
|
cached.push({
|
|
|
|
|
...body,
|
|
|
|
|
updatedAt: new Date(body.updatedAt),
|
|
|
|
|
lastUsedAt: new Date(body.lastUsedAt),
|
|
|
|
|
});
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'roleUpdated': {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const cached = this.rolesCache.get();
|
2023-01-12 13:02:26 +01:00
|
|
|
|
if (cached) {
|
|
|
|
|
const i = cached.findIndex(x => x.id === body.id);
|
|
|
|
|
if (i > -1) {
|
2023-01-25 03:18:16 +01:00
|
|
|
|
cached[i] = {
|
|
|
|
|
...body,
|
|
|
|
|
updatedAt: new Date(body.updatedAt),
|
|
|
|
|
lastUsedAt: new Date(body.lastUsedAt),
|
|
|
|
|
};
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'roleDeleted': {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const cached = this.rolesCache.get();
|
2023-01-12 13:02:26 +01:00
|
|
|
|
if (cached) {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
this.rolesCache.set(cached.filter(x => x.id !== body.id));
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'userRoleAssigned': {
|
|
|
|
|
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
|
|
|
|
|
if (cached) {
|
2024-01-22 09:44:03 +01:00
|
|
|
|
cached.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
2023-01-25 03:18:16 +01:00
|
|
|
|
...body,
|
2023-03-01 02:20:03 +01:00
|
|
|
|
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
2024-01-22 09:44:03 +01:00
|
|
|
|
user: null, // joinなカラムは通常取ってこないので
|
2024-01-22 10:00:46 +01:00
|
|
|
|
role: null, // joinなカラムは通常取ってこないので
|
2023-01-25 03:18:16 +01:00
|
|
|
|
});
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'userRoleUnassigned': {
|
|
|
|
|
const cached = this.roleAssignmentByUserIdCache.get(body.userId);
|
|
|
|
|
if (cached) {
|
|
|
|
|
this.roleAssignmentByUserIdCache.set(body.userId, cached.filter(x => x.id !== body.id));
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-13 03:03:54 +01:00
|
|
|
|
@bindThis
|
2024-02-27 10:45:46 +01:00
|
|
|
|
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
2023-01-13 03:03:54 +01:00
|
|
|
|
try {
|
|
|
|
|
switch (value.type) {
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ~かつ~
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'and': {
|
2024-02-27 10:45:46 +01:00
|
|
|
|
return value.values.every(v => this.evalCond(user, roles, v));
|
2023-01-13 03:03:54 +01:00
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ~または~
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'or': {
|
2024-02-27 10:45:46 +01:00
|
|
|
|
return value.values.some(v => this.evalCond(user, roles, v));
|
2023-01-13 03:03:54 +01:00
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ~ではない
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'not': {
|
2024-02-27 10:45:46 +01:00
|
|
|
|
return !this.evalCond(user, roles, value.value);
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// マニュアルロールがアサインされている
|
2024-02-27 10:45:46 +01:00
|
|
|
|
case 'roleAssignedTo': {
|
|
|
|
|
return roles.some(r => r.id === value.roleId);
|
2023-01-13 03:03:54 +01:00
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ローカルユーザのみ
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'isLocal': {
|
|
|
|
|
return this.userEntityService.isLocalUser(user);
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// リモートユーザのみ
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'isRemote': {
|
|
|
|
|
return this.userEntityService.isRemoteUser(user);
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// サスペンド済みユーザである
|
|
|
|
|
case 'isSuspended': {
|
|
|
|
|
return user.isSuspended;
|
|
|
|
|
}
|
|
|
|
|
// 鍵アカウントユーザである
|
|
|
|
|
case 'isLocked': {
|
|
|
|
|
return user.isLocked;
|
|
|
|
|
}
|
|
|
|
|
// botユーザである
|
|
|
|
|
case 'isBot': {
|
|
|
|
|
return user.isBot;
|
|
|
|
|
}
|
|
|
|
|
// 猫である
|
|
|
|
|
case 'isCat': {
|
|
|
|
|
return user.isCat;
|
|
|
|
|
}
|
|
|
|
|
// 「ユーザを見つけやすくする」が有効なアカウント
|
|
|
|
|
case 'isExplorable': {
|
|
|
|
|
return user.isExplorable;
|
|
|
|
|
}
|
|
|
|
|
// ユーザが作成されてから指定期間経過した
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'createdLessThan': {
|
2023-10-16 03:45:22 +02:00
|
|
|
|
return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000));
|
2023-01-13 03:03:54 +01:00
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ユーザが作成されてから指定期間経っていない
|
2023-01-13 03:03:54 +01:00
|
|
|
|
case 'createdMoreThan': {
|
2023-10-16 03:45:22 +02:00
|
|
|
|
return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000));
|
2023-01-13 03:03:54 +01:00
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// フォロワー数が指定値以下
|
2023-01-14 00:27:23 +01:00
|
|
|
|
case 'followersLessThanOrEq': {
|
|
|
|
|
return user.followersCount <= value.value;
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// フォロワー数が指定値以上
|
2023-01-14 00:27:23 +01:00
|
|
|
|
case 'followersMoreThanOrEq': {
|
|
|
|
|
return user.followersCount >= value.value;
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// フォロー数が指定値以下
|
2023-01-14 00:27:23 +01:00
|
|
|
|
case 'followingLessThanOrEq': {
|
|
|
|
|
return user.followingCount <= value.value;
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// フォロー数が指定値以上
|
2023-01-14 00:27:23 +01:00
|
|
|
|
case 'followingMoreThanOrEq': {
|
|
|
|
|
return user.followingCount >= value.value;
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ノート数が指定値以下
|
2023-03-23 09:18:38 +01:00
|
|
|
|
case 'notesLessThanOrEq': {
|
|
|
|
|
return user.notesCount <= value.value;
|
|
|
|
|
}
|
2024-04-19 08:22:23 +02:00
|
|
|
|
// ノート数が指定値以上
|
2023-03-23 09:18:38 +01:00
|
|
|
|
case 'notesMoreThanOrEq': {
|
|
|
|
|
return user.notesCount >= value.value;
|
|
|
|
|
}
|
2023-01-13 03:03:54 +01:00
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// TODO: log error
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-21 11:38:07 +02:00
|
|
|
|
@bindThis
|
|
|
|
|
public async getRoles() {
|
|
|
|
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
|
|
|
|
return roles;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getUserAssigns(userId: MiUser['id']) {
|
2023-03-01 02:20:03 +01:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
|
|
|
|
// 期限切れのロールを除外
|
|
|
|
|
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
2023-07-20 03:54:41 +02:00
|
|
|
|
return assigns;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getUserRoles(userId: MiUser['id']) {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
2023-07-20 03:54:41 +02:00
|
|
|
|
const assigns = await this.getUserAssigns(userId);
|
|
|
|
|
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
2024-02-27 10:45:46 +01:00
|
|
|
|
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
2023-01-13 03:03:54 +01:00
|
|
|
|
return [...assignedRoles, ...matchedCondRoles];
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-02-05 02:37:03 +01:00
|
|
|
|
/**
|
|
|
|
|
* 指定ユーザーのバッジロール一覧取得
|
|
|
|
|
*/
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getUserBadgeRoles(userId: MiUser['id']) {
|
2023-03-01 02:20:03 +01:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
|
|
|
|
// 期限切れのロールを除外
|
|
|
|
|
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
2024-02-27 10:45:46 +01:00
|
|
|
|
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
|
|
|
|
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
2023-02-11 01:03:43 +01:00
|
|
|
|
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
|
|
|
|
if (badgeCondRoles.length > 0) {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
2024-02-27 10:45:46 +01:00
|
|
|
|
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
2023-02-11 01:03:43 +01:00
|
|
|
|
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
|
|
|
|
} else {
|
|
|
|
|
return assignedBadgeRoles;
|
|
|
|
|
}
|
2023-02-05 02:37:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
const meta = await this.metaService.fetch();
|
2023-01-15 12:52:53 +01:00
|
|
|
|
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
if (userId == null) return basePolicies;
|
2023-01-12 13:02:26 +01:00
|
|
|
|
|
|
|
|
|
const roles = await this.getUserRoles(userId);
|
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
|
|
|
|
|
if (roles.length === 0) return basePolicies[name];
|
2023-01-15 11:10:39 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
const policies = roles.map(role => role.policies[name] ?? { priority: 0, useDefault: true });
|
2023-01-15 11:10:39 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
const p2 = policies.filter(policy => policy.priority === 2);
|
|
|
|
|
if (p2.length > 0) return aggregate(p2.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
|
2023-01-15 11:10:39 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
const p1 = policies.filter(policy => policy.priority === 1);
|
2023-01-15 19:16:44 +01:00
|
|
|
|
if (p1.length > 0) return aggregate(p1.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
|
2023-01-15 11:10:39 +01:00
|
|
|
|
|
2023-01-15 12:52:53 +01:00
|
|
|
|
return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2023-01-15 11:10:39 +01:00
|
|
|
|
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
|
|
|
|
|
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
|
|
|
|
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
|
2024-02-29 12:48:02 +01:00
|
|
|
|
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
2023-01-15 11:10:39 +01:00
|
|
|
|
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
2023-07-15 02:57:58 +02:00
|
|
|
|
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
|
|
|
|
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
|
|
|
|
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
|
2023-01-15 11:10:39 +01:00
|
|
|
|
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
|
2023-10-30 07:33:15 +01:00
|
|
|
|
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
|
2023-03-13 09:52:24 +01:00
|
|
|
|
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
|
2023-09-30 00:54:11 +02:00
|
|
|
|
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
|
2023-01-16 03:21:04 +01:00
|
|
|
|
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
2023-01-15 11:10:39 +01:00
|
|
|
|
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
2023-05-05 07:18:06 +02:00
|
|
|
|
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
|
2024-07-14 02:31:05 +02:00
|
|
|
|
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
|
2023-01-15 11:10:39 +01:00
|
|
|
|
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
|
|
|
|
|
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
|
|
|
|
|
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),
|
|
|
|
|
webhookLimit: calc('webhookLimit', vs => Math.max(...vs)),
|
|
|
|
|
clipLimit: calc('clipLimit', vs => Math.max(...vs)),
|
|
|
|
|
noteEachClipsLimit: calc('noteEachClipsLimit', vs => Math.max(...vs)),
|
|
|
|
|
userListLimit: calc('userListLimit', vs => Math.max(...vs)),
|
|
|
|
|
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
|
|
|
|
|
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
|
2023-12-13 08:56:19 +01:00
|
|
|
|
avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
|
2023-01-12 13:02:26 +01:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise<boolean> {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
if (user == null) return false;
|
|
|
|
|
return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise<boolean> {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
if (user == null) return false;
|
|
|
|
|
return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-19 10:12:22 +02:00
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
|
2023-05-19 10:12:22 +02:00
|
|
|
|
if (role == null) return false;
|
|
|
|
|
const check = await this.rolesRepository.findOneBy({ id: role.id });
|
|
|
|
|
if (check == null) return false;
|
|
|
|
|
return check.isExplorable;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
@bindThis
|
2024-06-08 08:34:19 +02:00
|
|
|
|
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
2024-06-08 08:34:19 +02:00
|
|
|
|
const moderatorRoles = includeAdmins
|
|
|
|
|
? roles.filter(r => r.isModerator || r.isAdministrator)
|
|
|
|
|
: roles.filter(r => r.isModerator);
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
// TODO: isRootなアカウントも含める
|
2024-06-08 08:34:19 +02:00
|
|
|
|
const assigns = moderatorRoles.length > 0
|
|
|
|
|
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const result = [
|
|
|
|
|
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
|
|
|
|
...new Set(
|
|
|
|
|
assigns
|
|
|
|
|
.filter(it =>
|
|
|
|
|
(excludeExpire)
|
|
|
|
|
? (it.expiresAt == null || it.expiresAt.getTime() > now)
|
|
|
|
|
: true,
|
|
|
|
|
)
|
|
|
|
|
.map(a => a.userId),
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return result.sort((x, y) => x.localeCompare(y));
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getModerators(includeAdmins = true): Promise<MiUser[]> {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
const ids = await this.getModeratorIds(includeAdmins);
|
|
|
|
|
const users = ids.length > 0 ? await this.usersRepository.findBy({
|
|
|
|
|
id: In(ids),
|
|
|
|
|
}) : [];
|
|
|
|
|
return users;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getAdministratorIds(): Promise<MiUser['id'][]> {
|
2023-04-04 10:32:09 +02:00
|
|
|
|
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
2023-01-12 13:02:26 +01:00
|
|
|
|
const administratorRoles = roles.filter(r => r.isAdministrator);
|
|
|
|
|
const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
|
|
|
|
|
roleId: In(administratorRoles.map(r => r.id)),
|
|
|
|
|
}) : [];
|
|
|
|
|
// TODO: isRootなアカウントも含める
|
|
|
|
|
return assigns.map(a => a.userId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 10:51:28 +02:00
|
|
|
|
public async getAdministrators(): Promise<MiUser[]> {
|
2023-01-12 13:02:26 +01:00
|
|
|
|
const ids = await this.getAdministratorIds();
|
|
|
|
|
const users = ids.length > 0 ? await this.usersRepository.findBy({
|
|
|
|
|
id: In(ids),
|
|
|
|
|
}) : [];
|
|
|
|
|
return users;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-01 02:20:03 +01:00
|
|
|
|
@bindThis
|
2023-09-23 11:28:16 +02:00
|
|
|
|
public async assign(userId: MiUser['id'], roleId: MiRole['id'], expiresAt: Date | null = null, moderator?: MiUser): Promise<void> {
|
2023-10-16 03:45:22 +02:00
|
|
|
|
const now = Date.now();
|
2023-03-01 02:20:03 +01:00
|
|
|
|
|
2023-09-23 11:28:16 +02:00
|
|
|
|
const role = await this.rolesRepository.findOneByOrFail({ id: roleId });
|
|
|
|
|
|
2023-03-01 02:20:03 +01:00
|
|
|
|
const existing = await this.roleAssignmentsRepository.findOneBy({
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
userId: userId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
2023-10-16 03:45:22 +02:00
|
|
|
|
if (existing.expiresAt && (existing.expiresAt.getTime() < now)) {
|
2023-03-01 02:20:03 +01:00
|
|
|
|
await this.roleAssignmentsRepository.delete({
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
userId: userId,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
throw new RoleService.AlreadyAssignedError();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-06-01 04:16:44 +02:00
|
|
|
|
const created = await this.roleAssignmentsRepository.insertOne({
|
2023-10-16 03:45:22 +02:00
|
|
|
|
id: this.idService.gen(now),
|
2023-03-01 02:20:03 +01:00
|
|
|
|
expiresAt: expiresAt,
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
userId: userId,
|
2024-06-01 04:16:44 +02:00
|
|
|
|
});
|
2023-03-01 02:20:03 +01:00
|
|
|
|
|
|
|
|
|
this.rolesRepository.update(roleId, {
|
|
|
|
|
lastUsedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.globalEventService.publishInternalEvent('userRoleAssigned', created);
|
2023-09-23 11:28:16 +02:00
|
|
|
|
|
2024-07-25 09:32:07 +02:00
|
|
|
|
const user = await this.usersRepository.findOneByOrFail({ id: userId });
|
|
|
|
|
|
|
|
|
|
if (role.isPublic && user.host === null) {
|
2023-12-21 02:39:11 +01:00
|
|
|
|
this.notificationService.createNotification(userId, 'roleAssigned', {
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-23 11:28:16 +02:00
|
|
|
|
if (moderator) {
|
|
|
|
|
this.moderationLogService.log(moderator, 'assignRole', {
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
roleName: role.name,
|
|
|
|
|
userId: userId,
|
2023-09-25 03:29:12 +02:00
|
|
|
|
userUsername: user.username,
|
|
|
|
|
userHost: user.host,
|
2023-09-23 11:28:16 +02:00
|
|
|
|
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
|
|
|
|
});
|
|
|
|
|
}
|
2023-03-01 02:20:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@bindThis
|
2023-09-23 11:28:16 +02:00
|
|
|
|
public async unassign(userId: MiUser['id'], roleId: MiRole['id'], moderator?: MiUser): Promise<void> {
|
2023-03-01 02:20:03 +01:00
|
|
|
|
const now = new Date();
|
2023-07-08 00:08:16 +02:00
|
|
|
|
|
2023-03-01 02:20:03 +01:00
|
|
|
|
const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
|
|
|
|
|
if (existing == null) {
|
|
|
|
|
throw new RoleService.NotAssignedError();
|
|
|
|
|
} else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
|
|
|
|
|
await this.roleAssignmentsRepository.delete({
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
userId: userId,
|
|
|
|
|
});
|
|
|
|
|
throw new RoleService.NotAssignedError();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.roleAssignmentsRepository.delete(existing.id);
|
|
|
|
|
|
|
|
|
|
this.rolesRepository.update(roleId, {
|
|
|
|
|
lastUsedAt: now,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
|
2023-09-23 11:28:16 +02:00
|
|
|
|
|
|
|
|
|
if (moderator) {
|
2023-09-25 03:29:12 +02:00
|
|
|
|
const [user, role] = await Promise.all([
|
|
|
|
|
this.usersRepository.findOneByOrFail({ id: userId }),
|
|
|
|
|
this.rolesRepository.findOneByOrFail({ id: roleId }),
|
|
|
|
|
]);
|
2023-09-23 11:28:16 +02:00
|
|
|
|
this.moderationLogService.log(moderator, 'unassignRole', {
|
|
|
|
|
roleId: roleId,
|
|
|
|
|
roleName: role.name,
|
|
|
|
|
userId: userId,
|
2023-09-25 03:29:12 +02:00
|
|
|
|
userUsername: user.username,
|
|
|
|
|
userHost: user.host,
|
2023-09-23 11:28:16 +02:00
|
|
|
|
});
|
|
|
|
|
}
|
2023-03-01 02:20:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-04-12 04:40:08 +02:00
|
|
|
|
@bindThis
|
|
|
|
|
public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise<void> {
|
|
|
|
|
const roles = await this.getUserRoles(note.userId);
|
|
|
|
|
|
2023-11-21 01:55:49 +01:00
|
|
|
|
const redisPipeline = this.redisForTimelines.pipeline();
|
2023-04-12 04:40:08 +02:00
|
|
|
|
|
|
|
|
|
for (const role of roles) {
|
2023-11-26 02:02:22 +01:00
|
|
|
|
this.fanoutTimelineService.push(`roleTimeline:${role.id}`, note.id, 1000, redisPipeline);
|
2023-04-12 04:40:08 +02:00
|
|
|
|
this.globalEventService.publishRoleTimelineStream(role.id, 'note', note);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
redisPipeline.exec();
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-25 03:29:12 +02:00
|
|
|
|
@bindThis
|
|
|
|
|
public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> {
|
|
|
|
|
const date = new Date();
|
2024-06-01 04:16:44 +02:00
|
|
|
|
const created = await this.rolesRepository.insertOne({
|
2023-10-16 03:45:22 +02:00
|
|
|
|
id: this.idService.gen(date.getTime()),
|
2023-09-25 03:29:12 +02:00
|
|
|
|
updatedAt: date,
|
|
|
|
|
lastUsedAt: date,
|
|
|
|
|
name: values.name,
|
|
|
|
|
description: values.description,
|
|
|
|
|
color: values.color,
|
|
|
|
|
iconUrl: values.iconUrl,
|
|
|
|
|
target: values.target,
|
|
|
|
|
condFormula: values.condFormula,
|
|
|
|
|
isPublic: values.isPublic,
|
|
|
|
|
isAdministrator: values.isAdministrator,
|
|
|
|
|
isModerator: values.isModerator,
|
|
|
|
|
isExplorable: values.isExplorable,
|
|
|
|
|
asBadge: values.asBadge,
|
|
|
|
|
canEditMembersByModerator: values.canEditMembersByModerator,
|
|
|
|
|
displayOrder: values.displayOrder,
|
|
|
|
|
policies: values.policies,
|
2024-06-01 04:16:44 +02:00
|
|
|
|
});
|
2023-09-25 03:29:12 +02:00
|
|
|
|
|
|
|
|
|
this.globalEventService.publishInternalEvent('roleCreated', created);
|
|
|
|
|
|
|
|
|
|
if (moderator) {
|
|
|
|
|
this.moderationLogService.log(moderator, 'createRole', {
|
|
|
|
|
roleId: created.id,
|
|
|
|
|
role: created,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return created;
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-23 11:28:16 +02:00
|
|
|
|
@bindThis
|
|
|
|
|
public async update(role: MiRole, params: Partial<MiRole>, moderator?: MiUser): Promise<void> {
|
|
|
|
|
const date = new Date();
|
|
|
|
|
await this.rolesRepository.update(role.id, {
|
|
|
|
|
updatedAt: date,
|
|
|
|
|
...params,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updated = await this.rolesRepository.findOneByOrFail({ id: role.id });
|
|
|
|
|
this.globalEventService.publishInternalEvent('roleUpdated', updated);
|
|
|
|
|
|
|
|
|
|
if (moderator) {
|
|
|
|
|
this.moderationLogService.log(moderator, 'updateRole', {
|
|
|
|
|
roleId: role.id,
|
|
|
|
|
before: role,
|
|
|
|
|
after: updated,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-24 03:33:30 +02:00
|
|
|
|
@bindThis
|
|
|
|
|
public async delete(role: MiRole, moderator?: MiUser): Promise<void> {
|
|
|
|
|
await this.rolesRepository.delete({ id: role.id });
|
|
|
|
|
this.globalEventService.publishInternalEvent('roleDeleted', role);
|
|
|
|
|
|
|
|
|
|
if (moderator) {
|
|
|
|
|
this.moderationLogService.log(moderator, 'deleteRole', {
|
|
|
|
|
roleId: role.id,
|
|
|
|
|
role: role,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-12 13:02:26 +01:00
|
|
|
|
@bindThis
|
2023-05-29 06:21:26 +02:00
|
|
|
|
public dispose(): void {
|
2023-04-09 10:09:27 +02:00
|
|
|
|
this.redisForSub.off('message', this.onMessage);
|
2023-06-10 06:45:11 +02:00
|
|
|
|
this.roleAssignmentByUserIdCache.dispose();
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|
2023-05-29 06:21:26 +02:00
|
|
|
|
|
|
|
|
|
@bindThis
|
|
|
|
|
public onApplicationShutdown(signal?: string | undefined): void {
|
|
|
|
|
this.dispose();
|
|
|
|
|
}
|
2023-01-12 13:02:26 +01:00
|
|
|
|
}
|