diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fce3195ee..615a3aad3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - ### Client -- +- ### Server - @@ -21,6 +21,7 @@ ### General - チャンネルをお気に入りに登録できるように - チャンネルにノートをピン留めできるように +- アカウントの引っ越し(フォロワー引き継ぎ)に対応 ### Client - 検索ページでURLを入力した際に照会したときと同等の挙動をするように diff --git a/locales/en-US.yml b/locales/en-US.yml index 0a6272ee47..a134bb3a3a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -988,6 +988,16 @@ enableChartsForFederatedInstances: "Generate remote instance data charts" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" largeNoteReactions: "Enlargen displayed reactions" noteIdOrUrl: "Note ID or URL" +migration: "Migration" +moveTo: "Move current account to new account" +moveToLabel: "Account you're moving to:" +moveAccountDescription: "This process is irreversible. Make sure you've set up an alias for this account on your new account before moving. Please enter the tag of the account formatted like @person@instance.com" +moveFrom: "Move to this account from an older account" +moveFromLabel: "Account you're moving from:" +moveFromDescription: "This will set an alias of your old account so that you can move from that account to this current one. Do this BEFORE moving from your older account. Please enter the tag of the account formatted like @person@instance.com" +migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}? Once you do this, you won't be able to reverse it, and you won't be able to use your account normally again.\nAlso, please ensure that you've set this current account as the account you're moving from." +accountMoved: "User has moved to a new account:" + _achievements: earnedAt: "Unlocked at" _types: @@ -1898,4 +1908,3 @@ _webhookSettings: renote: "When renoted" reaction: "When receiving a reaction" mention: "When being mentioned" - diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 04e4d52b28..af47408c67 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -989,6 +989,15 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きく表示" noteIdOrUrl: "ノートIDまたはURL" +migration: "アカウントの引っ越し" +moveTo: "このアカウントを新しいアカウントに引っ越す" +moveToLabel: "引っ越し先のアカウント:" +moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com" +moveFrom: "別のアカウントからこのアカウントに引っ越す" +moveFromLabel: "引っ越し元のアカウント:" +moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com" +migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" +accountMoved: "このユーザーは新しいアカウントに引っ越しました:" _achievements: earnedAt: "獲得日時" @@ -1955,4 +1964,3 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" - diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 0000000000..6a7727c57a --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } 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 { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.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'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private globalEventService: GlobalEventService, + private userFollowingService: UserFollowingService, + private accountUpdateService: AccountUpdateService, + private relayService: RelayService, + ) { + } + + /** + * Move a local account to a remote account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveToRemote(src: LocalUser, dst: User): Promise { + // Make sure that the destination is a remote account. + if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); + if (!dst.uri) throw new Error('destination uri is empty'); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; + update.movedToUri = dst.uri; + await this.usersRepository.update(src.id, update); + + const srcPerson = await this.apRendererService.renderPerson(src); + const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); + await this.apDeliverManagerService.deliverToFollowers(src, updateAct); + this.relayService.deliverToRelays(src, updateAct); + + // Deliver Move activity to the followers of the old account + const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); + await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: src.id, + followerHost: IsNull(), // follower is local + } + }); + followings.forEach(async (following) => { + if (!following.follower) return; + try { + await this.userFollowingService.follow(following.follower, dst); + await this.userFollowingService.unfollow(following.follower, src); + } catch { + /* empty */ + } + }); + + return iObj; + } + + /** + * Create an alias of an old remote account. + * + * The user's new profile will be published to the followers. + */ + @bindThis + public async createAlias(me: LocalUser, updates: Partial): Promise { + await this.usersRepository.update(me.id, updates); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + }); + this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + + if (me.isLocked === false) { + await this.userFollowingService.acceptAllFollowRequests(me); + } + + this.accountUpdateService.publishToFollowers(me.id); + + return iObj; + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index d8ba7b169d..b146fc66be 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -29,7 +29,7 @@ export class AccountUpdateService { public async publishToFollowers(userId: User['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); - + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index ea6e229610..8775536e4a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; @@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; @@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 055bffe731..40d7ba551d 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 } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.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 { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.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'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; @Injectable() export class ApInboxService { @@ -80,7 +80,7 @@ export class ApInboxService { ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -139,6 +139,8 @@ export class ApInboxService { await this.block(actor, activity); } else if (isFlag(activity)) { await this.flag(actor, activity); + } else if (isMove(activity)) { + await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -147,15 +149,15 @@ export class ApInboxService { @bindThis private async follow(actor: RemoteUser, activity: IFollow): Promise { const followee = await this.apDbResolverService.getUserFromApId(activity.object); - + if (followee == null) { return 'skip: followee not found'; } - + if (followee.host != null) { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } - + await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -183,16 +185,16 @@ export class ApInboxService { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); throw err; }); - + if (isFollow(object)) return await this.acceptFollow(actor, object); - + return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -225,18 +227,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.addPinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -405,10 +407,10 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + // 削除対象objectのtype let formerType: string | undefined; - + if (typeof activity.object === 'string') { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; @@ -420,19 +422,19 @@ export class ApInboxService { formerType = toSingle(object.type); } } - + const uri = getApId(activity.object); - + // type不明でもactorとobjectが同じならばそれはPersonに違いない if (!formerType && actor.uri === uri) { formerType = 'Person'; } - + // それでもなかったらおそらくNote if (!formerType) { formerType = 'Note'; } - + if (validPost.includes(formerType)) { return await this.deleteNote(actor, uri); } else if (validActor.includes(formerType)) { @@ -445,44 +447,44 @@ export class ApInboxService { @bindThis private async deleteActor(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Actor: ${uri}`); - + if (actor.uri !== uri) { return `skip: delete actor ${actor.uri} !== ${uri}`; } - + const user = await this.usersRepository.findOneBy({ id: actor.id }); if (user == null) { return 'skip: actor not found'; } else if (user.isDeleted) { return 'skip: already deleted'; } - + const job = await this.queueService.createDeleteAccountJob(actor); - + await this.usersRepository.update(actor.id, { isDeleted: true, }); - + return `ok: queued ${job.name} ${job.id}`; } @bindThis private async deleteNote(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - + const unlock = await this.appLockService.getApLock(uri); - + try { const note = await this.apDbResolverService.getNoteFromApId(uri); - + if (note == null) { return 'message not found'; } - + if (note.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.noteDeleteService.delete(actor, note); return 'ok: note deleted'; } finally { @@ -536,23 +538,23 @@ export class ApInboxService { @bindThis private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - + if (follower == null) { return 'skip: follower not found'; } - + if (!this.userEntityService.isLocalUser(follower)) { return 'skip: follower is not a local user'; } - + // relay const match = activity.id?.match(/follow-relay\/(\w+)/); if (match) { return await this.relayService.relayRejected(match[1]); } - + await this.userFollowingService.remoteReject(actor, follower); return 'ok'; } @@ -562,18 +564,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.removePinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -582,24 +584,24 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + const uri = activity.id ?? activity; - + this.logger.info(`Undo: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + 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); if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object); - + return `skip: unknown object type ${getApType(object)}`; } @@ -609,17 +611,17 @@ export class ApInboxService { if (follower == null) { return 'skip: follower not found'; } - + const following = await this.followingsRepository.findOneBy({ followerId: follower.id, followeeId: actor.id, }); - + if (following) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } - + return 'skip: フォローされていない'; } @@ -708,16 +710,16 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { return 'skip: invalid actor'; } - + this.logger.debug('Update'); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri!, resolver, object); return 'ok: Person updated'; @@ -728,4 +730,55 @@ export class ApInboxService { return `skip: Unknown type: ${getApType(object)}`; } } + + @bindThis + private async move(actor: RemoteUser, activity: IMove): Promise { + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return 'skip: invalid activity target'; + const new_acc = await this.apPersonService.resolvePerson(targetUri); + const 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); + + // 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 + } + }); + followings.forEach(async (following) => { + if (!following.follower) return; + try { + await this.userFollowingService.follow(following.follower, new_acc); + await this.userFollowingService.unfollow(following.follower, old_acc); + } catch { + /* empty */ + } + }); + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index b250b796d6..0b22aa9bcf 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -25,7 +25,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; +import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; import type { IIdentifier } from './models/identifier.js'; @Injectable() @@ -292,6 +292,22 @@ export class ApRendererService { }; } + @bindThis + public renderMove( + src: { id: User['id']; host: User['host']; uri: User['host'] }, + dst: { id: User['id']; host: User['host']; uri: User['host'] }, + ): 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!; + return { + id: `${this.config.url}/moves/${src.id}/${dst.id}`, + actor, + type: 'Move', + object: actor, + target, + }; + } + @bindThis public async renderNote(note: Note, dive = true): Promise { const getPromisedFiles = async (ids: string[]) => { @@ -498,6 +514,14 @@ export class ApRendererService { attachment: attachment.length ? attachment : undefined, } as any; + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 664e7eb4ea..21797cfcb7 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -281,6 +281,8 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -473,6 +475,8 @@ export class ApPersonService implements OnModuleInit { isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo ?? null, + alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, } as Partial; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 8851946330..625135da6c 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -157,6 +157,8 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -300,6 +302,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index cbe94451cc..e02f7535d4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -15,6 +15,7 @@ import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; @@ -25,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js'; type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -47,6 +48,7 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { @Injectable() export class UserEntityService implements OnModuleInit { + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; @@ -122,6 +124,7 @@ export class UserEntityService implements OnModuleInit { } onModuleInit() { + this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); @@ -237,7 +240,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - + const latestNotificationIdsRes = await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', @@ -363,6 +366,8 @@ 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, 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 ca53c57d5a..04dfa21107 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,19 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e388a77a5e..7d40979e3d 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + format: 'uri', + nullable: true, + optional: false, + }, createdAt: { type: 'string', nullable: false, optional: false, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index cab2477414..5a53b3faf7 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; 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'; @@ -551,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; 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 }; @@ -886,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1215,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $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 e33c2349cd..c8375ca149 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; 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'; @@ -549,6 +551,8 @@ 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/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 0000000000..964704d82b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: ms('1day'), + max: 30, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + notRemote: { + message: 'User is not remote. You can only migrate from other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + alsoKnownAs: { type: 'string' }, + }, + required: ['alsoKnownAs'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check parameter + if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + + let unfiltered = ps.alsoKnownAs; + const updates = {} as Partial; + + if (!unfiltered) { + updates.alsoKnownAs = null; + } else { + // Parse user's input into the old account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + const toUrl: string | null = knownAs.uri; + if (!toUrl) throw new ApiError(meta.errors.uriNull); + // Only allow moving from a remote account + if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); + + updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; + } + + return await this.accountMoveService.createAlias(me, updates); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 0000000000..346d8f95bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,136 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.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'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: 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: { + message: + 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', + code: 'REMOTE_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 cant migrate.', + code: 'NOT_ROOT_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + 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', + }, + localUriNull: { + message: 'Local User ActivityPup URI is null.', + code: 'URI_NULL', + id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', + }, + alreadyMoved: { + message: 'Account was already moved to another account.', + code: 'ALREADY_MOVED', + id: 'b234a14e-9ebe-4581-8000-074b3c215962', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check parameter + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + // 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('@'); + // Retrieve the destination account + const remoteMoveTo = 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.noSuchMoveTarget); + }); + const moveTo = await this.getterService.getRemoteUser(remoteMoveTo.id); + if (!moveTo.uri) throw new ApiError(meta.errors.uriNull); + await this.apPersonService.updatePerson(moveTo.uri); + // Only allow moving to a remote account + if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + + 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; + }); + + // Abort if unintended + if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + + return await this.accountMoveService.moveToRemote(me, moveTo); + }); + } +} diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 0000000000..1d4fbd9a54 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index ae36466eec..4e22d7b60b 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -130,6 +130,11 @@ const menuDef = computed(() => [{ }, { title: i18n.ts.otherSettings, items: [{ + icon: 'ti ti-plane', + text: i18n.ts.migration, + to: '/settings/migration', + active: currentPage?.route.name === 'migration', + }, { icon: 'ti ti-package', text: i18n.ts.importAndExport, to: '/settings/import-export', diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue new file mode 100644 index 0000000000..0295aeae18 --- /dev/null +++ b/packages/frontend/src/pages/settings/migration.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 1ce0bedbe3..8c3478d8f2 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -7,6 +7,7 @@
+
@@ -117,6 +118,7 @@ import calcAge from 's-age'; import * as misskey from 'misskey-js'; import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkAccountMoved from '@/components/MkAccountMoved.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index c8077edd28..0769ec2614 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -161,6 +161,10 @@ export const routes = [{ path: '/preferences-backups', name: 'preferences-backups', component: page(() => import('./pages/settings/preferences-backups.vue')), + }, { + path: '/migration', + name: 'migration', + component: page(() => import('./pages/settings/migration.vue')) }, { path: '/custom-css', name: 'general', diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index d72e163cd4..aed9f5bf84 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -362,6 +362,8 @@ export type Endpoints = { 'i/get-word-muted-notes-count': { req: TODO; res: TODO; }; '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']; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 7343fd74ad..0c90e44494 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -14,6 +14,8 @@ export type UserLite = { onlineStatus: 'online' | 'active' | 'offline' | 'unknown'; avatarUrl: string; avatarBlurhash: string; + alsoKnownAs: string[]; + movedToUri: any; emojis: { name: string; url: string;