summaryrefslogtreecommitdiff
path: root/packages/backend/src
diff options
context:
space:
mode:
authorNamekuji <11836635+nmkj-io@users.noreply.github.com>2023-04-08 01:16:26 -0400
committerGitHub <noreply@github.com>2023-04-08 14:16:26 +0900
commit25ebb73756017174c3197376b7485e5e7fcb5fd1 (patch)
tree9f313b388dd38db54c66c66554b10255450832ba /packages/backend/src
parentenhance(backend): Redisにチャンネル投稿がない場合はDBから持... (diff)
downloadmisskey-25ebb73756017174c3197376b7485e5e7fcb5fd1.tar.gz
misskey-25ebb73756017174c3197376b7485e5e7fcb5fd1.tar.bz2
misskey-25ebb73756017174c3197376b7485e5e7fcb5fd1.zip
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
Diffstat (limited to 'packages/backend/src')
-rw-r--r--packages/backend/src/core/AccountMoveService.ts114
-rw-r--r--packages/backend/src/core/AccountUpdateService.ts2
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/activitypub/ApInboxService.ts155
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts26
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts4
-rw-r--r--packages/backend/src/core/activitypub/type.ts8
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts9
-rw-r--r--packages/backend/src/models/entities/User.ts13
-rw-r--r--packages/backend/src/models/json-schema/user.ts12
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts8
-rw-r--r--packages/backend/src/server/api/endpoints.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/i/known-as.ts92
-rw-r--r--packages/backend/src/server/api/endpoints/i/move.ts136
14 files changed, 534 insertions, 55 deletions
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<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;
+ }
+}
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<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';
+ }
}
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()
@@ -293,6 +293,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[]) => {
if (!ids || ids.length === 0) return [];
@@ -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<User>;
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 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,
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<typeof meta, typeof paramDef> {
+ constructor(
+ private userEntityService: UserEntityService,
+ private remoteUserResolveService: RemoteUserResolveService,
+ private apiLoggerService: ApiLoggerService,
+ private accountMoveService: AccountMoveService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ // Check parameter
+ if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
+
+ let unfiltered = ps.alsoKnownAs;
+ const updates = {} as Partial<User>;
+
+ if (!unfiltered) {
+ updates.alsoKnownAs = null;
+ } else {
+ // Parse user's input into the old account
+ if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
+ if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
+ if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
+
+ const userAddress = unfiltered.split('@');
+ // Retrieve the old account
+ const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
+ this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
+ throw new ApiError(meta.errors.noSuchUser);
+ });
+
+ const toUrl: string | null = knownAs.uri;
+ if (!toUrl) throw new ApiError(meta.errors.uriNull);
+ // Only allow moving from a remote account
+ if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
+
+ updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
+ }
+
+ return await this.accountMoveService.createAlias(me, updates);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts
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<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);
+ });
+ }
+}