From daef7cb961de0b60e3c1331ff56d091efba19939 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 1 Oct 2025 14:49:43 -0400 Subject: [PATCH 01/46] fix misskey-js tests (unit and type) --- packages/misskey-js/api-extractor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/misskey-js/api-extractor.json b/packages/misskey-js/api-extractor.json index 0961dd24b9..70f9a08490 100644 --- a/packages/misskey-js/api-extractor.json +++ b/packages/misskey-js/api-extractor.json @@ -45,7 +45,7 @@ * * SUPPORTED TOKENS: , , */ - "mainEntryPointFilePath": "/built/index.d.ts", + "mainEntryPointFilePath": "/built/src/index.d.ts", /** * A list of NPM package names whose exports should be treated as part of this package. From b8a0979667c248df0b26fb89d700756f8581e539 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 1 Oct 2025 14:53:07 -0400 Subject: [PATCH 02/46] test misskey-js in pipelines --- .gitlab-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 31be935c47..9697a08965 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -184,6 +184,12 @@ megalodon_tests: - pnpm run --filter megalodon build - pnpm run --filter megalodon test +misskey-js_tests: + <<: *test_common + script: + - pnpm run --filter misskey-js build + - pnpm run --filter misskey-js test + get_image_tag: <<: *deploy_common image: From 0d0d8c86f25001e0ee7acf196dfd9ca97e60616b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 1 Oct 2025 14:53:07 -0400 Subject: [PATCH 03/46] test misskey-js in pipelines --- .gitlab-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9697a08965..66eff82fc9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -190,6 +190,12 @@ misskey-js_tests: - pnpm run --filter misskey-js build - pnpm run --filter misskey-js test +misskey-js_tests: + <<: *test_common + script: + - pnpm run --filter misskey-js build + - pnpm run --filter misskey-js test + get_image_tag: <<: *deploy_common image: From bb66332839e7ad71cc5c23784192862da409c15d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 1 Oct 2025 15:55:45 -0400 Subject: [PATCH 04/46] rework misskey-js build to preserve original package structure --- packages/misskey-js/api-extractor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/misskey-js/api-extractor.json b/packages/misskey-js/api-extractor.json index 70f9a08490..0961dd24b9 100644 --- a/packages/misskey-js/api-extractor.json +++ b/packages/misskey-js/api-extractor.json @@ -45,7 +45,7 @@ * * SUPPORTED TOKENS: , , */ - "mainEntryPointFilePath": "/built/src/index.d.ts", + "mainEntryPointFilePath": "/built/index.d.ts", /** * A list of NPM package names whose exports should be treated as part of this package. From aaa4e570fc352535e0bebeab1e231f3b454cd85e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 6 Oct 2025 00:01:25 -0400 Subject: [PATCH 05/46] add megalodon tests to CI --- .gitlab-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 66eff82fc9..e994b6d8f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -196,6 +196,12 @@ misskey-js_tests: - pnpm run --filter misskey-js build - pnpm run --filter misskey-js test +megalodon_tests: + <<: *test_common + script: + - pnpm run --filter megalodon build + - pnpm run --filter megalodon test + get_image_tag: <<: *deploy_common image: From 05461f938f44a6ef0a32a40275110e2ee7aa15d1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 7 Oct 2025 23:22:15 -0400 Subject: [PATCH 06/46] move global services to "global" directory --- packages/backend/src/global/DependencyService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/global/DependencyService.ts b/packages/backend/src/global/DependencyService.ts index 929fc319e2..814dcf5a00 100644 --- a/packages/backend/src/global/DependencyService.ts +++ b/packages/backend/src/global/DependencyService.ts @@ -8,6 +8,10 @@ import nodeFs from 'node:fs'; import { Injectable } from '@nestjs/common'; import { CacheManagementService, type ManagedMemoryKVCache } from '@/global/CacheManagementService.js'; import { bindThis } from '@/decorators.js'; +<<<<<<<< HEAD:packages/backend/src/global/DependencyService.ts +======== +import { type ManagedMemoryKVCache, CacheManagementService } from '@/global/CacheManagementService.js'; +>>>>>>>> 3e14e73c59 (move global services to "global" directory):packages/backend/src/global/EnvService.ts @Injectable() export class DependencyService { From 6ac973c5d96ada905bad9526223c01cf73f77d59 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:34:43 -0400 Subject: [PATCH 07/46] add missing JSON-LD context for as:movedTo --- packages/backend/src/core/activitypub/misc/contexts.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index fa003b1791..ba52281e9c 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -523,6 +523,10 @@ const activitystreams = { '@id': 'as:alsoKnownAs', '@type': '@id', }, + 'movedTo': { + '@id': 'as:movedTo', + '@type': '@id', + }, }, } satisfies JsonLd; From 39bdd691e84e391463330efc8eafdc3e6cabb974 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:41:00 -0400 Subject: [PATCH 08/46] use InternalEventService in AccountMoveService --- packages/backend/src/core/AccountMoveService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 82e68e32aa..50908dee73 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -29,6 +29,7 @@ import { AntennaService } from '@/core/AntennaService.js'; import { CacheService } from '@/core/CacheService.js'; import { UserListService } from '@/core/UserListService.js'; import { TimeService } from '@/global/TimeService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; @Injectable() export class AccountMoveService { @@ -74,6 +75,7 @@ export class AccountMoveService { private readonly cacheService: CacheService, private readonly userListService: UserListService, private readonly timeService: TimeService, + private readonly internalEventService: InternalEventService, ) { } @@ -96,7 +98,7 @@ export class AccountMoveService { Object.assign(src, update); // Update cache - this.globalEventService.publishInternalEvent('localUserUpdated', src); + await this.internalEventService.emit('localUserUpdated', { id: src.id }); const srcPerson = await this.apRendererService.renderPerson(src); const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); From db581d2ea9160ea66dfd31b0822637da1e56151f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:42:22 -0400 Subject: [PATCH 09/46] log non-fatal errors in AccountMoveService --- .../backend/src/core/AccountMoveService.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 50908dee73..3741cf278e 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -30,9 +30,14 @@ import { CacheService } from '@/core/CacheService.js'; import { UserListService } from '@/core/UserListService.js'; import { TimeService } from '@/global/TimeService.js'; import { InternalEventService } from '@/global/InternalEventService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type Logger from '@/logger.js'; +import { renderInlineError } from '@/misc/render-inline-error.js'; @Injectable() export class AccountMoveService { + private readonly logger: Logger; + constructor( @Inject(DI.meta) private meta: MiMeta, @@ -76,7 +81,9 @@ export class AccountMoveService { private readonly userListService: UserListService, private readonly timeService: TimeService, private readonly internalEventService: InternalEventService, + private readonly loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('account-move'); } /** @@ -130,7 +137,7 @@ export class AccountMoveService { public async postMoveProcess(src: MiUser, dst: MiUser): Promise { // Copy blockings and mutings, and update lists try { - await Promise.all([ + const results = await Promise.allSettled([ this.copyBlocking(src, dst), this.copyMutings(src, dst), this.deleteScheduledNotes(src), @@ -138,8 +145,16 @@ export class AccountMoveService { this.updateLists(src, dst), this.antennaService.onMoveAccount(src, dst), ]); - } catch { + + // Log errors, but keep moving + for (const result of results) { + if (result.status === 'rejected') { + this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(result.reason)}`); + } + } + } catch (err) { /* skip if any error happens */ + this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`); } // follow the new account @@ -155,8 +170,9 @@ export class AccountMoveService { // Decrease following count instead of unfollowing. try { await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src); - } catch { + } catch (err) { /* skip if any error happens */ + this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`); } // Should be queued because this can cause a number of follow per one move. From 45a194b7571039a323569dc002d34175d44c1f07 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:42:57 -0400 Subject: [PATCH 10/46] await more things in AccountMoveService --- packages/backend/src/core/AccountMoveService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 3741cf278e..cd565fe191 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -119,11 +119,11 @@ export class AccountMoveService { // Publish meUpdated event const iObj = await this.userEntityService.pack(src.id, src, { schema: 'MeDetailed', includeSecrets: true }); - this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + await this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); // Unfollow after 24 hours const followings = await this.cacheService.userFollowingsCache.fetch(src.id); - this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({ + await this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({ from: { id: src.id }, to: { id: followeeId }, })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); @@ -176,7 +176,7 @@ export class AccountMoveService { } // Should be queued because this can cause a number of follow per one move. - this.queueService.createFollowJob(followJobs); + await this.queueService.createFollowJob(followJobs); } @bindThis @@ -193,7 +193,7 @@ export class AccountMoveService { 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); + await this.queueService.createBlockJob(blockJobs); } @bindThis @@ -337,7 +337,7 @@ export class AccountMoveService { if (this.meta.enableStatsForFederatedInstances) { if (this.userEntityService.isRemoteUser(oldAccount)) { this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + await this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowers(i.host, false); } From 8089a67b4e92608ab7397c734194c6ce261491d0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:44:08 -0400 Subject: [PATCH 11/46] add TODO reminders for future merges --- packages/backend/src/core/AccountMoveService.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index cd565fe191..122045ef6c 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -128,6 +128,7 @@ export class AccountMoveService { to: { id: followeeId }, })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); + // TODO add this to relationship queue await this.postMoveProcess(src, dst); return iObj; @@ -322,20 +323,24 @@ export class AccountMoveService { if (localFollowerIds.length === 0) return; // Set the old account's following and followers counts to 0. + // TODO use CollapsedQueueService when merged await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); // Decrease following counts of local followers by 1. + // TODO use CollapsedQueueService when merged await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); // Decrease follower counts of local followees by 1. const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id); if (oldFollowings.size > 0) { + // TODO use CollapsedQueueService when merged await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1); } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. if (this.meta.enableStatsForFederatedInstances) { if (this.userEntityService.isRemoteUser(oldAccount)) { + // TODO use CollapsedQueueService when merged this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { await this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); if (this.meta.enableChartsForFederatedInstances) { From 3fb2b38c92983c6fdea70dc840053fbe7fc2c1bf Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:52:47 -0400 Subject: [PATCH 12/46] process account migrations on the relationship queue --- packages/backend/src/core/AccountMoveService.ts | 3 +-- packages/backend/src/core/QueueService.ts | 8 +++++++- .../core/activitypub/models/ApPersonService.ts | 2 +- .../backend/src/queue/QueueProcessorService.ts | 1 + .../processors/RelationshipProcessorService.ts | 16 +++++++++++++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 122045ef6c..74236157ac 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -128,8 +128,7 @@ export class AccountMoveService { to: { id: followeeId }, })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); - // TODO add this to relationship queue - await this.postMoveProcess(src, dst); + await this.queueService.createMoveJob(src, dst); return iObj; } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 5020614676..e8183f2c13 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -706,7 +706,13 @@ export class QueueService implements OnModuleInit { } @bindThis - private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobsOptions = {}): { + public createMoveJob(from: ThinUser, to: ThinUser) { + const job = this.generateRelationshipJobData('move', { from, to }); + return this.relationshipQueue.add(job.name, job.data, job.opts); + } + + @bindThis + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock' | 'move', data: RelationshipJobData, opts: Bull.JobsOptions = {}): { name: string, data: RelationshipJobData, opts: Bull.JobsOptions, diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 25cd0ce8c0..793da186f2 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -988,7 +988,7 @@ export class ApPersonService implements OnModuleInit { return 'skip: alsoKnownAs does not include from.uri'; } - await this.accountMoveService.postMoveProcess(src, dst); + await this.queueService.createMoveJob(src, dst); return 'ok'; } diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 35dc812652..a84705c019 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -423,6 +423,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); case 'block': return this.relationshipProcessorService.processBlock(job); case 'unblock': return this.relationshipProcessorService.processUnblock(job); + case 'move': return this.relationshipProcessorService.processMove(job); default: throw new Error(`unrecognized job type ${job.name} for relationship`); } }; diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index 408b02fb38..3964fa7952 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; @@ -28,8 +30,10 @@ export class RelationshipProcessorService { private queueLoggerService: QueueLoggerService, private userFollowingService: UserFollowingService, private userBlockingService: UserBlockingService, + private readonly cacheService: CacheService, + private readonly accountMoveService: AccountMoveService, ) { - this.logger = this.queueLoggerService.logger.createSubLogger('follow-block'); + this.logger = this.queueLoggerService.logger.createSubLogger('relationship'); } @bindThis @@ -75,4 +79,14 @@ export class RelationshipProcessorService { await this.userBlockingService.unblock(blockee, blocker); return 'ok'; } + + public async processMove(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to migrate to ${job.data.to.id}`); + const [src, dst] = await Promise.all([ + this.cacheService.findUserById(job.data.from.id), + this.cacheService.findUserById(job.data.to.id), + ]); + await this.accountMoveService.postMoveProcess(src, dst); + return 'ok'; + } } From 41d8c0409748205cb30542d5beb7a9ee4ddbcbcd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 09:53:41 -0400 Subject: [PATCH 13/46] use CacheService in all relationship jobs --- .../queue/processors/RelationshipProcessorService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts index 3964fa7952..99a88a93d8 100644 --- a/packages/backend/src/queue/processors/RelationshipProcessorService.ts +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -51,8 +51,8 @@ export class RelationshipProcessorService { public async processUnfollow(job: Bull.Job): Promise { this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`); const [follower, followee] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + this.cacheService.findUserById(job.data.from.id), + this.cacheService.findUserById(job.data.to.id), ]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser]; await this.userFollowingService.unfollow(follower, followee, job.data.silent); return 'ok'; @@ -62,8 +62,8 @@ export class RelationshipProcessorService { public async processBlock(job: Bull.Job): Promise { this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`); const [blockee, blocker] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + this.cacheService.findUserById(job.data.from.id), + this.cacheService.findUserById(job.data.to.id), ]); await this.userBlockingService.block(blockee, blocker, job.data.silent); return 'ok'; @@ -73,8 +73,8 @@ export class RelationshipProcessorService { public async processUnblock(job: Bull.Job): Promise { this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`); const [blockee, blocker] = await Promise.all([ - this.usersRepository.findOneByOrFail({ id: job.data.from.id }), - this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + this.cacheService.findUserById(job.data.from.id), + this.cacheService.findUserById(job.data.to.id), ]); await this.userBlockingService.unblock(blockee, blocker); return 'ok'; From 958ce7224943be1b5db9c577bdadd538ac8cbc99 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 10:00:46 -0400 Subject: [PATCH 14/46] when migrating accounts, don't block the target if already following --- packages/backend/src/core/AccountMoveService.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 74236157ac..360233e6d3 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -183,14 +183,17 @@ export class AccountMoveService { public async copyBlocking(src: ThinUser, dst: ThinUser): Promise { // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. // So block the destination account here. - const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id }); - const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id }); - const blockerIds = dstBlockings.map(blocking => blocking.blockerId); + const [srcBlockers, dstBlockers, dstFollowers] = await Promise.all([ + this.cacheService.userBlockedCache.fetch(src.id), + this.cacheService.userBlockedCache.fetch(dst.id), + this.cacheService.userFollowersCache.fetch(dst.id), + ]); // 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 } }); + for (const blockerId of srcBlockers) { + if (dstBlockers.has(blockerId)) continue; // skip if already blocked + if (dstFollowers.has(blockerId)) continue; // skip if already following + blockJobs.push({ from: { id: blockerId }, to: { id: dst.id } }); } // no need to unblock the old account because it may be still functional await this.queueService.createBlockJob(blockJobs); From be3c911e4836a0c3f3d4beb2b6af51c854607094 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 10:23:11 -0400 Subject: [PATCH 15/46] run account move steps 1-at-a-time --- .../backend/src/core/AccountMoveService.ts | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 360233e6d3..87c46ae74b 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -136,26 +136,18 @@ export class AccountMoveService { @bindThis public async postMoveProcess(src: MiUser, dst: MiUser): Promise { // Copy blockings and mutings, and update lists - try { - const results = await Promise.allSettled([ - this.copyBlocking(src, dst), - this.copyMutings(src, dst), - this.deleteScheduledNotes(src), - this.copyRoles(src, dst), - this.updateLists(src, dst), - this.antennaService.onMoveAccount(src, dst), - ]); - - // Log errors, but keep moving - for (const result of results) { - if (result.status === 'rejected') { - this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(result.reason)}`); - } - } - } catch (err) { - /* skip if any error happens */ - this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`); - } + await this.copyBlocking(src, dst) + .catch(err => this.logger.warn(`Error copying blockings in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + await this.copyMutings(src, dst) + .catch(err => this.logger.warn(`Error copying mutings in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + await this.deleteScheduledNotes(src) + .catch(err => this.logger.warn(`Error deleting scheduled notes in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + await this.copyRoles(src, dst) + .catch(err => this.logger.warn(`Error copying roles in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + await this.updateLists(src, dst) + .catch(err => this.logger.warn(`Error updating lists in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + await this.antennaService.onMoveAccount(src, dst) + .catch(err => this.logger.warn(`Error updating antennas in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); // follow the new account const proxy = await this.systemAccountService.fetch('proxy'); From 5003557e90fd40a9e80bcf188ce6b9efe41f0641 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 10:24:25 -0400 Subject: [PATCH 16/46] don't mute users who are already followed --- packages/backend/src/core/AccountMoveService.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 87c46ae74b..665798b848 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -201,9 +201,12 @@ export class AccountMoveService { 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 [existingMutingsMuterUserIds, dstFollowers] = await Promise.all([ + this.mutingsRepository.findBy( + { muteeId: dst.id, expiresAt: IsNull() }, + ).then(mutings => mutings.map(muting => muting.muterId)), + this.cacheService.userFollowersCache.fetch(dst.id), + ]); const newMutings: Map = new Map(); @@ -217,6 +220,7 @@ export class AccountMoveService { }; for (const muting of oldMutings) { if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely + if (dstFollowers.has(muting.muterId)) continue; // skip if already following newMutings.set(genId(), { ...muting, muteeId: dst.id, From b48e916b4a3f62cd22c5ec4005bc6d9326b42b4b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 10:44:57 -0400 Subject: [PATCH 17/46] update caches when moving accounts --- packages/backend/src/core/AccountMoveService.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 665798b848..12b353c1ae 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -323,23 +323,27 @@ export class AccountMoveService { // Set the old account's following and followers counts to 0. // TODO use CollapsedQueueService when merged await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); + await this.internalEventService.emit('userUpdated', { id: oldAccount.id }); // Decrease following counts of local followers by 1. // TODO use CollapsedQueueService when merged await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); + await this.internalEventService.emit('usersUpdated', { ids: localFollowerIds }); // Decrease follower counts of local followees by 1. const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id); - if (oldFollowings.size > 0) { + const oldFolloweeIds = Array.from(oldFollowings.keys()); + if (oldFolloweeIds.length > 0) { // TODO use CollapsedQueueService when merged - await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1); + await this.usersRepository.decrement({ id: In(oldFolloweeIds) }, 'followersCount', 1); + await this.internalEventService.emit('usersUpdated', { ids: oldFolloweeIds }); } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. if (this.meta.enableStatsForFederatedInstances) { if (this.userEntityService.isRemoteUser(oldAccount)) { // TODO use CollapsedQueueService when merged - this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { + await this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { await this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateFollowers(i.host, false); From f609c1cf11b86bc38acf4c724ee62f07823a1f80 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 10:53:43 -0400 Subject: [PATCH 18/46] fix cherry-pick error --- packages/backend/src/core/AccountMoveService.ts | 2 +- packages/backend/src/core/activitypub/models/ApPersonService.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 12b353c1ae..0da153d056 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -323,7 +323,7 @@ export class AccountMoveService { // Set the old account's following and followers counts to 0. // TODO use CollapsedQueueService when merged await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); - await this.internalEventService.emit('userUpdated', { id: oldAccount.id }); + await this.internalEventService.emit(oldAccount.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: oldAccount.id }); // Decrease following counts of local followers by 1. // TODO use CollapsedQueueService when merged diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 793da186f2..e2ecea939d 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -46,6 +46,7 @@ import { verifyFieldLinks } from '@/misc/verify-field-link.js'; import { isRetryableError } from '@/misc/is-retryable-error.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { QueueService } from '@/core/QueueService.js'; import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { extractApHashtags } from './tag.js'; @@ -121,6 +122,7 @@ export class ApPersonService implements OnModuleInit { private readonly apUtilityService: ApUtilityService, private readonly idService: IdService, private readonly timeService: TimeService, + private readonly queueService: QueueService, apLoggerService: ApLoggerService, ) { From 249205eef36a9ffccfc9988af59f8e68af56fed3 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:46:57 -0400 Subject: [PATCH 19/46] fix another cherry-pick mistake --- packages/backend/src/core/AccountMoveService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 0da153d056..72233c9332 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -119,7 +119,7 @@ export class AccountMoveService { // Publish meUpdated event const iObj = await this.userEntityService.pack(src.id, src, { schema: 'MeDetailed', includeSecrets: true }); - await this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); // Unfollow after 24 hours const followings = await this.cacheService.userFollowingsCache.fetch(src.id); From 42250146036874438c0b7356b33da101bf45cfa5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:49:50 -0400 Subject: [PATCH 20/46] fix return type of AccountMoveService.moveFromLocal --- packages/backend/src/core/AccountMoveService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 72233c9332..74aa17b040 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -92,7 +92,7 @@ export class AccountMoveService { * After delivering Move activity, its local followers unfollow the old account and then follow the new one. */ @bindThis - public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise { + public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise> { const srcUri = this.userEntityService.getUserUri(src); const dstUri = this.userEntityService.getUserUri(dst); From a347da6277950d51296410bde6f6d44d291c9d2d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:50:20 -0400 Subject: [PATCH 21/46] improve hostname logging in AccountMoveService --- .../backend/src/core/AccountMoveService.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 74aa17b040..122d0d3389 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -33,6 +33,8 @@ import { InternalEventService } from '@/global/InternalEventService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { Config } from '@/config.js'; @Injectable() export class AccountMoveService { @@ -42,6 +44,9 @@ export class AccountMoveService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.config) + private readonly config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -137,17 +142,17 @@ export class AccountMoveService { public async postMoveProcess(src: MiUser, dst: MiUser): Promise { // Copy blockings and mutings, and update lists await this.copyBlocking(src, dst) - .catch(err => this.logger.warn(`Error copying blockings in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error copying blockings in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); await this.copyMutings(src, dst) - .catch(err => this.logger.warn(`Error copying mutings in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error copying mutings in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); await this.deleteScheduledNotes(src) - .catch(err => this.logger.warn(`Error deleting scheduled notes in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error deleting scheduled notes in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); await this.copyRoles(src, dst) - .catch(err => this.logger.warn(`Error copying roles in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error copying roles in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); await this.updateLists(src, dst) - .catch(err => this.logger.warn(`Error updating lists in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error updating lists in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); await this.antennaService.onMoveAccount(src, dst) - .catch(err => this.logger.warn(`Error updating antennas in migration ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`)); + .catch(err => this.logger.warn(`Error updating antennas in migration ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`)); // follow the new account const proxy = await this.systemAccountService.fetch('proxy'); @@ -164,7 +169,7 @@ export class AccountMoveService { await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src); } catch (err) { /* skip if any error happens */ - this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host}): ${renderInlineError(err)}`); + this.logger.warn(`Non-fatal exception in migration from ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host}): ${renderInlineError(err)}`); } // Should be queued because this can cause a number of follow per one move. From 756e99e9c4f1cd211e9d21554ec2c5d646c73063 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:51:57 -0400 Subject: [PATCH 22/46] fix OpenAPI format for UserDetailed.movedTo --- .../backend/src/core/AccountMoveService.ts | 19 +++++++++++++++++++ .../backend/src/models/json-schema/user.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index 122d0d3389..5f88da983c 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,6 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; +import { isLocalUser } from '@/models/User.js'; import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; @@ -33,6 +34,7 @@ import { InternalEventService } from '@/global/InternalEventService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { renderInlineError } from '@/misc/render-inline-error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { Packed } from '@/misc/json-schema.js'; import type { Config } from '@/config.js'; @@ -91,6 +93,23 @@ export class AccountMoveService { this.logger = this.loggerService.getLogger('account-move'); } + @bindThis + public async restartMigration(src: MiUser): Promise { + if (!src.movedToUri) { + throw new IdentifiableError('ddcf173a-00f2-4aa4-ba12-cddd131bacf4', `Can't restart migrated for user ${src.id}: user has not migrated`); + } + + const dst = await this.apPersonService.resolvePerson(src.movedToUri); + this.logger.info(`Restarting migration from ${src.id} (@${src.usernameLower}@${src.host ?? this.config.host}) to ${dst.id} (@${dst.usernameLower}@${dst.host ?? this.config.host})`); + + if (isLocalUser(src)) { + // This calls createMoveJob at the end + await this.moveFromLocal(src, dst); + } else { + await this.queueService.createMoveJob(src, dst); + } + } + /** * Move a local account to a new account. * diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 69e81c7448..fdb038d3ce 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -310,7 +310,7 @@ export const packedUserDetailedNotMeOnlySchema = { }, movedTo: { type: 'string', - format: 'uri', + format: 'id', nullable: true, optional: false, }, alsoKnownAs: { From ba77e606f81fa9de435114b95a6cf090fa4cbfc6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:52:51 -0400 Subject: [PATCH 23/46] export migration URIs in addition to resolved user IDs, and move alsoKnownAs to MeDetailed for privacy --- .../src/core/entities/UserEntityService.ts | 37 +++++++++++++++-- .../backend/src/models/json-schema/user.ts | 41 +++++++++++++++---- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index f080cf1feb..6e7f15610f 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -409,6 +409,30 @@ export class UserEntityService implements OnModuleInit { ); } + @bindThis + public async resolveAlsoKnownAs(user: MiUser): Promise<{ uri: string, id: string | null }[] | null> { + if (!user.alsoKnownAs) { + return null; + } + + const alsoKnownAs: { uri: string, id: string | null }[] = []; + for (const uri of new Set(user.alsoKnownAs)) { + try { + const resolved = await this.apPersonService.resolvePerson(uri); + alsoKnownAs.push({ uri, id: resolved.id }); + } catch { + // ignore errors - we expect some users to be deleted or unavailable + alsoKnownAs.push({ uri, id: null }); + } + } + + if (alsoKnownAs.length < 1) { + return null; + } + + return alsoKnownAs; + } + @bindThis public getIdenticonUrl(user: MiUser): string { if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合 @@ -551,6 +575,10 @@ export class UserEntityService implements OnModuleInit { let fetchPoliciesPromise: Promise | null = null; const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user); + // This has a cache so it's fine to await here + const alsoKnownAs = await this.resolveAlsoKnownAs(user); + const alsoKnownAsIds = alsoKnownAs?.map(aka => aka.id).filter(id => id != null) ?? null; + const bypassSilence = isMe || (myFollowings ? myFollowings.has(user.id) : false); const packed = { @@ -617,10 +645,8 @@ export class UserEntityService implements OnModuleInit { url: profile!.url, uri: user.uri, movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null, - alsoKnownAs: user.alsoKnownAs - ? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))) - .then(xs => xs.length === 0 ? null : xs.filter(x => x != null)) - : null, + movedToUri: user.movedToUri, + // alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy bannerUrl: user.bannerId == null ? null : user.bannerUrl, bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash, backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl, @@ -711,6 +737,9 @@ export class UserEntityService implements OnModuleInit { defaultCW: profile!.defaultCW, defaultCWPriority: profile!.defaultCWPriority, allowUnsignedFetch: user.allowUnsignedFetch, + // alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy + alsoKnownAs: alsoKnownAsIds, + skAlsoKnownAs: alsoKnownAs, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index fdb038d3ce..ae6a888144 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -313,15 +313,12 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'id', nullable: true, optional: false, }, - alsoKnownAs: { - type: 'array', + movedToUri: { + type: 'string', + format: 'uri', nullable: true, optional: false, - items: { - type: 'string', - format: 'id', - nullable: false, optional: false, - }, }, + // alsoKnownAs moved to packedUserDetailedNotMeOnly for privacy bannerUrl: { type: 'string', format: 'url', @@ -814,6 +811,36 @@ export const packedMeDetailedOnlySchema = { enum: userUnsignedFetchOptions, nullable: false, optional: false, }, + // alsoKnownAs moved from packedUserDetailedNotMeOnly for privacy + alsoKnownAs: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'string', + format: 'id', + nullable: false, optional: false, + }, + }, + skAlsoKnownAs: { + type: 'array', + nullable: true, optional: false, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + uri: { + type: 'string', + format: 'uri', + nullable: false, optional: false, + }, + id: { + type: 'string', + format: 'id', + nullable: true, optional: false, + }, + }, + }, + }, }, } as const; From 42b51e31fd75438e93c3f939e1b07eedc1bc51e6 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:53:31 -0400 Subject: [PATCH 24/46] return movedAt, movedTo, and alsoKnownAs from admin/show-user endpoint --- .../server/api/endpoints/admin/show-user.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 6f0081f1f7..49ce0f8bd4 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -13,6 +13,8 @@ import { IdService } from '@/core/IdService.js'; import { notificationRecieveConfig } from '@/models/json-schema/user.js'; import { isSystemAccount } from '@/misc/is-system-account.js'; import { CacheService } from '@/core/CacheService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; export const meta = { tags: ['admin'], @@ -225,6 +227,46 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + movedAt: { + type: 'string', + optional: true, nullable: true, + }, + movedTo: { + type: 'object', + optional: true, nullable: true, + properties: { + uri: { + type: 'string', + format: 'uri', + nullable: false, optional: false, + }, + user: { + type: 'object', + ref: 'UserDetailed', + nullable: true, optional: true, + }, + }, + }, + alsoKnownAs: { + type: 'array', + nullable: true, optional: true, + items: { + type: 'object', + nullable: false, optional: false, + properties: { + uri: { + type: 'string', + format: 'uri', + nullable: false, optional: false, + }, + user: { + type: 'object', + ref: 'UserDetailed', + nullable: true, optional: true, + }, + }, + }, + }, }, }, } as const; @@ -253,6 +295,8 @@ export default class extends Endpoint { // eslint- private roleEntityService: RoleEntityService, private idService: IdService, private readonly cacheService: CacheService, + private readonly apPersonService: ApPersonService, + private readonly userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ @@ -280,6 +324,22 @@ export default class extends Endpoint { // eslint- const followStats = await this.cacheService.getFollowStats(user.id); + const movedAt = user.movedAt?.toISOString(); + + const movedToUser = user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null; + const movedTo = user.movedToUri ? { + uri: user.movedToUri, + user: movedToUser ? await this.userEntityService.pack(movedToUser, me, { schema: 'UserDetailed' }) : undefined, + } : null; + + // This is kinda heavy, but it's an admin endpoint so ok. + const aka = await this.userEntityService.resolveAlsoKnownAs(user); + const akaUsers = aka ? await this.userEntityService.packMany(aka.map(aka => aka.id).filter(id => id != null), me, { schema: 'UserDetailed' }) : []; + const alsoKnownAs = aka?.map(aka => ({ + uri: aka.uri, + user: aka.id ? akaUsers.find(u => u.id === aka.id) : undefined, + })); + return { email: profile.email, emailVerified: profile.emailVerified, @@ -318,6 +378,9 @@ export default class extends Endpoint { // eslint- totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers), totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing), }, + movedAt, + movedTo, + alsoKnownAs, }; }); } From 8310c128a890f09c44b82f538b2d61ceb9da8493 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:53:46 -0400 Subject: [PATCH 25/46] fix WebhookTestService --- packages/backend/src/core/WebhookTestService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 3d5b94254b..45073c145a 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -494,6 +494,7 @@ export class WebhookTestService { url: null, uri: null, movedTo: null, + movedToUri: null, alsoKnownAs: [], createdAt: this.timeService.date.toISOString(), updatedAt: user.updatedAt?.toISOString() ?? null, From b3e41b74a16baaaeb8c220f7bdbaaf4a6b362195 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:54:20 -0400 Subject: [PATCH 26/46] implement admin/restart-migration endpoint --- .../backend/src/server/api/endpoint-list.ts | 1 + .../api/endpoints/admin/restart-migration.ts | 67 +++++++++++++++++++ packages/backend/src/types.ts | 6 ++ packages/misskey-js/src/consts.ts | 6 ++ packages/misskey-js/src/entities.ts | 3 + 5 files changed, 83 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/admin/restart-migration.ts diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 54e8cfe841..6df96546a7 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -87,6 +87,7 @@ export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js'; export * as 'admin/reset-password' from './endpoints/admin/reset-password.js'; +export * as 'admin/restart-migration' from './endpoints/admin/restart-migration.js'; export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js'; export * as 'admin/roles/annotate-condition' from './endpoints/admin/roles/annotate-condition.js'; export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/restart-migration.ts b/packages/backend/src/server/api/endpoints/admin/restart-migration.ts new file mode 100644 index 0000000000..520bf2560a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/restart-migration.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { ApiError } from '@/server/api/error.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:restart-migration', + + errors: { + accountHasNotMigrated: { + message: 'Account has not migrated anywhere.', + code: 'ACCOUNT_HAS_NOT_MIGRATED', + id: 'ddcf173a-00f2-4aa4-ba12-cddd131bacf4', + }, + }, + + res: {}, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + private readonly accountMoveService: AccountMoveService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + const user = await this.cacheService.findUserById(ps.userId); + await this.accountMoveService.restartMigration(user); + + await this.moderationLogService.log(me, 'restartMigration', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + } catch (err) { + // TODO allow this mapping stuff to be defined in the meta + if (err instanceof IdentifiableError && err.id === 'ddcf173a-00f2-4aa4-ba12-cddd131bacf4') { + throw new ApiError(meta.errors.accountHasNotMigrated); + } else { + throw err; + } + } + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e799447117..850f47ad52 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -108,6 +108,7 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'restartMigration', 'setMandatoryCW', 'setMandatoryCWForNote', 'setMandatoryCWForInstance', @@ -290,6 +291,11 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + restartMigration: { + userId: string; + userUsername: string; + userHost: string | null; + }; setMandatoryCW: { newCW: string | null; oldCW: string | null; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 0b7f8f4eac..616e7488fa 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -90,6 +90,7 @@ export const permissions = [ 'write:admin:unset-user-banner', 'write:admin:unsuspend-user', 'write:admin:reject-quotes', + 'write:admin:restart-migration', 'write:admin:meta', 'write:admin:user-note', 'write:admin:roles', @@ -339,6 +340,11 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + restartMigration: { + userId: string; + userUsername: string; + userHost: string | null; + }; setMandatoryCW: { newCW: string | null; oldCW: string | null; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 6b369fc52d..abc3f08bb8 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -139,6 +139,9 @@ export type ModerationLog = { } | { type: 'setMandatoryCWForInstance'; info: ModerationLogPayloads['setMandatoryCWForInstance']; +} | { + type: 'restartMigration'; + info: ModerationLogPayloads['restartMigration']; } | { type: 'resetPassword'; info: ModerationLogPayloads['resetPassword']; From 800b7802670f0cec7daa4d32c8acdd97eda9249f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 14 Sep 2025 20:55:04 -0400 Subject: [PATCH 27/46] show "account moved" banner even if the URI couldn't be resolved --- .../components/MkAccountMoved.stories.impl.ts | 1 + .../src/components/MkAccountMoved.vue | 20 ++++++++++++++----- packages/frontend/src/pages/user/home.vue | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts index b907b5b25a..29734cdfb2 100644 --- a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -33,6 +33,7 @@ export const Default = { }, args: { movedTo: userDetailed().id, + movedToUri: 'https://example.com', }, parameters: { layout: 'centered', diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index cb8032c019..07dcdf0de3 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -7,25 +7,35 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.accountMoved }} - + +