feat: account migration (#10507)

* add Move activity

* add endpoint to move from local to remote

* follow move activity coming to inbox

* fix move endpoint

* add known-as endpoint to create account alias

* add migration page

* add route to migration page

* add move and known-as endpoints

* fix dependnecies error

* fix new endpoints

* fix move activity id

* fix refollow

* add movedToUri and alsoKnownAs to api

* fix moveToUri indicator

* fix missing context

* add chengelog

* rename MkMoved to MkAccountMoved

* add missing semicolon

* fix targetUri

* fix followings query

* remove redundant null check
This commit is contained in:
Namekuji 2023-04-08 01:16:26 -04:00 committed by GitHub
parent fa67fb42b1
commit 25ebb73756
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 676 additions and 58 deletions

View file

@ -5,7 +5,7 @@
-
### Client
-
-
### Server
-
@ -21,6 +21,7 @@
### General
- チャンネルをお気に入りに登録できるように
- チャンネルにノートをピン留めできるように
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
### Client
- 検索ページでURLを入力した際に照会したときと同等の挙動をするように

View file

@ -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"

View file

@ -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: "メンションされたとき"

View file

@ -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<unknown> {
// Make sure that the destination is a remote account.
if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
if (!dst.uri) throw new Error('destination uri is empty');
// add movedToUri to indicate that the user has moved
const update = {} as Partial<User>;
update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
update.movedToUri = dst.uri;
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<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
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<User>): Promise<unknown> {
await this.usersRepository.update(me.id, updates);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
detail: true,
includeSecrets: true,
});
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
if (me.isLocked === false) {
await this.userFollowingService.acceptAllFollowRequests(me);
}
this.accountUpdateService.publishToFollowers(me.id);
return iObj;
}
}

View file

@ -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));

View file

@ -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,

View file

@ -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<string> {
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<string> {
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<string> {
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<string> {
// ※ 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<string> {
// 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';
}
}

View file

@ -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<IPost> {
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;
}

View file

@ -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<User>;

View file

@ -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';

View file

@ -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 boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
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<boolean> {
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,

View file

@ -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.',

View file

@ -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,

View file

@ -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,

View file

@ -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],

View file

@ -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<typeof meta, typeof paramDef> {
constructor(
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
// Check parameter
if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
let unfiltered = ps.alsoKnownAs;
const updates = {} as Partial<User>;
if (!unfiltered) {
updates.alsoKnownAs = null;
} else {
// Parse user's input into the old account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
const toUrl: string | null = knownAs.uri;
if (!toUrl) throw new ApiError(meta.errors.uriNull);
// Only allow moving from a remote account
if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
}
return await this.accountMoveService.createAlias(me, updates);
});
}
}

View file

@ -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<typeof meta, typeof paramDef> {
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);
});
}
}

View file

@ -0,0 +1,33 @@
<template>
<div class="msjugskd _block">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<MkMention class="link" :username="acct" :host="host ?? localHost"/>
</div>
</template>
<script lang="ts" setup>
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
import { host as localHost } from '@/config';
defineProps<{
acct: string;
host: string;
}>();
</script>
<style lang="scss" scoped>
.msjugskd {
padding: 16px;
font-size: 90%;
background: var(--infoWarnBg);
color: var(--error);
border-radius: var(--radius);
> .link {
margin-left: 4px;
color: var(--accent);
}
}
</style>

View file

@ -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',

View file

@ -0,0 +1,73 @@
<template>
<div class="_gaps_m">
<FormSection>
<template #label>{{ i18n.ts.moveTo }}</template>
<MkInput v-model="moveToAccount" manual-save>
<template #prefix><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts.moveToLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts.moveAccountDescription }}</FormInfo>
<FormSection>
<template #label>{{ i18n.ts.moveFrom }}</template>
<MkInput v-model="accountAlias" manual-save>
<template #prefix><i class="ti ti-plane-arrival"></i></template>
<template #label>{{ i18n.ts.moveFromLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts.moveFromDescription }}</FormInfo>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const moveToAccount = ref('');
const accountAlias = ref('');
async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('migrationConfirm', { account: account.toString() }),
});
if (confirm.canceled) return;
os.apiWithDialog('i/move', {
moveToAccount: account,
});
}
async function save(): Promise<void> {
const account = accountAlias.value;
os.apiWithDialog('i/known-as', {
alsoKnownAs: account,
});
}
watch(accountAlias, async () => {
await save();
});
watch(moveToAccount, async () => {
await move();
});
definePageMetadata({
title: i18n.ts.migration,
icon: 'ti ti-plane',
});
</script>
<style lang="scss">
.description {
font-size: .85em;
padding: 1rem;
}
</style>

View file

@ -7,6 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@ -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';

View file

@ -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',

View file

@ -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'];

View file

@ -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;