summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/AccountMoveService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/core/AccountMoveService.ts')
-rw-r--r--packages/backend/src/core/AccountMoveService.ts327
1 files changed, 280 insertions, 47 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index 3f2a19b771..ab11785e28 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -1,55 +1,90 @@
import { Inject, Injectable } from '@nestjs/common';
-import { IsNull } from 'typeorm';
+import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.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 { Config } from '@/config.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
+import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
+import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
+import type { User } from '@/models/entities/User.js';
+import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { QueueService } from '@/core/QueueService.js';
+import { RelayService } from '@/core/RelayService.js';
+import { ApPersonService } from '@/core/activitypub/models/ApPersonService.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 { CacheService } from '@/core/CacheService.js';
+import { ProxyAccountService } from '@/core/ProxyAccountService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { MetaService } from '@/core/MetaService.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@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,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
private userEntityService: UserEntityService,
+ private idService: IdService,
+ private apPersonService: ApPersonService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
- private userFollowingService: UserFollowingService,
- private accountUpdateService: AccountUpdateService,
+ private proxyAccountService: ProxyAccountService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private federatedInstanceService: FederatedInstanceService,
+ private instanceChart: InstanceChart,
+ private metaService: MetaService,
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<unknown> {
- // Make sure that the destination is a remote account.
- if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
- if (!dst.uri) throw new Error('destination uri is empty');
+ public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise<unknown> {
+ const srcUri = this.userEntityService.getUserUri(src);
+ const dstUri = this.userEntityService.getUserUri(dst);
// add movedToUri to indicate that the user has moved
- const update = {} as Partial<User>;
- update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
- update.movedToUri = dst.uri;
+ const update = {} as Partial<LocalUser>;
+ update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri];
+ update.movedToUri = dstUri;
+ update.movedAt = new Date();
await this.usersRepository.update(src.id, update);
+ Object.assign(src, update);
+
+ // Update cache
+ this.cacheService.uriPersonCache.set(srcUri, src);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
@@ -64,51 +99,249 @@ export class AccountMoveService {
const iObj = await this.userEntityService.pack<true, true>(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,
- },
+ // Unfollow after 24 hours
+ const followings = await this.followingsRepository.findBy({
+ followerId: src.id,
+ });
+ this.queueService.createDelayedUnfollowJob(followings.map(following => ({
+ from: { id: src.id },
+ to: { id: following.followeeId },
+ })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
+
+ await this.postMoveProcess(src, dst);
+
+ return iObj;
+ }
+
+ @bindThis
+ public async postMoveProcess(src: User, dst: User): Promise<void> {
+ // Copy blockings and mutings, and update lists
+ try {
+ await Promise.all([
+ this.copyBlocking(src, dst),
+ this.copyMutings(src, dst),
+ this.updateLists(src, dst),
+ ]);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // follow the new account
+ const proxy = await this.proxyAccountService.fetch();
+ const followings = await this.followingsRepository.findBy({
+ followeeId: src.id,
+ followerHost: IsNull(), // follower is local
+ followerId: proxy ? Not(proxy.id) : undefined,
+ });
+ const followJobs = followings.map(following => ({
+ from: { id: following.followerId },
+ to: { id: dst.id },
+ })) as RelationshipJobData[];
+
+ // Decrease following count instead of unfollowing.
+ try {
+ await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // Should be queued because this can cause a number of follow per one move.
+ this.queueService.createFollowJob(followJobs);
+ }
+
+ @bindThis
+ public async copyBlocking(src: ThinUser, dst: ThinUser): Promise<void> {
+ // 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 srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id });
+ const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id });
+ const blockerIds = dstBlockings.map(blocking => blocking.blockerId);
+ // reblock the destination account
+ const blockJobs: RelationshipJobData[] = [];
+ for (const blocking of srcBlockings) {
+ if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked
+ blockJobs.push({ from: { id: blocking.blockerId }, 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<void> {
+ // Insert new mutings with the same values except mutee
+ const oldMutings = await this.mutingsRepository.findBy([
+ { muteeId: src.id, expiresAt: IsNull() },
+ { muteeId: src.id, expiresAt: MoreThan(new Date()) },
+ ]);
+ if (oldMutings.length === 0) return;
+
+ // Check if the destination account is already indefinitely muted by the muter
+ const existingMutingsMuterUserIds = await this.mutingsRepository.findBy(
+ { muteeId: dst.id, expiresAt: IsNull() },
+ ).then(mutings => mutings.map(muting => muting.muterId));
+
+ const newMutings: Map<string, { muterId: string; muteeId: string; createdAt: Date; expiresAt: Date | null; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newMutings.has(id));
+ return id;
+ };
+ for (const muting of oldMutings) {
+ if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely
+ newMutings.set(genId(), {
+ ...muting,
+ createdAt: new Date(),
+ muteeId: dst.id,
+ });
+ }
+
+ const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.mutingsRepository.insert(arrayToInsert);
+ }
+
+ /**
+ * Update lists while moving accounts.
+ * - No removal of the old account from the lists
+ * - Users number limit is not checked
+ *
+ * @param src ThinUser (old account)
+ * @param dst User (new account)
+ * @returns Promise<void>
+ */
+ @bindThis
+ public async updateLists(src: ThinUser, dst: User): Promise<void> {
+ // Return if there is no list to be updated.
+ const oldJoinings = await this.userListJoiningsRepository.find({
where: {
- followeeId: src.id,
- followerHost: IsNull(), // follower is local
+ userId: src.id,
},
});
- 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 */
+ if (oldJoinings.length === 0) return;
+
+ const existingUserListIds = await this.userListJoiningsRepository.find({
+ where: {
+ userId: dst.id,
+ },
+ }).then(joinings => joinings.map(joining => joining.userListId));
+
+ const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newJoinings.has(id));
+ return id;
+ };
+ for (const joining of oldJoinings) {
+ if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
+ newJoinings.set(genId(), {
+ createdAt: new Date(),
+ userId: dst.id,
+ userListId: joining.userListId,
+ });
+ }
+
+ const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.userListJoiningsRepository.insert(arrayToInsert);
+
+ // 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 } }]);
}
}
+ }
- return iObj;
+ @bindThis
+ private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise<void> {
+ if (localFollowerIds.length === 0) return;
+
+ // Set the old account's following and followers counts to 0.
+ await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 });
+
+ // Decrease following counts of local followers by 1.
+ await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
+
+ // Decrease follower counts of local followees by 1.
+ const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
+ if (oldFollowings.length > 0) {
+ await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
+ }
+
+ // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
+ if (this.userEntityService.isRemoteUser(oldAccount)) {
+ this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowers(i.host, false);
+ }
+ });
+ }
+
+ // FIXME: expensive?
+ for (const followerId of localFollowerIds) {
+ this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false);
+ }
}
/**
- * Create an alias of an old remote account.
+ * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
- * The user's new profile will be published to the followers.
+ * @param dst movedToUrlを指定するユーザー
+ * @param check
+ * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
+ * @returns Promise<LocalUser | RemoteUser | null>
*/
@bindThis
- public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
- await this.usersRepository.update(me.id, updates);
+ public async validateAlsoKnownAs(
+ dst: LocalUser | RemoteUser,
+ check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise<boolean> = () => true,
+ instant = false,
+ ): Promise<LocalUser | RemoteUser | null> {
+ let resultUser: LocalUser | RemoteUser | null = null;
- // Publish meUpdated event
- const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
- detail: true,
- includeSecrets: true,
- });
- this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
-
- if (me.isLocked === false) {
- await this.userFollowingService.acceptAllFollowRequests(me);
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(dst.uri);
+ }
+ dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
}
- this.accountUpdateService.publishToFollowers(me.id);
+ if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null;
- return iObj;
+ const dstUri = this.userEntityService.getUserUri(dst);
+
+ for (const srcUri of dst.alsoKnownAs) {
+ try {
+ let src = await this.apPersonService.fetchPerson(srcUri);
+ if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
+
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(srcUri);
+ }
+
+ src = await this.apPersonService.fetchPerson(srcUri) ?? src;
+ }
+
+ if (src.movedToUri === dstUri) {
+ if (await check(resultUser, src)) {
+ resultUser = src;
+ }
+ if (instant && resultUser) return resultUser;
+ }
+ } catch {
+ /* skip if any error happens */
+ }
+ }
+
+ return resultUser;
}
}