summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/example.yml16
-rw-r--r--.vscode/settings.json1
-rw-r--r--CHANGELOG.md2
-rw-r--r--locales/ja-JP.yml29
-rw-r--r--packages/backend/migration/1682190963894-movedAt.js13
-rw-r--r--packages/backend/src/config.ts2
-rw-r--r--packages/backend/src/core/AccountMoveService.ts327
-rw-r--r--packages/backend/src/core/QueueModule.ts2
-rw-r--r--packages/backend/src/core/QueueService.ts9
-rw-r--r--packages/backend/src/core/RemoteUserResolveService.ts10
-rw-r--r--packages/backend/src/core/UserFollowingService.ts207
-rw-r--r--packages/backend/src/core/UserSuspendService.ts4
-rw-r--r--packages/backend/src/core/activitypub/ApDbResolverService.ts8
-rw-r--r--packages/backend/src/core/activitypub/ApInboxService.ts60
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts66
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts4
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts122
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts25
-rw-r--r--packages/backend/src/models/entities/User.ts18
-rw-r--r--packages/backend/src/models/json-schema/user.ts2
-rw-r--r--packages/backend/src/queue/RelationshipQueueProcessorsService.ts2
-rw-r--r--packages/backend/src/queue/processors/InboxProcessorService.ts6
-rw-r--r--packages/backend/src/queue/processors/RelationshipProcessorService.ts3
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts6
-rw-r--r--packages/backend/src/server/WellKnownServerService.ts4
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts11
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/endpoints.ts10
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/channels/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/favorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/follow.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/unfavorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/channels/unfollow.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/add-note.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/clips/favorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/remove-note.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/unfavorite.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/clips/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/following/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/gallery/posts/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/claim-achievement.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-blocking.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-following.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-muting.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/import-user-lists.ts11
-rw-r--r--packages/backend/src/server/api/endpoints/i/known-as.ts92
-rw-r--r--packages/backend/src/server/api/endpoints/i/move.ts70
-rw-r--r--packages/backend/src/server/api/endpoints/i/pin.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts66
-rw-r--r--packages/backend/src/server/api/endpoints/mute/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/favorites/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/vote.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/like.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/unlike.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/pages/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/renote-mute/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/create.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/pull.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/lists/push.ts2
-rw-r--r--packages/backend/test/e2e/endpoints.ts23
-rw-r--r--packages/backend/test/e2e/move.ts455
-rw-r--r--packages/backend/test/e2e/users.ts4
-rw-r--r--packages/backend/test/unit/RelayService.ts10
-rw-r--r--packages/frontend/src/components/MkAccountMoved.vue16
-rw-r--r--packages/frontend/src/components/MkInfo.vue1
-rw-r--r--packages/frontend/src/components/MkNote.vue4
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue5
-rw-r--r--packages/frontend/src/os.ts3
-rw-r--r--packages/frontend/src/pages/settings/import-export.vue9
-rw-r--r--packages/frontend/src/pages/settings/index.vue6
-rw-r--r--packages/frontend/src/pages/settings/migration.vue118
-rw-r--r--packages/frontend/src/pages/settings/profile.vue8
-rw-r--r--packages/frontend/src/pages/user/home.vue2
-rw-r--r--packages/frontend/src/scripts/achievements.ts1
-rw-r--r--packages/frontend/src/scripts/please-login.ts2
-rw-r--r--packages/frontend/src/scripts/show-moved-dialog.ts16
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md9
-rw-r--r--packages/misskey-js/src/api.types.ts2
-rw-r--r--packages/misskey-js/src/entities.ts4
94 files changed, 1555 insertions, 467 deletions
diff --git a/.config/example.yml b/.config/example.yml
index 57e2b56b78..8111b1992e 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 baffbe18ec..71fb02a59d 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 4704c8050f..30a882298b 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 6a164b67b0..3225ff6216 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 0000000000..1f8f030a5c
--- /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 bb97d8c17c..4499475ee9 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 3f2a19b771..ab11785e28 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -1,55 +1,90 @@
import { Inject, Injectable } from '@nestjs/common';
-import { IsNull } from 'typeorm';
+import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
-import type { LocalUser } from '@/models/entities/User.js';
-import { User } from '@/models/entities/User.js';
-import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
+import type { Config } from '@/config.js';
+import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
+import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
+import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
+import type { User } from '@/models/entities/User.js';
+import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
-import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { QueueService } from '@/core/QueueService.js';
+import { RelayService } from '@/core/RelayService.js';
+import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import { AccountUpdateService } from '@/core/AccountUpdateService.js';
-import { RelayService } from '@/core/RelayService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { ProxyAccountService } from '@/core/ProxyAccountService.js';
+import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
+import { MetaService } from '@/core/MetaService.js';
+import InstanceChart from '@/core/chart/charts/instance.js';
+import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
@Injectable()
export class AccountMoveService {
constructor(
+ @Inject(DI.config)
+ private config: Config,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ @Inject(DI.userListJoiningsRepository)
+ private userListJoiningsRepository: UserListJoiningsRepository,
+
+ @Inject(DI.instancesRepository)
+ private instancesRepository: InstancesRepository,
+
private userEntityService: UserEntityService,
+ private idService: IdService,
+ private apPersonService: ApPersonService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
- private userFollowingService: UserFollowingService,
- private accountUpdateService: AccountUpdateService,
+ private proxyAccountService: ProxyAccountService,
+ private perUserFollowingChart: PerUserFollowingChart,
+ private federatedInstanceService: FederatedInstanceService,
+ private instanceChart: InstanceChart,
+ private metaService: MetaService,
private relayService: RelayService,
+ private cacheService: CacheService,
+ private queueService: QueueService,
) {
}
/**
- * Move a local account to a remote account.
+ * Move a local account to a new account.
*
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
*/
@bindThis
- public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
- // Make sure that the destination is a remote account.
- if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
- if (!dst.uri) throw new Error('destination uri is empty');
+ public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise<unknown> {
+ const srcUri = this.userEntityService.getUserUri(src);
+ const dstUri = this.userEntityService.getUserUri(dst);
// add movedToUri to indicate that the user has moved
- const update = {} as Partial<User>;
- update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
- update.movedToUri = dst.uri;
+ const update = {} as Partial<LocalUser>;
+ update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri];
+ update.movedToUri = dstUri;
+ update.movedAt = new Date();
await this.usersRepository.update(src.id, update);
+ Object.assign(src, update);
+
+ // Update cache
+ this.cacheService.uriPersonCache.set(srcUri, src);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
@@ -64,51 +99,249 @@ export class AccountMoveService {
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
- // follow the new account and unfollow the old one
- const followings = await this.followingsRepository.find({
- relations: {
- follower: true,
- },
+ // Unfollow after 24 hours
+ const followings = await this.followingsRepository.findBy({
+ followerId: src.id,
+ });
+ this.queueService.createDelayedUnfollowJob(followings.map(following => ({
+ from: { id: src.id },
+ to: { id: following.followeeId },
+ })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
+
+ await this.postMoveProcess(src, dst);
+
+ return iObj;
+ }
+
+ @bindThis
+ public async postMoveProcess(src: User, dst: User): Promise<void> {
+ // Copy blockings and mutings, and update lists
+ try {
+ await Promise.all([
+ this.copyBlocking(src, dst),
+ this.copyMutings(src, dst),
+ this.updateLists(src, dst),
+ ]);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // follow the new account
+ const proxy = await this.proxyAccountService.fetch();
+ const followings = await this.followingsRepository.findBy({
+ followeeId: src.id,
+ followerHost: IsNull(), // follower is local
+ followerId: proxy ? Not(proxy.id) : undefined,
+ });
+ const followJobs = followings.map(following => ({
+ from: { id: following.followerId },
+ to: { id: dst.id },
+ })) as RelationshipJobData[];
+
+ // Decrease following count instead of unfollowing.
+ try {
+ await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src);
+ } catch {
+ /* skip if any error happens */
+ }
+
+ // Should be queued because this can cause a number of follow per one move.
+ this.queueService.createFollowJob(followJobs);
+ }
+
+ @bindThis
+ public async copyBlocking(src: ThinUser, dst: ThinUser): Promise<void> {
+ // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving.
+ // So block the destination account here.
+ const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id });
+ const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id });
+ const blockerIds = dstBlockings.map(blocking => blocking.blockerId);
+ // reblock the destination account
+ const blockJobs: RelationshipJobData[] = [];
+ for (const blocking of srcBlockings) {
+ if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked
+ blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } });
+ }
+ // no need to unblock the old account because it may be still functional
+ this.queueService.createBlockJob(blockJobs);
+ }
+
+ @bindThis
+ public async copyMutings(src: ThinUser, dst: ThinUser): Promise<void> {
+ // Insert new mutings with the same values except mutee
+ const oldMutings = await this.mutingsRepository.findBy([
+ { muteeId: src.id, expiresAt: IsNull() },
+ { muteeId: src.id, expiresAt: MoreThan(new Date()) },
+ ]);
+ if (oldMutings.length === 0) return;
+
+ // Check if the destination account is already indefinitely muted by the muter
+ const existingMutingsMuterUserIds = await this.mutingsRepository.findBy(
+ { muteeId: dst.id, expiresAt: IsNull() },
+ ).then(mutings => mutings.map(muting => muting.muterId));
+
+ const newMutings: Map<string, { muterId: string; muteeId: string; createdAt: Date; expiresAt: Date | null; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newMutings.has(id));
+ return id;
+ };
+ for (const muting of oldMutings) {
+ if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely
+ newMutings.set(genId(), {
+ ...muting,
+ createdAt: new Date(),
+ muteeId: dst.id,
+ });
+ }
+
+ const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.mutingsRepository.insert(arrayToInsert);
+ }
+
+ /**
+ * Update lists while moving accounts.
+ * - No removal of the old account from the lists
+ * - Users number limit is not checked
+ *
+ * @param src ThinUser (old account)
+ * @param dst User (new account)
+ * @returns Promise<void>
+ */
+ @bindThis
+ public async updateLists(src: ThinUser, dst: User): Promise<void> {
+ // Return if there is no list to be updated.
+ const oldJoinings = await this.userListJoiningsRepository.find({
where: {
- followeeId: src.id,
- followerHost: IsNull(), // follower is local
+ userId: src.id,
},
});
- for (const following of followings) {
- if (!following.follower) continue;
- try {
- await this.userFollowingService.follow(following.follower, dst);
- await this.userFollowingService.unfollow(following.follower, src);
- } catch {
- /* empty */
+ if (oldJoinings.length === 0) return;
+
+ const existingUserListIds = await this.userListJoiningsRepository.find({
+ where: {
+ userId: dst.id,
+ },
+ }).then(joinings => joinings.map(joining => joining.userListId));
+
+ const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
+
+ // 重複しないようにIDを生成
+ const genId = (): string => {
+ let id: string;
+ do {
+ id = this.idService.genId();
+ } while (newJoinings.has(id));
+ return id;
+ };
+ for (const joining of oldJoinings) {
+ if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
+ newJoinings.set(genId(), {
+ createdAt: new Date(),
+ userId: dst.id,
+ userListId: joining.userListId,
+ });
+ }
+
+ const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
+ await this.userListJoiningsRepository.insert(arrayToInsert);
+
+ // Have the proxy account follow the new account in the same way as UserListService.push
+ if (this.userEntityService.isRemoteUser(dst)) {
+ const proxy = await this.proxyAccountService.fetch();
+ if (proxy) {
+ this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
}
}
+ }
- return iObj;
+ @bindThis
+ private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise<void> {
+ if (localFollowerIds.length === 0) return;
+
+ // Set the old account's following and followers counts to 0.
+ await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 });
+
+ // Decrease following counts of local followers by 1.
+ await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
+
+ // Decrease follower counts of local followees by 1.
+ const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
+ if (oldFollowings.length > 0) {
+ await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
+ }
+
+ // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
+ if (this.userEntityService.isRemoteUser(oldAccount)) {
+ this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
+ this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
+ if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
+ this.instanceChart.updateFollowers(i.host, false);
+ }
+ });
+ }
+
+ // FIXME: expensive?
+ for (const followerId of localFollowerIds) {
+ this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false);
+ }
}
/**
- * Create an alias of an old remote account.
+ * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
- * The user's new profile will be published to the followers.
+ * @param dst movedToUrlを指定するユーザー
+ * @param check
+ * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
+ * @returns Promise<LocalUser | RemoteUser | null>
*/
@bindThis
- public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
- await this.usersRepository.update(me.id, updates);
+ public async validateAlsoKnownAs(
+ dst: LocalUser | RemoteUser,
+ check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise<boolean> = () => true,
+ instant = false,
+ ): Promise<LocalUser | RemoteUser | null> {
+ let resultUser: LocalUser | RemoteUser | null = null;
- // Publish meUpdated event
- const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
- detail: true,
- includeSecrets: true,
- });
- this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
-
- if (me.isLocked === false) {
- await this.userFollowingService.acceptAllFollowRequests(me);
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(dst.uri);
+ }
+ dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
}
- this.accountUpdateService.publishToFollowers(me.id);
+ if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null;
- return iObj;
+ const dstUri = this.userEntityService.getUserUri(dst);
+
+ for (const srcUri of dst.alsoKnownAs) {
+ try {
+ let src = await this.apPersonService.fetchPerson(srcUri);
+ if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
+
+ if (this.userEntityService.isRemoteUser(dst)) {
+ if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
+ await this.apPersonService.updatePerson(srcUri);
+ }
+
+ src = await this.apPersonService.fetchPerson(srcUri) ?? src;
+ }
+
+ if (src.movedToUri === dstUri) {
+ if (await check(resultUser, src)) {
+ resultUser = src;
+ }
+ if (instant && resultUser) return resultUser;
+ }
+ } catch {
+ /* skip if any error happens */
+ }
+ }
+
+ return resultUser;
}
}
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index bac85d7a15..d4905a5f88 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 375ac49911..3590846d75 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -259,6 +259,12 @@ export class QueueService {
}
@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));
return this.relationshipQueue.addBulk(jobs);
@@ -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 b72dce5180..ff68c24219 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<User> {
+ public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
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 a8eded6733..7d90bc2c08 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);
- }
- });
- }
- //#endregion
+ // 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);
+ 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<void> {
- 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<void> {
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);
- }
- });
- }
- //#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);
+ }
+ });
+ }
+ //#endregion
- this.perUserFollowingChart.update(follower, followee, false);
+ 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
+ }
}
@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<void> {
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<void> {
- 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 d00bb89c76..b197d335d8 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 4b032be89a..2b404ebeca 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<User | null> {
+ public async getUserFromApId(value: string | IObject): Promise<LocalUser | RemoteUser | null> {
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 3fca0bb1fd..efef777fb0 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 0b22aa9bcf..60e19bfca5 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 df7bb46405..d3e0345c9c 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 6f2b8e5c3d..eea1d1b848 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<User | null> {
+ public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
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<User> {
+ public async createPerson(uri: string, resolver?: Resolver): Promise<RemoteUser> {
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<void> {
+ public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
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<User>;
+ } as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
+
+ 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<User> {
+ public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
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<string> {
+ 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 2c67cb772b..7c9a11ee88 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<ExpectsMe extends boolean | null, Detailed extends bo
const ajv = new Ajv();
function isLocalUser(user: User): user is LocalUser;
-function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; };
+function isLocalUser<T extends { host: User['host'] }>(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<T extends { host: User['host'] }>(user: T): user is T & { host: string; };
+function isRemoteUser<T extends { host: User['host'] }>(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<ExpectsMe extends boolean | null = null, D extends boolean = false>(
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 04dfa21107..8e10f999b6 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<User> & {
+ id: User['id'];
+ host: null;
+ uri: null;
+}
+
export type RemoteUser = User & {
host: string;
uri: string;
}
+export type PartialRemoteUser = Partial<User> & {
+ 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 895934f8bd..42b5d53acd 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 af086fa4e7..736b4fa80d 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 ada6f9e967..ab8b1e9e22 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 a5006dcf03..ff454df455 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 e13e9265ab..e675d9cf1b 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 e722563036..9bf8deb221 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 bf5cb20918..e3483c82c6 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 e4e594ec54..6dc1313e59 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 305ce3b34c..acd7f7ec3e 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],
@@ -705,6 +703,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 b7ce3363a9..5754a9f12a 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 3f85442131..5f980bdbeb 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<typeof meta, typeof paramDef> {
@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 dff8a9d10d..6294b08fa0 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 f52b45ccf3..c8544273a1 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 8ab59991c7..f3ca66cfd2 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 0c3f6c4855..67fb1ea03e 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 855ba47f8c..f46ff9f286 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 b9d8dce47a..c3561e2a71 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 a770dc986d..5395a5c373 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<typeof meta, typeof paramDef> {
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 6addf743a2..f08caaf8d7 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 5d88870ed2..50c5d758bd 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 244843d50f..3da252a226 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 a103c3f7d3..70f1959353 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 b3bdef41d3..a1c1f9325e 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 cfef793831..c835587c4a 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 f21d9d5c33..3172bdbfda 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 5581b8ec60..23de2f3970 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 b994f5d347..696512b06c 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 cd4e413a40..78dfd4a06a 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 411c39110a..4ad16de911 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 cb8b6a2e3e..ca6bfa7e0f 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 519e56ed6a..6ac5fa8606 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 cfbedcc4d9..513089217d 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 f14d644a3a..a2a10d8400 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 102dae4fb7..4eef496385 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 8c1c158ab1..811971591a 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<typeof meta, typeof paramDef> {
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 383bdc02b5..8af278c883 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<typeof meta, typeof paramDef> {
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 345ad916cb..eb0f9ba474 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<typeof meta, typeof paramDef> {
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 875af7ec23..4568e93901 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<typeof meta, typeof paramDef> {
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 964704d82b..0000000000
--- 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<typeof meta, typeof paramDef> {
- 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<User>;
-
- 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 ac76e1f620..261dd527c0 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<typeof meta, typeof paramDef> {
@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 d4af00027e..2293500945 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 97699f3bef..738edf3978 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<typeof meta, typeof paramDef> {
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<typeof meta, typeof paramDef> {
});
}
+ 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<string>();
+ 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<typeof meta, typeof paramDef> {
//#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<true, true>(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 6e24e1024d..ee358d5c6c 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 69fafcb9c7..fa2dc447d8 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 0ce80a1a63..611ea19560 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 2a44dc537e..3a33b037f8 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 04e374d1ae..97cb026779 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 4015bf1f29..e08ab399f8 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 d27990f7e1..543c126d9c 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 e397e2a23b..f0c0198460 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 35b402ec56..751274067e 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 b285269617..beb5850d78 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 a840c1a04e..7510889526 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<typeof meta, typeof paramDef> {
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 d2dd5731ee..d50b70efc2 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 1c1fdc23f1..925037e484 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 c662b16f18..6898435084 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 0000000000..4dd5cbb9dd
--- /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 2c4716c060..c1a57f102f 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 529e923b2c..c2280142a6 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<QueueService>;
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>(RelayService);
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
relaysRepository = app.get<RelaysRepository>(DI.relaysRepository);
+ userEntityService = app.get<UserEntityService>(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 98979de236..b02bfdc2b8 100644
--- a/packages/frontend/src/components/MkAccountMoved.vue
+++ b/packages/frontend/src/components/MkAccountMoved.vue
@@ -1,8 +1,8 @@
<template>
-<div :class="$style.root">
+<div v-if="user" :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
- <MkMention :class="$style.link" :username="username" :host="host ?? localHost"/>
+ <MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
</div>
</template>
@@ -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<UserLite>();
+
+const props = defineProps<{
+ movedTo: string; // user id
}>();
+
+api('users/show', { userId: props.movedTo }).then(u => user.value = u);
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue
index dc7344d707..cda428a77c 100644
--- a/packages/frontend/src/components/MkInfo.vue
+++ b/packages/frontend/src/components/MkInfo.vue
@@ -21,6 +21,7 @@ const props = defineProps<{
background: var(--infoBg);
color: var(--infoFg);
border-radius: var(--radius);
+ white-space: pre-wrap;
&.warn {
background: var(--infoWarnBg);
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index cb4bb8f23e..eba3c211a6 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -162,6 +162,7 @@ import { claimAchievement } from '@/scripts/achievements';
import { getNoteSummary } from '@/scripts/get-note-summary';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { showMovedDialog } from '@/scripts/show-moved-dialog';
const props = defineProps<{
note: misskey.entities.Note;
@@ -255,6 +256,7 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
+ showMovedDialog();
let items = [] as MenuItem[];
@@ -335,6 +337,7 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
@@ -401,6 +404,7 @@ async function clip() {
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
+ pleaseLogin();
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index b9ab366850..0d6d329d98 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -166,6 +166,7 @@ import { useTooltip } from '@/scripts/use-tooltip';
import { claimAchievement } from '@/scripts/achievements';
import { MenuItem } from '@/types/menu';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
+import { showMovedDialog } from '@/scripts/show-moved-dialog';
const props = defineProps<{
note: misskey.entities.Note;
@@ -248,6 +249,7 @@ useTooltip(renoteButton, async (showing) => {
function renote(viaKeyboard = false) {
pleaseLogin();
+ showMovedDialog();
let items = [] as MenuItem[];
@@ -318,6 +320,7 @@ function renote(viaKeyboard = false) {
function reply(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
os.post({
reply: appearNote,
animation: !viaKeyboard,
@@ -328,6 +331,7 @@ function reply(viaKeyboard = false): void {
function react(viaKeyboard = false): void {
pleaseLogin();
+ showMovedDialog();
if (appearNote.reactionAcceptance === 'likeOnly') {
os.api('notes/reactions/create', {
noteId: appearNote.id,
@@ -394,6 +398,7 @@ async function clip() {
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
+ pleaseLogin();
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 157aa79064..67acee5aca 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -18,6 +18,7 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue';
import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu';
import copyToClipboard from './scripts/copy-to-clipboard';
+import { showMovedDialog } from './scripts/show-moved-dialog';
export const openingWindowsCount = ref(0);
@@ -578,6 +579,8 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
}
export function post(props: Record<string, any> = {}): Promise<void> {
+ showMovedDialog();
+
return new Promise((resolve, reject) => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue
index a8274f5601..c883efe691 100644
--- a/packages/frontend/src/pages/settings/import-export.vue
+++ b/packages/frontend/src/pages/settings/import-export.vue
@@ -32,7 +32,7 @@
<MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</div>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -47,7 +47,7 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -62,7 +62,7 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -77,7 +77,7 @@
<template #icon><i class="ti ti-download"></i></template>
<MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="$i && !$i.movedTo">
<template #label>{{ i18n.ts.import }}</template>
<template #icon><i class="ti ti-upload"></i></template>
<MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton>
@@ -97,6 +97,7 @@ import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
const excludeMutingUsers = ref(false);
const excludeInactiveUsers = ref(false);
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 17af7417fd..34a962ef4c 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -164,12 +164,12 @@ const menuDef = computed(() => [{
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
- }, /*{
+ }, {
icon: 'ti ti-plane',
- text: i18n.ts.accountMigration,
+ text: `${i18n.ts.accountMigration} (${i18n.ts.experimental})`,
to: '/settings/migration',
active: currentPage?.route.name === 'migration',
- },*/ {
+ }, {
icon: 'ti ti-dots',
text: i18n.ts.other,
to: '/settings/other',
diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue
index 2ef8af7481..fa4da0dae3 100644
--- a/packages/frontend/src/pages/settings/migration.vue
+++ b/packages/frontend/src/pages/settings/migration.vue
@@ -1,63 +1,121 @@
<template>
<div class="_gaps_m">
- <FormSection first>
+ <FormInfo warn>
+ {{ i18n.ts.ThisIsExperimentalFeature }}
+ </FormInfo>
+ <MkFolder :default-open="true">
+ <template #icon><i class="ti ti-plane-arrival"></i></template>
+ <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
+ <template #caption>{{ i18n.ts._accountMigration.moveFromSub }}</template>
+
+ <div class="_gaps_m">
+ <FormInfo warn>
+ {{ i18n.ts._accountMigration.moveFromDescription }}
+ </FormInfo>
+ <div>
+ <MkButton :disabled="accountAliases.length >= 10" inline style="margin-right: 8px;" @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
+ <div class="_gaps">
+ <MkInput v-for="(_, i) in accountAliases" v-model="accountAliases[i]">
+ <template #prefix><i class="ti ti-plane-arrival"></i></template>
+ <template #label>{{ i18n.t('_accountMigration.moveFromLabel', { n: i + 1 }) }}</template>
+ </MkInput>
+ </div>
+ </div>
+ </MkFolder>
+
+ <MkFolder :default-open="!!$i?.movedTo">
+ <template #icon><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
- <MkInput v-model="moveToAccount" manual-save>
- <template #prefix><i class="ti ti-plane-departure"></i></template>
- <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
- </MkInput>
- </FormSection>
- <FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
- <FormSection>
- <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
- <MkInput v-model="accountAlias" manual-save>
- <template #prefix><i class="ti ti-plane-arrival"></i></template>
- <template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template>
- </MkInput>
- </FormSection>
- <FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo>
+ <div class="_gaps_m">
+ <FormInfo>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
+
+ <template v-if="$i && !$i.movedTo">
+ <FormInfo>{{ i18n.ts._accountMigration.moveAccountHowTo }}</FormInfo>
+ <FormInfo warn>{{ i18n.ts._accountMigration.moveCannotBeUndone }}</FormInfo>
+
+ <MkInput v-model="moveToAccount">
+ <template #prefix><i class="ti ti-plane-departure"></i></template>
+ <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
+ </MkInput>
+ <MkButton inline danger :disabled="!moveToAccount" @click="move">
+ <i class="ti ti-check"></i> {{ i18n.ts._accountMigration.startMigration }}
+ </MkButton>
+ </template>
+ <template v-else-if="$i">
+ <FormInfo>{{ i18n.ts._accountMigration.postMigrationNote }}</FormInfo>
+ <FormInfo warn>{{ i18n.ts._accountMigration.movedAndCannotBeUndone }}</FormInfo>
+ <div>{{ i18n.ts._accountMigration.movedTo }}</div>
+ <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow" />
+ </template>
+ </div>
+ </MkFolder>
</div>
</template>
<script lang="ts" setup>
-import { ref, watch } from 'vue';
-import FormSection from '@/components/form/section.vue';
+import { ref } from 'vue';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkUserInfo from '@/components/MkUserInfo.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+import { toString } from 'misskey-js/built/acct';
+import { UserDetailed } from 'misskey-js/built/entities';
+import { unisonReload } from '@/scripts/unison-reload';
const moveToAccount = ref('');
-const accountAlias = ref('');
+const movedTo = ref<UserDetailed>();
+const accountAliases = ref(['']);
+
+async function init() {
+ if ($i?.movedTo) {
+ movedTo.value = await os.api('users/show', { userId: $i.movedTo });
+ } else {
+ moveToAccount.value = '';
+ }
+
+ if ($i?.alsoKnownAs && $i.alsoKnownAs.length > 0) {
+ const alsoKnownAs = await os.api('users/show', { userIds: $i.alsoKnownAs });
+ accountAliases.value = (alsoKnownAs && alsoKnownAs.length > 0) ? alsoKnownAs.map(user => `@${toString(user)}`) : [''];
+ } else {
+ accountAliases.value = [''];
+ }
+}
async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
- text: i18n.t('migrationConfirm', { account: account.toString() }),
+ text: i18n.t('_accountMigration.migrationConfirm', { account }),
});
if (confirm.canceled) return;
- os.apiWithDialog('i/move', {
+ await os.apiWithDialog('i/move', {
moveToAccount: account,
});
+ unisonReload();
+}
+
+function add(): void {
+ accountAliases.value.push('');
}
async function save(): Promise<void> {
- const account = accountAlias.value;
- os.apiWithDialog('i/known-as', {
- alsoKnownAs: account,
+ const alsoKnownAs = accountAliases.value.map(alias => alias.trim()).filter(alias => alias !== '');
+ const i = await os.apiWithDialog('i/update', {
+ alsoKnownAs,
});
+ $i.alsoKnownAs = i.alsoKnownAs;
+ init();
}
-watch(accountAlias, async () => {
- await save();
-});
-
-watch(moveToAccount, async () => {
- await move();
-});
+init();
definePageMetadata({
title: i18n.ts.accountMigration,
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index a5f6c11f89..db21cf49da 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -38,6 +38,10 @@
<template #label>{{ i18n.ts._profile.metadataEdit }}</template>
<div class="_gaps_m">
+ <div>
+ <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
<FormSplit v-for="(record, i) in fields" :min-width="250">
<MkInput v-model="record.name" small>
<template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template>
@@ -46,10 +50,6 @@
<template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template>
</MkInput>
</FormSplit>
- <div>
- <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
- <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
- </div>
</div>
</MkFolder>
<template #caption>{{ i18n.ts._profile.metadataDescription }}</template>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 0f9145e974..57063c92de 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -7,7 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
- <MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :username="user.movedToUri.username"/>
+ <MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo" />
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts
index f1da8a76da..fbca005769 100644
--- a/packages/frontend/src/scripts/achievements.ts
+++ b/packages/frontend/src/scripts/achievements.ts
@@ -464,6 +464,7 @@ const claimingQueue = new Set<string>();
export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
if ($i == null) return;
+ if ($i.movedTo) return;
if (claimedAchievements.includes(type)) return;
claimingQueue.add(type);
claimedAchievements.push(type);
diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts
index b8fb853cc1..c101a127f3 100644
--- a/packages/frontend/src/scripts/please-login.ts
+++ b/packages/frontend/src/scripts/please-login.ts
@@ -17,5 +17,5 @@ export function pleaseLogin(path?: string) {
},
}, 'closed');
- if (!path) throw new Error('signin required');
+ throw new Error('signin required');
}
diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/scripts/show-moved-dialog.ts
new file mode 100644
index 0000000000..acb26c36e2
--- /dev/null
+++ b/packages/frontend/src/scripts/show-moved-dialog.ts
@@ -0,0 +1,16 @@
+import * as os from '@/os';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+export function showMovedDialog() {
+ if (!$i) return;
+ if (!$i.movedTo) return;
+
+ os.alert({
+ type: 'error',
+ title: i18n.ts.accountMovedShort,
+ text: i18n.ts.operationForbidden,
+ });
+
+ throw new Error('account moved');
+}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 2fae84c171..19e5b75443 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1357,10 +1357,6 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
- 'i/known-as': {
- req: TODO;
- res: TODO;
- };
'i/notifications': {
req: {
limit?: number;
@@ -1511,6 +1507,7 @@ export type Endpoints = {
mutedWords?: string[][];
mutingNotificationTypes?: Notification_2['type'][];
emailNotificationTypes?: string[];
+ alsoKnownAs?: string[];
};
res: MeDetailed;
};
@@ -2634,6 +2631,7 @@ type User = UserLite | UserDetailed;
// @public (undocumented)
type UserDetailed = UserLite & {
+ alsoKnownAs: string[];
bannerBlurhash: string | null;
bannerColor: string | null;
bannerUrl: string | null;
@@ -2664,6 +2662,7 @@ type UserDetailed = UserLite & {
lang: string | null;
lastFetchedAt?: DateString;
location: string | null;
+ movedTo: string;
notesCount: number;
pinnedNoteIds: ID[];
pinnedNotes: Note[];
@@ -2697,8 +2696,6 @@ type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
- alsoKnownAs: string[];
- movedToUri: any;
emojis: {
name: string;
url: string;
diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts
index aed9f5bf84..cc88c4b1a4 100644
--- a/packages/misskey-js/src/api.types.ts
+++ b/packages/misskey-js/src/api.types.ts
@@ -363,7 +363,6 @@ export type Endpoints = {
'i/import-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; };
- 'i/known-as': { req: TODO; res: TODO; };
'i/notifications': { req: {
limit?: number;
sinceId?: Notification['id'];
@@ -421,6 +420,7 @@ export type Endpoints = {
mutedWords?: string[][];
mutingNotificationTypes?: Notification['type'][];
emailNotificationTypes?: string[];
+ alsoKnownAs?: string[];
}; res: MeDetailed; };
'i/user-group-invites': { req: TODO; res: TODO; };
'i/2fa/done': { req: TODO; res: TODO; };
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 34857f431f..04065c51c9 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -14,8 +14,6 @@ export type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
- alsoKnownAs: string[];
- movedToUri: any;
emojis: {
name: string;
url: string;
@@ -31,6 +29,7 @@ export type UserLite = {
};
export type UserDetailed = UserLite & {
+ alsoKnownAs: string[];
bannerBlurhash: string | null;
bannerColor: string | null;
bannerUrl: string | null;
@@ -58,6 +57,7 @@ export type UserDetailed = UserLite & {
lang: string | null;
lastFetchedAt?: DateString;
location: string | null;
+ movedTo: string;
notesCount: number;
pinnedNoteIds: ID[];
pinnedNotes: Note[];