diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 78d12ae705..5f63d30089 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -5,8 +5,8 @@ import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { LocalUser } from '@/models/entities/User.js'; -import type { BlockingsRepository, FollowingsRepository, Muting, MutingsRepository, UsersRepository } from '@/models/index.js'; -import type { RelationshipJobData } from '@/queue/types.js'; +import type { BlockingsRepository, FollowingsRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { User } from '@/models/entities/User.js'; @@ -20,6 +20,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService'; +import { ProxyAccountService } from '@/core/ProxyAccountService.js'; @Injectable() export class AccountMoveService { @@ -39,6 +40,9 @@ export class AccountMoveService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + private idService: IdService, private userEntityService: UserEntityService, private apRendererService: ApRendererService, @@ -46,6 +50,7 @@ export class AccountMoveService { private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, + private proxyAccountService: ProxyAccountService, private relayService: RelayService, private cacheService: CacheService, private queueService: QueueService, @@ -114,43 +119,13 @@ export class AccountMoveService { @bindThis public async move(src: User, dst: User): Promise { // Copy blockings: - // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. - // So block the destination account here. - const blockings = await this.blockingsRepository.find({ - relations: { - blocker: true - }, - where: { - blockeeId: src.id - } - }) - // reblock the destination account - const blockJobs: RelationshipJobData[] = []; - for (const blocking of blockings) { - if (!blocking.blocker) continue; - blockJobs.push({ from: blocking.blocker, to: dst }); - } - // no need to unblock the old account because it may be still functional - this.queueService.createBlockJob(blockJobs); + await this.copyBlocking(src, dst); // Copy mutings: - // Insert new mutings with the same values except mutee - const mutings = await this.mutingsRepository.findBy({ muteeId: src.id }); - const newMuting: Partial[] = []; - for (const muting of mutings) { - newMuting.push({ - id: this.idService.genId(), - createdAt: new Date(), - expiresAt: muting.expiresAt, - muterId: muting.muterId, - muteeId: dst.id, - }) - } - this.mutingsRepository.insert(mutings); // no need to wait - for (const mute of mutings) { - if (mute.muter) this.cacheService.userMutingsCache.refresh(mute.muter.id); - } - // no need to unmute the old account because it may be still functional + await this.copyMutings(src, dst); + + // Update lists: + await this.updateLists(src, dst); // follow the new account and unfollow the old one const followings = await this.followingsRepository.find({ @@ -166,8 +141,8 @@ export class AccountMoveService { const unfollowJobs: RelationshipJobData[] = []; for (const following of followings) { if (!following.follower) continue; - followJobs.push({ from: following.follower, to: dst }); - unfollowJobs.push({ from: following.follower, to: src }); + followJobs.push({ from: { id: following.follower.id }, to: { id: dst.id } }); + unfollowJobs.push({ from: { id: following.follower.id }, to: { id: src.id } }); } // Should be queued because this can cause a number of follow/unfollow per one move. // No need to care job orders as there should be no overlaps of follow/unfollow target. @@ -175,6 +150,69 @@ export class AccountMoveService { this.queueService.createUnfollowJob(unfollowJobs); } + @bindThis + public async copyBlocking(src: ThinUser, dst: ThinUser): Promise { + // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. + // So block the destination account here. + const blockings = await this.blockingsRepository.find({ // FIXME: might be expensive + relations: { + blocker: true + }, + where: { + blockeeId: src.id + } + }); + // reblock the destination account + const blockJobs: RelationshipJobData[] = []; + for (const blocking of blockings) { + if (!blocking.blocker) continue; + blockJobs.push({ from: { id: blocking.blocker.id }, to: { id: dst.id } }); + } + // no need to unblock the old account because it may be still functional + this.queueService.createBlockJob(blockJobs); + } + + @bindThis + public async copyMutings(src: ThinUser, dst: ThinUser): Promise { + // Insert new mutings with the same values except mutee + const mutings = await this.mutingsRepository.findBy({ muteeId: src.id }); + const newMuting: Partial[] = []; + for (const muting of mutings) { + newMuting.push({ + id: this.idService.genId(), + createdAt: new Date(), + expiresAt: muting.expiresAt, + muterId: muting.muterId, + muteeId: dst.id, + }); + } + this.mutingsRepository.insert(mutings); // no need to wait + for (const mute of mutings) { + if (mute.muter) this.cacheService.userMutingsCache.refresh(mute.muter.id); + } + // no need to unmute the old account because it may be still functional + } + + @bindThis + public async updateLists(src: ThinUser, dst: User): Promise { + // Return if there is no list to be updated + const numOfLists = await this.userListJoiningsRepository.countBy({ userId: src.id }); + if (numOfLists === 0) return; + + await this.userListJoiningsRepository.update( + { userId: src.id }, + { userId: dst.id, user: dst } + ); + + // Have the proxy account follow the new account in the same way as UserListService.push + if (this.userEntityService.isRemoteUser(dst)) { + const proxy = await this.proxyAccountService.fetch(); + if (proxy) { + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); + } + } + } + @bindThis public getUserUri(user: User): string { return this.userEntityService.isRemoteUser(user) diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 982487e5f5..cf8172c9fc 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -747,11 +747,11 @@ export class ApInboxService { // update them if they're remote if (newAccount.uri) { - await this.apPersonService.updatePerson(newAccount.uri); + await this.apPersonService.updatePerson(newAccount.uri); newAccount = await this.apPersonService.resolvePerson(newAccount.uri); } if (oldAccount.uri) { - await this.apPersonService.updatePerson(oldAccount.uri); + await this.apPersonService.updatePerson(oldAccount.uri); oldAccount = await this.apPersonService.resolvePerson(oldAccount.uri); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 21797cfcb7..2a5d380356 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; @@ -42,6 +42,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; +import type { AccountMoveService } from '@/core/AccountMoveService.js'; const nameLength = 128; const summaryLength = 2048; @@ -66,6 +67,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -131,6 +133,7 @@ export class ApPersonService implements OnModuleInit { this.usersChart = this.moduleRef.get('UsersChart'); this.instanceChart = this.moduleRef.get('InstanceChart'); this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.accountMoveService = this.moduleRef.get('AccountMoveService'); this.logger = this.apLoggerService.logger; } @@ -413,14 +416,14 @@ export class ApPersonService implements OnModuleInit { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { return; } //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - if (exist == null) { + if (exist === null) { return; } //#endregion @@ -523,6 +526,20 @@ export class ApPersonService implements OnModuleInit { }); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + // Copy blocking and muting if we know its moving for the first time. + if (!exist.movedToUri && updates.movedToUri) { + try { + const newAccount = await this.resolvePerson(updates.movedToUri); + // Aggressively block and/or mute the new account: + // This does NOT check alsoKnownAs, assuming that other implmenetations properly check alsoKnownAs when firing account migration + await this.accountMoveService.copyBlocking(exist, newAccount); + await this.accountMoveService.copyMutings(exist, newAccount); + await this.accountMoveService.updateLists(exist, newAccount); + } catch { + /* skip if any error happens */ + } + } } /** diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index 53195d9c63..c1aa0f4ccb 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -109,7 +109,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchMoveTarget); }); const destination = await this.getterService.getUser(moveTo.id); - moveTo.uri = this.accountMoveService.getUserUri(destination) + moveTo.uri = this.accountMoveService.getUserUri(destination); // update local db await this.apPersonService.updatePerson(moveTo.uri);