diff --git a/locales/index.d.ts b/locales/index.d.ts index 1772bba78..69afc0374 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1796,6 +1796,22 @@ export interface Locale extends ILocale { * モデログ */ "moderationLogs": string; + /** + * アカウント移行使用ログ + */ + "userAccountMoveLogs": string; + /** + * {from} が {to} にアカウントを移行しました + */ + "userAccountMoveLogsTitle": ParameterizedString<"from" | "to">; + /** + * 移行先のアカウントのID + */ + "movedToId": string; + /** + * 移行元のアカウントのID + */ + "moveFromId": string; /** * {n}人が投稿 */ @@ -4375,6 +4391,10 @@ export interface Locale extends ILocale { * このユーザーは新しいアカウントに移行しました: */ "accountMoved": string; + /** + * このユーザーは次のアカウントから移行されました: + */ + "accountMovedFrom": string; /** * このアカウントは移行されています */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ab2b0c749..b01bcd90a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -445,6 +445,10 @@ moderation: "モデレーション" moderationNote: "モデレーションノート" addModerationNote: "モデレーションノートを追加する" moderationLogs: "モデログ" +userAccountMoveLogs: "アカウント移行使用ログ" +userAccountMoveLogsTitle: "{from} が {to} にアカウントを移行しました" +movedToId: "移行先のアカウントのID" +moveFromId: "移行元のアカウントのID" nUsersMentioned: "{n}人が投稿" securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" @@ -1089,6 +1093,7 @@ audioFiles: "音声" dataSaver: "データセーバー" accountMigration: "アカウントの移行" accountMoved: "このユーザーは新しいアカウントに移行しました:" +accountMovedFrom: "このユーザーは次のアカウントから移行されました:" accountMovedShort: "このアカウントは移行されています" operationForbidden: "この操作はできません" forceShowAds: "常に広告を表示する" diff --git a/packages/backend/migration/1724749627479-useraccountmovelogs.js b/packages/backend/migration/1724749627479-useraccountmovelogs.js new file mode 100644 index 000000000..4b601281b --- /dev/null +++ b/packages/backend/migration/1724749627479-useraccountmovelogs.js @@ -0,0 +1,19 @@ +export class Useraccountmovelogs1724749627479 { + name = 'Useraccountmovelogs1724749627479' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_account_move_log" ("id" character varying(32) NOT NULL, "movedToId" character varying(32) NOT NULL, "movedFromId" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8ffd4ae965a5e3a0fbf4b084212" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_account_move_log"."createdAt" IS 'The created date of the UserIp.'`); + await queryRunner.query(`CREATE INDEX "IDX_d5ee7d4d1b5e7a69d8855ab069" ON "user_account_move_log" ("movedToId") `); + await queryRunner.query(`CREATE INDEX "IDX_82930731d6390e7bb429a1938f" ON "user_account_move_log" ("movedFromId") `); + await queryRunner.query(`ALTER TABLE "user_account_move_log" ADD CONSTRAINT "FK_d5ee7d4d1b5e7a69d8855ab0696" FOREIGN KEY ("movedToId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_account_move_log" ADD CONSTRAINT "FK_82930731d6390e7bb429a1938f8" FOREIGN KEY ("movedFromId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_account_move_log" DROP CONSTRAINT "FK_82930731d6390e7bb429a1938f8"`); + await queryRunner.query(`ALTER TABLE "user_account_move_log" DROP CONSTRAINT "FK_d5ee7d4d1b5e7a69d8855ab0696"`); + await queryRunner.query(`DROP INDEX "public"."IDX_82930731d6390e7bb429a1938f"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d5ee7d4d1b5e7a69d8855ab069"`); + await queryRunner.query(`DROP TABLE "user_account_move_log"`); + } +} diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index fb768bdeb..ec615fb25 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -10,7 +10,7 @@ import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UserAccountMoveLogRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -48,6 +48,15 @@ export class AccountMoveService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userAccountMoveLogRepository) + private userAccountMoveLogRepository: UserAccountMoveLogRepository, + + @Inject(DI.config) + private config: Config, + private userEntityService: UserEntityService, private idService: IdService, private apPersonService: ApPersonService, @@ -119,6 +128,8 @@ export class AccountMoveService { this.copyBlocking(src, dst), this.copyMutings(src, dst), this.updateLists(src, dst), + this.mergeModerationNote(src, dst), + this.insertAccountMoveLog(src, dst), ]); } catch { /* skip if any error happens */ @@ -256,6 +267,32 @@ export class AccountMoveService { } } + @bindThis + private async mergeModerationNote(src: ThinUser, dst: MiUser): Promise { + const srcprofile = await this.userProfilesRepository.findOneBy({ userId: src.id }); + const dstprofile = await this.userProfilesRepository.findOneBy({ userId: dst.id }); + + if (!srcprofile || !dstprofile) return; + + await this.userProfilesRepository.update({ userId: dst.id }, { + moderationNote: srcprofile.moderationNote + '\n' + dstprofile.moderationNote, + }); + + await this.userProfilesRepository.update({ userId: src.id }, { + moderationNote: srcprofile.moderationNote + '\n' + dstprofile.moderationNote, + }); + } + + @bindThis + private async insertAccountMoveLog(src: ThinUser, dst: MiUser): Promise { + await this.userAccountMoveLogRepository.insert({ + id: this.idService.gen(), + movedToId: dst.id, + movedFromId: src.id, + createdAt: new Date(), + }); + } + @bindThis private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: MiUser): Promise { if (localFollowerIds.length === 0) return; diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 3e5223944..eaa828091 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -101,6 +101,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; +import { UserAccountMoveLogEntityService } from './entities/UserAccountMoveLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; @@ -242,6 +243,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; +const $UserAccountMoveLogEntityService: Provider = { provide: 'UserAccountMoveLogEntityService', useExisting: UserAccountMoveLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; @@ -382,6 +384,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, InviteCodeEntityService, ModerationLogEntityService, + UserAccountMoveLogEntityService, MutingEntityService, RenoteMutingEntityService, NoteEntityService, @@ -518,6 +521,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $InviteCodeEntityService, $ModerationLogEntityService, + $UserAccountMoveLogEntityService, $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, @@ -654,6 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, InviteCodeEntityService, ModerationLogEntityService, + UserAccountMoveLogEntityService, MutingEntityService, RenoteMutingEntityService, NoteEntityService, @@ -789,6 +794,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $InviteCodeEntityService, $ModerationLogEntityService, + $UserAccountMoveLogEntityService, $MutingEntityService, $RenoteMutingEntityService, $NoteEntityService, diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 6a3b54306..430ec714a 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -682,10 +682,7 @@ export class ApPersonService implements OnModuleInit { // まずサーバー内で検索して様子見 let dst = await this.fetchPerson(src.movedToUri); - if (dst && this.userEntityService.isLocalUser(dst)) { - // targetがローカルユーザーだった場合データベースから引っ張ってくる - dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as MiLocalUser; - } else if (dst) { + if (dst) { if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; // targetを見つけたことがあるならtargetをupdatePersonする @@ -702,13 +699,15 @@ export class ApPersonService implements OnModuleInit { dst = await this.resolvePerson(src.movedToUri); } - if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? - if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? - if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; + const dstUri = this.userEntityService.getUserUri(dst); + const srcUri = this.userEntityService.getUserUri(src); + if (dst.movedToUri === dstUri) return 'skip: movedTo itself (dst)'; // ??? + if (src.movedToUri !== dstUri) return 'skip: missmatch uri'; // ??? + if (dst.movedToUri === srcUri) return 'skip: dst.movedToUri === src.uri'; if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { return 'skip: dst.alsoKnownAs is empty'; } - if (!dst.alsoKnownAs.includes(src.uri)) { + if (!dst.alsoKnownAs.includes(srcUri)) { return 'skip: alsoKnownAs does not include from.uri'; } diff --git a/packages/backend/src/core/entities/UserAccountMoveLogEntityService.ts b/packages/backend/src/core/entities/UserAccountMoveLogEntityService.ts new file mode 100644 index 000000000..3b0e9bf16 --- /dev/null +++ b/packages/backend/src/core/entities/UserAccountMoveLogEntityService.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiUserAccountMoveLog, UserAccountMoveLogRepository } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { MiUser } from '@/models/User.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class UserAccountMoveLogEntityService { + constructor( + @Inject(DI.userAccountMoveLogRepository) + private userAccountMoveLogRepository: UserAccountMoveLogRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + ) { + } + + @bindThis + public async pack( + src: MiUserAccountMoveLog['id'] | MiUserAccountMoveLog, + me: { id: MiUser['id'] } | null | undefined, + ) : Promise> { + const log = typeof src === 'object' ? src : await this.userAccountMoveLogRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: log.id, + createdAt: this.idService.parse(log.id).date.toISOString(), + movedFromId: log.movedFromId, + movedFrom: this.userEntityService.pack(log.movedFrom ?? log.movedFromId, me, { + schema: 'UserDetailed', + }), + movedToId: log.movedToId, + movedTo: this.userEntityService.pack(log.movedTo ?? log.movedToId, me, { + schema: 'UserDetailed', + }), + }); + } + + @bindThis + public async packMany( + reports: (MiUserAccountMoveLog['id'] | MiUserAccountMoveLog)[], + me: { id: MiUser['id'] } | null | undefined, + ) : Promise[]> { + return (await Promise.allSettled(reports.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} + diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index fb570d0b4..a70cce451 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -37,6 +37,7 @@ export const DI = { userListMembershipsRepository: Symbol('userListMembershipsRepository'), userNotePiningsRepository: Symbol('userNotePiningsRepository'), userIpsRepository: Symbol('userIpsRepository'), + userAccountMoveLogRepository: Symbol('userAccountMoveLogRepository'), usedUsernamesRepository: Symbol('usedUsernamesRepository'), followingsRepository: Symbol('followingsRepository'), followRequestsRepository: Symbol('followRequestsRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 27fd280a8..5f169c37d 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -37,6 +37,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; import { packedUserListMembershipSchema, packedUserListSchema } from '@/models/json-schema/user-list.js'; +import { packedUserAccountMoveLogSchema } from '@/models/json-schema/user-account-move-log.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; import { @@ -71,6 +72,7 @@ export const refs = { UserList: packedUserListSchema, UserListMembership: packedUserListMembershipSchema, + UserAccountMoveLog: packedUserAccountMoveLogSchema, Ad: packedAdSchema, Announcement: packedAnnouncementSchema, App: packedAppSchema, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a61094500..83eee55ff 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -73,6 +73,7 @@ import { MiUserProfile, MiUserPublickey, MiUserSecurityKey, + MiUserAccountMoveLog, MiWebhook, MiBubbleGameRecord, MiReversiGame, @@ -200,6 +201,12 @@ const $userListMembershipsRepository: Provider = { inject: [DI.db], }; +const $userAccountMoveLogRepository: Provider = { + provide: DI.userAccountMoveLogRepository, + useFactory: (db: DataSource) => db.getRepository(MiUserAccountMoveLog), + inject: [DI.db], +}; + const $userNotePiningsRepository: Provider = { provide: DI.userNotePiningsRepository, useFactory: (db: DataSource) => db.getRepository(MiUserNotePining), @@ -524,6 +531,7 @@ const $abuseReportResolversRepository: Provider = { $userListsRepository, $userListFavoritesRepository, $userListMembershipsRepository, + $userAccountMoveLogRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, @@ -596,6 +604,7 @@ const $abuseReportResolversRepository: Provider = { $userListsRepository, $userListFavoritesRepository, $userListMembershipsRepository, + $userAccountMoveLogRepository, $userNotePiningsRepository, $userIpsRepository, $usedUsernamesRepository, diff --git a/packages/backend/src/models/UserAccountMoveLog.ts b/packages/backend/src/models/UserAccountMoveLog.ts new file mode 100644 index 000000000..6e1869c01 --- /dev/null +++ b/packages/backend/src/models/UserAccountMoveLog.ts @@ -0,0 +1,35 @@ +import { Entity, Index, Column, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('user_account_move_log') +export class MiUserAccountMoveLog { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public movedToId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public movedTo: MiUser | null; + + @Index() + @Column(id()) + public movedFromId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public movedFrom: MiUser | null; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserIp.', + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index ca1410c24..800af98b1 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -63,6 +63,7 @@ import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiUserAccountMoveLog } from '@/models/UserAccountMoveLog.js'; import { MiWebhook } from '@/models/Webhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; @@ -146,6 +147,7 @@ export { MiUserMemo, MiBubbleGameRecord, MiReversiGame, + MiUserAccountMoveLog, }; export type AbuseReportResolversRepository = Repository; @@ -208,6 +210,7 @@ export type UserPendingsRepository = Repository; export type UserProfilesRepository = Repository; export type UserPublickeysRepository = Repository; export type UserSecurityKeysRepository = Repository; +export type UserAccountMoveLogRepository = Repository; export type WebhooksRepository = Repository; export type ChannelsRepository = Repository; export type RetentionAggregationsRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user-account-move-log.ts b/packages/backend/src/models/json-schema/user-account-move-log.ts new file mode 100644 index 000000000..ad0f6bc3c --- /dev/null +++ b/packages/backend/src/models/json-schema/user-account-move-log.ts @@ -0,0 +1,36 @@ +export const packedUserAccountMoveLogSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + movedToId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + movedTo: { + type: 'object', + ref: 'UserDetailed', + optional: false, nullable: false, + }, + movedFromId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + movedFrom: { + type: 'object', + ref: 'UserDetailed', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index dfa5d86e1..58a40ae3c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -73,6 +73,7 @@ import { MiUserPending } from '@/models/UserPending.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; +import { MiUserAccountMoveLog } from '@/models/UserAccountMoveLog.js'; import { MiWebhook } from '@/models/Webhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; @@ -153,6 +154,7 @@ export const entities = [ MiUserListMembership, MiUserNotePining, MiUserSecurityKey, + MiUserAccountMoveLog, MiUsedUsername, MiFollowing, MiFollowRequest, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c7670bd0f..1b669a31c 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -77,6 +77,7 @@ import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-ab import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; +import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user-account-move-logs.js'; import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; @@ -468,6 +469,7 @@ const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abu const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; +const $admin_showUserAccountMoveLogs: Provider = { provide: 'ep:admin/show-user-account-move-logs', useClass: ep___admin_showUserAccountMoveLogs.default }; const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; @@ -863,6 +865,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, + $admin_showUserAccountMoveLogs, $admin_showUser, $admin_showUsers, $admin_suspendUser, @@ -1252,6 +1255,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_sendEmail, $admin_serverInfo, $admin_showModerationLogs, + $admin_showUserAccountMoveLogs, $admin_showUser, $admin_showUsers, $admin_suspendUser, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 298796002..0dc4d9ffb 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -77,6 +77,7 @@ import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-ab import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; +import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user-account-move-logs.js'; import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; @@ -466,6 +467,7 @@ const eps = [ ['admin/send-email', ep___admin_sendEmail], ['admin/server-info', ep___admin_serverInfo], ['admin/show-moderation-logs', ep___admin_showModerationLogs], + ['admin/show-user-account-move-logs', ep___admin_showUserAccountMoveLogs], ['admin/show-user', ep___admin_showUser], ['admin/show-users', ep___admin_showUsers], ['admin/suspend-user', ep___admin_suspendUser], diff --git a/packages/backend/src/server/api/endpoints/admin/show-user-account-move-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-user-account-move-logs.ts new file mode 100644 index 000000000..46e4bd8cc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-user-account-move-logs.ts @@ -0,0 +1,94 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import type { UserAccountMoveLogRepository } from '@/models/_.js'; +import { UserAccountMoveLogEntityService } from '@/core/entities/UserAccountMoveLogEntityService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'read:admin:show-account-move-log', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + movedToId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + movedTo: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + movedFromId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + movedFrom: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + movedFromId: { type: 'string', format: 'misskey:id', nullable: true }, + movedToId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userAccountMoveLogRepository) + private userAccountMoveLogRepository: UserAccountMoveLogRepository, + + private userAccountMoveLogEntityService: UserAccountMoveLogEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.userAccountMoveLogRepository.createQueryBuilder('accountMoveLogs'), ps.sinceId, ps.untilId); + + if (ps.movedFromId != null) { + query.andWhere('accountMoveLogs.movedFromId = :movedFromId', { movedFromId: ps.movedFromId }); + } + + if (ps.movedToId != null) { + query.andWhere('accountMoveLogs.movedToId = :movedToId', { movedToId: ps.movedToId }); + } + + const accountMoveLogs = await query.limit(ps.limit).getMany(); + + return await this.userAccountMoveLogEntityService.packMany(accountMoveLogs, me); + }); + } +} diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index 6c0774b63..594e5ab7e 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -22,10 +23,11 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; const user = ref(); const props = defineProps<{ - movedTo: string; // user id + movedTo?: string; // user id + movedFrom?: string; // user id }>(); -misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u); +misskeyApi('users/show', { userId: props.movedTo ?? props.movedFrom }).then(u => user.value = u); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 42e6bd9fd..2cd06bb7b 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -280,6 +281,7 @@ const memoDraft = ref(props.user.memo); const isEditingMemo = ref(false); const moderationNote = ref(props.user.moderationNote); const editModerationNote = ref(false); +const movedFromLog = ref(null); watch(moderationNote, async () => { await misskeyApi('admin/update-user-note', { userId: props.user.id, text: moderationNote.value }); @@ -301,6 +303,15 @@ function menu(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } +async function fetchMovedFromLog() { + if (!props.user.id) { + movedFromLog.value = null; + return; + } + + movedFromLog.value = await misskeyApi('admin/show-user-account-move-logs', { movedToId: props.user.id }); +} + function parallaxLoop() { parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop); parallax(); @@ -377,6 +388,9 @@ function buildSkebStatus(): string { watch([props.user], () => { memoDraft.value = props.user.memo; fetchSkebStatus(); + if ($i?.isModerator) { + fetchMovedFromLog(); + } }); onMounted(() => { @@ -395,6 +409,9 @@ onMounted(() => { } } fetchSkebStatus(); + if ($i?.isModerator) { + fetchMovedFromLog(); + } nextTick(() => { adjustMemoTextarea(); }); diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index e7f426bf2..456baaf78 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -426,6 +426,10 @@ const routes: RouteDef[] = [{ path: '/modlog', name: 'modlog', component: page(() => import('@/pages/admin/modlog.vue')), + }, { + path: '/useraccountmovelog', + name: 'useraccountmovelog', + component: page(() => import('@/pages/admin/useraccountmovelog.vue')), }, { path: '/settings', name: 'settings', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index acb3973df..48b0369cc 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -337,6 +337,12 @@ type AdminShowModerationLogsRequest = operations['admin___show-moderation-logs'] // @public (undocumented) type AdminShowModerationLogsResponse = operations['admin___show-moderation-logs']['responses']['200']['content']['application/json']; +// @public (undocumented) +type AdminShowUserAccountMoveLogsRequest = operations['admin___show-user-account-move-logs']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminShowUserAccountMoveLogsResponse = operations['admin___show-user-account-move-logs']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminShowUserRequest = operations['admin___show-user']['requestBody']['content']['application/json']; @@ -1292,6 +1298,8 @@ declare namespace entities { AdminServerInfoResponse, AdminShowModerationLogsRequest, AdminShowModerationLogsResponse, + AdminShowUserAccountMoveLogsRequest, + AdminShowUserAccountMoveLogsResponse, AdminShowUserRequest, AdminShowUserResponse, AdminShowUsersRequest, @@ -1789,6 +1797,7 @@ declare namespace entities { User, UserList, UserListMembership, + UserAccountMoveLog, Ad, Announcement, App, @@ -2758,7 +2767,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content'] function parse(acct: string): Acct; // @public (undocumented) -export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unset-user-mutual-link", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; +export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unset-user-mutual-link", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; // @public (undocumented) type PingResponse = operations['ping']['responses']['200']['content']['application/json']; @@ -3057,6 +3066,9 @@ function toString_2(acct: Acct): string; // @public (undocumented) type User = components['schemas']['User']; +// @public (undocumented) +type UserAccountMoveLog = components['schemas']['UserAccountMoveLog']; + // @public (undocumented) type UserDetailed = components['schemas']['UserDetailed']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 60d8d6ccf..b58577288 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -785,6 +785,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 59aa29aa7..e4caf32ed 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -94,6 +94,8 @@ import type { AdminServerInfoResponse, AdminShowModerationLogsRequest, AdminShowModerationLogsResponse, + AdminShowUserAccountMoveLogsRequest, + AdminShowUserAccountMoveLogsResponse, AdminShowUserRequest, AdminShowUserResponse, AdminShowUsersRequest, @@ -655,6 +657,7 @@ export type Endpoints = { 'admin/send-email': { req: AdminSendEmailRequest; res: EmptyResponse }; 'admin/server-info': { req: EmptyRequest; res: AdminServerInfoResponse }; 'admin/show-moderation-logs': { req: AdminShowModerationLogsRequest; res: AdminShowModerationLogsResponse }; + 'admin/show-user-account-move-logs': { req: AdminShowUserAccountMoveLogsRequest; res: AdminShowUserAccountMoveLogsResponse }; 'admin/show-user': { req: AdminShowUserRequest; res: AdminShowUserResponse }; 'admin/show-users': { req: AdminShowUsersRequest; res: AdminShowUsersResponse }; 'admin/suspend-user': { req: AdminSuspendUserRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index e9ee6a253..26da94dcb 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -97,6 +97,8 @@ export type AdminSendEmailRequest = operations['admin___send-email']['requestBod export type AdminServerInfoResponse = operations['admin___server-info']['responses']['200']['content']['application/json']; export type AdminShowModerationLogsRequest = operations['admin___show-moderation-logs']['requestBody']['content']['application/json']; export type AdminShowModerationLogsResponse = operations['admin___show-moderation-logs']['responses']['200']['content']['application/json']; +export type AdminShowUserAccountMoveLogsRequest = operations['admin___show-user-account-move-logs']['requestBody']['content']['application/json']; +export type AdminShowUserAccountMoveLogsResponse = operations['admin___show-user-account-move-logs']['responses']['200']['content']['application/json']; export type AdminShowUserRequest = operations['admin___show-user']['requestBody']['content']['application/json']; export type AdminShowUserResponse = operations['admin___show-user']['responses']['200']['content']['application/json']; export type AdminShowUsersRequest = operations['admin___show-users']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index 6a8eccbf4..e60fe35aa 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -9,6 +9,7 @@ export type UserDetailed = components['schemas']['UserDetailed']; export type User = components['schemas']['User']; export type UserList = components['schemas']['UserList']; export type UserListMembership = components['schemas']['UserListMembership']; +export type UserAccountMoveLog = components['schemas']['UserAccountMoveLog']; export type Ad = components['schemas']['Ad']; export type Announcement = components['schemas']['Announcement']; export type App = components['schemas']['App']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index ff91ef10f..92b3b7769 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -652,6 +652,15 @@ export type paths = { */ post: operations['admin___show-moderation-logs']; }; + '/admin/show-user-account-move-logs': { + /** + * admin/show-user-account-move-logs + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log* + */ + post: operations['admin___show-user-account-move-logs']; + }; '/admin/show-user': { /** * admin/show-user @@ -4078,6 +4087,21 @@ export type components = { user: components['schemas']['UserLite']; withReplies: boolean; }; + UserAccountMoveLog: { + /** + * Format: id + * @example xxxxxxxxxx + */ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: id */ + movedToId: string; + movedTo: components['schemas']['UserDetailed']; + /** Format: id */ + movedFromId: string; + movedFrom: components['schemas']['UserDetailed']; + }; Ad: { /** * Format: id @@ -9440,6 +9464,79 @@ export type operations = { }; }; }; + /** + * admin/show-user-account-move-logs + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:show-account-move-log* + */ + 'admin___show-user-account-move-logs': { + requestBody: { + content: { + 'application/json': { + /** @default 10 */ + limit?: number; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** Format: misskey:id */ + movedFromId?: string | null; + /** Format: misskey:id */ + movedToId?: string | null; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: id */ + movedToId: string; + movedTo: components['schemas']['UserDetailed']; + /** Format: id */ + movedFromId: string; + movedFrom: components['schemas']['UserDetailed']; + }[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/show-user * @description No description provided. diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index abc03ca5c..5740c9065 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -57,6 +57,7 @@ export const permissions = [ 'write:admin:send-email', 'read:admin:server-info', 'read:admin:show-moderation-log', + 'read:admin:show-account-move-log', 'read:admin:show-user', 'read:admin:show-users', 'write:admin:suspend-user',