diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index 0aee4c90b..6cfca187a 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -1,4 +1,4 @@ -import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Entity, Column, Index, OneToOne, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { id } from '../id.js'; import { DriveFile } from './drive-file.js'; @@ -230,6 +230,18 @@ export class User { }) public federateBlocks: boolean; + @Column({ + ...id(), + nullable: true, + }) + public movedToId: User['id'] | null; + + @ManyToOne(() => User, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public movedTo: User | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts index 79e2dce75..46a972a7e 100644 --- a/packages/backend/src/remote/activitypub/kernel/index.ts +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -4,7 +4,7 @@ import { Resolver } from '@/remote/activitypub/resolver.js'; import { extractDbHost } from '@/misc/convert-host.js'; import { shouldBlockInstance } from '@/misc/should-block-instance.js'; import { apLogger } from '../logger.js'; -import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, getApId } from '../type.js'; +import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, isMove, getApId } from '../type.js'; import create from './create/index.js'; import performDeleteActivity from './delete/index.js'; import performUpdateActivity from './update/index.js'; @@ -19,6 +19,7 @@ import add from './add/index.js'; import remove from './remove/index.js'; import block from './block/index.js'; import flag from './flag/index.js'; +import { move } from './move/index.js'; export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise { if (isCollectionOrOrderedCollection(activity)) { @@ -73,6 +74,8 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject, await block(actor, activity); } else if (isFlag(activity)) { await flag(actor, activity); + } else if (isMove(activity)) { + await move(actor, activity, resolver); } else { apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); } diff --git a/packages/backend/src/remote/activitypub/kernel/move/index.ts b/packages/backend/src/remote/activitypub/kernel/move/index.ts new file mode 100644 index 000000000..e64656e09 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/move/index.ts @@ -0,0 +1,62 @@ +import { IsNull } from 'typeorm'; +import { CacheableRemoteUser } from '@/models/entities/user.js'; +import { resolvePerson } from '@/remote/activitypub/models/person.js'; +import { Followings, Users } from '@/models/index.js'; +import { createNotification } from '@/services/create-notification.js'; +import Resolver from '../../resolver.js'; +import { IMove, isActor, getApId } from '../../type.js'; + +export async function move(actor: CacheableRemoteUser, activity: IMove, resolver: Resolver): Promise { + // actor is not move origin + if (activity.object == null || getApId(activity.object) !== actor.uri) return; + + // actor already moved + if (actor.movedTo != null) return; + + // no move target + if (activity.target == null) return; + + /* the database resolver can not be used here, because: + * 1. It must be ensured that the latest data is used. + * 2. The AP representation is needed, because `alsoKnownAs` + * is not stored in the database. + * This also checks whether the move target is blocked + */ + const movedToAp = await resolver.resolve(getApId(activity.target)); + + // move target is not an actor + if (!isActor(movedToAp)) return; + + // move destination has not accepted + if (!Array.isArray(movedToAp.alsoKnownAs) || !movedToAp.alsoKnownAs.includes(actor.id)) return; + + // ensure the user exists + const movedTo = await resolvePerson(getApId(activity.target), resolver, movedToAp); + // move target is already suspended + if (movedTo.isSuspended) return; + + // process move for local followers + const followings = Followings.find({ + select: { + followerId: true, + }, + where: { + followeeId: actor.id, + followerHost: IsNull(), + }, + }); + + await Promise.all([ + Users.update(actor.id, { + movedToId: movedTo.id, + }), + ...followings.map(async (following) => { + // TODO: autoAcceptMove? + + await createNotification(following.followerId, 'move', { + notifierId: actor.id, + moveTargetId: movedTo.id, + }); + }), + ]); +} diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index 6298d44d8..8a46ac594 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -296,6 +296,10 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; +} + 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'; @@ -310,6 +314,7 @@ 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'; export interface ILink { href: string;