From d28866f71a2fd1809f595bf599cc3914834f74e2 Mon Sep 17 00:00:00 2001 From: Namekuji <11836635+nmkj-io@users.noreply.github.com> Date: Sat, 29 Apr 2023 11:09:29 -0400 Subject: [PATCH] enhance: account migration (#10592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * copy block and mute then create follow and unfollow jobs * copy block and mute and update lists when detecting an account has moved * no need to care promise orders * refactor updating actor and target * automatically accept if a locked account had accepted an old account * fix exception format * prevent the old account from calling some endpoints * do not unfollow when moving * adjust following and follower counts * check movedToUri when receiving a follow request * skip if no need to adjust * Revert "disable account migration" This reverts commit 2321214c98591bcfe1385c1ab5bf0ff7b471ae1d. * fix translation specifier * fix checking alsoKnownAs and uri * fix updating account * fix refollowing locked account * decrease followersCount if followed by the old account * adjust following and followers counts when unfollowing * fix copying mutings * prohibit moved account from moving again * fix move service * allow app creation after moving * fix lint * remove unnecessary field * fix cache update * add e2e test * add e2e test of accepting the new account automatically * force follow if any error happens * remove unnecessary joins * use Array.map instead of for const of * ユーザーリストã®ç§»è¡Œã¯è¿½åŠ ã®ã¿ã‚’行ㆠ* nanka iroiro * fix misskey-js? * :v: * 移行を行ã£ãŸã‚¢ã‚«ã‚¦ãƒ³ãƒˆã‹ã‚‰ã®ãƒ•ã‚©ãƒãƒ¼ãƒªã‚¯ã‚¨ã‚¹ãƒˆã®è‡ªå‹•è¨±å¯ã‚’調整 * newUriを外ã«å‡ºã™ * newUriを外ã«å‡ºã™2 * clean up * fix newUri * prevent moving if the destination account has already moved * set alsoKnownAs via /i/update * fix database initialization * add return type * prohibit updating alsoKnownAs after moving * skip to add to alsoKnownAs if toUrl is known * skip adding to the list if it already has * use Acct.parse instead * rename error code * :art: * 制é™ã‚’5ã‹ã‚‰10ã«ç·©å’Œ * movedTo(Uri), alsoKnownAsã¯ãƒ¦ãƒ¼ã‚¶ãƒ¼idã‚’è¿”ã™ã‚ˆã†ã« * test api res * fix * 元アカウントã¯ãƒŸãƒ¥ãƒ¼ãƒˆã—続ã‘ã‚‹ * :art: * unfollow * fix * getUserUriã‚’UserEntityServiceã« * ? * job! * :art: * instance => server * accountMovedShort, forbiddenBecauseYouAreMigrated * accountMovedShort * fix test * import, pinç¦æ¢ * 実績をå‡çµã™ã‚‹ * clean up * :v: * change message * ブãƒãƒƒã‚¯, フォãƒãƒ¼, ミュート, リストã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆãƒ•ã‚¡ã‚¤ãƒ«ã®åˆ¶é™ã‚’32MiBã« * Revert "ブãƒãƒƒã‚¯, フォãƒãƒ¼, ミュート, リストã®ã‚¤ãƒ³ãƒãƒ¼ãƒˆãƒ•ã‚¡ã‚¤ãƒ«ã®åˆ¶é™ã‚’32MiBã«" This reverts commit 3bd7be35d8aa455cb01ae58f8172a71a50485db1. * validateAlsoKnownAs * 移行後2時間以内ã¯ã‚¤ãƒ³ãƒãƒ¼ãƒˆå¯èƒ½ãªãƒ•ã‚¡ã‚¤ãƒ«ã‚µã‚¤ã‚ºã‚’拡大 * clean up * ã©ã†ã›actorã‚’updatePersonã§æ›´æ–°ã™ã‚‹ãªã‚‰updatePersonã—ã‹ç§»è¡Œå‡¦ç†ã‚’発行ã—ãªã„ã“ã¨ã«ã™ã‚‹ * handle error? * リモートã‹ã‚‰ã®ç§»è¡Œå‡¦ç†ã®æ¡ä»¶ã‚’æ˜¯æ£ * log, port * fix * fix * enhance(dev): non-production環境ã§httpサーãƒãƒ¼é–“ã§ã‚‚ユーザーã€ãƒŽãƒ¼ãƒˆã®é€£åˆãŒå¯èƒ½ãªã‚ˆã†ã« * refactor (use checkHttps) * MISSKEY_WEBFINGER_USE_HTTP * Environment Variable readme * NEVER USE IN PRODUCTION * fix punyHost * fix indent * fix * experimental --------- Co-authored-by: tamaina <tamaina@hotmail.co.jp> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> --- .config/example.yml | 16 +- .vscode/settings.json | 1 + CHANGELOG.md | 2 + locales/ja-JP.yml | 29 +- .../migration/1682190963894-movedAt.js | 13 + packages/backend/src/config.ts | 2 + .../backend/src/core/AccountMoveService.ts | 327 +++++++++++-- packages/backend/src/core/QueueModule.ts | 2 +- packages/backend/src/core/QueueService.ts | 9 +- .../src/core/RemoteUserResolveService.ts | 10 +- .../backend/src/core/UserFollowingService.ts | 207 +++++--- .../backend/src/core/UserSuspendService.ts | 4 +- .../core/activitypub/ApDbResolverService.ts | 8 +- .../src/core/activitypub/ApInboxService.ts | 60 +-- .../src/core/activitypub/ApRendererService.ts | 66 +-- .../src/core/activitypub/ApResolverService.ts | 4 +- .../activitypub/models/ApPersonService.ts | 122 ++++- .../src/core/entities/UserEntityService.ts | 25 +- packages/backend/src/models/entities/User.ts | 18 + .../backend/src/models/json-schema/user.ts | 2 +- .../RelationshipQueueProcessorsService.ts | 2 +- .../queue/processors/InboxProcessorService.ts | 6 +- .../RelationshipProcessorService.ts | 3 +- .../src/server/ActivityPubServerService.ts | 6 +- .../src/server/WellKnownServerService.ts | 4 +- .../backend/src/server/api/ApiCallService.ts | 11 + .../backend/src/server/api/EndpointsModule.ts | 4 - packages/backend/src/server/api/endpoints.ts | 10 +- .../server/api/endpoints/antennas/create.ts | 2 + .../server/api/endpoints/antennas/update.ts | 4 +- .../server/api/endpoints/channels/create.ts | 2 + .../server/api/endpoints/channels/favorite.ts | 2 + .../server/api/endpoints/channels/follow.ts | 2 + .../api/endpoints/channels/unfavorite.ts | 2 + .../server/api/endpoints/channels/unfollow.ts | 2 + .../server/api/endpoints/clips/add-note.ts | 2 + .../src/server/api/endpoints/clips/create.ts | 4 +- .../server/api/endpoints/clips/favorite.ts | 2 + .../server/api/endpoints/clips/remove-note.ts | 2 + .../server/api/endpoints/clips/unfavorite.ts | 2 + .../src/server/api/endpoints/clips/update.ts | 2 + .../api/endpoints/drive/files/create.ts | 2 + .../endpoints/drive/files/upload-from-url.ts | 2 + .../src/server/api/endpoints/flash/create.ts | 2 + .../src/server/api/endpoints/flash/like.ts | 2 + .../src/server/api/endpoints/flash/unlike.ts | 2 + .../src/server/api/endpoints/flash/update.ts | 2 + .../server/api/endpoints/following/create.ts | 2 + .../api/endpoints/gallery/posts/create.ts | 2 + .../api/endpoints/gallery/posts/like.ts | 2 + .../api/endpoints/gallery/posts/unlike.ts | 2 + .../api/endpoints/gallery/posts/update.ts | 2 + .../api/endpoints/i/claim-achievement.ts | 1 + .../server/api/endpoints/i/import-blocking.ts | 11 +- .../api/endpoints/i/import-following.ts | 11 +- .../server/api/endpoints/i/import-muting.ts | 11 +- .../api/endpoints/i/import-user-lists.ts | 11 +- .../src/server/api/endpoints/i/known-as.ts | 92 ---- .../src/server/api/endpoints/i/move.ts | 70 ++- .../backend/src/server/api/endpoints/i/pin.ts | 1 + .../src/server/api/endpoints/i/update.ts | 66 +++ .../src/server/api/endpoints/mute/create.ts | 1 + .../src/server/api/endpoints/notes/create.ts | 2 + .../api/endpoints/notes/favorites/create.ts | 1 + .../server/api/endpoints/notes/polls/vote.ts | 2 + .../api/endpoints/notes/reactions/create.ts | 2 + .../src/server/api/endpoints/pages/create.ts | 2 + .../src/server/api/endpoints/pages/like.ts | 2 + .../src/server/api/endpoints/pages/unlike.ts | 2 + .../src/server/api/endpoints/pages/update.ts | 2 + .../api/endpoints/renote-mute/create.ts | 1 + .../api/endpoints/users/lists/create.ts | 4 +- .../server/api/endpoints/users/lists/pull.ts | 2 + .../server/api/endpoints/users/lists/push.ts | 2 + packages/backend/test/e2e/endpoints.ts | 23 +- packages/backend/test/e2e/move.ts | 455 ++++++++++++++++++ packages/backend/test/e2e/users.ts | 4 +- packages/backend/test/unit/RelayService.ts | 10 +- .../src/components/MkAccountMoved.vue | 16 +- packages/frontend/src/components/MkInfo.vue | 1 + packages/frontend/src/components/MkNote.vue | 4 + .../src/components/MkNoteDetailed.vue | 5 + packages/frontend/src/os.ts | 3 + .../src/pages/settings/import-export.vue | 9 +- .../frontend/src/pages/settings/index.vue | 6 +- .../frontend/src/pages/settings/migration.vue | 118 +++-- .../frontend/src/pages/settings/profile.vue | 8 +- packages/frontend/src/pages/user/home.vue | 2 +- packages/frontend/src/scripts/achievements.ts | 1 + packages/frontend/src/scripts/please-login.ts | 2 +- .../frontend/src/scripts/show-moved-dialog.ts | 16 + packages/misskey-js/etc/misskey-js.api.md | 9 +- packages/misskey-js/src/api.types.ts | 2 +- packages/misskey-js/src/entities.ts | 4 +- 94 files changed, 1555 insertions(+), 467 deletions(-) create mode 100644 packages/backend/migration/1682190963894-movedAt.js delete mode 100644 packages/backend/src/server/api/endpoints/i/known-as.ts create mode 100644 packages/backend/test/e2e/move.ts create mode 100644 packages/frontend/src/scripts/show-moved-dialog.ts 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 @@ -258,6 +258,12 @@ export class QueueService { return this.relationshipQueue.addBulk(jobs); } + @bindThis + public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); + return this.relationshipQueue.addBulk(jobs); + } + @bindThis public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); @@ -271,7 +277,7 @@ export class QueueService { } @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): { + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { name: string, data: RelationshipJobData, opts: Bull.JobOptions, @@ -287,6 +293,7 @@ export class QueueService { opts: { removeOnComplete: true, removeOnFail: true, + ...opts, }, }; } diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index 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); + } 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 }, + ); + } - this.perUserFollowingChart.update(follower, followee, false); + // 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], @@ -704,6 +702,12 @@ export interface IEndpointMeta { readonly requireRolePolicy?: keyof RolePolicies; + /** + * 引ã£è¶Šã—済ã¿ã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã«ã‚ˆã‚‹ãƒªã‚¯ã‚¨ã‚¹ãƒˆã‚’ç¦æ¢ã™ã‚‹ã‹ + * çœç•¥ã—ãŸå ´åˆã¯ false ã¨ã—ã¦è§£é‡ˆã•ã‚Œã¾ã™ã€‚ + */ + readonly prohibitMoved?: boolean; + /** * エンドãƒã‚¤ãƒ³ãƒˆã®ãƒªãƒŸãƒ†ãƒ¼ã‚·ãƒ§ãƒ³ã«é–¢ã™ã‚‹ã‚„㤠* çœç•¥ã—ãŸå ´åˆã¯ãƒªãƒŸãƒ†ãƒ¼ã‚·ãƒ§ãƒ³ã¯ç„¡ã„ã‚‚ã®ã¨ã—ã¦è§£é‡ˆã•ã‚Œã¾ã™ã€‚ diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 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[]; -- GitLab