From fd1ef4a62d670aab5f0c0089ab3806639c779813 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 21 Aug 2021 12:41:56 +0900 Subject: [PATCH] enhance(server): Use job queue for account delete (#7668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(server): Use job queue for account delete Fix #5336 * ジョブをひとつに * remove done call * clean up * add User.isDeleted * コミット忘れ * Update 1629512953000-user-is-deleted.ts * show dialog * lint * Update 1629512953000-user-is-deleted.ts --- CHANGELOG.md | 1 + locales/ja-JP.yml | 1 + migration/1629512953000-user-is-deleted.ts | 15 ++++ src/client/account.ts | 1 + src/client/init.ts | 7 ++ src/models/entities/user.ts | 7 ++ src/models/repositories/user.ts | 1 + src/queue/index.ts | 9 +++ src/queue/processors/db/delete-account.ts | 79 ++++++++++++++++++++ src/queue/processors/db/index.ts | 4 +- src/server/api/endpoints/i/delete-account.ts | 13 +++- 11 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 migration/1629512953000-user-is-deleted.ts create mode 100644 src/queue/processors/db/delete-account.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f3add690..54c0554e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ## 12.x.x (unreleased) ### Improvements +- アカウント削除の安定性を向上 - 絵文字オートコンプリートの挙動を改修 - localStorageのaccountsはindexedDBで保持するように - ActivityPub: ジョブキューの試行タイミングを調整 (#7635) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7499523b08..f27fc0abe0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -777,6 +777,7 @@ misskeyUpdated: "Misskeyが更新されました!" whatIsNew: "更新情報を見る" translate: "翻訳" translatedFrom: "{x}から翻訳" +accountDeletionInProgress: "アカウントの削除が進行中です" _docs: continueReading: "続きを読む" diff --git a/migration/1629512953000-user-is-deleted.ts b/migration/1629512953000-user-is-deleted.ts new file mode 100644 index 0000000000..10b7d1d7b7 --- /dev/null +++ b/migration/1629512953000-user-is-deleted.ts @@ -0,0 +1,15 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class isUserDeleted1629512953000 implements MigrationInterface { + name = 'isUserDeleted1629512953000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "isDeleted" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isDeleted" IS 'Whether the User is deleted.'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isDeleted"`); + } + +} diff --git a/src/client/account.ts b/src/client/account.ts index 7cd3d8cb88..ee1d845493 100644 --- a/src/client/account.ts +++ b/src/client/account.ts @@ -11,6 +11,7 @@ type Account = { token: string; isModerator: boolean; isAdmin: boolean; + isDeleted: boolean; }; const data = localStorage.getItem('account'); diff --git a/src/client/init.ts b/src/client/init.ts index 0313af4374..194ece886b 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -310,6 +310,13 @@ for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { } if ($i) { + if ($i.isDeleted) { + dialog({ + type: 'warning', + text: i18n.locale.accountDeletionInProgress, + }); + } + if ('Notification' in window) { // 許可を得ていなかったらリクエスト if (Notification.permission === 'default') { diff --git a/src/models/entities/user.ts b/src/models/entities/user.ts index 060ec06b9a..65aebd2d1a 100644 --- a/src/models/entities/user.ts +++ b/src/models/entities/user.ts @@ -175,6 +175,13 @@ export class User { }) public isExplorable: boolean; + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ + @Column('boolean', { + default: false, + comment: 'Whether the User is deleted.' + }) + public isDeleted: boolean; + @Column('varchar', { length: 128, array: true, default: '{}' }) diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index f56090bb82..d4bb995ce2 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -252,6 +252,7 @@ export class UserRepository extends Repository { autoAcceptFollowed: profile!.autoAcceptFollowed, noCrawle: profile!.noCrawle, isExplorable: user.isExplorable, + isDeleted: user.isDeleted, hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: NoteUnreads.count({ where: { userId: user.id, isSpecified: true }, diff --git a/src/queue/index.ts b/src/queue/index.ts index ff96c0fb15..4ca7998e61 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -171,6 +171,15 @@ export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id'] }); } +export function createDeleteAccountJob(user: ThinUser) { + return dbQueue.add('deleteAccount', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + export function createDeleteObjectStorageFileJob(key: string) { return objectStorageQueue.add('deleteFile', { key: key diff --git a/src/queue/processors/db/delete-account.ts b/src/queue/processors/db/delete-account.ts new file mode 100644 index 0000000000..95614b61aa --- /dev/null +++ b/src/queue/processors/db/delete-account.ts @@ -0,0 +1,79 @@ +import * as Bull from 'bull'; +import { queueLogger } from '../../logger'; +import { DriveFiles, Notes, Users } from '@/models/index'; +import { DbUserJobData } from '@/queue/types'; +import { Note } from '@/models/entities/note'; +import { DriveFile } from '@/models/entities/drive-file'; +import { MoreThan } from 'typeorm'; +import { deleteFileSync } from '@/services/drive/delete-file'; + +const logger = queueLogger.createSubLogger('delete-account'); + +export async function deleteAccount(job: Bull.Job): Promise { + logger.info(`Deleting account of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + return; + } + + { // Delete notes + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (notes.length === 0) { + break; + } + + cursor = notes[notes.length - 1].id; + + await Notes.delete(notes.map(note => note.id)); + } + + logger.succ(`All of notes deleted`); + } + + { // Delete files + let cursor: DriveFile['id'] | null = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 10, + order: { + id: 1 + } + }); + + if (files.length === 0) { + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + } + } + + logger.succ(`All of files deleted`); + } + + await Users.delete(job.data.user.id); + + return 'Account deleted'; +} diff --git a/src/queue/processors/db/index.ts b/src/queue/processors/db/index.ts index b56b7bfa2c..b051a28e0b 100644 --- a/src/queue/processors/db/index.ts +++ b/src/queue/processors/db/index.ts @@ -8,6 +8,7 @@ import { exportBlocking } from './export-blocking'; import { exportUserLists } from './export-user-lists'; import { importFollowing } from './import-following'; import { importUserLists } from './import-user-lists'; +import { deleteAccount } from './delete-account'; const jobs = { deleteDriveFiles, @@ -17,7 +18,8 @@ const jobs = { exportBlocking, exportUserLists, importFollowing, - importUserLists + importUserLists, + deleteAccount, } as Record | Bull.ProcessPromiseFunction>; export default function(dbQueue: Bull.Queue) { diff --git a/src/server/api/endpoints/i/delete-account.ts b/src/server/api/endpoints/i/delete-account.ts index f761e5cc34..77f11925cd 100644 --- a/src/server/api/endpoints/i/delete-account.ts +++ b/src/server/api/endpoints/i/delete-account.ts @@ -1,9 +1,10 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../define'; -import { Users, UserProfiles } from '@/models/index'; +import { UserProfiles, Users } from '@/models/index'; import { doPostSuspend } from '@/services/suspend-user'; import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; export const meta = { requireCredential: true as const, @@ -19,6 +20,10 @@ export const meta = { export default define(meta, async (ps, user) => { const profile = await UserProfiles.findOneOrFail(user.id); + const userDetailed = await Users.findOneOrFail(user.id); + if (userDetailed.isDeleted) { + return; + } // Compare password const same = await bcrypt.compare(ps.password, profile.password!); @@ -30,7 +35,11 @@ export default define(meta, async (ps, user) => { // 物理削除する前にDelete activityを送信する await doPostSuspend(user).catch(e => {}); - await Users.delete(user.id); + createDeleteAccountJob(user); + + await Users.update(user.id, { + isDeleted: true, + }); // Terminate streaming publishUserEvent(user.id, 'terminate', {});