From 3409a51cca11e611cc9629c0b0587d0c0ff78be2 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 21 Feb 2019 01:30:21 +0900 Subject: [PATCH] Resolve #2017 --- CHANGELOG.md | 1 + locales/ja-JP.yml | 4 ++ .../views/components/profile-editor.vue | 26 ++++++++ src/models/user.ts | 2 + src/queue/index.ts | 26 ++++++++ src/queue/processors/delete-drive-files.ts | 55 +++++++++++++++++ src/queue/processors/delete-notes.ts | 55 +++++++++++++++++ src/queue/processors/index.ts | 4 ++ src/server/api/endpoints/i/delete-account.ts | 49 +++++++++++++++ src/services/note/delete.ts | 60 ++++++++++--------- 10 files changed, 253 insertions(+), 29 deletions(-) create mode 100644 src/queue/processors/delete-drive-files.ts create mode 100644 src/queue/processors/delete-notes.ts create mode 100644 src/server/api/endpoints/i/delete-account.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 975aff3ae..4b7fd4cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ChangeLog unreleased ---------- +* アカウントの削除を試験的に実装 * デッキでメディア投稿のみ表示するオプションが機能していない問題を修正 * デッキでユーザーを表示したときにタイムラインが残存する問題を修正 * モバイルのユーザーページで、ユーザーAのタイムラインから他のユーザーBを選択してユーザーBのタイムラインに移動したとき、ユーザーAのタイムラインが残る問題を修正 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8f4e1e4b2..67f847a61 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -589,6 +589,10 @@ common/views/components/profile-editor.vue: mute-list: "ミュート" blocking-list: "ブロック" export-requested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、ドライブにファイルが追加されます。" + enter-password: "パスワードを入力してください" + danger-zone: "危険な設定" + delete-account: "アカウントを削除" + account-deleted: "アカウントが削除されました。データが消えるまで時間がかかる場合があります。" common/views/components/user-list-editor.vue: users: "ユーザー" diff --git a/src/client/app/common/views/components/profile-editor.vue b/src/client/app/common/views/components/profile-editor.vue index 929e4738b..91f6e91b3 100644 --- a/src/client/app/common/views/components/profile-editor.vue +++ b/src/client/app/common/views/components/profile-editor.vue @@ -101,6 +101,13 @@ {{ $t('export') }} + +
+
+ {{ $t('danger-zone') }} + {{ $t('delete-account') }} +
+
@@ -283,6 +290,25 @@ export default Vue.extend({ type: 'info', text: this.$t('export-requested') }); + }, + + async deleteAccount() { + const { canceled: canceled, result: password } = await this.$root.dialog({ + title: this.$t('enter-password'), + input: { + type: 'password' + } + }); + if (canceled) return; + + this.$root.api('i/delete-account', { + password + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('account-deleted') + }); + }); } } }); diff --git a/src/models/user.ts b/src/models/user.ts index 2549b2568..6d187b310 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -55,6 +55,8 @@ type IUserBase = { emojis?: string[]; tags?: string[]; + isDeleted: boolean; + /** * 凍結されているか否か */ diff --git a/src/queue/index.ts b/src/queue/index.ts index 7dc2319f5..9f874fea0 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -70,6 +70,32 @@ export function processInbox(activity: any, signature: httpSignature.IParsedSign } } +export function createDeleteNotesJob(user: ILocalUser) { + const data = { + type: 'deleteNotes', + user: user + }; + + if (queueAvailable && enableQueueProcessing) { + return queue.createJob(data).save(); + } else { + return handler({ data }, () => {}); + } +} + +export function createDeleteDriveFilesJob(user: ILocalUser) { + const data = { + type: 'deleteDriveFiles', + user: user + }; + + if (queueAvailable && enableQueueProcessing) { + return queue.createJob(data).save(); + } else { + return handler({ data }, () => {}); + } +} + export function createExportNotesJob(user: ILocalUser) { const data = { type: 'exportNotes', diff --git a/src/queue/processors/delete-drive-files.ts b/src/queue/processors/delete-drive-files.ts new file mode 100644 index 000000000..7e76aa73e --- /dev/null +++ b/src/queue/processors/delete-drive-files.ts @@ -0,0 +1,55 @@ +import * as bq from 'bee-queue'; +import * as mongo from 'mongodb'; + +import { queueLogger } from '../logger'; +import User from '../../models/user'; +import DriveFile from '../../models/drive-file'; +import deleteFile from '../../services/drive/delete-file'; + +const logger = queueLogger.createSubLogger('delete-drive-files'); + +export async function deleteDriveFiles(job: bq.Job, done: any): Promise { + logger.info(`Deleting drive files of ${job.data.user._id} ...`); + + const user = await User.findOne({ + _id: new mongo.ObjectID(job.data.user._id.toString()) + }); + + let deletedCount = 0; + let ended = false; + let cursor: any = null; + + while (!ended) { + const files = await DriveFile.find({ + userId: user._id, + ...(cursor ? { _id: { $gt: cursor } } : {}) + }, { + limit: 100, + sort: { + _id: 1 + } + }); + + if (files.length === 0) { + ended = true; + if (job.reportProgress) job.reportProgress(100); + break; + } + + cursor = files[files.length - 1]._id; + + for (const file of files) { + await deleteFile(file); + deletedCount++; + } + + const total = await DriveFile.count({ + userId: user._id, + }); + + if (job.reportProgress) job.reportProgress(deletedCount / total); + } + + logger.succ(`All drive files (${deletedCount}) of ${user._id} has been deleted.`); + done(); +} diff --git a/src/queue/processors/delete-notes.ts b/src/queue/processors/delete-notes.ts new file mode 100644 index 000000000..13c6042b1 --- /dev/null +++ b/src/queue/processors/delete-notes.ts @@ -0,0 +1,55 @@ +import * as bq from 'bee-queue'; +import * as mongo from 'mongodb'; + +import { queueLogger } from '../logger'; +import Note from '../../models/note'; +import deleteNote from '../../services/note/delete'; +import User from '../../models/user'; + +const logger = queueLogger.createSubLogger('delete-notes'); + +export async function deleteNotes(job: bq.Job, done: any): Promise { + logger.info(`Deleting notes of ${job.data.user._id} ...`); + + const user = await User.findOne({ + _id: new mongo.ObjectID(job.data.user._id.toString()) + }); + + let deletedCount = 0; + let ended = false; + let cursor: any = null; + + while (!ended) { + const notes = await Note.find({ + userId: user._id, + ...(cursor ? { _id: { $gt: cursor } } : {}) + }, { + limit: 100, + sort: { + _id: 1 + } + }); + + if (notes.length === 0) { + ended = true; + if (job.reportProgress) job.reportProgress(100); + break; + } + + cursor = notes[notes.length - 1]._id; + + for (const note of notes) { + await deleteNote(user, note, true); + deletedCount++; + } + + const total = await Note.count({ + userId: user._id, + }); + + if (job.reportProgress) job.reportProgress(deletedCount / total); + } + + logger.succ(`All notes (${deletedCount}) of ${user._id} has been deleted.`); + done(); +} diff --git a/src/queue/processors/index.ts b/src/queue/processors/index.ts index 686998320..31e87c3f6 100644 --- a/src/queue/processors/index.ts +++ b/src/queue/processors/index.ts @@ -1,5 +1,7 @@ import deliver from './http/deliver'; import processInbox from './http/process-inbox'; +import { deleteNotes } from './delete-notes'; +import { deleteDriveFiles } from './delete-drive-files'; import { exportNotes } from './export-notes'; import { exportFollowing } from './export-following'; import { exportMute } from './export-mute'; @@ -9,6 +11,8 @@ import { queueLogger } from '../logger'; const handlers: any = { deliver, processInbox, + deleteNotes, + deleteDriveFiles, exportNotes, exportFollowing, exportMute, diff --git a/src/server/api/endpoints/i/delete-account.ts b/src/server/api/endpoints/i/delete-account.ts new file mode 100644 index 000000000..217ad0010 --- /dev/null +++ b/src/server/api/endpoints/i/delete-account.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import User from '../../../../models/user'; +import define from '../../define'; +import { createDeleteNotesJob, createDeleteDriveFilesJob } from '../../../../queue'; + +export const meta = { + requireCredential: true, + + secure: true, + + params: { + password: { + validator: $.str + }, + } +}; + +export default define(meta, (ps, user) => new Promise(async (res, rej) => { + // Compare password + const same = await bcrypt.compare(ps.password, user.password); + + if (!same) { + return rej('incorrect password'); + } + + await User.update({ _id: user._id }, { + $set: { + isDeleted: true, + token: null, + name: null, + description: null, + pinnedNoteIds: [], + password: null, + email: null, + twitter: null, + github: null, + discord: null, + profile: {}, + fields: [], + clientSettings: {}, + } + }); + + createDeleteNotesJob(user); + createDeleteDriveFilesJob(user); + + res(); +})); diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts index 2b797545e..efea46bca 100644 --- a/src/services/note/delete.ts +++ b/src/services/note/delete.ts @@ -21,7 +21,7 @@ import instanceChart from '../../services/chart/instance'; * @param user 投稿者 * @param note 投稿 */ -export default async function(user: IUser, note: INote) { +export default async function(user: IUser, note: INote, quiet = false) { const deletedAt = new Date(); await Note.update({ @@ -52,10 +52,6 @@ export default async function(user: IUser, note: INote) { }); } - publishNoteStream(note._id, 'deleted', { - deletedAt: deletedAt - }); - // この投稿が関わる未読通知を削除 NoteUnread.find({ noteId: note._id @@ -76,34 +72,40 @@ export default async function(user: IUser, note: INote) { } } - //#region ローカルの投稿なら削除アクティビティを配送 - if (isLocalUser(user)) { - const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${note._id}`), user)); - - const followings = await Following.find({ - followeeId: user._id, - '_follower.host': { $ne: null } + if (!quiet) { + publishNoteStream(note._id, 'deleted', { + deletedAt: deletedAt }); - for (const following of followings) { - deliver(user, content, following._follower.inbox); - } - } - //#endregion + //#region ローカルの投稿なら削除アクティビティを配送 + if (isLocalUser(user)) { + const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${note._id}`), user)); - // 統計を更新 - notesChart.update(note, false); - perUserNotesChart.update(user, note, false); - - if (isRemoteUser(user)) { - registerOrFetchInstanceDoc(user.host).then(i => { - Instance.update({ _id: i._id }, { - $inc: { - notesCount: -1 - } + const followings = await Following.find({ + followeeId: user._id, + '_follower.host': { $ne: null } }); - instanceChart.updateNote(i.host, false); - }); + for (const following of followings) { + deliver(user, content, following._follower.inbox); + } + } + //#endregion + + // 統計を更新 + notesChart.update(note, false); + perUserNotesChart.update(user, note, false); + + if (isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + notesCount: -1 + } + }); + + instanceChart.updateNote(i.host, false); + }); + } } }