diff --git a/locales/en-US.yml b/locales/en-US.yml index 887d7d287..a7c7316bf 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -829,6 +829,7 @@ oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party ap \ Please go back and try again." appAuthorization: "App authorization" noPermissionsRequested: "(No permissions requested.)" +movedTo: "This user has moved to {handle}." _emailUnavailable: used: "This email address is already being used" format: "The format of this email address is invalid" @@ -1306,6 +1307,7 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" groupInvited: "Group invitations" + move: "Others moving accounts" app: "Notifications from linked apps" _actions: followBack: "followed you back" diff --git a/packages/backend/assets/notification-badges/suitcase-solid.png b/packages/backend/assets/notification-badges/suitcase-solid.png new file mode 100644 index 000000000..b52688586 Binary files /dev/null and b/packages/backend/assets/notification-badges/suitcase-solid.png differ diff --git a/packages/backend/migration/1668977715500-movedTo.js b/packages/backend/migration/1668977715500-movedTo.js new file mode 100644 index 000000000..721e01743 --- /dev/null +++ b/packages/backend/migration/1668977715500-movedTo.js @@ -0,0 +1,35 @@ +export class movedTo1668977715500 { + name = 'movedTo1668977715500'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "notification" ADD "moveTargetId" character varying(32)`); + await queryRunner.query(`COMMENT ON COLUMN "notification"."moveTargetId" IS 'The ID of the moved to account.'`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e" FOREIGN KEY ("movedToId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_078db271ad52ccc345b7b2b026a" FOREIGN KEY ("moveTargetId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TYPE "notification_type_enum" ADD VALUE 'move'`); + await queryRunner.query(`ALTER TYPE "user_profile_mutingnotificationtypes_enum" ADD VALUE 'move'`); + } + + async down(queryRunner) { + // remove 'move' from user muting notifications type enum + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); + + // remove 'move' from notification type enum + await queryRunner.query(`DELETE FROM "notification" WHERE "type" = 'move'`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); + + await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_078db271ad52ccc345b7b2b026a"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e"`); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "moveTargetId"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToId"`); + } +} diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts index cab6551f7..a0d6cbde4 100644 --- a/packages/backend/src/models/entities/notification.ts +++ b/packages/backend/src/models/entities/notification.ts @@ -52,19 +52,20 @@ export class Notification { public notifier: User | null; /** - * 通知の種類。 - * follow - フォローされた - * mention - 投稿で自分が言及された - * reply - (自分または自分がWatchしている)投稿が返信された - * renote - (自分または自分がWatchしている)投稿がRenoteされた - * quote - (自分または自分がWatchしている)投稿が引用Renoteされた - * reaction - (自分または自分がWatchしている)投稿にリアクションされた - * pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された - * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した - * receiveFollowRequest - フォローリクエストされた - * followRequestAccepted - 自分の送ったフォローリクエストが承認された - * groupInvited - グループに招待された - * app - アプリ通知 + * Type of notification. + * follow - notifier followed notifiee + * mention - notifiee was mentioned + * reply - notifiee (author or watching) was replied to + * renote - notifiee (author or watching) was renoted + * quote - notifiee (author or watching) was quoted + * reaction - notifiee (author or watching) had a reaction added to the note + * pollVote - new vote in a poll notifiee authored or watched + * pollEnded - notifiee's poll ended + * receiveFollowRequest - notifiee received a new follow request + * followRequestAccepted - notifier accepted notifees follow request + * groupInvited - notifiee was invited into a group + * move - notifier moved + * app - custom application notification */ @Index() @Column('enum', { @@ -129,6 +130,19 @@ export class Notification { }) public choice: number | null; + @Column({ + ...id(), + nullable: true, + comment: 'The ID of the moved to account.', + }) + public moveTargetId: User['id'] | null; + + @ManyToOne(() => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public moveTarget: User | null; + /** * アプリ通知のbody */ 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/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts index c62a24959..c1e2273be 100644 --- a/packages/backend/src/models/repositories/notification.ts +++ b/packages/backend/src/models/repositories/notification.ts @@ -44,6 +44,9 @@ export const NotificationRepository = db.getRepository(Notification).extend({ ...(notification.type === 'groupInvited' ? { invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), } : {}), + ...(notification.type === 'move' ? { + moveTarget: Users.pack(notification.moveTarget ?? notification.moveTargetId), + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader || token?.name, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 060bc0cd6..134a4dcbd 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -300,6 +300,10 @@ export const UserRepository = db.getRepository(User).extend({ }), emojis: populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), + movedTo: !user.movedToId ? undefined : this.pack(user.movedTo ?? user.movedToId, me, { + ...opts, + detail: false, + }), ...(opts.detail ? { url: profile!.url, 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/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts index 803fd03c9..17d9858e4 100644 --- a/packages/backend/src/remote/activitypub/models/person.ts +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -39,7 +39,7 @@ const summaryLength = 2048; * @param x Fetched object * @param uri Fetch target URI */ -function validateActor(x: IObject): IActor { +async function validateActor(x: IObject, resolver: Resolver): Promise { if (x == null) { throw new Error('invalid Actor: object is null'); } @@ -61,6 +61,22 @@ function validateActor(x: IObject): IActor { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } + if (x.movedTo !== undefined) { + if (!(typeof x.movedTo === 'string' && x.movedTo.length > 0)) { + throw new Error('invalid Actor: wrong movedTo'); + } + if (x.movedTo === uri) { + throw new Error('invalid Actor: moved to self'); + } + // This may throw an exception if we cannot resolve the move target. + // If we are processing an incoming activity, this is desired behaviour + // because that will cause the activity to be retried. + await resolvePerson(x.movedTo, resolver) + .then(moveTarget => { + x.movedTo = moveTarget.id + }); + } + if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) { throw new Error('invalid Actor: wrong inbox'); } @@ -137,7 +153,7 @@ export async function fetchPerson(uri: string): Promise { export async function createPerson(value: string | IObject, resolver: Resolver): Promise { const object = await resolver.resolve(value) as any; - const person = validateActor(object); + const person = await validateActor(object, resolver); apLogger.info(`Creating the Person: ${person.id}`); @@ -177,6 +193,7 @@ export async function createPerson(value: string | IObject, resolver: Resolver): isBot, isCat: (person as any).isCat === true, showTimelineReplies: false, + movedToId: person.movedTo, })) as IRemoteUser; await transactionalEntityManager.save(new UserProfile({ @@ -287,7 +304,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): const object = await resolver.resolve(value); - const person = validateActor(object); + const person = await validateActor(object, resolver); apLogger.info(`Updating the Person: ${person.id}`); @@ -328,6 +345,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, isExplorable: !!person.discoverable, + movedToId: person.movedTo, } as Partial; if (avatar) { @@ -376,7 +394,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver): * If the target Person is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it. * Fetch the person from the remote server, register it in FoundKey, and return it. */ -export async function resolvePerson(uri: string, resolver: Resolver): Promise { +export async function resolvePerson(uri: string, resolver: Resolver, hint?: IObject): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -388,7 +406,7 @@ export async function resolvePerson(uri: string, resolver: Resolver): Promise 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; diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index 43ceb38a4..197711426 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -15,6 +15,7 @@ + {{ i18n.ts.reject }} + + + + +
+
diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue index 2fcee6987..9236d1905 100644 --- a/packages/client/src/pages/user/home.vue +++ b/packages/client/src/pages/user/home.vue @@ -9,6 +9,16 @@
+ + + + + +