From 8c031e9f42bb2a6f7b6f3f57711e27573979998e Mon Sep 17 00:00:00 2001 From: Namekuji Date: Tue, 11 Apr 2023 20:47:54 -0400 Subject: [PATCH] copy block and mute then create follow and unfollow jobs --- .../backend/src/core/AccountMoveService.ts | 123 ++++++++++++++---- .../src/core/activitypub/ApInboxService.ts | 61 ++++----- .../src/server/api/endpoints/i/known-as.ts | 13 +- .../src/server/api/endpoints/i/move.ts | 30 ++--- 4 files changed, 136 insertions(+), 91 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 3f2a19b771..78d12ae705 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -3,27 +3,43 @@ import { IsNull } from 'typeorm'; 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 { User } from '@/models/entities/User.js'; -import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; +import type { BlockingsRepository, FollowingsRepository, Muting, MutingsRepository, UsersRepository } from '@/models/index.js'; +import type { RelationshipJobData } from '@/queue/types.js'; +import { User } from '@/models/entities/User.js'; + +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { AccountUpdateService } from '@/core/AccountUpdateService.js'; -import { RelayService } from '@/core/RelayService.js'; +import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService'; @Injectable() export class AccountMoveService { constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private idService: IdService, private userEntityService: UserEntityService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, @@ -31,18 +47,18 @@ export class AccountMoveService { private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, private relayService: RelayService, + private cacheService: CacheService, + private queueService: QueueService, ) { } /** - * Move a local account to a remote account. + * Move a local account to a new account. * * After delivering Move activity, its local followers unfollow the old account and then follow the new one. */ @bindThis - public async moveToRemote(src: LocalUser, dst: User): Promise { - // Make sure that the destination is a remote account. - if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); + public async moveFromLocal(src: LocalUser, dst: User): Promise { if (!dst.uri) throw new Error('destination uri is empty'); // add movedToUri to indicate that the user has moved @@ -64,25 +80,8 @@ export class AccountMoveService { const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); - // follow the new account and unfollow the old one - const followings = await this.followingsRepository.find({ - relations: { - follower: true, - }, - where: { - followeeId: src.id, - followerHost: IsNull(), // follower is local - }, - }); - for (const following of followings) { - if (!following.follower) continue; - try { - await this.userFollowingService.follow(following.follower, dst); - await this.userFollowingService.unfollow(following.follower, src); - } catch { - /* empty */ - } - } + // Move! + await this.move(src, dst); return iObj; } @@ -111,4 +110,74 @@ export class AccountMoveService { return iObj; } + + @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); + + // 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 + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: src.id, + followerHost: IsNull(), // follower is local + }, + }); + const followJobs: RelationshipJobData[] = []; + 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 }); + } + // 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. + this.queueService.createFollowJob(followJobs); + this.queueService.createUnfollowJob(unfollowJobs); + } + + @bindThis + public getUserUri(user: User): string { + return this.userEntityService.isRemoteUser(user) + ? user.uri : `${this.config.url}/users/${user.id}`; + } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 3fca0bb1fd..982487e5f5 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -13,13 +13,15 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; @@ -76,6 +78,8 @@ export class ApInboxService { private apNoteService: ApNoteService, private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, + private accountMoveService: AccountMoveService, + private cacheService: CacheService, private queueService: QueueService, ) { this.logger = this.apLoggerService.logger; @@ -140,7 +144,7 @@ export class ApInboxService { } else if (isFlag(activity)) { await this.flag(actor, activity); } else if (isMove(activity)) { - //await this.move(actor, activity); + await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -158,6 +162,7 @@ export class ApInboxService { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } + // don't queue because the sender may attempt again when timeout await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -596,6 +601,7 @@ export class ApInboxService { throw e; }); + // don't queue because the sender may attempt again when timeout if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); @@ -736,52 +742,37 @@ export class ApInboxService { // fetch the new and old accounts const targetUri = getApHrefNullable(activity.target); if (!targetUri) return 'skip: invalid activity target'; - let new_acc = await this.apPersonService.resolvePerson(targetUri); - let old_acc = await this.apPersonService.resolvePerson(actor.uri); + let newAccount = await this.apPersonService.resolvePerson(targetUri); + let oldAccount = await this.apPersonService.resolvePerson(actor.uri); // update them if they're remote - if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); - if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); - - // retrieve updated users - new_acc = await this.apPersonService.resolvePerson(targetUri); - old_acc = await this.apPersonService.resolvePerson(actor.uri); + if (newAccount.uri) { + await this.apPersonService.updatePerson(newAccount.uri); + newAccount = await this.apPersonService.resolvePerson(newAccount.uri); + } + if (oldAccount.uri) { + await this.apPersonService.updatePerson(oldAccount.uri); + oldAccount = await this.apPersonService.resolvePerson(oldAccount.uri); + } // check if alsoKnownAs of the new account is valid let isValidMove = true; - if (old_acc.uri) { - if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { + if (oldAccount.uri) { + if (!newAccount.alsoKnownAs?.includes(oldAccount.uri)) { isValidMove = false; } - } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { + } else if (!newAccount.alsoKnownAs?.includes(this.accountMoveService.getUserUri(oldAccount))) { isValidMove = false; } if (!isValidMove) { - return 'skip: accounts invalid'; + return 'skip: destination account invalid'; } // add target uri to movedToUri in order to indicate that the user has moved - await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); + this.usersRepository.update(oldAccount.id, { movedToUri: targetUri }); - // follow the new account and unfollow the old one - const followings = await this.followingsRepository.find({ - relations: { - follower: true, - }, - where: { - followeeId: old_acc.id, - followerHost: IsNull(), // follower is local - }, - }); - for (const following of followings) { - if (!following.follower) continue; - try { - await this.userFollowingService.follow(following.follower, new_acc); - await this.userFollowingService.unfollow(following.follower, old_acc); - } catch { - /* empty */ - } - } + // Move! + await this.accountMoveService.move(oldAccount, newAccount); return 'ok'; } diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts index 964704d82b..7aa401e9bb 100644 --- a/packages/backend/src/server/api/endpoints/i/known-as.ts +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -27,11 +27,6 @@ export const meta = { code: 'NO_SUCH_USER', id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', }, - notRemote: { - message: 'User is not remote. You can only migrate from other instances.', - code: 'NOT_REMOTE', - id: '4362f8dc-731f-4ad8-a694-be2a88922a24', - }, uriNull: { message: 'User ActivityPup URI is null.', code: 'URI_NULL', @@ -69,19 +64,17 @@ export default class extends Endpoint { // Parse user's input into the old account if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); - if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.noSuchUser); const userAddress = unfiltered.split('@'); // Retrieve the old account const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { - this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); throw new ApiError(meta.errors.noSuchUser); }); - const toUrl: string | null = knownAs.uri; + const toUrl = this.accountMoveService.getUserUri(knownAs); if (!toUrl) throw new ApiError(meta.errors.uriNull); - // Only allow moving from a remote account - if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; } diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index ac76e1f620..53195d9c63 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -30,17 +30,12 @@ export const meta = { code: 'NO_SUCH_MOVE_TARGET', id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', }, - remoteAccountForbids: { + destinationAccountForbids: { message: - 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', + 'Destination account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', code: 'REMOTE_ACCOUNT_FORBIDS', id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', }, - notRemote: { - message: 'User is not remote. You can only migrate to other instances.', - code: 'NOT_REMOTE', - id: '4362f8dc-731f-4ad8-a694-be2a88922a24', - }, rootForbidden: { message: 'The root can\'t migrate.', code: 'NOT_ROOT_FORBIDDEN', @@ -105,7 +100,7 @@ export default class extends Endpoint { // parse user's input into the destination account if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); - if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.noSuchMoveTarget); const userAddress = unfiltered.split('@'); // retrieve the destination account @@ -113,28 +108,25 @@ export default class extends Endpoint { this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); throw new ApiError(meta.errors.noSuchMoveTarget); }); - const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id); - if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull); + const destination = await this.getterService.getUser(moveTo.id); + moveTo.uri = this.accountMoveService.getUserUri(destination) // update local db - await this.apPersonService.updatePerson(remoteMoveTo.uri); + await this.apPersonService.updatePerson(moveTo.uri); // retrieve updated user - moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri); - // only allow moving to a remote account - if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + moveTo = await this.apPersonService.resolvePerson(moveTo.uri); - let allowed = false; - - const fromUrl = `${this.config.url}/users/${me.id}`; // make sure that the user has indicated the old account as an alias + const fromUrl = `${this.config.url}/users/${me.id}`; + let allowed = false; moveTo.alsoKnownAs?.forEach((elem) => { if (fromUrl.includes(elem)) allowed = true; }); // abort if unintended - if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.destinationAccountForbids); - return await this.accountMoveService.moveToRemote(me, moveTo); + return await this.accountMoveService.moveFromLocal(me, moveTo); }); } }