diff --git a/locales/index.d.ts b/locales/index.d.ts index 18fbfd15f0..8a0f38203f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6989,6 +6989,10 @@ export interface Locale extends ILocale { * リストのインポートを許可 */ "canImportUserLists": string; + /** + * モデレーターの活動状況チェックの対象に含める + */ + "isModeratorInactivityCheckTarget": string; }; "_condition": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 439ae708c5..7b011885ab 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1806,6 +1806,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + isModeratorInactivityCheckTarget: "モデレーターの活動状況チェックの対象に含める" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 5af6b05942..677533583e 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -63,6 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + isModeratorInactivityCheckTarget: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + isModeratorInactivityCheckTarget: false, }; @Injectable() @@ -402,9 +404,23 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + isModeratorInactivityCheckTarget: calc('isModeratorInactivityCheckTarget', vs => vs.some(v => v === true)), }; } + @bindThis + public async getUsersByRoleIds(roleIds: MiRole['id'][]): Promise { + // 今のところこの関数の使用頻度は低めなのでキャッシュは作らない. + // 使用頻度が増えた場合はroleAssignmentByUserIdCacheのようなキャッシュを作るべきか否かを検討する必要がある. + const users = await this.roleAssignmentsRepository.createQueryBuilder('roleAssignment') + .innerJoinAndSelect('roleAssignment.user', 'user') + .where('roleAssignment.roleId IN (:...roleIds)', { roleIds }) + .getMany() + .then(it => it.map(it => it.user).filter(it => it != null)); + + return [...new Map(users.map(it => [it.id, it])).values()]; + } + @bindThis public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { if (user == null) return false; @@ -465,6 +481,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { if (includeRoot) { const rootUserId = await this.rootUserIdCache.fetch(async () => { + // rootは必ず1人存在するという前提のもと const it = await this.usersRepository.createQueryBuilder('users') .select('id') .where({ isRoot: true }) @@ -687,6 +704,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } } + /** + * Service内部で保持しているキャッシュをすべて削除する. + * 主にテスト向けの機能で、通常はこのメソッドを呼ぶ必要はない. + */ + @bindThis + public flushCaches(): void { + this.rootUserIdCache.delete(); + this.rolesCache.delete(); + this.roleAssignmentByUserIdCache.deleteAll(); + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onMessage); diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f9692ce5d5..5cb347d34a 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -242,6 +242,11 @@ export class MemoryKVCache { this.cache.delete(key); } + @bindThis + public deleteAll() { + this.cache.clear(); + } + /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c8..76cfdc0c04 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + isModeratorInactivityCheckTarget: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts index 87183cb342..6e5bbcd5e3 100644 --- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -8,7 +8,7 @@ import { In } from 'typeorm'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; -import { RoleService } from '@/core/RoleService.js'; +import { RolePolicies, RoleService } from '@/core/RoleService.js'; import { EmailService } from '@/core/EmailService.js'; import { MiUser, type UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -281,12 +281,47 @@ export class CheckModeratorsActivityProcessorService { } @bindThis - private async fetchModerators() { - // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する - return this.roleService.getModerators({ - includeAdmins: true, - includeRoot: true, - excludeExpire: true, - }); + public async fetchModerators() { + const resultMap = await this.roleService + .getModerators({ includeAdmins: true, includeRoot: true, excludeExpire: true }) + .then(it => new Map(it.map(it => [it.id, it]))); + + const additionalUsers = await this.fetchAdditionalTargetUsers(); + for (const user of additionalUsers) { + resultMap.set(user.id, user); + } + + return [...resultMap.values()]; + } + + @bindThis + private async fetchAdditionalTargetUsers() { + const roles = await this.roleService.getRoles(); + const targetRoleIds = roles + .filter(it => (it.policies as unknown as Partial).isModeratorInactivityCheckTarget ?? false) + .map(it => it.id); + if (targetRoleIds.length === 0) { + // 該当ポリシーが有効なロールが存在しない + return []; + } + + const tmpTargetUsers = await this.roleService.getUsersByRoleIds(targetRoleIds) + .then(it => [...new Map(it.map(it => [it.id, it])).values()]); + if (tmpTargetUsers.length === 0) { + // 該当ポリシーが有効なロールにアサインされたユーザが存在しない + return []; + } + + const tmpTargetUsersWithPolicies = await Promise.all( + tmpTargetUsers.map(async user => { + // 複数ロールを組み合わせた最終的なポリシーを計算する必要がある + const policies = await this.roleService.getUserPolicies(user.id); + return { user, policies }; + }), + ); + + return tmpTargetUsersWithPolicies + .filter(it => it.policies.isModeratorInactivityCheckTarget) + .map(it => it.user); } } diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 9c1b1008d6..36762b5df9 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -480,6 +480,27 @@ describe('RoleService', () => { }); }); + describe('getUsersByRoleIds', () => { + test('get users by role ids', async () => { + const [user1, user2, user3, role1, role2, role3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + createRole(), + createRole(), + createRole(), + ]); + await Promise.all([ + roleService.assign(user1.id, role1.id), + roleService.assign(user2.id, role1.id), + roleService.assign(user3.id, role2.id), + ]); + + const result = await roleService.getUsersByRoleIds([role1.id]); + expect(result.map(u => u.id)).toEqual([user1.id, user2.id]); + }); + }); + describe('conditional role', () => { test('~かつ~', async () => { const [user1, user2, user3, user4] = await Promise.all([ diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts index 1506283a3c..66a1e9374d 100644 --- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -8,7 +8,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns'; import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; -import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { + MiRole, + MiRoleAssignment, + MiSystemWebhook, + MiUser, + MiUserProfile, + RoleAssignmentsRepository, + RolesRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { RoleService } from '@/core/RoleService.js'; import { GlobalModule } from '@/GlobalModule.js'; @@ -18,6 +28,12 @@ import { QueueLoggerService } from '@/queue/QueueLoggerService.js'; import { EmailService } from '@/core/EmailService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { CacheService } from '@/core/CacheService.js'; const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0)); @@ -93,7 +109,10 @@ describe('CheckModeratorsActivityProcessorService', () => { CheckModeratorsActivityProcessorService, IdService, { - provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }), + provide: RoleService, useFactory: () => ({ + getModerators: jest.fn(), + getRoles: () => Promise.resolve([]), + }), }, { provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), @@ -377,3 +396,269 @@ describe('CheckModeratorsActivityProcessorService', () => { }); }); }); + +// 本物のRoleServiceと結合しないと出来ないテスト +describe('CheckModeratorsActivityProcessorService with RoleService', () => { + let app: TestingModule; + let clock: lolex.InstalledClock; + let service: CheckModeratorsActivityProcessorService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let rolesRepository: RolesRepository; + let roleAssignmentsRepository: RoleAssignmentsRepository; + let idService: IdService; + let roleService: RoleService; + + let root: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}, profile: Partial = {}): Promise { + const id = idService.gen(); + const user = await usersRepository + .insert({ + id: id, + username: `user_${id}`, + usernameLower: `user_${id}`.toLowerCase(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + ...profile, + }); + + return user; + } + + async function createRole(data: Partial = {}) { + const x = await rolesRepository.insert({ + id: genAidx(Date.now()), + updatedAt: new Date(), + lastUsedAt: new Date(), + name: '', + description: '', + ...data, + }); + return await rolesRepository.findOneByOrFail(x.identifiers[0]); + } + + async function assignRole(args: Partial) { + const id = genAidx(Date.now()); + await roleAssignmentsRepository.insert({ + id, + ...args, + }); + + return await roleAssignmentsRepository.findOneByOrFail({ id }); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CheckModeratorsActivityProcessorService, + IdService, + RoleService, + GlobalEventService, + CacheService, + { + provide: ModerationLogService, useFactory: () => ({ log: jest.fn() }), + }, + { + provide: FanoutTimelineService, useFactory: () => ({ push: jest.fn() }), + }, + { + provide: UserEntityService, useFactory: () => ({ pack: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ + fetchActiveSystemWebhooks: jest.fn(), + enqueueSystemWebhook: jest.fn(), + }), + }, + { + provide: QueueLoggerService, useFactory: () => ({ + logger: ({ + createSubLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + succ: jest.fn(), + }), + }), + }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + rolesRepository = app.get(DI.rolesRepository); + roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); + + service = app.get(CheckModeratorsActivityProcessorService); + idService = app.get(IdService); + roleService = app.get(RoleService); + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + clock = lolex.install({ + now: new Date(baseDate), + shouldClearNativeTimers: true, + }); + + root = await createUser({ isRoot: true, lastActiveDate: new Date() }); + }); + + afterEach(async () => { + clock.uninstall(); + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + await roleAssignmentsRepository.delete({}); + await rolesRepository.delete({}); + + roleService.flushCaches(); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('fetchModerators', () => { + function expectUsers(users: MiUser[], expected: MiUser[]) { + expect(users.sort((x, y) => x.id.localeCompare(y.id))) + .toEqual(expected.sort((x, y) => x.id.localeCompare(y.id))); + } + + test('モデレーターロール無し -> root', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + + const [role1, role2] = await Promise.all([ + createRole({ isModerator: false }), + createRole({ isModerator: false }), + ]); + + await Promise.all([ + assignRole({ userId: user1.id, roleId: role1.id }), + assignRole({ userId: user2.id, roleId: role2.id }), + assignRole({ userId: user3.id, roleId: role1.id }), + assignRole({ userId: user3.id, roleId: role2.id }), + ]); + + const result = await service.fetchModerators(); + expectUsers(result, [root]); + }); + + test('モデレーターロール有り -> root, user2, user3', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + + const [role1, role2] = await Promise.all([ + createRole({ isModerator: false }), + createRole({ isModerator: true }), + ]); + + await Promise.all([ + assignRole({ userId: user1.id, roleId: role1.id }), + assignRole({ userId: user2.id, roleId: role2.id }), + assignRole({ userId: user3.id, roleId: role1.id }), + assignRole({ userId: user3.id, roleId: role2.id }), + ]); + + const result = await service.fetchModerators(); + expectUsers(result, [root, user2, user3]); + }); + + test('モデレーターロール無し + 特殊ポリシーロール -> root, user1, user3', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + + const [role1, role2] = await Promise.all([ + createRole({ + isModerator: false, policies: { + isModeratorInactivityCheckTarget: { + useDefault: false, + value: true, + priority: 0, + }, + }, + }), + createRole({ isModerator: false }), + ]); + + await Promise.all([ + assignRole({ userId: user1.id, roleId: role1.id }), + assignRole({ userId: user2.id, roleId: role2.id }), + assignRole({ userId: user3.id, roleId: role1.id }), + assignRole({ userId: user3.id, roleId: role2.id }), + ]); + + const result = await service.fetchModerators(); + expectUsers(result, [root, user1, user3]); + }); + + test('モデレーターロールあり + 特殊ポリシーロール -> root, user1, user2, user3', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser(), + createUser(), + createUser(), + ]); + + const [role1, role2] = await Promise.all([ + createRole({ + isModerator: false, policies: { + isModeratorInactivityCheckTarget: { + useDefault: false, + value: true, + priority: 0, + }, + }, + }), + createRole({ isModerator: true }), + ]); + + await Promise.all([ + assignRole({ userId: user1.id, roleId: role1.id }), + assignRole({ userId: user2.id, roleId: role2.id }), + assignRole({ userId: user3.id, roleId: role1.id }), + assignRole({ userId: user3.id, roleId: role2.id }), + ]); + + const result = await service.fetchModerators(); + expectUsers(result, [root, user1, user2, user3]); + }); + }); +}); diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 4fe5cbb205..4476075f61 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -106,6 +106,7 @@ export const ROLE_POLICIES = [ 'canImportFollowing', 'canImportMuting', 'canImportUserLists', + 'isModeratorInactivityCheckTarget', ] as const; // なんか動かない diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index ae01432d0c..edc1c83b91 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -690,6 +690,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ + + + + + + + + +
+
@@ -698,6 +718,7 @@ SPDX-License-Identifier: AGPL-3.0-only