diff --git a/locales/en-US.yml b/locales/en-US.yml index 772f002e07..104dcadcfc 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1691,6 +1691,7 @@ _role: canCreateContent: "Can create contents" canUpdateContent: "Can edit contents" canDeleteContent: "Can delete contents" + canPurgeAccount: "Can delete account completely" canUpdateAvatar: "Can change avatar" canUpdateBanner: "Can change banner" mentionMax: "Maximum number of mentions in a note" diff --git a/locales/index.d.ts b/locales/index.d.ts index a02c88697b..80d6334e20 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6594,6 +6594,10 @@ export interface Locale extends ILocale { * コンテンツの削除 */ "canDeleteContent": string; + /** + * 完全なアカウントの削除 + */ + "canPurgeAccount": string; /** * アイコンの変更 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a049169f76..02d38f0868 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1706,6 +1706,7 @@ _role: canCreateContent: "コンテンツの作成" canUpdateContent: "コンテンツの編集" canDeleteContent: "コンテンツの削除" + canPurgeAccount: "完全なアカウントの削除" canUpdateAvatar: "アイコンの変更" canUpdateBanner: "バナーの変更" mentionMax: "ノート内の最大メンション数" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 955d7c3c76..3b186bf99c 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1674,6 +1674,7 @@ _role: canCreateContent: "컨텐츠 생성 허용" canUpdateContent: "컨텐츠 수정 허용" canDeleteContent: "컨텐츠 삭제 허용" + canPurgeAccount: "완전한 계정 삭제 허용" canUpdateAvatar: "아바타 변경 허용" canUpdateBanner: "배너 변경 허용" canInvite: "서버 초대 코드 발행" diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 79b614edba..385f54b42b 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -4,38 +4,47 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; import type { UsersRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import { RoleService } from '@/core/RoleService.js'; import { QueueService } from '@/core/QueueService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { LoggerService } from '@/core/LoggerService.js'; @Injectable() export class DeleteAccountService { + public logger: Logger; + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private userSuspendService: UserSuspendService, + private roleService: RoleService, private queueService: QueueService, + private userSuspendService: UserSuspendService, private globalEventService: GlobalEventService, + private loggerService: LoggerService, ) { + this.logger = this.loggerService.getLogger('delete-account'); } @bindThis - public async deleteAccount(user: { - id: string; - host: string | null; - }): Promise { + public async deleteAccount(user: MiUser, soft: boolean, me: MiUser | null): Promise { + this.logger.warn(`Delete account requested by ${me ? me.id : 'remote'} for ${user.id} (soft: ${soft})`); + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); if (_user.isRoot) throw new Error('cannot delete a root account'); // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(e => {}); + await this.userSuspendService.doPostSuspend(user).catch(err => this.logger.error(err)); this.queueService.createDeleteAccountJob(user, { - soft: false, + force: me ? await this.roleService.isModerator(me) : false, + soft: soft, }); await this.usersRepository.update(user.id, { @@ -44,4 +53,16 @@ export class DeleteAccountService { this.globalEventService.publishInternalEvent('userChangeDeletedState', { id: user.id, isDeleted: true }); } + + @bindThis + public async deleteAllDriveFiles(user: MiUser, me: MiUser | null): Promise { + this.logger.warn(`Delete all drive files requested by ${me ? me.id : 'remote'} for ${user.id}`); + + await this.usersRepository.findOneByOrFail({ id: user.id }); + + this.queueService.createDeleteAccountJob(user, { + force: me ? await this.roleService.isModerator(me) : false, + onlyFiles: true, + }); + } } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index b645fa8470..7c92dd4021 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -352,10 +352,12 @@ export class QueueService { } @bindThis - public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { + public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean, force?: boolean, onlyFiles?: boolean } = {}) { return this.dbQueue.add('deleteAccount', { user: { id: user.id }, soft: opts.soft, + force: opts.force, + onlyFiles: opts.onlyFiles, }, { removeOnComplete: true, removeOnFail: true, diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b0217679ed..7607c711af 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -40,6 +40,7 @@ export type RolePolicies = { canCreateContent: boolean; canUpdateContent: boolean; canDeleteContent: boolean; + canPurgeAccount: boolean; canUpdateAvatar: boolean; canUpdateBanner: boolean; mentionLimit: number; @@ -77,6 +78,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canCreateContent: true, canUpdateContent: true, canDeleteContent: true, + canPurgeAccount: true, canUpdateAvatar: true, canUpdateBanner: true, mentionLimit: 20, @@ -353,6 +355,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)), canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)), canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)), + canPurgeAccount: calc('canPurgeAccount', vs => vs.some(v => v === true)), canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)), canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)), mentionLimit: calc('mentionLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e27797fca6..fd9c7c2ba0 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -487,7 +487,8 @@ export class ApInboxService { return 'skip: already deleted'; } - const job = await this.queueService.createDeleteAccountJob(actor); + // リモートから消されたということなので、物理削除する + const job = await this.queueService.createDeleteAccountJob(actor, { force: true, soft: false }); await this.usersRepository.update(actor.id, { isDeleted: true, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index e757d109e8..0708142110 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -179,6 +179,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canPurgeAccount: { + type: 'boolean', + optional: false, nullable: false, + }, canUpdateAvatar: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 660e1d1ca4..08dc31e546 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -4,14 +4,15 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; -import type Logger from '@/logger.js'; -import { DriveService } from '@/core/DriveService.js'; -import type { MiUser } from '@/models/User.js'; -import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import { DriveService } from '@/core/DriveService.js'; +import { EmailService } from '@/core/EmailService.js'; import { SearchService } from '@/core/SearchService.js'; +import { RoleService } from '@/core/RoleService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; @@ -35,8 +36,9 @@ export class DeleteAccountProcessorService { private driveService: DriveService, private emailService: EmailService, - private queueLoggerService: QueueLoggerService, private searchService: SearchService, + private roleService: RoleService, + private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -87,19 +89,32 @@ export class DeleteAccountProcessorService { @bindThis public async process(job: Bull.Job): Promise { - this.logger.info(`Deleting account of ${job.data.user.id} ...`); + this.logger.info(`Deleting account of ${job.data.user.id} ...`, { userDeleteJobData: job.data }); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); if (user == null) { return; } - await Promise.all([ - this.deleteNotes(user), - this.deleteFiles(user), - ]); + const { canDeleteContent, canPurgeAccount } = !job.data.force + ? await this.roleService.getUserPolicies(user.id) + : { canDeleteContent: true, canPurgeAccount: true }; - { // Send email notification + if (job.data.onlyFiles) { + if (!canDeleteContent) return 'Permission denied'; + + await this.deleteFiles(user); + return 'Files deleted'; + } + + if (canDeleteContent) { + await Promise.all([ + this.deleteNotes(user), + this.deleteFiles(user), + ]); + } + + if (user.token) { // Send email notification const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.email && profile.emailVerified) { await this.emailService.sendEmail(profile.email, 'Account deleted', @@ -108,9 +123,13 @@ export class DeleteAccountProcessorService { } } - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop + // 制限されている もしくは soft指定されている場合は物理削除しない、代わりに凍結+削除フラグを立てる + if (!(canDeleteContent && canPurgeAccount) || job.data.soft) { + await this.usersRepository.update(user.id, { + token: null, + isSuspended: true, + isDeleted: true, + }); } else { await this.usersRepository.delete(job.data.user.id); } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 081bdd503b..9541689640 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -79,6 +79,8 @@ export type DBExportAntennasData = { export type DbUserDeleteJobData = { user: ThinUser; soft?: boolean; + force?: boolean; + onlyFiles?: boolean; }; export type DbUserImportJobData = { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index b29717f341..313d2cd042 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -27,11 +27,11 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; +import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js'; import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; @@ -79,7 +79,6 @@ import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; @@ -413,11 +412,11 @@ const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-de const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; -const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; +const $admin_drive_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/drive/delete-all-files-of-a-user', useClass: ep___admin_drive_deleteAllFilesOfAUser.default }; const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default }; const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default }; const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default }; @@ -465,7 +464,6 @@ const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: e const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; -const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; const $admin_roles_create: Provider = { provide: 'ep:admin/roles/create', useClass: ep___admin_roles_create.default }; const $admin_roles_delete: Provider = { provide: 'ep:admin/roles/delete', useClass: ep___admin_roles_delete.default }; @@ -803,11 +801,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, + $admin_drive_deleteAllFilesOfAUser, $admin_drive_files, $admin_drive_showFile, $admin_emoji_addAliasesBulk, @@ -855,7 +853,6 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_suspendUser, $admin_unsuspendUser, $admin_updateMeta, - $admin_deleteAccount, $admin_updateUserNote, $admin_roles_create, $admin_roles_delete, @@ -1187,11 +1184,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_avatarDecorations_delete, $admin_avatarDecorations_list, $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, $admin_unsetUserAvatar, $admin_unsetUserBanner, $admin_drive_cleanRemoteFiles, $admin_drive_cleanup, + $admin_drive_deleteAllFilesOfAUser, $admin_drive_files, $admin_drive_showFile, $admin_emoji_addAliasesBulk, @@ -1239,7 +1236,6 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_suspendUser, $admin_unsuspendUser, $admin_updateMeta, - $admin_deleteAccount, $admin_updateUserNote, $admin_roles_create, $admin_roles_delete, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index c671d42932..2d9195bf02 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -124,6 +124,13 @@ export class SigninApiService { }); } + if (user.isDeleted && user.isSuspended) { + logger.error('No such user. (logical deletion)'); + return error(404, { + id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + }); + } + if (user.isSuspended) { logger.error('User is suspended.'); return error(403, { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f39bae4f0f..4160490e62 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -27,11 +27,11 @@ import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-d import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; +import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js'; import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; @@ -79,7 +79,6 @@ import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; @@ -411,11 +410,11 @@ const eps = [ ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], - ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], ['admin/unset-user-banner', ep___admin_unsetUserBanner], ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], ['admin/drive/cleanup', ep___admin_drive_cleanup], + ['admin/drive/delete-all-files-of-a-user', ep___admin_drive_deleteAllFilesOfAUser], ['admin/drive/files', ep___admin_drive_files], ['admin/drive/show-file', ep___admin_drive_showFile], ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], @@ -463,7 +462,6 @@ const eps = [ ['admin/suspend-user', ep___admin_suspendUser], ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], - ['admin/delete-account', ep___admin_deleteAccount], ['admin/update-user-note', ep___admin_updateUserNote], ['admin/roles/create', ep___admin_roles_create], ['admin/roles/delete', ep___admin_roles_delete], diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 4074e416b8..ed7a1424dd 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -5,11 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; -import { QueueService } from '@/core/QueueService.js'; -import { UserSuspendService } from '@/core/UserSuspendService.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { UsersRepository } from '@/models/_.js'; +import { RoleService } from '@/core/RoleService.js'; +import { DeleteAccountService } from '@/core/DeleteAccountService.js'; +import { ApiError } from '@/server/api/error.js'; export const meta = { tags: ['admin'], @@ -17,6 +17,20 @@ export const meta = { requireCredential: true, requireAdmin: true, kind: 'write:admin:account', + + errors: { + userNotFound: { + message: 'User not found.', + code: 'USER_NOT_FOUND', + id: '6c45276a-525e-46b0-892f-17a5036258bf', + }, + + cannotDeleteModerator: { + message: 'Cannot delete a moderator.', + code: 'CANNOT_DELETE_MODERATOR', + id: 'd195c621-f21a-4c2f-a634-484c2a616311', + }, + }, } as const; export const paramDef = { @@ -33,37 +47,17 @@ export default class extends Endpoint { // eslint- @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private userEntityService: UserEntityService, - private queueService: QueueService, - private userSuspendService: UserSuspendService, + private roleService: RoleService, + private deleteAccountService: DeleteAccountService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); - if (user == null) { - throw new Error('user not found'); - } + if (user == null) throw new ApiError(meta.errors.userNotFound); + if (await this.roleService.isModerator(user)) throw new ApiError(meta.errors.cannotDeleteModerator); - if (user.isRoot) { - throw new Error('cannot delete a root account'); - } - - if (this.userEntityService.isLocalUser(user)) { - // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(err => {}); - - this.queueService.createDeleteAccountJob(user, { - soft: false, - }); - } else { - this.queueService.createDeleteAccountJob(user, { - soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - }); - } - - await this.usersRepository.update(user.id, { - isDeleted: true, - }); + // 管理者からの削除ということはモデレーション行為なので、soft delete にする + await this.deleteAccountService.deleteAccount(user, true, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts deleted file mode 100644 index d8341b3ad7..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DriveFilesRepository } from '@/models/_.js'; -import { DriveService } from '@/core/DriveService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireAdmin: true, - kind: 'write:admin:delete-all-files-of-a-user', -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -@Injectable() -export default class extends Endpoint { // eslint-disable-line import/no-default-export - constructor( - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - private driveService: DriveService, - ) { - super(meta, paramDef, async (ps, me) => { - const files = await this.driveFilesRepository.findBy({ - userId: ps.userId, - }); - - for (const file of files) { - this.driveService.deleteFile(file); - } - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/drive/delete-all-files-of-a-user.ts similarity index 83% rename from packages/backend/src/server/api/endpoints/admin/delete-account.ts rename to packages/backend/src/server/api/endpoints/admin/drive/delete-all-files-of-a-user.ts index b6f0f22d60..7f211d027d 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/delete-all-files-of-a-user.ts @@ -4,17 +4,17 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { DeleteAccountService } from '@/core/DeleteAccountService.js'; import { DI } from '@/di-symbols.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DeleteAccountService } from '@/core/DeleteAccountService.js'; export const meta = { tags: ['admin'], requireCredential: true, - requireAdmin: true, - kind: 'write:admin:delete-account', + requireModerator: true, + kind: 'write:admin:drive', } as const; export const paramDef = { @@ -33,13 +33,10 @@ export default class extends Endpoint { // eslint- private deleteAccountService: DeleteAccountService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); - if (user.isDeleted) { - return; - } - await this.deleteAccountService.deleteAccount(user); + await this.deleteAccountService.deleteAllDriveFiles(user, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index b9cc7a555d..f1818086ae 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -115,6 +115,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isLimited: { + type: 'boolean', + optional: false, nullable: false, + }, + isDeleted: { + type: 'boolean', + optional: false, nullable: false, + }, isSuspended: { type: 'boolean', optional: false, nullable: false, @@ -242,6 +250,7 @@ export default class extends Endpoint { // eslint- isModerator: isModerator, isSilenced: isSilenced, isLimited: isLimited, + isDeleted: user.isDeleted, isSuspended: user.isSuspended, isHibernated: user.isHibernated, lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null, diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index eab3e29a14..70037b5dda 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -14,7 +14,6 @@ import { ApiError } from '@/server/api/error.js'; export const meta = { requireCredential: true, - requireRolePolicy: 'canDeleteContent', secure: true, @@ -76,7 +75,7 @@ export default class extends Endpoint { // eslint- await this.userAuthService.twoFactorAuthenticate(profile, token); } - await this.deleteAccountService.deleteAccount(me); + await this.deleteAccountService.deleteAccount(me, false, me); }); } } diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index c3d34eaf48..34d5b4739c 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -104,7 +104,7 @@ describe('アンテナ', () => { await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); await post(userDeletedByAdmin, { text: 'test' }); - await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + await api('admin/accounts/delete', { userId: userDeletedByAdmin.id }, root); userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); await post(userFollowedByAlice, { text: 'test' }); await api('following/create', { userId: userFollowedByAlice.id }, alice); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 79b5b66d9b..f56b73b240 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -246,7 +246,7 @@ describe('ユーザー', () => { await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); await post(userDeletedByAdmin, { text: 'test' }); - await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + await api('admin/accounts/delete', { userId: userDeletedByAdmin.id }, root); userFollowingAlice = await signup({ username: 'userFollowingAlice' }); await post(userFollowingAlice, { text: 'test' }); await api('following/create', { userId: alice.id }, userFollowingAlice); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 4ead125398..6522bb42b7 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -108,6 +108,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi isMuted: false, isSilenced: false, isLimited: false, + isDeleted: false, isSuspended: false, lang: 'en', location: 'Fediverse', diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 9ce2ceddf0..8521e2a0e6 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -79,6 +79,7 @@ export const ROLE_POLICIES = [ 'canCreateContent', 'canUpdateContent', 'canDeleteContent', + 'canPurgeAccount', 'canUpdateAvatar', 'canUpdateBanner', 'mentionLimit', diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index e768bd4474..403c6619ba 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -15,10 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only @{{ acct(user) }} - Suspended - Limited - Silenced + Admin Moderator + Silenced + Limited + Suspended + Deleted @@ -32,12 +34,6 @@ SPDX-License-Identifier: AGPL-3.0-only - @@ -50,48 +46,16 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + - -
{{ i18n.ts.suspend }} @@ -123,8 +87,8 @@ SPDX-License-Identifier: AGPL-3.0-only -
- {{ i18n.ts.unsetUserAvatar }} +
+ {{ i18n.ts.unsetUserAvatar }} {{ i18n.ts.unsetUserBanner }}
{{ i18n.ts.deleteAccount }} @@ -172,6 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+ {{ i18n.ts.deleteAllFiles }}
@@ -191,6 +156,36 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+ + + + + + + + + + + + + + + + +
+ + {{ i18n.ts.updateRemoteUser }} + + + + + + + +
+
@@ -243,10 +238,12 @@ const init = ref>(); const info = ref(); const ips = ref(null); const ap = ref(null); +const admin = ref(false); const moderator = ref(false); const silenced = ref(false); const limited = ref(false); const suspended = ref(false); +const deleted = ref(false); const moderationNote = ref(''); const filesPagination = { endpoint: 'admin/drive/files' as const, @@ -277,15 +274,18 @@ function createFetcher() { user.value = _user; info.value = _info; ips.value = _ips; + admin.value = info.value.isAdmin; moderator.value = info.value.isModerator; silenced.value = info.value.isSilenced; limited.value = info.value.isLimited; suspended.value = info.value.isSuspended; + deleted.value = info.value.isDeleted; moderationNote.value = info.value.moderationNote; watch(moderationNote, async () => { - await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); - await refreshUser(); + await misskeyApi('admin/update-user-note', { + userId: user.value.id, text: moderationNote.value + }).then(refreshUser); }); }); } @@ -295,8 +295,9 @@ function refreshUser() { } async function updateRemoteUser() { - await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); - refreshUser(); + await os.apiWithDialog('federation/update-remote-user', { + userId: user.value.id + }).then(refreshUser); } async function resetPassword() { @@ -325,8 +326,9 @@ async function toggleSuspend(v) { if (confirm.canceled) { suspended.value = !v; } else { - await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); - await refreshUser(); + await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { + userId: user.value.id + }).then(refreshUser); } } @@ -336,17 +338,10 @@ async function unsetUserAvatar() { text: i18n.ts.unsetUserAvatarConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-avatar', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); - }); - refreshUser(); + + await os.apiWithDialog('admin/unset-user-avatar', { + userId: user.value.id + }).then(refreshUser); } async function unsetUserBanner() { @@ -355,17 +350,10 @@ async function unsetUserBanner() { text: i18n.ts.unsetUserBannerConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-banner', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); - }); - refreshUser(); + + await os.apiWithDialog('admin/unset-user-banner', { + userId: user.value.id + }).then(refreshUser); } async function deleteAllFiles() { @@ -374,17 +362,22 @@ async function deleteAllFiles() { text: i18n.ts.deleteAllFilesConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/delete-all-files-of-a-user', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { + + const typed = await os.inputText({ + text: i18n.tsx.typeToConfirm({ x: user.value?.username }), + }); + if (typed.canceled) return; + + if (typed.result === user.value?.username) { + await os.apiWithDialog('admin/drive/delete-all-files-of-a-user', { + userId: user.value.id + }).then(refreshUser); + } else { os.alert({ type: 'error', - text: err.toString(), + text: 'input not match', }); - }); - await refreshUser(); + } } async function deleteAccount() { @@ -400,9 +393,9 @@ async function deleteAccount() { if (typed.canceled) return; if (typed.result === user.value?.username) { - await os.apiWithDialog('admin/delete-account', { + await os.apiWithDialog('admin/accounts/delete', { userId: user.value.id, - }); + }).then(refreshUser); } else { os.alert({ type: 'error', @@ -444,8 +437,9 @@ async function assignRole() { : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) : null; - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt }); - refreshUser(); + await os.apiWithDialog('admin/roles/assign', { + roleId, userId: user.value.id, expiresAt + }).then(refreshUser); } async function unassignRole(role, ev) { @@ -454,8 +448,9 @@ async function unassignRole(role, ev) { icon: 'ti ti-x', danger: true, action: async () => { - await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.value.id }); - refreshUser(); + await os.apiWithDialog('admin/roles/unassign', { + roleId: role.id, userId: user.value.id + }).then(refreshUser); }, }], ev.currentTarget ?? ev.target); } @@ -525,6 +520,10 @@ const headerTabs = computed(() => [{ key: 'chart', title: i18n.ts.charts, icon: 'ti ti-chart-line', +}, { + key: 'activitypub', + title: 'ActivityPub', + icon: 'ti ti-share', }, { key: 'raw', title: 'Raw', @@ -581,7 +580,12 @@ definePageMetadata(() => ({ display: none; } - > .suspended, > .limited, > .silenced, > .moderator { + > .admin, + > .moderator, + > .silenced, + > .limited, + > .suspended, + > .deleted { display: inline-block; border: solid 1px; border-radius: 6px; @@ -589,14 +593,14 @@ definePageMetadata(() => ({ font-size: 85%; } - > .suspended { - color: var(--error); - border-color: var(--error); + > .admin { + color: var(--success); + border-color: var(--success); } - > .limited { - color: var(--error); - border-color: var(--error); + > .moderator { + color: var(--success); + border-color: var(--success); } > .silenced { @@ -604,9 +608,19 @@ definePageMetadata(() => ({ border-color: var(--warn); } - > .moderator { - color: var(--success); - border-color: var(--success); + > .limited { + color: var(--error); + border-color: var(--error); + } + + > .suspended { + color: var(--error); + border-color: var(--error); + } + + > .deleted { + color: var(--error); + border-color: var(--error); } } } diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index ee6ea14464..7143bc4de6 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -240,6 +240,26 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + +
+ + + + + + + + + +
+
+