diff --git a/.config/example.yml b/.config/example.yml index 57e2b56b7..8111b1992 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -133,16 +133,20 @@ id: 'aid' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 +#relashionshipJobConcurrency: 16 +# What's relashionshipJob?: +# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 16 +#deliverJobPerSec: 128 +#inboxJobPerSec: 16 +#relashionshipJobPerSec: 64 # Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 +#deliverJobMaxAttempts: 12 +#inboxJobMaxAttempts: 8 # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 diff --git a/.vscode/settings.json b/.vscode/settings.json index baffbe18e..71fb02a59 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "files.associations": { "*.test.ts": "typescript" }, + "jest.jestCommandLine": "pnpm run jest", "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4704c8050..30a882298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ (自分自身に対してもメモを追加できます。) * ユーザーメニューから追加できます。 (デスクトップ表示ではusernameの右側のボタンからも追加可能) +- アカウントの引っ越し(フォロワー引き継ぎ)に対応 + * 一度引っ越したアカウントは利用に制限がかかります - ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 - カスタム絵文字のライセンスを複数でセットできるようになりました。 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6a164b67b..3225ff621 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -703,6 +703,8 @@ contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使う" clips: "クリップ" experimentalFeatures: "実験的機能" +experimental: "実験的" +ThisIsExperimentalFeature: "これは実験的な機能です。仕様が変更されたり、正常に動作しなかったりする可能性があります。" developer: "開発者" makeExplorable: "アカウントを見つけやすくする" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" @@ -1003,8 +1005,10 @@ noteIdOrUrl: "ノートIDまたはURL" video: "動画" videos: "動画" dataSaver: "データセーバー" -accountMigration: "アカウントの引っ越し" -accountMoved: "このユーザーは新しいアカウントに引っ越しました:" +accountMigration: "アカウントの移行" +accountMoved: "このユーザーは新しいアカウントに移行しました:" +accountMovedShort: "このアカウントは移行されています" +operationForbidden: "この操作はできません" forceShowAds: "常に広告を表示する" addMemo: "メモを追加" editMemo: "メモを編集" @@ -1030,13 +1034,20 @@ _serverRules: description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" _accountMigration: - moveTo: "このアカウントを新しいアカウントに引っ越す" - moveToLabel: "引っ越し先のアカウント:" - moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com" - moveFrom: "別のアカウントからこのアカウントに引っ越す" - moveFromLabel: "引っ越し元のアカウント:" - moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com" - migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" + moveFrom: "別のアカウントからこのアカウントに移行" + moveFromSub: "別のアカウントへエイリアスを作成" + moveFromLabel: "移行元のアカウント #{n}" + moveFromDescription: "別のアカウントからこのアカウントに移行したい場合、ここでエイリアスを作成しておく必要があります。\n移行元のアカウントをこのように入力してください: @username@server.example.com\n削除するには、入力欄を空にして保存します(非推奨)。" + moveTo: "このアカウントを新しいアカウントへ移行" + moveToLabel: "移行先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできません。" + moveAccountDescription: "新しいアカウントへ移行します。\n ・フォロワーが新しいアカウントを自動でフォローします\n ・このアカウントからのフォローは全て解除されます\n ・このアカウントではノートの作成などができなくなります\n\nフォロワーの移行は自動ですが、フォローの移行は手動で行う必要があります。移行前にこのアカウントでフォローエクスポートし、移行後すぐに移行先アカウントでインポートを行なってください。\nリスト・ミュート・ブロックについても同様ですので、手動で移行する必要があります。\n\n(この説明はこのサーバー(Misskey v13.12.0以降)の仕様です。Mastodonなどの他のActivityPubソフトウェアでは挙動が異なる場合があります。)" + moveAccountHowTo: "アカウントの移行には、まずは移行先のアカウントでこのアカウントに対しエイリアスを作成します。\nエイリアス作成後、移行先のアカウントを次のように入力してください: @username@server.example.com" + startMigration: "移行する" + migrationConfirm: "本当にこのアカウントを {account} に移行しますか?一度移行すると取り消せず、二度とこのアカウントを元の状態で使用できなくなります。" + movedAndCannotBeUndone: "\nアカウントは移行されています。\n移行を取り消すことはできません。" + postMigrationNote: "このアカウントからのフォロー解除は移行操作から24時間後に実行されます。\nこのアカウントのフォロー・フォロワー数は0になっています。フォロワーの解除はされないため、あなたのフォロワーはこのアカウントのフォロワー向け投稿を引き続き閲覧できます。" + movedTo: "移行先のアカウント:" _achievements: earnedAt: "獲得日時" diff --git a/packages/backend/migration/1682190963894-movedAt.js b/packages/backend/migration/1682190963894-movedAt.js new file mode 100644 index 000000000..1f8f030a5 --- /dev/null +++ b/packages/backend/migration/1682190963894-movedAt.js @@ -0,0 +1,13 @@ +export class MovedAt1682190963894 { + name = 'MovedAt1682190963894' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index bb97d8c17..4499475ee 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -84,8 +84,10 @@ export type Source = { deliverJobConcurrency?: number; inboxJobConcurrency?: number; + relashionshipJobConcurrency?: number; deliverJobPerSec?: number; inboxJobPerSec?: number; + relashionshipJobPerSec?: number; deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 3f2a19b77..ab11785e2 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 { - // 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 { + 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; - update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; - update.movedToUri = dst.uri; + const update = {} as Partial; + 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(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 - }, + // Unfollow after 24 hours + const followings = await this.followingsRepository.findBy({ + followerId: 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 */ - } - } + 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 { + // 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 { + // 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 { + // 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 = 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); + } + /** - * Create an alias of an old remote account. + * Update lists while moving accounts. + * - No removal of the old account from the lists + * - Users number limit is not checked * - * The user's new profile will be published to the followers. + * @param src ThinUser (old account) + * @param dst User (new account) + * @returns Promise */ @bindThis - public async createAlias(me: LocalUser, updates: Partial): Promise { - await this.usersRepository.update(me.id, updates); - - // Publish meUpdated event - const iObj = await this.userEntityService.pack(me.id, me, { - detail: true, - includeSecrets: true, + public async updateLists(src: ThinUser, dst: User): Promise { + // Return if there is no list to be updated. + const oldJoinings = await this.userListJoiningsRepository.find({ + where: { + userId: src.id, + }, }); - this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + if (oldJoinings.length === 0) return; - if (me.isLocked === false) { - await this.userFollowingService.acceptAllFollowRequests(me); + const existingUserListIds = await this.userListJoiningsRepository.find({ + where: { + userId: dst.id, + }, + }).then(joinings => joinings.map(joining => joining.userListId)); + + const newJoinings: Map = 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, + }); } - this.accountUpdateService.publishToFollowers(me.id); + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); - return iObj; + // 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 + private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise { + 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); + } + } + + /** + * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる + * + * @param dst movedToUrlを指定するユーザー + * @param check + * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか + * @returns Promise + */ + @bindThis + public async validateAlsoKnownAs( + dst: LocalUser | RemoteUser, + check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise = () => true, + instant = false, + ): Promise { + let resultUser: LocalUser | RemoteUser | null = null; + + 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; + } + + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null; + + 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; } } diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index bac85d7a1..d4905a5f8 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -78,7 +78,7 @@ const $db: Provider = { const $relationship: Provider = { provide: 'queue:relationship', - useFactory: (config: Config) => q(config, 'relationship'), + useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64), inject: [DI.config], }; diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 375ac4991..3590846d7 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -258,6 +258,12 @@ export class QueueService { return this.relationshipQueue.addBulk(jobs); } + @bindThis + public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); + return this.relationshipQueue.addBulk(jobs); + } + @bindThis public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); @@ -271,7 +277,7 @@ export class QueueService { } @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): { + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { name: string, data: RelationshipJobData, opts: Bull.JobOptions, @@ -287,6 +293,7 @@ export class QueueService { opts: { removeOnComplete: true, removeOnFail: true, + ...opts, }, }; } diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index b72dce518..ff68c2421 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; -import type { RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -33,7 +33,7 @@ export class RemoteUserResolveService { } @bindThis - public async resolveUser(username: string, host: string | null): Promise { + public async resolveUser(username: string, host: string | null): Promise { const usernameLower = username.toLowerCase(); if (host == null) { @@ -44,7 +44,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } host = this.utilityService.toPuny(host); @@ -57,7 +57,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null; @@ -109,7 +109,7 @@ export class RemoteUserResolveService { if (u == null) { throw new Error('user not found'); } else { - return u; + return u as LocalUser | RemoteUser; } }); } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index a8eded673..7d90bc2c0 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; @@ -22,6 +22,8 @@ import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import Logger from '../logger.js'; +import { IsNull } from 'typeorm'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; const logger = new Logger('following/create'); @@ -73,6 +75,7 @@ export class UserFollowingService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, + private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { @@ -87,7 +90,7 @@ export class UserFollowingService implements OnModuleInit { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; // check blocking const [blocking, blocked] = await Promise.all([ @@ -137,6 +140,20 @@ export class UserFollowingService implements OnModuleInit { if (followed) autoAccept = true; } + // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. + if (followee.isLocked && !autoAccept) { + autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( + follower, + (oldSrc, newSrc) => this.followingsRepository.exist({ + where: { + followeeId: followee.id, + followerId: newSrc.id, + }, + }), + true, + )); + } + if (!autoAccept) { await this.createFollowRequest(follower, followee, requestId); return; @@ -210,32 +227,40 @@ export class UserFollowingService implements OnModuleInit { this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - //#region Increment counts - await Promise.all([ - this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), - this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + const [followeeUser, followerUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: followee.id }), + this.usersRepository.findOneByOrFail({ id: follower.id }), ]); - //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + // Neither followee nor follower has moved. + if (!followeeUser.movedToUri && !followerUser.movedToUri) { + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); } - //#endregion - - this.perUserFollowingChart.update(follower, followee, true); // Publish follow event if (this.userEntityService.isLocalUser(follower) && !silent) { @@ -283,12 +308,18 @@ export class UserFollowingService implements OnModuleInit { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const following = await this.followingsRepository.findOne({ + relations: { + follower: true, + followee: true, + }, + where: { + followerId: follower.id, + followeeId: followee.id, + } }); - if (following == null) { + if (following === null || !following.follower || !following.followee) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } @@ -297,7 +328,7 @@ export class UserFollowingService implements OnModuleInit { this.cacheService.userFollowingsCache.refresh(follower.id); - this.decrementFollowing(follower, followee); + this.decrementFollowing(following.follower, following.followee); // Publish unfollow event if (!silent && this.userEntityService.isLocalUser(follower)) { @@ -316,50 +347,87 @@ export class UserFollowingService implements OnModuleInit { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower)); this.queueService.deliver(follower, content, followee.inbox, false); } if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { // local user has null host - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } } @bindThis private async decrementFollowing( - follower: { id: User['id']; host: User['host']; }, - followee: { id: User['id']; host: User['host']; }, + follower: User, + followee: User, ): Promise { this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - //#region Decrement following / followers counts - await Promise.all([ - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion + // Neither followee nor follower has moved. + if (!follower.movedToUri && !followee.movedToUri) { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } else { + // Adjust following/followers counts + for (const user of [follower, followee]) { + if (user.movedToUri) continue; // No need to update if the user has already moved. + + const nonMovedFollowees = await this.followingsRepository.count({ + relations: { + followee: true, + }, + where: { + followerId: user.id, + followee: { + movedToUri: IsNull(), + } + } + }); + const nonMovedFollowers = await this.followingsRepository.count({ + relations: { + follower: true, + }, + where: { + followeeId: user.id, + follower: { + movedToUri: IsNull(), + } + } + }); + await this.usersRepository.update( + { id: user.id }, + { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers }, + ); + } + + // TODO: adjust charts } - //#endregion - - this.perUserFollowingChart.update(follower, followee, false); } @bindThis @@ -415,7 +483,7 @@ export class UserFollowingService implements OnModuleInit { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); + const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -430,7 +498,7 @@ export class UserFollowingService implements OnModuleInit { }, ): Promise { if (this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower)); if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので this.queueService.deliver(follower, content, followee.inbox, false); @@ -475,7 +543,7 @@ export class UserFollowingService implements OnModuleInit { await this.insertFollowingDoc(followee, follower); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } @@ -562,15 +630,22 @@ export class UserFollowingService implements OnModuleInit { */ @bindThis private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const following = await this.followingsRepository.findOne({ + relations: { + followee: true, + follower: true, + }, + where: { + followeeId: followee.id, + followerId: follower.id, + } }); - if (!following) return; + if (!following || !following.followee || !following.follower) return; await this.followingsRepository.delete(following.id); - this.decrementFollowing(follower, followee); + + this.decrementFollowing(following.follower, following.followee); } /** diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d00bb89c7..b197d335d 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -35,7 +35,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const queue: string[] = []; @@ -65,7 +65,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); const queue: string[] = []; diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 4b032be89..2b404ebec 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js'; import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; -import { RemoteUser, User } from '@/models/entities/User.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { IObject } from './type.js'; @@ -101,7 +101,7 @@ export class ApDbResolverService { * AP Person => Misskey User in DB */ @bindThis - public async getUserFromApId(value: string | IObject): Promise { + public async getUserFromApId(value: string | IObject): Promise { const parsed = this.parseUri(value); if (parsed.local) { @@ -109,11 +109,11 @@ export class ApDbResolverService { return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, - }).then(x => x ?? undefined)) ?? null; + }).then(x => x ?? undefined)) as LocalUser | undefined ?? null; } else { return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, - })); + })) as RemoteUser | null; } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 3fca0bb1f..efef777fb 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,53 +742,7 @@ 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); - // 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); - - // check if alsoKnownAs of the new account is valid - let isValidMove = true; - if (old_acc.uri) { - if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { - isValidMove = false; - } - } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { - isValidMove = false; - } - if (!isValidMove) { - return 'skip: accounts invalid'; - } - - // add target uri to movedToUri in order to indicate that the user has moved - await this.usersRepository.update(old_acc.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 */ - } - } - - return 'ok'; + return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0b22aa9bc..60e19bfca 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { Relay } from '@/models/entities/Relay.js'; @@ -66,7 +66,7 @@ export class ApRendererService { public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept { return { type: 'Accept', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -75,7 +75,7 @@ export class ApRendererService { public renderAdd(user: LocalUser, target: any, object: any): IAdd { return { type: 'Add', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -83,7 +83,7 @@ export class ApRendererService { @bindThis public renderAnnounce(object: any, note: Note): IAnnounce { - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); let to: string[] = []; let cc: string[] = []; @@ -103,7 +103,7 @@ export class ApRendererService { return { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: note.createdAt.toISOString(), to, @@ -126,7 +126,7 @@ export class ApRendererService { return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, - actor: `${this.config.url}/users/${block.blockerId}`, + actor: this.userEntityService.genLocalUserUri(block.blockerId), object: block.blockee.uri, }; } @@ -135,7 +135,7 @@ export class ApRendererService { public renderCreate(object: IObject, note: Note): ICreate { const activity = { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: note.createdAt.toISOString(), object, @@ -151,7 +151,7 @@ export class ApRendererService { public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete { return { type: 'Delete', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -188,7 +188,7 @@ export class ApRendererService { public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag { return { type: 'Flag', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content, object, }; @@ -199,7 +199,7 @@ export class ApRendererService { return { id: `${this.config.url}/activities/follow-relay/${relay.id}`, type: 'Follow', - actor: `${this.config.url}/users/${relayActor.id}`, + actor: this.userEntityService.genLocalUserUri(relayActor.id), object: 'https://www.w3.org/ns/activitystreams#Public', }; } @@ -210,21 +210,21 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: User['id']) { - const user = await this.usersRepository.findOneByOrFail({ id: id }); - return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser; + return this.userEntityService.getUserUri(user); } @bindThis public renderFollow( - follower: { id: User['id']; host: User['host']; uri: User['host'] }, - followee: { id: User['id']; host: User['host']; uri: User['host'] }, + follower: PartialLocalUser | PartialRemoteUser, + followee: PartialLocalUser | PartialRemoteUser, requestId?: string, ): IFollow { return { id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', - actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!, - object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!, + actor: this.userEntityService.getUserUri(follower)!, + object: this.userEntityService.getUserUri(followee)!, }; } @@ -252,7 +252,7 @@ export class ApRendererService { return { id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', - owner: `${this.config.url}/users/${user.id}`, + owner: this.userEntityService.genLocalUserUri(user.id), publicKeyPem: createPublicKey(key.publicKey).export({ type: 'spki', format: 'pem', @@ -284,21 +284,21 @@ export class ApRendererService { } @bindThis - public renderMention(mention: User): IApMention { + public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention { return { type: 'Mention', - href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`, + href: this.userEntityService.getUserUri(mention)!, name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`, }; } @bindThis public renderMove( - src: { id: User['id']; host: User['host']; uri: User['host'] }, - dst: { id: User['id']; host: User['host']; uri: User['host'] }, + src: PartialLocalUser | PartialRemoteUser, + dst: PartialLocalUser | PartialRemoteUser, ): IMove { - const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; - const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; + const actor = this.userEntityService.getUserUri(src)!; + const target = this.userEntityService.getUserUri(dst)!; return { id: `${this.config.url}/moves/${src.id}/${dst.id}`, actor, @@ -351,7 +351,7 @@ export class ApRendererService { } } - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); @@ -376,7 +376,7 @@ export class ApRendererService { }) : []; const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u)); + const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser)); const files = await getPromisedFiles(note.fileIds); @@ -450,7 +450,7 @@ export class ApRendererService { @bindThis public async renderPerson(user: LocalUser) { - const id = `${this.config.url}/users/${user.id}`; + const id = this.userEntityService.genLocalUserUri(user.id); const isSystem = !!user.username.match(/\./); const [avatar, banner, profile] = await Promise.all([ @@ -538,7 +538,7 @@ export class ApRendererService { return { type: 'Question', id: `${this.config.url}/questions/${note.id}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content: note.text ?? '', [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ name: text, @@ -555,7 +555,7 @@ export class ApRendererService { public renderReject(object: any, user: { id: User['id'] }): IReject { return { type: 'Reject', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -564,7 +564,7 @@ export class ApRendererService { public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove { return { type: 'Remove', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -585,7 +585,7 @@ export class ApRendererService { return { type: 'Undo', ...(id ? { id } : {}), - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -595,7 +595,7 @@ export class ApRendererService { public renderUpdate(object: any, user: { id: User['id'] }): IUpdate { return { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Update', to: ['https://www.w3.org/ns/activitystreams#Public'], object, @@ -607,14 +607,14 @@ export class ApRendererService { public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate { return { id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Create', to: [pollOwner.uri], published: new Date().toISOString(), object: { id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, type: 'Note', - attributedTo: `${this.config.url}/users/${user.id}`, + attributedTo: this.userEntityService.genLocalUserUri(user.id), to: [pollOwner.uri], inReplyTo: note.uri, name: poll.choices[vote.choice], diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index df7bb4640..d3e0345c9 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -151,7 +151,7 @@ export class Resolver { return Promise.all( [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), ) - .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url))); + .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url))); default: throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 6f2b8e5c3..eea1d1b84 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,9 +3,9 @@ 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 type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; import type { CacheService } from '@/core/CacheService.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'; import { checkHttps } from '@/misc/check-https.js'; const nameLength = 128; @@ -67,6 +68,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -132,6 +134,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; } @@ -209,27 +212,27 @@ export class ApPersonService implements OnModuleInit { } /** - * Personをフェッチします。 + * uriからUser(Person)をフェッチします。 * - * Misskeyに対象のPersonが登録されていればそれを返します。 + * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 */ @bindThis - public async fetchPerson(uri: string, resolver?: Resolver): Promise { + public async fetchPerson(uri: string): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.cacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }); + const u = await this.usersRepository.findOneBy({ id }) as LocalUser; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }); + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -244,7 +247,7 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { + public async createPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); if (uri.startsWith(this.config.url)) { @@ -289,6 +292,7 @@ export class ApPersonService implements OnModuleInit { name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, movedToUri: person.movedTo, + movedAt: person.movedTo ? new Date() : null, alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, @@ -411,23 +415,26 @@ export class ApPersonService implements OnModuleInit { /** * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 + * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) */ @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { 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 @@ -485,7 +492,16 @@ export class ApPersonService implements OnModuleInit { movedToUri: person.movedTo ?? null, alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, - } as Partial; + } as Partial & Pick; + + const moving = + // 移行先がない→ある + (!exist.movedToUri && updates.movedToUri) || + // 移行先がある→別のもの + (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + // 移行先がある→ない、ない→ないは無視 + + if (moving) updates.movedAt = new Date(); if (avatar) { updates.avatarId = avatar.id; @@ -530,6 +546,31 @@ export class ApPersonService implements OnModuleInit { }); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + const updated = { ...exist, ...updates }; + + this.cacheService.uriPersonCache.set(uri, updated); + + // 移行処理を行う + if (updated.movedAt && ( + // 初めて移行する場合はmovedAtがnullなので移行処理を許可 + exist.movedAt == null || + // 以前のmovingから14日以上経過した場合のみ移行処理を許可 + // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + )) { + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + return this.processRemoteMove(updated, movePreventUris) + .then(result => { + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + return result; + }) + .catch(e => { + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + }); + } + + return 'skip'; } /** @@ -539,7 +580,7 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -614,4 +655,53 @@ export class ApPersonService implements OnModuleInit { } }); } + + /** + * リモート由来のアカウント移行処理を行います + * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提) + * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止) + */ + @bindThis + private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise { + if (!src.movedToUri) return 'skip: no movedToUri'; + if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ??? + if (movePreventUris.length > 10) return 'skip: too many moves'; + + // まずサーバー内で検索して様子見 + let dst = await this.fetchPerson(src.movedToUri); + + if (dst && this.userEntityService.isLocalUser(dst)) { + // targetがローカルユーザーだった場合データベースから引っ張ってくる + dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser; + } else if (dst) { + if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; + + // targetを見つけたことがあるならtargetをupdatePersonする + await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); + dst = await this.fetchPerson(src.movedToUri) ?? dst; + } else { + if (src.movedToUri.startsWith(`${this.config.url}/`)) { + // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている + return 'failed: movedTo is local but not found'; + } + + // targetが知らない人だったらresolvePerson + // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) + dst = await this.resolvePerson(src.movedToUri); + } + + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? + if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? + if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { + return 'skip: dst.alsoKnownAs is empty'; + } + if (!dst.alsoKnownAs?.includes(src.uri)) { + return 'skip: alsoKnownAs does not include from.uri'; + } + + await this.accountMoveService.postMoveProcess(src, dst); + + return 'ok'; + } } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 2c67cb772..7c9a11ee8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -9,8 +9,7 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import type { Instance } from '@/models/entities/Instance.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; @@ -35,13 +34,13 @@ type IsMeAndIsUserDetailed(user: T): user is T & { host: null; }; +function isLocalUser(user: T): user is (T & { host: null; }); function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is RemoteUser; -function isRemoteUser(user: T): user is T & { host: string; }; +function isRemoteUser(user: T): user is (T & { host: string; }); function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } @@ -280,6 +279,17 @@ export class UserEntityService implements OnModuleInit { return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } + @bindThis + public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string { + return this.isRemoteUser(user) + ? user.uri : this.genLocalUserUri(user.id); + } + + @bindThis + public genLocalUserUri(userId: string): string { + return `${this.config.url}/users/${userId}`; + } + public async pack( src: User['id'] | User, me?: { id: User['id'] } | null | undefined, @@ -369,8 +379,11 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail ? { url: profile!.url, uri: user.uri, - movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, - alsoKnownAs: user.alsoKnownAs, + movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, + alsoKnownAs: user.alsoKnownAs + ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[]) + : null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 04dfa2110..8e10f999b 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -75,6 +75,12 @@ export class User { }) public movedToUri: string | null; + @Column('timestamp with time zone', { + nullable: true, + comment: 'When the user moved to another account', + }) + public movedAt: Date | null; + @Column('simple-array', { nullable: true, comment: 'URIs the user is known as too', @@ -253,11 +259,23 @@ export type LocalUser = User & { uri: null; } +export type PartialLocalUser = Partial & { + id: User['id']; + host: null; + uri: null; +} + export type RemoteUser = User & { host: string; uri: string; } +export type PartialRemoteUser = Partial & { + id: User['id']; + host: string; + uri: string; +} + export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 895934f8b..42b5d53ac 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -84,7 +84,7 @@ export const packedUserDetailedNotMeOnlySchema = { optional: false, items: { type: 'string', - format: 'uri', + format: 'id', nullable: false, optional: false, }, diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts index af086fa4e..736b4fa80 100644 --- a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts +++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts @@ -17,7 +17,7 @@ export class RelationshipQueueProcessorsService { @bindThis public start(q: Bull.Queue): void { - const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative? + const maxJobs = this.config.relashionshipJobConcurrency ?? 16; q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index ada6f9e96..ab8b1e9e2 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -84,9 +84,9 @@ export class InboxProcessorService { // HTTP-Signature keyIdを元にDBから取得 let authUser: { - user: RemoteUser; - key: UserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + user: RemoteUser; + key: UserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 if (authUser == null) { diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index a5006dcf0..ff454df45 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -10,6 +10,7 @@ import { QueueLoggerService } from '../QueueLoggerService.js'; import { RelationshipJobData } from '../types.js'; import type { UsersRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; @Injectable() export class RelationshipProcessorService { @@ -39,7 +40,7 @@ export class RelationshipProcessorService { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: job.data.from.id }), this.usersRepository.findOneByOrFail({ id: job.data.to.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; await this.userFollowingService.unfollow(follower, followee, job.data.silent); return 'ok'; } diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index e13e9265a..e675d9cf1 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -11,7 +11,7 @@ import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; import type { Following } from '@/models/entities/Following.js'; import { countIf } from '@/misc/prelude/array.js'; @@ -630,7 +630,7 @@ export class ActivityPubServerService { id: request.params.followee, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); @@ -665,7 +665,7 @@ export class ActivityPubServerService { id: followRequest.followeeId, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index e72256303..9bf8deb22 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { User } from '@/models/entities/User.js'; import * as Acct from '@/misc/acct.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FindOptionsWhere } from 'typeorm'; import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -23,6 +24,7 @@ export class WellKnownServerService { private usersRepository: UsersRepository, private nodeinfoServerService: NodeinfoServerService, + private userEntityService: UserEntityService, ) { //this.createServer = this.createServer.bind(this); } @@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const self = { rel: 'self', type: 'application/activity+json', - href: `${this.config.url}/users/${user.id}`, + href: this.userEntityService.genLocalUserUri(user.id), }; const profilePage = { rel: 'http://webfinger.net/rel/profile-page', diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index bf5cb2091..e3483c82c 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown { } } + if (ep.meta.prohibitMoved) { + if (user?.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + } + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index e4e594ec5..6dc1313e5 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -223,7 +223,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -560,7 +559,6 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; -const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -901,7 +899,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_updateEmail, $i_update, $i_move, - $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1236,7 +1233,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_updateEmail, $i_update, $i_move, - $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 305ce3b34..acd7f7ec3 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -223,7 +223,6 @@ import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_knownAs from './endpoints/i/known-as.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -557,8 +556,7 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], - //['i/move', ep___i_move], - //['i/known-as', ep___i_knownAs], + ['i/move', ep___i_move], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -704,6 +702,12 @@ export interface IEndpointMeta { readonly requireRolePolicy?: keyof RolePolicies; + /** + * 引っ越し済みのユーザーによるリクエストを禁止するか + * 省略した場合は false として解釈されます。 + */ + readonly prohibitMoved?: boolean; + /** * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index b7ce3363a..5754a9f12 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 3f8544213..5f980bdbe 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -71,7 +73,7 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - + private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, ) { diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index dff8a9d10..6294b08fa 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', limit: { diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts index f52b45ccf..c8544273a 100644 --- a/packages/backend/src/server/api/endpoints/channels/favorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 8ab59991c..f3ca66cfd 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts index 0c3f6c485..67fb1ea03 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index 855ba47f8..f46ff9f28 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index b9d8dce47..c3561e2a7 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', limit: { diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index a770dc986..5395a5c37 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', res: { @@ -57,7 +59,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ApiError(meta.errors.tooManyClips); } - + const clip = await this.clipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts index 6addf743a..f08caaf8d 100644 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 5d88870ed..50c5d758b 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts index 244843d50..3da252a22 100644 --- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index a103c3f7d..70f195935 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index b3bdef41d..a1c1f9325 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -15,6 +15,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 120, diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index cfef79383..c835587c4 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:drive', } as const; diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index f21d9d5c3..3172bdbfd 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index 5581b8ec6..23de2f397 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts index b994f5d34..696512b06 100644 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts index cd4e413a4..78dfd4a06 100644 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 411c39110..4ad16de91 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:following', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index cb8b6a2e3..ca6bfa7e0 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 519e56ed6..6ac5fa860 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index cfbedcc4d..513089217 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index f14d644a3..a2a10d840 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index 102dae4fb..4eef49638 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService export const meta = { requireCredential: true, + prohibitMoved: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8c1c158ab..811971591 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportBlockingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 383bdc02b..8af278c88 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportFollowingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 345ad916c..eb0f9ba47 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportMutingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 875af7ec2..4568e9390 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportUserListsJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts deleted file mode 100644 index 964704d82..000000000 --- a/packages/backend/src/server/api/endpoints/i/known-as.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import ms from 'ms'; - -import { User } from '@/models/entities/User.js'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ApiError } from '@/server/api/error.js'; - -import { AccountMoveService } from '@/core/AccountMoveService.js'; -import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; - -export const meta = { - tags: ['users'], - - secure: true, - requireCredential: true, - - limit: { - duration: ms('1day'), - max: 30, - }, - - errors: { - noSuchUser: { - message: 'No such user.', - 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', - id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', - }, - }, -} as const; - -export const paramDef = { - type: 'object', - properties: { - alsoKnownAs: { type: 'string' }, - }, - required: ['alsoKnownAs'], -} as const; - -@Injectable() -export default class extends Endpoint { - constructor( - private userEntityService: UserEntityService, - private remoteUserResolveService: RemoteUserResolveService, - private apiLoggerService: ApiLoggerService, - private accountMoveService: AccountMoveService, - ) { - super(meta, paramDef, async (ps, me) => { - // Check parameter - if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); - - let unfiltered = ps.alsoKnownAs; - const updates = {} as Partial; - - if (!unfiltered) { - updates.alsoKnownAs = null; - } else { - // 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); - - 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}`); - throw new ApiError(meta.errors.noSuchUser); - }); - - const toUrl: string | null = knownAs.uri; - 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]; - } - - return await this.accountMoveService.createAlias(me, updates); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index ac76e1f62..261dd527c 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -7,40 +7,35 @@ import { DI } from '@/di-symbols.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ApiError } from '@/server/api/error.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + import { AccountMoveService } from '@/core/AccountMoveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +import * as Acct from '@/misc/acct.js'; export const meta = { tags: ['users'], secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1day'), max: 5, }, errors: { - noSuchMoveTarget: { - message: 'No such move target.', - 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?', - code: 'REMOTE_ACCOUNT_FORBIDS', + 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.', + code: 'DESTINATION_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', @@ -84,57 +79,52 @@ export default class extends Endpoint { @Inject(DI.config) private config: Config, - private userEntityService: UserEntityService, private remoteUserResolveService: RemoteUserResolveService, private apiLoggerService: ApiLoggerService, private accountMoveService: AccountMoveService, private getterService: GetterService, private apPersonService: ApPersonService, + private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { // check parameter - if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); // abort if user is the root if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); // abort if user has already moved if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); - let unfiltered = ps.moveToAccount; - if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget); - // 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); - - const userAddress = unfiltered.split('@'); + const { username, host } = Acct.parse(ps.moveToAccount); // retrieve the destination account - let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); - throw new ApiError(meta.errors.noSuchMoveTarget); + throw new ApiError(meta.errors.noSuchUser); }); - 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) as LocalUser | RemoteUser; + const newUri = this.userEntityService.getUserUri(destination); // update local db - await this.apPersonService.updatePerson(remoteMoveTo.uri); + await this.apPersonService.updatePerson(newUri); // 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(newUri); - let allowed = false; - - const fromUrl = `${this.config.url}/users/${me.id}`; // make sure that the user has indicated the old account as an alias - moveTo.alsoKnownAs?.forEach((elem) => { - if (fromUrl.includes(elem)) allowed = true; - }); + const fromUrl = this.userEntityService.genLocalUserUri(me.id); + let allowed = false; + if (moveTo.alsoKnownAs) { + for (const knownAs of moveTo.alsoKnownAs) { + if (knownAs.includes(fromUrl)) { + allowed = true; + break; + } + } + } // abort if unintended - if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids); - return await this.accountMoveService.moveToRemote(me, moveTo); + return await this.accountMoveService.moveFromLocal(me, moveTo); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index d4af00027..229350094 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -8,6 +8,7 @@ export const meta = { tags: ['account', 'notes'], requireCredential: true, + prohibitMoved: true, kind: 'write:account', diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 97699f3be..738edf397 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,6 +3,7 @@ import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; +import * as Acct from '@/misc/acct.js'; import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js'; @@ -19,7 +20,10 @@ import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -71,6 +75,24 @@ export const meta = { code: 'TOO_MANY_MUTED_WORDS', id: '010665b1-a211-42d2-bc64-8f6609d79785', }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + + forbiddenToSetYourself: { + message: 'You can\'t set yourself as your own alias.', + code: 'FORBIDDEN_TO_SET_YOURSELF', + id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', + }, }, res: { @@ -129,6 +151,12 @@ export const paramDef = { emailNotificationTypes: { type: 'array', items: { type: 'string', } }, + alsoKnownAs: { + type: 'array', + maxItems: 10, + uniqueItems: true, + items: { type: 'string' }, + }, }, } as const; @@ -153,6 +181,9 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, + private accountMoveService: AccountMoveService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, private hashtagService: HashtagService, private roleService: RoleService, private cacheService: CacheService, @@ -260,6 +291,38 @@ export default class extends Endpoint { }); } + if (ps.alsoKnownAs) { + if (_user.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + + // Parse user's input into the old account + const newAlsoKnownAs = new Set(); + for (const line of ps.alsoKnownAs) { + if (!line) throw new ApiError(meta.errors.noSuchUser); + const { username, host } = Acct.parse(line); + + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); + + const toUrl = this.userEntityService.getUserUri(knownAs); + if (!toUrl) throw new ApiError(meta.errors.uriNull); + + newAlsoKnownAs.add(toUrl); + } + + updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; + } + //#region emojis/tags let emojis = [] as string[]; @@ -287,6 +350,9 @@ export default class extends Endpoint { //#endregion if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); + if (Object.keys(updates).includes('alsoKnownAs')) { + this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); + } if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); const iObj = await this.userEntityService.pack(user.id, user, { diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 6e24e1024..ee358d5c6 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -11,6 +11,7 @@ export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 69fafcb9c..fa2dc447d 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -18,6 +18,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 300, diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 0ce80a1a6..611ea1956 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -12,6 +12,7 @@ export const meta = { tags: ['notes', 'favorites'], requireCredential: true, + prohibitMoved: true, kind: 'write:favorites', diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 2a44dc537..3a33b037f 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -17,6 +17,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:votes', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 04e374d1a..97cb02677 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:reactions', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 4015bf1f2..e08ab399f 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index d27990f7e..543c126d9 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index e397e2a23..f0c019846 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index 35b402ec5..751274067 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index b28526961..beb5850d7 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -13,6 +13,7 @@ export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index a840c1a04..751088952 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Create a new list of users.', @@ -58,7 +60,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - + const userList = await this.userListsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index d2dd5731e..d50b70efc 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Remove a user from a list.', diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 1c1fdc23f..925037e48 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Add a user to an existing list.', diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index c662b16f1..689843508 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,8 +4,9 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; -import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js'; +import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import { User } from '@/models/index.js'; describe('Endpoints', () => { let app: INestApplicationContext; @@ -289,6 +290,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 1); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 1); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('既にフォローしている場合は怒る', async () => { @@ -341,6 +352,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 0); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('フォローしていない場合は怒る', async () => { diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts new file mode 100644 index 000000000..4dd5cbb9d --- /dev/null +++ b/packages/backend/test/e2e/move.ts @@ -0,0 +1,455 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { loadConfig } from '@/config.js'; +import { User, UsersRepository } from '@/models/index.js'; +import { jobQueue } from '@/boot/common.js'; +import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Account Move', () => { + let app: INestApplicationContext; + let url: URL; + + let root: any; + let alice: any; + let bob: any; + let carol: any; + let dave: any; + let eve: any; + let frank: any; + + let Users: UsersRepository; + + beforeAll(async () => { + app = await startServer(); + await jobQueue(); + const config = loadConfig(); + url = new URL(config.url); + const connection = await initTestDb(false); + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + eve = await signup({ username: 'eve' }); + frank = await signup({ username: 'frank' }); + Users = connection.getRepository(User); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + describe('Create Alias', () => { + afterEach(async () => { + await Users.update(bob.id, { alsoKnownAs: null }); + }, 1000 * 10); + + test('Able to create an alias', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(res.body.alsoKnownAs?.length, 1); + assert.strictEqual(res.body.alsoKnownAs[0], alice.id); + }); + + test('Able to create a local alias without hostname', async () => { + await api('/i/update', { + alsoKnownAs: ['@alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to create a local alias without @', async () => { + await api('/i/update', { + alsoKnownAs: ['alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to set remote user (but may fail)', async () => { + const res = await api('/i/update', { + alsoKnownAs: ['@syuilo@example.com'], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to add duplicated aliases to alsoKnownAs', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); + assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + }); + + test('Unable to add itself', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@bob@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + }); + + test('Unable to add a nonexisting local account to alsoKnownAs', async () => { + const res1 = await api('/i/update', { + alsoKnownAs: [`@nonexist@${url.hostname}`], + }, bob); + + assert.strictEqual(res1.status, 400); + assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + + const res2 = await api('/i/update', { + alsoKnownAs: ['@alice', 'nonexist'], + }, bob); + + assert.strictEqual(res2.status, 400); + assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Able to add two existing local account to alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`); + }); + + test('Able to properly overwrite alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + await api('/i/update', { + alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`); + }); + }); + + describe('Local to Local', () => { + let antennaId = ''; + + beforeAll(async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, root); + const listRoot = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, root); + await api('/users/lists/push', { + listId: listRoot.body.id, + userId: alice.id, + }, root); + + await api('/following/create', { + userId: root.id, + }, alice); + await api('/following/create', { + userId: eve.id, + }, alice); + const antenna = await api('/antennas/create', { + name: rndstr('0-9a-z', 8), + src: 'home', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + antennaId = antenna.body.id; + + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + await api('/following/create', { + userId: alice.id, + }, carol); + + await api('/mute/create', { + userId: alice.id, + }, dave); + await api('/blocking/create', { + userId: alice.id, + }, dave); + await api('/following/create', { + userId: eve.id, + }, dave); + + await api('/following/create', { + userId: dave.id, + }, eve); + const listEve = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, eve); + await api('/users/lists/push', { + listId: listEve.body.id, + userId: bob.id, + }, eve); + + await api('/i/update', { + isLocked: true, + }, frank); + await api('/following/create', { + userId: frank.id, + }, alice); + await api('/following/requests/accept', { + userId: alice.id, + }, frank); + }, 1000 * 10); + + test('Prohibit the root account from moving', async () => { + const res = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, root); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + }); + + test('Unable to move to a nonexisting local account', async () => { + const res = await api('/i/move', { + moveToAccount: `@nonexist@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to move if alsoKnownAs is invalid', async () => { + const res = await api('/i/move', { + moveToAccount: `@carol@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Relationships have been properly migrated', async () => { + const move = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, alice); + + assert.strictEqual(move.status, 200); + + await sleep(1000 * 3); // wait for jobs to finish + + // Unfollow delayed? + const aliceFollowings = await api('/users/following', { + userId: alice.id, + }, alice); + assert.strictEqual(aliceFollowings.status, 200); + assert.strictEqual(aliceFollowings.body.length, 3); + + const carolFollowings = await api('/users/following', { + userId: carol.id, + }, carol); + assert.strictEqual(carolFollowings.status, 200); + assert.strictEqual(carolFollowings.body.length, 2); + assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); + assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); + + const blockings = await api('/blocking/list', {}, dave); + assert.strictEqual(blockings.status, 200); + assert.strictEqual(blockings.body.length, 2); + assert.strictEqual(blockings.body[0].blockeeId, bob.id); + assert.strictEqual(blockings.body[1].blockeeId, alice.id); + + const mutings = await api('/mute/list', {}, dave); + assert.strictEqual(mutings.status, 200); + assert.strictEqual(mutings.body.length, 2); + assert.strictEqual(mutings.body[0].muteeId, bob.id); + assert.strictEqual(mutings.body[1].muteeId, alice.id); + + const rootLists = await api('/users/lists/list', {}, root); + assert.strictEqual(rootLists.status, 200); + assert.strictEqual(rootLists.body[0].userIds.length, 2); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); + + const eveLists = await api('/users/lists/list', {}, eve); + assert.strictEqual(eveLists.status, 200); + assert.strictEqual(eveLists.body[0].userIds.length, 1); + assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); + }); + + test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => { + await successfulApiCall({ + endpoint: '/following/create', + parameters: { + userId: frank.id, + }, + user: bob, + }); + const followers = await api('/users/followers', { + userId: frank.id, + }, frank); + + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.length, 2); + assert.strictEqual(followers.body[0].followerId, bob.id); + }); + + test('Unfollowed after 10 sec (24 hours in production).', async () => { + await sleep(1000 * 8); + + const following = await api('/users/following', { + userId: alice.id, + }, alice); + + assert.strictEqual(following.status, 200); + assert.strictEqual(following.body.length, 0); + }); + + test('Unable to move if the destination account has already moved.', async () => { + const res = await api('/i/move', { + moveToAccount: `@alice@${url.hostname}`, + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Follow and follower counts are properly adjusted', async () => { + await api('/following/create', { + userId: alice.id, + }, eve); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + const newCarol = await Users.findOneByOrFail({ id: carol.id }); + let newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`); + assert.strictEqual(newAlice.followingCount, 0); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newCarol.followingCount, 1); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + + await api('/following/delete', { + userId: alice.id, + }, eve); + newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + }); + + test.each([ + '/antennas/create', + '/channels/create', + '/channels/favorite', + '/channels/follow', + '/channels/unfavorite', + '/channels/unfollow', + '/clips/add-note', + '/clips/create', + '/clips/favorite', + '/clips/remove-note', + '/clips/unfavorite', + '/clips/update', + '/drive/files/upload-from-url', + '/flash/create', + '/flash/like', + '/flash/unlike', + '/flash/update', + '/following/create', + '/gallery/posts/create', + '/gallery/posts/like', + '/gallery/posts/unlike', + '/gallery/posts/update', + '/i/claim-achievement', + '/i/move', + '/i/import-blocking', + '/i/import-following', + '/i/import-muting', + '/i/import-user-lists', + '/i/pin', + '/mute/create', + '/notes/create', + '/notes/favorites/create', + '/notes/polls/vote', + '/notes/reactions/create', + '/pages/create', + '/pages/like', + '/pages/unlike', + '/pages/update', + '/renote-mute/create', + '/users/lists/create', + '/users/lists/pull', + '/users/lists/push', + ])('Prohibit access after moving: %s', async (endpoint) => { + const res = await api(endpoint, {}, alice); + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /antennas/update', async () => { + const res = await api('/antennas/update', { + antennaId, + name: rndstr('0-9a-z', 8), + src: 'users', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [eve.id], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /drive/files/create', async () => { + const res = await uploadFile(alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit updating alsoKnownAs after moving', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@eve@${url.hostname}`], + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + }); +}); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 2c4716c06..c1a57f102 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -83,7 +83,7 @@ describe('ユーザー', () => { ...userLite(user), url: user.url, uri: user.uri, - movedToUri: user.movedToUri, + movedTo: user.movedTo, alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt, updatedAt: user.updatedAt, @@ -348,7 +348,7 @@ describe('ユーザー', () => { // UserDetailedNotMeOnly assert.strictEqual(response.url, null); assert.strictEqual(response.uri, null); - assert.strictEqual(response.movedToUri, null); + assert.strictEqual(response.movedTo, null); assert.strictEqual(response.alsoKnownAs, null); assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); assert.strictEqual(response.updatedAt, null); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 529e923b2..c2280142a 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -7,6 +7,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { RelayService } from '@/core/RelayService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { IdService } from '@/core/IdService.js'; import type { RelaysRepository } from '@/models/index.js'; @@ -21,6 +22,7 @@ describe('RelayService', () => { let relayService: RelayService; let queueService: jest.Mocked; let relaysRepository: RelaysRepository; + let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -32,6 +34,7 @@ describe('RelayService', () => { CreateSystemUserService, ApRendererService, RelayService, + UserEntityService, ], }) .useMocker((token) => { @@ -51,6 +54,7 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; relaysRepository = app.get(DI.relaysRepository); + userEntityService = app.get(UserEntityService); }); afterAll(async () => { @@ -63,7 +67,7 @@ describe('RelayService', () => { expect(result.inbox).toBe('https://example.com'); expect(result.status).toBe('requesting'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); }); @@ -80,8 +84,8 @@ describe('RelayService', () => { await relayService.removeRelay('https://example.com'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo'); - expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo'); + expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 98979de23..b02bfdc2b 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -1,8 +1,8 @@ @@ -10,11 +10,17 @@ import MkMention from './MkMention.vue'; import { i18n } from '@/i18n'; import { host as localHost } from '@/config'; +import { ref } from 'vue'; +import { UserLite } from 'misskey-js/built/entities'; +import { api } from '@/os'; -defineProps<{ - username: string; - host: string; +const user = ref(); + +const props = defineProps<{ + movedTo: string; // user id }>(); + +api('users/show', { userId: props.movedTo }).then(u => user.value = u);