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/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 61f7731d1b..e425b01c9f 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; +import type { MiRemoteUser, MiUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -45,11 +45,20 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch((e: any) => {}); - await this.suspendFollowings(user).catch((e: any) => {}); + 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, + }); + + this.postSuspend(user, true); + } + @bindThis public async unsuspend(user: MiUser, moderator: MiUser): Promise { await this.usersRepository.update(user.id, { @@ -63,14 +72,26 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch((e: any) => {}); - await this.restoreFollowings(user).catch((e: any) => {}); + 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, + }); + + this.postUnsuspend(user, true); + } + + @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, @@ -85,8 +106,11 @@ export class UserSuspendService { } @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)) { this.accountUpdateService.publishToFollowersAndSharedInboxAndRelays(user.id); @@ -94,7 +118,7 @@ export class UserSuspendService { } @bindThis - private async suspendFollowings(follower: MiUser) { + private async suspendFollowings(follower: { id: MiUser['id'] }) { await this.followingsRepository.update( { followerId: follower.id, @@ -106,7 +130,18 @@ export class UserSuspendService { } @bindThis - private async restoreFollowings(follower: MiUser) { + private async restoreFollowings(_follower: { id: MiUser['id'] }) { + // 最新の情報を取得 + const follower = await this.usersRepository.findOneBy({ id: _follower.id }); + if (follower == null) { + // ユーザーが削除されている場合は何もしないでおく + return; + } + if (follower.isSuspended || follower.isRemoteSuspended) { + // フォロー関係を復元しない + return; + } + // フォロー関係を復元(isFollowerSuspended: false)に変更 await this.followingsRepository.update( { diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 4673875e1f..4e548347f0 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -48,6 +48,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApImageService } from './ApImageService.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; const nameLength = 128; const summaryLength = 2048; @@ -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; } @@ -600,6 +603,17 @@ export class ApPersonService implements OnModuleInit { return 'skip'; } + //#region suspend + if (exist.isRemoteSuspended === false && person.suspended === true) { + // リモートサーバーでアカウントが凍結された + this.userSuspendService.suspendFromRemote({ id: exist.id, host: exist.host }); + } + if (exist.isRemoteSuspended === true && person.suspended === false) { + // リモートサーバーでアカウントが解凍された + 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/test-federation/test/user-suspension.test.ts b/packages/backend/test-federation/test/user-suspension.test.ts index ae7f88a6a7..e5d119d564 100644 --- a/packages/backend/test-federation/test/user-suspension.test.ts +++ b/packages/backend/test-federation/test/user-suspension.test.ts @@ -113,449 +113,4 @@ describe('User Suspension', () => { }); }); }); - - describe('Profile', () => { - describe('Consistency of profile', () => { - let alice: LoginUser; - let aliceWatcher: LoginUser; - let aliceWatcherInB: LoginUser; - - beforeAll(async () => { - alice = await createAccount('a.test'); - [ - aliceWatcher, - aliceWatcherInB, - ] = await Promise.all([ - createAccount('a.test'), - createAccount('b.test'), - ]); - }); - - test('Check consistency', async () => { - const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id }); - const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB); - const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id }); - - // console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`); - // console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`); - - deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [ - 'id', - 'host', - 'avatarUrl', - 'avatarBlurhash', - 'instance', - 'badgeRoles', - 'url', - 'uri', - 'createdAt', - 'lastFetchedAt', - 'publicReactions', - ]); - }); - }); - - describe('ffVisibility is federated', () => { - 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), - ]); - - // NOTE: follow each other - await Promise.all([ - alice.client.request('following/create', { userId: bobInA.id }), - bob.client.request('following/create', { userId: aliceInB.id }), - ]); - await sleep(); - }); - - test('Visibility set public by default', async () => { - for (const user of await Promise.all([ - alice.client.request('users/show', { userId: bobInA.id }), - bob.client.request('users/show', { userId: aliceInB.id }), - ])) { - strictEqual(user.followersVisibility, 'public'); - strictEqual(user.followingVisibility, 'public'); - } - }); - - /** FIXME: not working */ - test.skip('Setting private for followersVisibility is federated', async () => { - await Promise.all([ - alice.client.request('i/update', { followersVisibility: 'private' }), - bob.client.request('i/update', { followersVisibility: 'private' }), - ]); - await sleep(); - - for (const user of await Promise.all([ - alice.client.request('users/show', { userId: bobInA.id }), - bob.client.request('users/show', { userId: aliceInB.id }), - ])) { - strictEqual(user.followersVisibility, 'private'); - strictEqual(user.followingVisibility, 'public'); - } - }); - - test.skip('Setting private for followingVisibility is federated', async () => { - await Promise.all([ - alice.client.request('i/update', { followingVisibility: 'private' }), - bob.client.request('i/update', { followingVisibility: 'private' }), - ]); - await sleep(); - - for (const user of await Promise.all([ - alice.client.request('users/show', { userId: bobInA.id }), - bob.client.request('users/show', { userId: aliceInB.id }), - ])) { - strictEqual(user.followersVisibility, 'private'); - strictEqual(user.followingVisibility, 'private'); - } - }); - }); - - describe('isCat is federated', () => { - 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('Not isCat for default', () => { - strictEqual(aliceInB.isCat, false); - }); - - test('Becoming a cat is sent to their followers', async () => { - await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - await alice.client.request('i/update', { isCat: true }); - await sleep(); - - const res = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(res.isCat, true); - }); - }); - - describe('Pinning Notes', () => { - let alice: LoginUser, bob: LoginUser; - let aliceInB: Misskey.entities.UserDetailedNotMe; - - beforeAll(async () => { - [alice, bob] = await Promise.all([ - createAccount('a.test'), - createAccount('b.test'), - ]); - aliceInB = await resolveRemoteUser('a.test', alice.id, bob); - - await bob.client.request('following/create', { userId: aliceInB.id }); - }); - - test('Pinning localOnly Note is not delivered', async () => { - const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote; - await alice.client.request('i/pin', { noteId: note.id }); - await sleep(); - - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 0); - }); - - test('Pinning followers-only Note is not delivered', async () => { - const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote; - await alice.client.request('i/pin', { noteId: note.id }); - await sleep(); - - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 0); - }); - - let pinnedNote: Misskey.entities.Note; - - test('Pinning normal Note is delivered', async () => { - pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote; - await alice.client.request('i/pin', { noteId: pinnedNote.id }); - await sleep(); - - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 1); - const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob); - strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id); - }); - - test('Unpinning normal Note is delivered', async () => { - await alice.client.request('i/unpin', { noteId: pinnedNote.id }); - await sleep(); - - const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); - strictEqual(_aliceInB.pinnedNoteIds.length, 0); - }); - }); - }); - - describe('Follow / Unfollow', () => { - 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), - ]); - }); - - describe('Follow a.test ==> b.test', () => { - beforeAll(async () => { - await alice.client.request('following/create', { userId: bobInA.id }); - - await sleep(); - }); - - test('Check consistency with `users/following` and `users/followers` endpoints', async () => { - await Promise.all([ - strictEqual( - (await alice.client.request('users/following', { userId: alice.id })) - .some(v => v.followeeId === bobInA.id), - true, - ), - strictEqual( - (await bob.client.request('users/followers', { userId: bob.id })) - .some(v => v.followerId === aliceInB.id), - true, - ), - ]); - }); - }); - - describe('Unfollow a.test ==> b.test', () => { - beforeAll(async () => { - await alice.client.request('following/delete', { userId: bobInA.id }); - - await sleep(); - }); - - test('Check consistency with `users/following` and `users/followers` endpoints', async () => { - await Promise.all([ - strictEqual( - (await alice.client.request('users/following', { userId: alice.id })) - .some(v => v.followeeId === bobInA.id), - false, - ), - strictEqual( - (await bob.client.request('users/followers', { userId: bob.id })) - .some(v => v.followerId === aliceInB.id), - false, - ), - ]); - }); - }); - }); - - describe('Follow requests', () => { - 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), - ]); - - await alice.client.request('i/update', { isLocked: true }); - }); - - describe('Send follow request from Bob to Alice and cancel', () => { - describe('Bob sends follow request to Alice', () => { - beforeAll(async () => { - await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - }); - - test('Alice should have a request', async () => { - const requests = await alice.client.request('following/requests/list', {}); - strictEqual(requests.length, 1); - strictEqual(requests[0].followee.id, alice.id); - strictEqual(requests[0].follower.id, bobInA.id); - }); - }); - - describe('Alice cancels it', () => { - beforeAll(async () => { - await bob.client.request('following/requests/cancel', { userId: aliceInB.id }); - await sleep(); - }); - - test('Alice should have no requests', async () => { - const requests = await alice.client.request('following/requests/list', {}); - strictEqual(requests.length, 0); - }); - }); - }); - - describe('Send follow request from Bob to Alice and reject', () => { - beforeAll(async () => { - await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - await alice.client.request('following/requests/reject', { userId: bobInA.id }); - await sleep(); - }); - - test('Bob should have no requests', async () => { - await rejects( - async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }), - (err: any) => { - strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND'); - return true; - }, - ); - }); - - test('Bob doesn\'t follow Alice', async () => { - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 0); - }); - }); - - describe('Send follow request from Bob to Alice and accept', () => { - beforeAll(async () => { - await bob.client.request('following/create', { userId: aliceInB.id }); - await sleep(); - - await alice.client.request('following/requests/accept', { userId: bobInA.id }); - await sleep(); - }); - - test('Bob follows Alice', async () => { - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 1); - strictEqual(following[0].followeeId, aliceInB.id); - strictEqual(following[0].followerId, bob.id); - }); - }); - }); - - describe('Deletion', () => { - describe('Check Delete 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 deleted themself', 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 alice.client.request('i/delete-account', { password: alice.password }); - 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; - }, - ); - }); - }); - - describe('Deletion of remote user for moderation', () => { - 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, then Alice gets deleted in B server', 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 bAdmin.client.request('admin/delete-account', { userId: aliceInB.id }); - await sleep(); - - /** - * FIXME: remote account is not deleted! - * @see https://github.com/misskey-dev/misskey/issues/14728 - */ - const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id }); - assert(deletedAlice.id, aliceInB.id); - - // TODO: why still following relation? - const following = await bob.client.request('users/following', { userId: bob.id }); - strictEqual(following.length, 1); - await rejects( - async () => await bob.client.request('following/create', { userId: aliceInB.id }), - (err: any) => { - strictEqual(err.code, 'ALREADY_FOLLOWING'); - return true; - }, - ); - }); - - test('Alice tries to follow Bob, but it is not processed', async () => { - await alice.client.request('following/create', { userId: bobInA.id }); - await sleep(); - - const following = await alice.client.request('users/following', { userId: alice.id }); - strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept - - const followers = await bob.client.request('users/followers', { userId: bob.id }); - strictEqual(followers.length, 0); // Alice's Follow is not processed - }); - }); - }); }); diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts index 974b2c1903..b80e79b64d 100644 --- a/packages/backend/test/unit/UserSuspendService.ts +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -400,7 +400,7 @@ describe('UserSuspendService', () => { }); }); - describe('remote user suspension', () => { + describe('suspension for remote user', () => { test('should suspend remote user without AP delivery', async () => { const remoteUser = await createUser({ host: genHost() }); const moderator = await createUser(); @@ -422,9 +422,7 @@ describe('UserSuspendService', () => { // ActivityPub配信が呼ばれていないことを確認 expect(queueService.deliver).not.toHaveBeenCalled(); }); - }); - describe('remote user unsuspension', () => { test('should unsuspend remote user without AP delivery', async () => { const remoteUser = await createUser({ host: genHost(), isSuspended: true }); const moderator = await createUser(); @@ -448,4 +446,36 @@ describe('UserSuspendService', () => { expect(queueService.deliver).not.toHaveBeenCalled(); }); }); + + describe('suspension from remote', () => { + test('should suspend remote user and post suspend event', async () => { + const remoteUser = { id: secureRndstr(16), host: genHost() }; + 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 = { id: secureRndstr(16), host: genHost() }; + 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 }, + ); + }); + }); });