diff --git a/packages/backend/migration/1751848750315-RemoteSuspend.js b/packages/backend/migration/1751848750315-RemoteSuspend.js new file mode 100644 index 0000000000..efa7b83026 --- /dev/null +++ b/packages/backend/migration/1751848750315-RemoteSuspend.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +export class RemoteSuspend1751848750315 { + name = 'RemoteSuspend1751848750315' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "isRemoteSuspended" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSuspended" IS 'Whether the User is suspended by the local moderators.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isRemoteSuspended" IS 'Whether the User is suspended by the remote moderators.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."isRemoteSuspended" IS 'Whether the User is suspended by the remote moderators.'`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSuspended" IS 'Whether the User is suspended.'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isRemoteSuspended"`); + } +} diff --git a/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js new file mode 100644 index 0000000000..ce63c16fb7 --- /dev/null +++ b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FollowingIsFollowerSuspended1752410859370 { + name = 'FollowingIsFollowerSuspended1752410859370' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`); + await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerSuspended" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_1896254b78a41a50e0396fdabd" ON "following" ("followeeId", "followerHost", "isFollowerSuspended", "isFollowerHibernated") `); + await queryRunner.query(`CREATE INDEX "IDX_d2b8dbf0b772042f4fe241a29d" ON "following" ("followerId", "followeeId", "isFollowerSuspended") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d2b8dbf0b772042f4fe241a29d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1896254b78a41a50e0396fdabd"`); + await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerSuspended"`); + await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `); + } +} diff --git a/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js new file mode 100644 index 0000000000..185d8cbe1a --- /dev/null +++ b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 { + name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000' + + async up(queryRunner) { + // Update existing records based on user suspension status + await queryRunner.query(` + UPDATE "following" + SET "isFollowerSuspended" = "user"."isSuspended" + FROM "user" + WHERE "following"."followerId" = "user"."id" + `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index 69a57b4854..bdbcc3fd1f 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { RelayService } from '@/core/RelayService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; @@ -26,16 +26,49 @@ export class AccountUpdateService { ) { } + private async createUpdatePersonActivity(user: MiLocalUser) { + return this.apRendererService.addContext( + this.apRendererService.renderUpdate( + await this.apRendererService.renderPerson(user), user + ) + ); + } + @bindThis public async publishToFollowers(userId: MiUser['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); - if (user == null) throw new Error('user not found'); + if (user == null || user.isDeleted) { + // ユーザーが存在しない、または削除されている場合は何もしない + return; + } - // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + // ローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); + const content = await this.createUpdatePersonActivity(user); this.apDeliverManagerService.deliverToFollowers(user, content); this.relayService.deliverToRelays(user, content); } } + + @bindThis + async publishToFollowersAndSharedInboxAndRelays(userId: MiUser['id']) { + const user = await this.usersRepository.findOneBy({ id: userId }); + if (user == null || user.isDeleted) { + // ユーザーが存在しない、または削除されている場合は何もしない + return; + } + + // ローカルユーザーならUpdateを配信 + if (this.userEntityService.isLocalUser(user)) { + const content = await this.createUpdatePersonActivity(user); + const manager = this.apDeliverManagerService.createDeliverManager(user, content); + manager.addAllKnowingSharedInboxRecipe(); + manager.addFollowersRecipe(); + + await Promise.allSettled([ + manager.execute(), + this.relayService.deliverToRelays(user, content), + ]); + } + } } diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 94c5691bf4..69f9634e07 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -149,6 +149,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (!ps.ignoreAuthorFromUserSuspension) { if (note.user!.isSuspended) return false; + if (note.user!.isRemoteSuspended) return false; } if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false; if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false; diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 3215b41c8d..d82a99fbf4 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -225,7 +225,7 @@ type UndefinedAsNullAll = { }; export interface InternalEventTypes { - userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; + userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; } | { id: MiUser['id']; isRemoteSuspended: MiUser['isRemoteSuspended']; }; userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; }; userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; remoteUserUpdated: { id: MiUser['id']; }; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1eefcfa054..e397821bd4 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -549,6 +549,7 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, + isFollowerSuspended: false, notify: 'normal', }).then(async followings => { if (note.visibility !== 'specified') { @@ -854,6 +855,7 @@ export class NoteCreateService implements OnApplicationShutdown { where: { followeeId: user.id, followerHost: IsNull(), + isFollowerSuspended: false, isFollowerHibernated: false, }, select: ['followerId', 'withReplies'], diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 2d0e7b5d83..e0b0f08aa2 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -174,6 +174,9 @@ export class QueueService { @bindThis public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map) { if (content == null) return null; + inboxes.delete(null as unknown as string); // remove null inboxes + if (inboxes.size === 0) return null; + const contentBody = JSON.stringify(content); const digest = ApRequestCreator.createDigest(contentBody); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 9e1d9cc370..11a5d7a7dc 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -260,7 +260,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } // サスペンド済みユーザである case 'isSuspended': { - return user.isSuspended; + return this.userEntityService.isSuspendedEither(user); } // 鍵アカウントユーザである case 'isLocked': { diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb..f5d8222a8e 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit { followee: { id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] }, - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] - }, + follower: MiUser, silent = false, withReplies?: boolean, ): Promise { @@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit { followerId: follower.id, followeeId: followee.id, withReplies: withReplies, + isFollowerSuspended: follower.isSuspended, // 非正規化 followerHost: follower.host, @@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit { return this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') .where('following.followerId = :followerId', { followerId: userId }) + .andWhere('following.isFollowerSuspended = false') .getMany(); } @@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit { where: { followerId, followeeId, + isFollowerSuspended: false, }, }); } diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts index 4be7bd9bdb..8c243a5368 100644 --- a/packages/backend/src/core/UserSearchService.ts +++ b/packages/backend/src/core/UserSearchService.ts @@ -207,7 +207,7 @@ export class UserSearchService { } } - userQuery.andWhere('user.isSuspended = FALSE'); + userQuery.andWhere('user.isSuspended = FALSE').andWhere('user.isRemoteSuspended = FALSE'); return userQuery; } @@ -243,7 +243,8 @@ export class UserSearchService { .where('user.updatedAt IS NULL') .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); })) - .andWhere('user.isSuspended = FALSE'); + .andWhere('user.isSuspended = FALSE') + .andWhere('user.isRemoteSuspended = FALSE'); if (mutingQuery) { nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); @@ -286,6 +287,7 @@ export class UserSearchService { .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); })) .andWhere('user.isSuspended = FALSE') + .andWhere('user.isRemoteSuspended = FALSE') .setParameters(profQuery.getParameters()); users = users.concat(await userQuery diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e36..3d109e0a40 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -4,17 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Not, IsNull } from 'typeorm'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; -import { QueueService } from '@/core/QueueService.js'; +import type { MiRemoteUser, MiUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; @Injectable() export class UserSuspendService { @@ -29,9 +26,8 @@ export class UserSuspendService { private followRequestsRepository: FollowRequestsRepository, private userEntityService: UserEntityService, - private queueService: QueueService, private globalEventService: GlobalEventService, - private apRendererService: ApRendererService, + private accountUpdateService: AccountUpdateService, private moderationLogService: ModerationLogService, ) { } @@ -49,8 +45,20 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); + await this.postSuspend(user, false).catch((e: any) => { }); + await this.suspendFollowings(user).catch((e: any) => { }); + })(); + } + + @bindThis + public async suspendFromRemote(user: { id: MiRemoteUser['id']; host: MiRemoteUser['host'] }): Promise { + await this.usersRepository.update(user.id, { + isRemoteSuspended: true, + }); + + (async () => { + await this.postSuspend(user, true).catch((e: any) => { }); + await this.suspendFollowings(user).catch((e: any) => { }); })(); } @@ -67,13 +75,29 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch(e => {}); + await this.postUnsuspend(user, false).catch((e: any) => { }); + await this.restoreFollowings(user).catch((e: any) => { }); })(); } @bindThis - private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise { - this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); + public async unsuspendFromRemote(user: { id: MiRemoteUser['id']; host: MiRemoteUser['host'] }): Promise { + await this.usersRepository.update(user.id, { + isRemoteSuspended: false, + }); + + (async () => { + await this.postUnsuspend(user, true).catch((e: any) => { }); + await this.restoreFollowings(user).catch((e: any) => { }); + })(); + } + + @bindThis + private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }, isFromRemote: boolean): Promise { + this.globalEventService.publishInternalEvent( + 'userChangeSuspendedState', + isFromRemote ? { id: user.id, isRemoteSuspended: true } : { id: user.id, isSuspended: true } + ); this.followRequestsRepository.delete({ followeeId: user.id, @@ -83,80 +107,55 @@ export class UserSuspendService { }); if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id); } } @bindThis - private async postUnsuspend(user: MiUser): Promise { - this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); + private async postUnsuspend(user: { id: MiUser['id']; host: MiUser['host'] }, isFromRemote: boolean): Promise { + this.globalEventService.publishInternalEvent( + 'userChangeSuspendedState', + isFromRemote ? { id: user.id, isRemoteSuspended: false } : { id: user.id, isSuspended: false } + ); if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox, true); - } + this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id); } } @bindThis - private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { + private async suspendFollowings(follower: { id: MiUser['id'] }) { + await this.followingsRepository.update( + { followerId: follower.id, - followeeId: Not(IsNull()), }, - }); - - const jobs: RelationshipJobData[] = []; - for (const following of followings) { - if (following.followeeId && following.followerId) { - jobs.push({ - from: { id: following.followerId }, - to: { id: following.followeeId }, - silent: true, - }); + { + isFollowerSuspended: true, } + ); + } + + @bindThis + private async restoreFollowings(_follower: { id: MiUser['id'] }) { + // 最新の情報を取得 + const follower = await this.usersRepository.findOneBy({ id: _follower.id }); + if (follower == null) { + // ユーザーが削除されている場合は何もしないでおく + return; } - this.queueService.createUnfollowJob(jobs); + if (this.userEntityService.isSuspendedEither(follower)) { + // フォロー関係を復元しない + return; + } + + // フォロー関係を復元(isFollowerSuspended: false)に変更 + await this.followingsRepository.update( + { + followerId: follower.id, + }, + { + isFollowerSuspended: false, + } + ); } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 6714bda9a9..1f73e5151b 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -44,6 +44,7 @@ function generateDummyUser(override?: Partial): MiUser { avatarDecorations: [], tags: [], isSuspended: false, + isRemoteSuspended: false, isLocked: false, isBot: false, isCat: true, diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 0140ce9fd6..a8a6a1491e 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -9,10 +9,12 @@ import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import type { IActivity } from '@/core/activitypub/type.js'; import { ThinUser } from '@/queue/types.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import type Logger from '@/logger.js'; +import { ApLoggerService } from './ApLoggerService.js'; interface IRecipe { type: string; @@ -27,12 +29,19 @@ interface IDirectRecipe extends IRecipe { to: MiRemoteUser; } +interface IAllKnowingSharedInboxRecipe extends IRecipe { + type: 'AllKnowingSharedInbox'; +} + const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => recipe.type === 'Followers'; const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => recipe.type === 'Direct'; +const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe => + recipe.type === 'AllKnowingSharedInbox'; + class DeliverManager { private actor: ThinUser; private activity: IActivity | null; @@ -40,16 +49,15 @@ class DeliverManager { /** * Constructor - * @param userEntityService * @param followingsRepository * @param queueService * @param actor Actor * @param activity Activity to deliver */ constructor( - private userEntityService: UserEntityService, private followingsRepository: FollowingsRepository, private queueService: QueueService, + private logger: Logger, actor: { id: MiUser['id']; host: null; }, activity: IActivity | null, @@ -91,6 +99,18 @@ class DeliverManager { this.addRecipe(recipe); } + /** + * Add recipe for all-knowing shared inbox deliver + */ + @bindThis + public addAllKnowingSharedInboxRecipe(): void { + const deliver: IAllKnowingSharedInboxRecipe = { + type: 'AllKnowingSharedInbox', + }; + + this.addRecipe(deliver); + } + /** * Add recipe * @param recipe Recipe @@ -104,11 +124,30 @@ class DeliverManager { * Execute delivers */ @bindThis - public async execute(): Promise { + public async execute(opts: { ignoreSuspend?: boolean } = {}): Promise { + //#region collect inboxes by recipes // The value flags whether it is shared or not. // key: inbox URL, value: whether it is sharedInbox const inboxes = new Map(); + if (this.recipes.some(r => isAllKnowingSharedInbox(r))) { + // all-knowing shared inbox + const followings = await this.followingsRepository.createQueryBuilder('f') + .select([ + 'f.followerSharedInbox', + 'f.followeeSharedInbox', + ]) + .where('f.followerSharedInbox IS NOT NULL') + .orWhere('f.followeeSharedInbox IS NOT NULL') + .distinct() + .getRawMany<{ f_followerSharedInbox: string | null; f_followeeSharedInbox: string | null; }>(); + + for (const following of followings) { + if (following.f_followeeSharedInbox) inboxes.set(following.f_followeeSharedInbox, true); + if (following.f_followerSharedInbox) inboxes.set(following.f_followerSharedInbox, true); + } + } + // build inbox list // Process follower recipes first to avoid duplication when processing direct recipes later. if (this.recipes.some(r => isFollowers(r))) { @@ -119,6 +158,7 @@ class DeliverManager { where: { followeeId: this.actor.id, followerHost: Not(IsNull()), + isFollowerSuspended: opts.ignoreSuspend ? undefined : false, }, select: { followerSharedInbox: true, @@ -142,21 +182,26 @@ class DeliverManager { inboxes.set(recipe.to.inbox, false); } + //#endregion // deliver await this.queueService.deliverMany(this.actor, this.activity, inboxes); + this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`); } } @Injectable() export class ApDeliverManagerService { + private logger: Logger; + constructor( @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private userEntityService: UserEntityService, private queueService: QueueService, + private apLoggerService: ApLoggerService, ) { + this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager'); } /** @@ -167,9 +212,9 @@ export class ApDeliverManagerService { @bindThis public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { const manager = new DeliverManager( - this.userEntityService, this.followingsRepository, this.queueService, + this.logger, actor, activity, ); @@ -186,9 +231,9 @@ export class ApDeliverManagerService { @bindThis public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise { const manager = new DeliverManager( - this.userEntityService, this.followingsRepository, this.queueService, + this.logger, actor, activity, ); @@ -205,9 +250,9 @@ export class ApDeliverManagerService { @bindThis public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise { const manager = new DeliverManager( - this.userEntityService, this.followingsRepository, this.queueService, + this.logger, actor, activity, ); @@ -218,10 +263,9 @@ export class ApDeliverManagerService { @bindThis public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( - this.userEntityService, this.followingsRepository, this.queueService, - + this.logger, actor, activity, ); diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e88f60b806..623fde4d63 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -141,7 +141,7 @@ export class ApInboxService { @bindThis public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise { - if (actor.isSuspended) return; + // ここでは凍結されているかどうかはチェックせず、各処理で判断する if (isCreate(activity)) { return await this.create(actor, activity, resolver); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 55521d6e3a..2ba0dfc7e9 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -577,6 +577,7 @@ export class ApRendererService { publicKey: this.renderKey(user, keypair, '#main-key'), isCat: user.isCat, attachment: attachment.length ? attachment : undefined, + suspended: user.isSuspended, }; if (user.movedToUri) { diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 6611e4b7f9..7b8278b2ba 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -543,6 +543,7 @@ const extension_context_definition = { Emoji: 'toot:Emoji', featured: 'toot:featured', discoverable: 'toot:discoverable', + suspended: 'toot:suspended', // schema schema: 'http://schema.org#', PropertyValue: 'schema:PropertyValue', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e52078ed0f..fdba16eab4 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -38,6 +38,7 @@ import { RoleService } from '@/core/RoleService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -74,6 +75,7 @@ export class ApPersonService implements OnModuleInit { private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; private accountMoveService: AccountMoveService; + private userSuspendService: UserSuspendService; private logger: Logger; constructor( @@ -126,6 +128,7 @@ export class ApPersonService implements OnModuleInit { this.instanceChart = this.moduleRef.get('InstanceChart'); this.apLoggerService = this.moduleRef.get('ApLoggerService'); this.accountMoveService = this.moduleRef.get('AccountMoveService'); + this.userSuspendService = this.moduleRef.get('UserSuspendService'); this.logger = this.apLoggerService.logger; } @@ -393,6 +396,7 @@ export class ApPersonService implements OnModuleInit { makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, + isRemoteSuspended: person.suspended === true, })) as MiRemoteUser; let _description: string | null = null; @@ -570,6 +574,7 @@ export class ApPersonService implements OnModuleInit { movedToUri: person.movedTo ?? null, alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: person.discoverable, + isRemoteSuspended: person.suspended === true, ...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))), } as Partial & Pick; @@ -598,6 +603,19 @@ export class ApPersonService implements OnModuleInit { return 'skip'; } + //#region suspend + if (exist.isRemoteSuspended === false && person.suspended === true) { + // リモートサーバーでアカウントが凍結された + this.logger.info(`Remote User Suspended: acct=${exist.username}@${exist.host} id=${exist.id} uri=${exist.uri}`); + this.userSuspendService.suspendFromRemote({ id: exist.id, host: exist.host }); + } + if (exist.isRemoteSuspended === true && person.suspended === false) { + // リモートサーバーでアカウントが解凍された + this.logger.info(`Remote User Unsuspended: acct=${exist.username}@${exist.host} id=${exist.id} uri=${exist.uri}`); + this.userSuspendService.unsuspendFromRemote({ id: exist.id, host: exist.host }); + } + //#endregion + if (person.publicKey) { await this.userPublickeysRepository.update({ userId: exist.id }, { keyId: person.publicKey.id, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 72732b01df..bc6da82bef 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -195,6 +195,7 @@ export interface IActor extends IObject { }; 'vcard:bday'?: string; 'vcard:Address'?: string; + suspended?: boolean; } export const isCollection = (object: IObject): object is ICollection => diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index e91fb9eb51..199bb9bf25 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -290,7 +290,7 @@ export class NotificationEntityService implements OnModuleInit { if (notifier == null) return false; if (notifier.host && userMutedInstances.has(notifier.host)) return false; - if (notifier.isSuspended) return false; + if (this.userEntityService.isSuspendedEither(notifier)) return false; return true; } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 47021359e1..7aa7e54a97 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -69,6 +69,10 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { return !isLocalUser(user); } +function isSuspendedEither(user: MiUser): boolean { + return user.isSuspended || user.isRemoteSuspended; +} + export type UserRelation = { id: MiUser['id'] following: MiFollowing | null, @@ -163,6 +167,7 @@ export class UserEntityService implements OnModuleInit { public isLocalUser = isLocalUser; public isRemoteUser = isRemoteUser; + public isSuspendedEither = isSuspendedEither; @bindThis public async getRelation(me: MiUser['id'], target: MiUser['id']): Promise { @@ -538,7 +543,7 @@ export class UserEntityService implements OnModuleInit { bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), - isSuspended: user.isSuspended, + isSuspended: this.isSuspendedEither(user), description: profile!.description, location: profile!.location, birthday: profile!.birthday, diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..b9ac2e5005 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -9,7 +9,8 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) -@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) +@Index(['followerId', 'followeeId', 'isFollowerSuspended']) +@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated']) export class MiFollowing { @PrimaryColumn(id()) public id: string; @@ -45,6 +46,11 @@ export class MiFollowing { }) public isFollowerHibernated: boolean; + @Column('boolean', { + default: false, + }) + public isFollowerSuspended: boolean; + // タイムラインにその人のリプライまで含めるかどうか @Column('boolean', { default: false, diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index a6e9edcf5f..33cd01dfe3 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -166,10 +166,16 @@ export class MiUser { @Column('boolean', { default: false, - comment: 'Whether the User is suspended.', + comment: 'Whether the User is suspended by the local moderators.', }) public isSuspended: boolean; + @Column('boolean', { + default: false, + comment: 'Whether the User is suspended by the remote moderators.', + }) + public isRemoteSuspended: boolean; + @Column('boolean', { default: false, comment: 'Whether the User is locked.', diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 1286b4dad6..b043e71c53 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -215,6 +215,7 @@ export class ServerService implements OnApplicationShutdown { usernameLower: username.toLowerCase(), host: (host == null) || (host === this.config.host) ? IsNull() : host, isSuspended: false, + isRemoteSuspended: false, }, }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 1ba6853dbe..8364a9613b 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -124,6 +124,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRemoteSuspended: { + type: 'boolean', + optional: false, nullable: false, + }, isHibernated: { type: 'boolean', optional: false, nullable: false, @@ -246,6 +250,7 @@ export default class extends Endpoint { // eslint- isModerator: isModerator, isSilenced: isSilenced, isSuspended: user.isSuspended, + isRemoteSuspended: user.isRemoteSuspended, isHibernated: user.isHibernated, lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null, moderationNote: profile.moderationNote ?? '', diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 2b2c8c60ab..d8e4a636c4 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- const query = this.usersRepository.createQueryBuilder('user'); switch (ps.state) { - case 'available': query.where('user.isSuspended = FALSE'); break; + case 'available': query.where('user.isSuspended = FALSE').andWhere('user.isRemoteSuspended = FALSE'); break; case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; case 'suspended': query.where('user.isSuspended = TRUE'); break; case 'admin': { diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index 296bc7c5a8..f4d4caf988 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -50,7 +50,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('following.followeeHost = :host', { host: ps.host }); + .andWhere('following.followeeHost = :host', { host: ps.host }) + .andWhere('following.isFollowerSuspended = false'); const followings = await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index 091bf442af..fa8af37c1c 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -50,7 +50,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('following.followerHost = :host', { host: ps.host }); + .andWhere('following.followerHost = :host', { host: ps.host }) + .andWhere('following.isFollowerSuspended = false'); const followings = await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index 69900bff9a..3093aa1e86 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -94,11 +94,13 @@ export default class extends Endpoint { // eslint- this.followingsRepository.count({ where: { followeeHost: Not(IsNull()), + isFollowerSuspended: false, }, }), this.followingsRepository.count({ where: { followerHost: Not(IsNull()), + isFollowerSuspended: false, }, }), ]); diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index 30f0c1b0c8..d5a1ecdaed 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -51,7 +51,8 @@ export default class extends Endpoint { // eslint- if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); const query = this.usersRepository.createQueryBuilder('user') .where(':tag <@ user.tags', { tag: [normalizeForSearch(ps.tag)] }) - .andWhere('user.isSuspended = FALSE'); + .andWhere('user.isSuspended = FALSE') + .andWhere('user.isRemoteSuspended = FALSE'); const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 84c4c80d01..d2dd8cd744 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -125,6 +125,7 @@ export default class extends Endpoint { // eslint- where: { followeeId: user.id, followerId: me.id, + isFollowerSuspended: false, }, }); if (!isFollowing) { @@ -136,12 +137,15 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.isFollowerSuspended = FALSE') .innerJoinAndSelect('following.follower', 'follower'); const followings = await query .limit(ps.limit) .getMany(); + console.log(followings); + return await this.followingEntityService.packMany(followings, me, { populateFollower: true }); }); } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..fb85106e11 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -133,6 +133,7 @@ export default class extends Endpoint { // eslint- where: { followeeId: user.id, followerId: me.id, + isFollowerSuspended: false, }, }); if (!isFollowing) { @@ -144,6 +145,7 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followerId = :userId', { userId: user.id }) + .andWhere('following.isFollowerSuspended = false') .innerJoinAndSelect('following.followee', 'followee'); if (ps.birthday) { diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 769a72d7a1..3ef7848ebb 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -68,7 +68,8 @@ export default class extends Endpoint { // eslint- const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + .where('following.followerId = :followerId', { followerId: me.id }) + .andWhere('following.isFollowerSuspended = false'); query .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 5ff3a63d6a..04dd3f7dd4 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -138,6 +138,7 @@ export default class extends Endpoint { // eslint- } : { id: In(ps.userIds), isSuspended: false, + isRemoteSuspended: false, }); // リクエストされた通りに並べ替え diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 3cd83efa1a..aedec54e9e 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -435,6 +435,7 @@ export class ClientServerService { usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, + isRemoteSuspended: false, requireSigninToViewContents: false, }); @@ -494,6 +495,7 @@ export class ClientServerService { usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, + isRemoteSuspended: false, }); vary(reply.raw, 'Accept'); @@ -543,6 +545,7 @@ export class ClientServerService { id: request.params.user, host: IsNull(), isSuspended: false, + isRemoteSuspended: false, }); if (user == null) { diff --git a/packages/backend/test-federation/test/user-suspension.test.ts b/packages/backend/test-federation/test/user-suspension.test.ts new file mode 100644 index 0000000000..a2e6fb787d --- /dev/null +++ b/packages/backend/test-federation/test/user-suspension.test.ts @@ -0,0 +1,95 @@ +import assert, { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +const [aAdmin, bAdmin] = await Promise.all([ + fetchAdmin('a.test'), + fetchAdmin('b.test'), +]); + +describe('User Suspension', () => { + describe('Suspension', () => { + describe('Check suspend/unsuspend consistency', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); + await sleep(); + + const aliceInBRaw = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id }); + strictEqual(aliceInBRaw.isRemoteSuspended, true); + const renewedAliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedAliceInB.isSuspended, true); + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'ALREADY_FOLLOWING'); + return true; + }, + ); + }); + + test('Alice gets unsuspended, Bob succeeds in following Alice', async () => { + await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id }); + await sleep(); + + const aliceInBRenewed = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id }); + strictEqual(aliceInBRenewed.isRemoteSuspended, false); + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'ALREADY_FOLLOWING'); + return true; + }, + ); + }); + + test('Alice can follow Bob', async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + await sleep(); + + const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(bobFollowers.length, 1); // followed by Alice + assert(bobFollowers[0].follower != null); + const renewedAliceInB = bobFollowers[0].follower; + assert(aliceInB.username === renewedAliceInB.username); + assert(aliceInB.host === renewedAliceInB.host); + assert(aliceInB.id === renewedAliceInB.id); + }); + + test('Alice follows Bob, and Alice gets suspended, the following relation hidden', async () => { + await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); + await sleep(1000); + + const renewedAliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedAliceInB.isSuspended, true); + const aliceInBRaw = await bAdmin.client.request('admin/show-user', { userId: aliceInB.id }); + strictEqual(aliceInBRaw.isRemoteSuspended, true); + + const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(bobFollowers.length, 0); // Relation is hidden + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts index ebbe9ff5ba..38c1251600 100644 --- a/packages/backend/test-federation/test/user.test.ts +++ b/packages/backend/test-federation/test/user.test.ts @@ -452,110 +452,4 @@ describe('User', () => { }); }); }); - - describe('Suspension', () => { - describe('Check suspend/unsuspend consistency', () => { - let alice: LoginUser, bob: LoginUser; - let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; - - beforeAll(async () => { - [alice, bob] = await Promise.all([ - createAccount('a.test'), - createAccount('b.test'), - ]); - - [bobInA, aliceInB] = await Promise.all([ - resolveRemoteUser('b.test', bob.id, alice), - resolveRemoteUser('a.test', alice.id, bob), - ]); - }); - - test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => { - await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - const followers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(followers.length, 1); // followed by Bob - - await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); - await sleep(); - - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 0); // no following relation - - await rejects( - async () => await bob.client.request('following/create', { userId: aliceInB.id }), - (err: any) => { - strictEqual(err.code, 'NO_SUCH_USER'); - return true; - }, - ); - }); - - test('Alice gets unsuspended, Bob succeeds in following Alice', async () => { - await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id }); - await sleep(); - - const followers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(followers.length, 1); // FIXME: followers are not deleted?? - - /** - * FIXME: still rejected! - * seems to can't process Undo Delete activity because it is not implemented - * related @see https://github.com/misskey-dev/misskey/issues/13273 - */ - await rejects( - async () => await bob.client.request('following/create', { userId: aliceInB.id }), - (err: any) => { - strictEqual(err.code, 'NO_SUCH_USER'); - return true; - }, - ); - - // FIXME: resolving also fails - await rejects( - async () => await resolveRemoteUser('a.test', alice.id, bob), - (err: any) => { - strictEqual(err.code, 'INTERNAL_ERROR'); - return true; - }, - ); - }); - - /** - * instead of simple unsuspension, let's tell existence by following from Alice - */ - test('Alice can follow Bob', async () => { - await alice.client.request('following/create', { userId: bobInA.id }); - await sleep(); - - const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); - strictEqual(bobFollowers.length, 1); // followed by Alice - assert(bobFollowers[0].follower != null); - const renewedaliceInB = bobFollowers[0].follower; - assert(aliceInB.username === renewedaliceInB.username); - assert(aliceInB.host === renewedaliceInB.host); - assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK? - - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 0); // following are deleted - - // Bob tries to follow Alice - await bob.client.request('following/create', { userId: renewedaliceInB.id }); - await sleep(); - - const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id }); - strictEqual(aliceFollowers.length, 1); - - // FIXME: but resolving still fails ... - await rejects( - async () => await resolveRemoteUser('a.test', alice.id, bob), - (err: any) => { - strictEqual(err.code, 'INTERNAL_ERROR'); - return true; - }, - ); - }); - }); - }); }); diff --git a/packages/backend/test/unit/ApDeliverManagerService.ts b/packages/backend/test/unit/ApDeliverManagerService.ts new file mode 100644 index 0000000000..26cec66b63 --- /dev/null +++ b/packages/backend/test/unit/ApDeliverManagerService.ts @@ -0,0 +1,620 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { FollowingsRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +describe('ApDeliverManagerService', () => { + let service: ApDeliverManagerService; + let followingsRepository: jest.Mocked; + let queueService: jest.Mocked; + let apLoggerService: jest.Mocked; + + const mockLocalUser: MiLocalUser = { + id: 'local-user-id', + host: null, + } as MiLocalUser; + + const mockRemoteUser1: MiRemoteUser & { inbox: string; sharedInbox: string; } = { + id: 'remote-user-1', + host: 'remote.example.com', + inbox: 'https://remote.example.com/inbox', + sharedInbox: 'https://remote.example.com/shared-inbox', + } as MiRemoteUser & { inbox: string; sharedInbox: string; }; + + const mockRemoteUser2: MiRemoteUser & { inbox: string; } = { + id: 'remote-user-2', + host: 'another.example.com', + inbox: 'https://another.example.com/inbox', + sharedInbox: null, + } as MiRemoteUser & { inbox: string; }; + + const mockActivity: IActivity = { + type: 'Create', + id: 'activity-id', + actor: 'https://local.example.com/users/local-user-id', + object: { + type: 'Note', + id: 'note-id', + content: 'Hello, world!', + }, + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApDeliverManagerService, + { + provide: DI.followingsRepository, + useValue: { + find: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: QueueService, + useValue: { + deliverMany: jest.fn(), + }, + }, + { + provide: ApLoggerService, + useValue: { + logger: { + createSubLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + }, + }, + }, + ], + }).compile(); + + service = module.get(ApDeliverManagerService); + followingsRepository = module.get(DI.followingsRepository); + queueService = module.get(QueueService); + apLoggerService = module.get(ApLoggerService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('deliverToFollowers', () => { + it('should deliver activity to all followers', async () => { + const mockFollowings = [ + { + followerSharedInbox: 'https://remote1.example.com/shared-inbox', + followerInbox: 'https://remote1.example.com/inbox', + }, + { + followerSharedInbox: 'https://remote2.example.com/shared-inbox', + followerInbox: 'https://remote2.example.com/inbox', + }, + { + followerSharedInbox: null, + followerInbox: 'https://remote3.example.com/inbox', + }, + ]; + + followingsRepository.find.mockResolvedValue(mockFollowings as any); + + await service.deliverToFollowers(mockLocalUser, mockActivity); + + expect(followingsRepository.find).toHaveBeenCalledWith({ + where: { + followeeId: mockLocalUser.id, + followerHost: expect.anything(), // Not(IsNull()) + isFollowerSuspended: false, + }, + select: { + followerSharedInbox: true, + followerInbox: true, + }, + }); + + expect(queueService.deliverMany).toHaveBeenCalledWith( + { id: mockLocalUser.id }, + mockActivity, + expect.any(Map), + ); + + // 呼び出されたinboxesを確認 + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(3); + expect(inboxes.has('https://remote1.example.com/shared-inbox')).toBe(true); + expect(inboxes.has('https://remote2.example.com/shared-inbox')).toBe(true); + expect(inboxes.has('https://remote3.example.com/inbox')).toBe(true); + }); + + it('should exclude suspended followers by default', async () => { + followingsRepository.find.mockResolvedValue([]); + + await service.deliverToFollowers(mockLocalUser, mockActivity); + + expect(followingsRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isFollowerSuspended: false, + }), + }), + ); + }); + }); + + describe('deliverToUser', () => { + it('should deliver activity to specific remote user', async () => { + await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser1); + + expect(queueService.deliverMany).toHaveBeenCalledWith( + { id: mockLocalUser.id }, + mockActivity, + expect.any(Map), + ); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(1); + expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true); + }); + + it('should handle user without shared inbox', async () => { + await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser2); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(1); + expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true); + }); + + it('should skip user with null inbox', async () => { + const userWithoutInbox = { + ...mockRemoteUser1, + inbox: null, + } as MiRemoteUser; + + await service.deliverToUser(mockLocalUser, mockActivity, userWithoutInbox); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(0); + }); + }); + + describe('deliverToUsers', () => { + it('should deliver activity to multiple remote users', async () => { + await service.deliverToUsers(mockLocalUser, mockActivity, [mockRemoteUser1, mockRemoteUser2]); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(2); + expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true); + expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true); + }); + }); + + describe('createDeliverManager', () => { + it('should create a DeliverManager instance', () => { + const manager = service.createDeliverManager(mockLocalUser, mockActivity); + + expect(manager).toBeDefined(); + expect(typeof manager.addFollowersRecipe).toBe('function'); + expect(typeof manager.addDirectRecipe).toBe('function'); + expect(typeof manager.addAllKnowingSharedInboxRecipe).toBe('function'); + expect(typeof manager.execute).toBe('function'); + }); + + it('should allow manual recipe management', async () => { + const manager = service.createDeliverManager(mockLocalUser, mockActivity); + + followingsRepository.find.mockResolvedValue([ + { + followerSharedInbox: null, + followerInbox: 'https://follower.example.com/inbox', + }, + ] as any); + + // フォロワー配信のレシピを追加 + manager.addFollowersRecipe(); + // ダイレクト配信のレシピを追加 + manager.addDirectRecipe(mockRemoteUser1); + + await manager.execute(); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(2); + expect(inboxes.has('https://follower.example.com/inbox')).toBe(true); + expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true); + }); + + it('should support ignoreSuspend option', async () => { + const manager = service.createDeliverManager(mockLocalUser, mockActivity); + + followingsRepository.find.mockResolvedValue([]); + + manager.addFollowersRecipe(); + await manager.execute({ ignoreSuspend: true }); + + expect(followingsRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isFollowerSuspended: undefined, // ignoreSuspend: true なので undefined + }), + }), + ); + }); + + it('followers and directs mixture: 先にfollowersでsharedInboxが追加されていた場合、directsでユーザーがそのsharedInboxを持っていたらinboxを追加しない', async () => { + const manager = service.createDeliverManager(mockLocalUser, mockActivity); + followingsRepository.find.mockResolvedValue([ + { + followerSharedInbox: mockRemoteUser1.sharedInbox, + followerInbox: mockRemoteUser2.inbox, + }, + ] as any); + manager.addFollowersRecipe(); + manager.addDirectRecipe(mockRemoteUser1); + await manager.execute(); + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(1); + expect(inboxes.has(mockRemoteUser1.sharedInbox)).toBe(true); + expect(inboxes.has(mockRemoteUser1.inbox)).toBe(false); + }); + }); + + describe('error handling', () => { + it('should throw error for non-local actor', () => { + const remoteActor = { id: 'remote-id', host: 'remote.example.com' } as any; + + expect(() => { + service.createDeliverManager(remoteActor, mockActivity); + }).toThrow('actor.host must be null'); + }); + + it('should throw error when follower has null inbox', async () => { + const mockFollowings = [ + { + followerSharedInbox: null, + followerInbox: null, // null inbox + }, + ]; + + followingsRepository.find.mockResolvedValue(mockFollowings as any); + + await expect(service.deliverToFollowers(mockLocalUser, mockActivity)).rejects.toThrow('inbox is null'); + }); + }); + + describe('AllKnowingSharedInbox recipe', () => { + it('should collect all shared inboxes when using AllKnowingSharedInbox', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), + distinct: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { f_followerSharedInbox: 'https://shared1.example.com/inbox' }, + { f_followeeSharedInbox: 'https://shared2.example.com/inbox' }, + ]), + }; + + followingsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const manager = service.createDeliverManager(mockLocalUser, mockActivity); + manager.addAllKnowingSharedInboxRecipe(); + + await manager.execute(); + + expect(followingsRepository.createQueryBuilder).toHaveBeenCalledWith('f'); + expect(mockQueryBuilder.select).toHaveBeenCalledWith([ + 'f.followerSharedInbox', + 'f.followeeSharedInbox', + ]); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(2); + expect(inboxes.has('https://shared1.example.com/inbox')).toBe(true); + expect(inboxes.has('https://shared2.example.com/inbox')).toBe(true); + }); + }); +}); + +describe('ApDeliverManagerService (SQL)', () => { + // followerにデータを挿入して、SQLの動作を確認します + let app: TestingModule; + let service: ApDeliverManagerService; + let followingsRepository: FollowingsRepository; + let usersRepository: UsersRepository; + let queueService: jest.Mocked; + + async function createUser(data: Partial<{ id: string; username: string; host: string | null; inbox: string | null; sharedInbox: string | null; isSuspended: boolean }> = {}): Promise { + const user = { + id: secureRndstr(16), + username: secureRndstr(16), + usernameLower: (data.username ?? secureRndstr(16)).toLowerCase(), + host: data.host ?? null, + inbox: data.inbox ?? null, + sharedInbox: data.sharedInbox ?? null, + isSuspended: data.isSuspended ?? false, + ...data, + }; + + await usersRepository.insert(user); + return user; + } + + async function createFollowing(follower: any, followee: any, data: Partial<{ + followerInbox: string | null; + followerSharedInbox: string | null; + followeeInbox: string | null; + followeeSharedInbox: string | null; + isFollowerSuspended: boolean; + }> = {}): Promise { + const following = { + id: secureRndstr(16), + followerId: follower.id, + followeeId: followee.id, + followerHost: follower.host, + followeeHost: followee.host, + followerInbox: data.followerInbox ?? follower.inbox, + followerSharedInbox: data.followerSharedInbox ?? follower.sharedInbox, + followeeInbox: data.followeeInbox ?? null, + followeeSharedInbox: data.followeeSharedInbox ?? null, + isFollowerSuspended: data.isFollowerSuspended ?? false, + isFollowerHibernated: false, + withReplies: false, + notify: null, + }; + + await followingsRepository.insert(following); + return following; + } + + beforeEach(async () => { + const { Test } = await import('@nestjs/testing'); + const { GlobalModule } = await import('@/GlobalModule.js'); + const { DI } = await import('@/di-symbols.js'); + + app = await Test.createTestingModule({ + imports: [GlobalModule], + providers: [ + ApDeliverManagerService, + { + provide: QueueService, + useFactory: () => ({ + deliverMany: jest.fn(), + }), + }, + { + provide: ApLoggerService, + useValue: { + logger: { + createSubLogger: jest.fn().mockReturnValue({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), + }, + }, + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ApDeliverManagerService); + followingsRepository = app.get(DI.followingsRepository); + usersRepository = app.get(DI.usersRepository); + queueService = app.get(QueueService) as jest.Mocked; + + // Reset mocks + jest.clearAllMocks(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('deliverToFollowers with real data', () => { + it('should deliver to followers excluding suspended ones', async () => { + // Create local user (followee) + const localUser = await createUser({ + host: null, + username: 'localuser', + }); + + // Create remote followers + const activeFollower = await createUser({ + host: 'active.example.com', + username: 'activefollower', + inbox: 'https://active.example.com/inbox', + sharedInbox: 'https://active.example.com/shared-inbox', + isSuspended: false, + }); + + const suspendedFollower = await createUser({ + host: 'suspended.example.com', + username: 'suspendedfollower', + inbox: 'https://suspended.example.com/inbox', + sharedInbox: 'https://suspended.example.com/shared-inbox', + isSuspended: true, + }); + + const followerWithoutSharedInbox = await createUser({ + host: 'noshared.example.com', + username: 'noshared', + inbox: 'https://noshared.example.com/inbox', + sharedInbox: null, + isSuspended: false, + }); + + // Create following relationships + await createFollowing(activeFollower, localUser, { + followerInbox: activeFollower.inbox, + followerSharedInbox: activeFollower.sharedInbox, + isFollowerSuspended: false, + }); + + await createFollowing(suspendedFollower, localUser, { + followerInbox: suspendedFollower.inbox, + followerSharedInbox: suspendedFollower.sharedInbox, + isFollowerSuspended: true, // 凍結されたフォロワー + }); + + await createFollowing(followerWithoutSharedInbox, localUser, { + followerInbox: followerWithoutSharedInbox.inbox, + followerSharedInbox: null, + isFollowerSuspended: false, + }); + + const mockActivity = { + type: 'Create', + id: 'test-activity', + actor: `https://local.example.com/users/${localUser.id}`, + object: { type: 'Note', content: 'Hello' }, + } as any; + + // Execute delivery + await service.deliverToFollowers(localUser, mockActivity); + + // Verify delivery was queued + expect(queueService.deliverMany).toHaveBeenCalledTimes(1); + const [actor, activity, inboxes] = queueService.deliverMany.mock.calls[0]; + + expect(actor.id).toBe(localUser.id); + expect(activity).toBe(mockActivity); + + // Check inboxes - should include active followers but exclude suspended ones + expect(inboxes.size).toBe(2); + expect(inboxes.has('https://active.example.com/shared-inbox')).toBe(true); + expect(inboxes.has('https://noshared.example.com/inbox')).toBe(true); + expect(inboxes.has('https://suspended.example.com/shared-inbox')).toBe(false); + }); + + it('should include suspended followers when ignoreSuspend is true', async () => { + const localUser = await createUser({ host: null }); + const suspendedFollower = await createUser({ + host: 'suspended.example.com', + inbox: 'https://suspended.example.com/inbox', + isSuspended: true, + }); + + await createFollowing(suspendedFollower, localUser, { + isFollowerSuspended: true, + }); + + const manager = service.createDeliverManager(localUser, { type: 'Test' } as any); + manager.addFollowersRecipe(); + + // Execute with ignoreSuspend: true + await manager.execute({ ignoreSuspend: true }); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + expect(inboxes.size).toBe(1); + expect(inboxes.has('https://suspended.example.com/inbox')).toBe(true); + }); + + it('should handle mixed follower types correctly', async () => { + const localUser = await createUser({ host: null }); + + // フォロワー1: shared inbox あり + const follower1 = await createUser({ + host: 'server1.example.com', + inbox: 'https://server1.example.com/users/user1/inbox', + sharedInbox: 'https://server1.example.com/inbox', + }); + + // フォロワー2: 同じサーバーの別ユーザー(shared inbox は同じ) + const follower2 = await createUser({ + host: 'server1.example.com', + inbox: 'https://server1.example.com/users/user2/inbox', + sharedInbox: 'https://server1.example.com/inbox', + }); + + // フォロワー3: 別サーバー、shared inbox なし + const follower3 = await createUser({ + host: 'server2.example.com', + inbox: 'https://server2.example.com/users/user3/inbox', + sharedInbox: null, + }); + + await createFollowing(follower1, localUser); + await createFollowing(follower2, localUser); + await createFollowing(follower3, localUser); + + await service.deliverToFollowers(localUser, { type: 'Test' } as any); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + + // shared inbox は重複排除されるので、2つのinboxのみ + expect(inboxes.size).toBe(2); + expect(inboxes.has('https://server1.example.com/inbox')).toBe(true); // shared inbox + expect(inboxes.has('https://server2.example.com/users/user3/inbox')).toBe(true); // individual inbox + + // individual inbox は shared inbox があるので使用されない + expect(inboxes.has('https://server1.example.com/users/user1/inbox')).toBe(false); + expect(inboxes.has('https://server1.example.com/users/user2/inbox')).toBe(false); + }); + }); + + describe('AllKnowingSharedInbox with real data', () => { + it('should collect all unique shared inboxes from database', async () => { + // Create users with various inbox configurations + const user1 = await createUser({ host: null }); + const user2 = await createUser({ host: null }); + + const remoteUser1 = await createUser({ + host: 'server1.example.com', + sharedInbox: 'https://server1.example.com/shared', + }); + + const remoteUser2 = await createUser({ + host: 'server2.example.com', + sharedInbox: 'https://server2.example.com/shared', + }); + + const remoteUser3 = await createUser({ + host: 'server1.example.com', // 同じサーバー + sharedInbox: 'https://server1.example.com/shared', // 同じ shared inbox + }); + + // Create following relationships + await createFollowing(remoteUser1, user1, { + followerSharedInbox: 'https://server1.example.com/shared', + }); + + await createFollowing(user1, remoteUser2, { + followerSharedInbox: null, + followeeSharedInbox: 'https://server2.example.com/shared', + }); + + await createFollowing(remoteUser3, user2, { + followerSharedInbox: 'https://server1.example.com/shared', // 重複 + }); + + const manager = service.createDeliverManager(user1, { type: 'Test' } as any); + manager.addAllKnowingSharedInboxRecipe(); + + await manager.execute(); + + const [, , inboxes] = queueService.deliverMany.mock.calls[0]; + + // 重複は除去されて2つのユニークな shared inbox + expect(inboxes.size).toBe(2); + expect(inboxes.has('https://server1.example.com/shared')).toBe(true); + expect(inboxes.has('https://server2.example.com/shared')).toBe(true); + }); + }); +}); + diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts new file mode 100644 index 0000000000..6d69a8e5e1 --- /dev/null +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -0,0 +1,479 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import { setTimeout } from 'node:timers/promises'; +import type { TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { + MiFollowing, + MiUser, + FollowingsRepository, + FollowRequestsRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { randomString } from '../utils.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js'; +import { MiRemoteUser } from '@/models/User.js'; + +function genHost() { + return randomString() + '.example.com'; +} + +describe('UserSuspendService', () => { + let app: TestingModule; + let userSuspendService: UserSuspendService; + let usersRepository: UsersRepository; + let followingsRepository: FollowingsRepository; + let followRequestsRepository: FollowRequestsRepository; + let userEntityService: jest.Mocked; + let queueService: jest.Mocked; + let globalEventService: jest.Mocked; + let apRendererService: jest.Mocked; + let moderationLogService: jest.Mocked; + + async function createUser(data: Partial = {}): Promise { + const user = { + id: secureRndstr(16), + username: secureRndstr(16), + usernameLower: secureRndstr(16).toLowerCase(), + host: null, + isSuspended: false, + isRemoteSuspended: false, + ...data, + } as MiUser; + + await usersRepository.insert(user); + return user; + } + + async function createFollowing(follower: MiUser, followee: MiUser, data: Partial = {}): Promise { + const following = { + id: secureRndstr(16), + followerId: follower.id, + followeeId: followee.id, + isFollowerSuspended: false, + isFollowerHibernated: false, + withReplies: false, + notify: null, + followerHost: follower.host, + followerInbox: null, + followerSharedInbox: null, + followeeHost: followee.host, + followeeInbox: null, + followeeSharedInbox: null, + ...data, + } as MiFollowing; + + await followingsRepository.insert(following); + return following; + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule], + providers: [ + UserSuspendService, + AccountUpdateService, + ApDeliverManagerService, + { + provide: UserEntityService, + useFactory: () => ({ + isLocalUser: jest.fn(), + genLocalUserUri: jest.fn(), + isSuspendedEither: jest.fn(), + }), + }, + { + provide: QueueService, + useFactory: () => ({ + deliverMany: jest.fn(), + deliver: jest.fn(), + }), + }, + { + provide: GlobalEventService, + useFactory: () => ({ + publishInternalEvent: jest.fn(), + }), + }, + { + provide: ModerationLogService, + useFactory: () => ({ + log: jest.fn(), + }), + }, + { + provide: RelayService, + useFactory: () => ({ + deliverToRelays: jest.fn(), + }), + }, + { + provide: ApRendererService, + useFactory: () => ({ + renderDelete: jest.fn(), + renderUndo: jest.fn(), + renderPerson: jest.fn(), + renderUpdate: jest.fn(), + addContext: jest.fn(), + }), + }, + { + provide: ApLoggerService, + useFactory: () => ({ + logger: { + createSubLogger: jest.fn().mockReturnValue({ + info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), + }), + }, + }), + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + userSuspendService = app.get(UserSuspendService); + usersRepository = app.get(DI.usersRepository); + followingsRepository = app.get(DI.followingsRepository); + followRequestsRepository = app.get(DI.followRequestsRepository); + userEntityService = app.get(UserEntityService) as jest.Mocked; + queueService = app.get(QueueService) as jest.Mocked; + globalEventService = app.get(GlobalEventService) as jest.Mocked; + apRendererService = app.get(ApRendererService) as jest.Mocked; + moderationLogService = app.get(ModerationLogService) as jest.Mocked; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('suspend', () => { + test('should suspend user and update database', async () => { + const user = await createUser(); + const moderator = await createUser(); + + await userSuspendService.suspend(user, moderator); + + // ユーザーが凍結されているかチェック + const suspendedUser = await usersRepository.findOneBy({ id: user.id }); + expect(suspendedUser?.isSuspended).toBe(true); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + }); + + test('should mark follower relationships as suspended', async () => { + const user = await createUser(); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // ユーザーがフォローしている関係を作成 + await createFollowing(user, followee1); + await createFollowing(user, followee2); + + await userSuspendService.suspend(user, moderator); + await setTimeout(250); + + // フォロー関係が論理削除されているかチェック + const followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(true); + }); + }); + + test('should publish internal event for suspension', async () => { + const user = await createUser(); + const moderator = await createUser(); + + await userSuspendService.suspend(user, moderator); + await setTimeout(250); + + // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) + await setTimeout(100); + + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: user.id, isSuspended: true }, + ); + }); + }); + + describe('unsuspend', () => { + test('should unsuspend user and update database', async () => { + const user = await createUser({ isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(user, moderator); + await setTimeout(250); + + // ユーザーの凍結が解除されているかチェック + const unsuspendedUser = await usersRepository.findOneBy({ id: user.id }); + expect(unsuspendedUser?.isSuspended).toBe(false); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + }); + + test('should restore follower relationships', async () => { + userEntityService.isSuspendedEither.mockReturnValue(false); + + const user = await createUser({ isSuspended: true }); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // 凍結状態のフォロー関係を作成 + await createFollowing(user, followee1, { isFollowerSuspended: true }); + await createFollowing(user, followee2, { isFollowerSuspended: true }); + + await userSuspendService.unsuspend(user, moderator); + await setTimeout(250); + + // フォロー関係が復元されているかチェック + const followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + }); + + test('should publish internal event for unsuspension', async () => { + const user = await createUser({ isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(user, moderator); + await setTimeout(250); + + // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) + await setTimeout(100); + + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: user.id, isSuspended: false }, + ); + }); + }); + + describe('integration test: suspend and unsuspend cycle', () => { + test('should preserve follow relationships through suspend/unsuspend cycle', async () => { + userEntityService.isSuspendedEither.mockReturnValue(false); + + const user = await createUser(); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // 初期のフォロー関係を作成 + await createFollowing(user, followee1); + await createFollowing(user, followee2); + + // 初期状態の確認 + let followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + + // 凍結 + await userSuspendService.suspend(user, moderator); + await setTimeout(250); + + // 凍結後の状態確認 + followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(true); + }); + + // 凍結解除 + const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id }); + await userSuspendService.unsuspend(suspendedUser, moderator); + await setTimeout(250); + + // 凍結解除後の状態確認 + followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + }); + }); + + describe('ActivityPub delivery', () => { + test('should deliver Update Person activity on suspend of local user', async () => { + const localUser = await createUser({ host: null }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(true); + userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`); + apRendererService.renderUpdate.mockReturnValue({ type: 'Update' } as any); + apRendererService.renderPerson.mockReturnValue({ type: 'Person' } as any); + apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Update' } as any); + + await userSuspendService.suspend(localUser, moderator); + await setTimeout(250); + + // ActivityPub配信が呼ばれているかチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); + expect(apRendererService.renderUpdate).toHaveBeenCalled(); + expect(apRendererService.renderPerson).toHaveBeenCalled(); + expect(apRendererService.addContext).toHaveBeenCalled(); + expect(queueService.deliverMany).toHaveBeenCalled(); + }); + + test('should deliver Update Person activity on unsuspend of local user', async () => { + const localUser = await createUser({ host: null, isSuspended: true }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(true); + userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`); + apRendererService.renderUpdate.mockReturnValue({ type: 'Update' } as any); + apRendererService.renderPerson.mockReturnValue({ type: 'Person' } as any); + apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Update' } as any); + + await userSuspendService.suspend(localUser, moderator); + await setTimeout(250); + + // ActivityPub配信が呼ばれているかチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); + expect(apRendererService.renderUpdate).toHaveBeenCalled(); + expect(apRendererService.renderPerson).toHaveBeenCalled(); + expect(apRendererService.addContext).toHaveBeenCalled(); + expect(queueService.deliverMany).toHaveBeenCalled(); + }); + + test('should not deliver any activity on suspend of remote user', async () => { + const remoteUser = await createUser({ host: 'remote.example.com' }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(false); + + await userSuspendService.suspend(remoteUser, moderator); + await setTimeout(250); + + // ActivityPub配信が呼ばれていないことをチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser); + expect(apRendererService.renderDelete).not.toHaveBeenCalled(); + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + }); + + describe('suspension for remote user', () => { + test('should suspend remote user without AP delivery', async () => { + const remoteUser = await createUser({ host: genHost() }); + const moderator = await createUser(); + + await userSuspendService.suspend(remoteUser, moderator); + await setTimeout(250); + + // ユーザーが凍結されているかチェック + const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(suspendedUser?.isSuspended).toBe(true); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', { + userId: remoteUser.id, + userUsername: remoteUser.username, + userHost: remoteUser.host, + }); + + // ActivityPub配信が呼ばれていないことを確認 + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + + test('should unsuspend remote user without AP delivery', async () => { + const remoteUser = await createUser({ host: genHost(), isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(remoteUser, moderator); + + await setTimeout(250); + + // ユーザーの凍結が解除されているかチェック + const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(unsuspendedUser?.isSuspended).toBe(false); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', { + userId: remoteUser.id, + userUsername: remoteUser.username, + userHost: remoteUser.host, + }); + + // ActivityPub配信が呼ばれていないことを確認 + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + }); + + describe('suspension from remote', () => { + test('should suspend remote user and post suspend event', async () => { + const remoteUser = await createUser({ host: genHost() }) as MiRemoteUser; + await userSuspendService.suspendFromRemote(remoteUser); + + // ユーザーがリモート凍結されているかチェック + const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(suspendedUser?.isRemoteSuspended).toBe(true); + + // イベントが発行されているかチェック + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: remoteUser.id, isRemoteSuspended: true }, + ); + }); + + test('should unsuspend remote user and post unsuspend event', async () => { + const remoteUser = await createUser({ host: genHost(), isRemoteSuspended: true }) as MiRemoteUser; + await userSuspendService.unsuspendFromRemote(remoteUser); + + // ユーザーのリモート凍結が解除されているかチェック + const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(unsuspendedUser?.isRemoteSuspended).toBe(false); + + // イベントが発行されているかチェック + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: remoteUser.id, isRemoteSuspended: false }, + ); + }); + }); +}); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index ca6a639be8..f2b40e83a8 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -51,6 +51,7 @@ import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ChatService } from '@/core/ChatService.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; process.env.NODE_ENV = 'test'; @@ -170,6 +171,7 @@ describe('UserEntityService', () => { InstanceChart, ApLoggerService, AccountMoveService, + UserSuspendService, ReactionService, ReactionsBufferingService, NotificationService, diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 24f2d0d5eb..34bcbec738 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only @{{ acct(user) }} Suspended + Suspended in Remote Silenced Moderator @@ -261,6 +262,7 @@ const ap = ref(null); const moderator = ref(info.value.isModerator); const silenced = ref(info.value.isSilenced); const suspended = ref(info.value.isSuspended); +const remoteSuspended = ref(info.value.isRemoteSuspended); const isSystem = ref(user.value.host == null && user.value.username.includes('.')); const moderationNote = ref(info.value.moderationNote); const filesPaginator = markRaw(new Paginator('admin/drive/files', { @@ -317,6 +319,7 @@ async function refreshUser() { moderator.value = info.value.isModerator; silenced.value = info.value.isSilenced; suspended.value = info.value.isSuspended; + remoteSuspended.value = info.value.isRemoteSuspended; isSystem.value = user.value.host == null && user.value.username.includes('.'); moderationNote.value = info.value.moderationNote; } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 22a3733fcb..9e8b971e5f 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -11720,6 +11720,7 @@ export interface operations { isModerator: boolean; isSilenced: boolean; isSuspended: boolean; + isRemoteSuspended: boolean; isHibernated: boolean; lastActiveDate: string | null; moderationNote: string;