diff --git a/CHANGELOG.md b/CHANGELOG.md index 975aff3aeaf4b8bedb9ee2bce94e21d13f58f330..4b7fd4cb04dbc5a042e53a6902370af8f34b33c9 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 8f4e1e4b293e63a2b1475c8e815437017194ab58..67f847a61f4716860cc74bb9bc9e077d42c308ef 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 929e4738b43dc34c62b58368c17cb715c9663706..91f6e91b33d373e5e2b8ff7b209a5ddb903f32cd 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 @@ <ui-button @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</ui-button> </div> </section> + + <section> + <details> + <summary>{{ $t('danger-zone') }}</summary> + <ui-button @click="deleteAccount()">{{ $t('delete-account') }}</ui-button> + </details> + </section> </ui-card> </template> @@ -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 2549b2568ad9ba76dc29c00257080760bce9ea85..6d187b310c5a7cdaf66732630a55066a5e3aeeaa 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 7dc2319f506b3205ad6f526328e1edd9945690db..9f874fea04b9a29e0829b339431a63485efedb44 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 0000000000000000000000000000000000000000..7e76aa73e6fe37da14fb9c27728faf8c5f70adcd --- /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<void> { + 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 0000000000000000000000000000000000000000..13c6042b1630ef8889fa0a42c11736c79ac22d4e --- /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<void> { + 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 6869983205919284049166af8ade2ec96df4a493..31e87c3f67baa51b5ad0d9393512bbdc0f807d01 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 0000000000000000000000000000000000000000..217ad0010b8e1eb3fa7133c23a815e2da10f7a8d --- /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 2b797545eda2eeca229b1b25d58c5acbe287cd85..efea46bca66388abb7b3d59398878723c3df1527 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); + //#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 } + }); + + for (const following of followings) { + deliver(user, content, following._follower.inbox); + } } - } - //#endregion + //#endregion - // 統計を更新 - notesChart.update(note, false); - perUserNotesChart.update(user, note, false); + // 統計を更新 + 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 - } - }); + if (isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instance.update({ _id: i._id }, { + $inc: { + notesCount: -1 + } + }); - instanceChart.updateNote(i.host, false); - }); + instanceChart.updateNote(i.host, false); + }); + } } }