From fd1ef4a62d670aab5f0c0089ab3806639c779813 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
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<void> {
+        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<void> {
+        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<User> {
 				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<DbUserJobData>): Promise<string | void> {
+	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<string, Bull.ProcessCallbackFunction<DbJobData> | Bull.ProcessPromiseFunction<DbJobData>>;
 
 export default function(dbQueue: Bull.Queue<DbJobData>) {
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', {});
-- 
GitLab