diff --git a/CHANGELOG_CHERRYPICK.md b/CHANGELOG_CHERRYPICK.md index 9d97871792..a4d108d97a 100644 --- a/CHANGELOG_CHERRYPICK.md +++ b/CHANGELOG_CHERRYPICK.md @@ -28,6 +28,10 @@ Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGE 기반 Misskey 버전: 2024.x.x
Misskey의 전체 변경 사항을 확인하려면, [CHANGELOG.md#2024xx](CHANGELOG.md#2024xx) 문서를 참고하십시오. +### General +- Feat: 계정 정리 기능 ([yodangang-express/cherrypick@dc51c907](https://github.com/yodangang-express/cherrypick/commit/dc51c907236570d6f072409832d312c937239514)) + - `다이렉트 메시지` 및 `고정된 노트`와 관련된 파일을 제외한 모든 노트와 파일을 자동으로 삭제할 수 있음 + ### Client - Enhance: 사용자 페이지에서 `이름`, `자기소개`, `팔로우 메시지`, `추가 정보`에 포함된 외부 이모지를 가져올 수 있음 - Fix: 노트 헤더의 사용자 이름을 클릭하면 페이지가 중복으로 이동됨 diff --git a/locales/en-US.yml b/locales/en-US.yml index c4753f2c2b..37a4f765d2 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1,5 +1,7 @@ --- _lang_: "English" +truncateAccount: "Truncate account" +truncateAccountConfirm: "All notes and files will be deleted except those associated with direct messages and pinned notes. Still continue?" bubbleTimeline: "Bubble Timeline" bubbleTimelineDescription: "After enabling this option navigate to the Moderation section to configure which servers should be shown." bubbleInstancesDescription: "Set the host names of servers to be displayed in the bubble timeline, separated by line breaks." @@ -3177,3 +3179,10 @@ _searchSite: otherDescription: "Use other search engine" query: "Query" queryDescription: "Input query scheme for search engine. For example, if https://www.google.com/search?q=test, input 'q'." +_accountTruncate: + accountTruncate: "Truncate account" + mayTakeTime: "As account truncation is a resource-heavy process, it may take some time to complete depending on how much content you have created and how many files you have uploaded." + sendEmail: "Once account truncation has been completed, an email will be sent to the email address registered to this account." + requestAccountDelete: "Request account truncation" + started: "Truncation has been started." + inProgress: "Truncation is currently in progress" diff --git a/locales/index.d.ts b/locales/index.d.ts index d37608a5f7..3432b30a8a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -13,6 +13,14 @@ export interface Locale extends ILocale { * 日本語 */ "_lang_": string; + /** + * アカウント整理 + */ + "truncateAccount": string; + /** + * ダイレクトメッセージと固定されたノートに関連付けられているファイルを除くすべてのノートとファイルが削除されます。それでも続行しますか? + */ + "truncateAccountConfirm": string; /** * バブルタイムライン */ @@ -12388,6 +12396,32 @@ export interface Locale extends ILocale { */ "queryDescription": string; }; + "_accountTruncate": { + /** + * アカウントの整理 + */ + "accountDelete": string; + /** + * アカウントの整理は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。 + */ + "mayTakeTime": string; + /** + * アカウントの整理が完了する際は、登録してあったメールアドレス宛に通知を送信します。 + */ + "sendEmail": string; + /** + * アカウント整理をリクエスト + */ + "requestAccountTruncate": string; + /** + * 整理処理が開始されました。 + */ + "started": string; + /** + * 整理が進行中 + */ + "inProgress": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index bed15fb492..00b3cde941 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1,5 +1,7 @@ _lang_: "日本語" +truncateAccount: "アカウント整理" +truncateAccountConfirm: "ダイレクトメッセージと固定されたノートに関連付けられているファイルを除くすべてのノートとファイルが削除されます。それでも続行しますか?" bubbleTimeline: "バブルタイムライン" bubbleTimelineDescription: "このオプションを有効にし、モデレーションに移動して、表示するサーバを設定します。" bubbleInstancesDescription: "バブルタイムラインに表示するサーバのホスト名を改行で区切って設定します。" @@ -3305,3 +3307,11 @@ _searchSite: otherDescription: "その他の検索エンジンを使用します。" query: "検索クエリ" queryDescription: "検索エンジンが使用するクエリを入力します。(例: https://www.google.com/search?q=test の場合qを入れる)" + +_accountTruncate: + accountDelete: "アカウントの整理" + mayTakeTime: "アカウントの整理は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。" + sendEmail: "アカウントの整理が完了する際は、登録してあったメールアドレス宛に通知を送信します。" + requestAccountTruncate: "アカウント整理をリクエスト" + started: "整理処理が開始されました。" + inProgress: "整理が進行中" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 668c0e9b1b..1493678ff1 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1,5 +1,7 @@ --- _lang_: "한국어" +truncateAccount: "계정 정리" +truncateAccountConfirm: "다이렉트 메시지 및 고정된 노트와 관련된 파일을 제외한 모든 노트와 파일이 삭제돼요. 그래도 계속할까요?" bubbleTimeline: "버블 타임라인" bubbleTimelineDescription: "이 옵션을 활성화하고 모더레이션으로 이동해 버블 타임라인에 표시할 서버를 구성해 주세요." bubbleInstancesDescription: "버블 타임라인에 표시할 서버의 호스트 이름을 줄바꿈으로 구분하여 설정해요." @@ -2112,7 +2114,7 @@ _signup: _accountDelete: accountDelete: "계정 삭제" mayTakeTime: "계정 삭제는 서버에 부하를 가하기 때문에, 작성한 콘텐츠나 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있어요." - sendEmail: "계정 삭제가 완료되면 등록된 메일 주소로 알림을 보내드려요." + sendEmail: "계정 삭제가 완료되면 등록된 메일 주소로 알림을 보내드릴게요." requestAccountDelete: "계정 삭제 요청" started: "삭제 작업이 시작되었어요." inProgress: "삭제 진행 중" @@ -3184,3 +3186,10 @@ _searchSite: otherDescription: "검색 엔진을 직접 지정할 수 있어요." query: "검색 쿼리" queryDescription: "검색 엔진이 사용할 쿼리를 입력해 주세요. (예: https://www.google.com/search?q=test 의 경우 q를 입력)" +_accountTruncate: + accountTruncate: "계정 정리" + mayTakeTime: "계정 정리는 서버에 부하를 가하기 때문에, 작성한 콘텐츠나 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있어요." + sendEmail: "계정 삭제가 완료되면 등록된 메일 주소로 알림을 보내드릴게요." + requestAccountTruncate: "계정 정리 요청" + started: "정리 작업이 시작되었어요." + inProgress: "정리 진행 중" diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 6d09100e6f..37cb18927e 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -27,6 +27,7 @@ import { CaptchaService } from './CaptchaService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; +import { TruncateAccountService } from './TruncateAccountService.js'; import { DownloadService } from './DownloadService.js'; import { DriveService } from './DriveService.js'; import { EmailService } from './EmailService.js'; @@ -176,6 +177,7 @@ const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: Capt const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; +const $TruncateAccountService: Provider = { provide: 'TruncateAccountService', useExisting: TruncateAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; const $EmailService: Provider = { provide: 'EmailService', useExisting: EmailService }; @@ -333,6 +335,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv CreateSystemUserService, CustomEmojiService, DeleteAccountService, + TruncateAccountService, DownloadService, DriveService, EmailService, @@ -486,6 +489,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, + $TruncateAccountService, $DownloadService, $DriveService, $EmailService, @@ -640,6 +644,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv CreateSystemUserService, CustomEmojiService, DeleteAccountService, + TruncateAccountService, DownloadService, DriveService, EmailService, @@ -792,6 +797,7 @@ const $ApEventService: Provider = { provide: 'ApEventService', useExisting: ApEv $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, + $TruncateAccountService, $DownloadService, $DriveService, $EmailService, diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 3f8e903602..5fde6f796c 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -399,6 +399,16 @@ export class QueueService { }); } + @bindThis + public createTruncateAccountJob(user: ThinUser) { + return this.dbQueue.add('truncateAccount', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createReportAbuseJob(report: MiAbuseUserReport) { return this.dbQueue.add('reportAbuse', report); diff --git a/packages/backend/src/core/TruncateAccountService.ts b/packages/backend/src/core/TruncateAccountService.ts new file mode 100644 index 0000000000..dae35f22bf --- /dev/null +++ b/packages/backend/src/core/TruncateAccountService.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository } from '@/models/_.js'; +import { QueueService } from '@/core/QueueService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class TruncateAccountService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private queueService: QueueService, + ) { + } + + @bindThis + public async truncateAccount(user: { + id: string; + host: string | null; + }): Promise { + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); + + this.queueService.createTruncateAccountJob(user, { + soft: false, + }); + } +} + diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 4c567685e3..02b9c5dd79 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -20,6 +20,7 @@ import { CleanChartsProcessorService } from './processors/CleanChartsProcessorSe import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; @@ -75,6 +76,7 @@ import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostP ImportCustomEmojisProcessorService, ImportAntennasProcessorService, DeleteAccountProcessorService, + TruncateAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, RelationshipProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 0c72f96cb0..97a25c056d 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -33,6 +33,7 @@ import { ImportUserListsProcessorService } from './processors/ImportUserListsPro import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js'; import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js'; import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js'; +import { TruncateAccountProcessorService } from './processors/TruncateAccountProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js'; import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js'; @@ -121,6 +122,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService, private importAntennasProcessorService: ImportAntennasProcessorService, private deleteAccountProcessorService: DeleteAccountProcessorService, + private truncateAccountProcessorService: TruncateAccountProcessorService, private deleteFileProcessorService: DeleteFileProcessorService, private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService, private relationshipProcessorService: RelationshipProcessorService, @@ -233,6 +235,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job); case 'importAntennas': return this.importAntennasProcessorService.process(job); case 'deleteAccount': return this.deleteAccountProcessorService.process(job); + case 'truncateAccount': return this.truncateAccountProcessorService.process(job); case 'reportAbuse': return this.reportAbuseProcessorService.process(job); default: throw new Error(`unrecognized job type ${job.name} for db`); } diff --git a/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts b/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts new file mode 100644 index 0000000000..36dfba9275 --- /dev/null +++ b/packages/backend/src/queue/processors/TruncateAccountProcessorService.ts @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { And, In, MoreThan, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { DriveFilesRepository, NotesRepository, UserNotePiningsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiNote } from '@/models/Note.js'; +import { EmailService } from '@/core/EmailService.js'; +import { bindThis } from '@/decorators.js'; +import { NoteDeleteService } from '@/core/NoteDeleteService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbUserTruncateJobData } from '../types.js'; + +@Injectable() +export class TruncateAccountProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private driveService: DriveService, + private emailService: EmailService, + private queueLoggerService: QueueLoggerService, + private noteDeleteService: NoteDeleteService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('truncate-account'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`Truncate notes and drives account of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id }); + const piningNoteIds = pinings.map(pining => pining.noteId); // pining.note always undefined (bug?) + + const specifiedNotes = await this.notesRepository.findBy({ + userId: user.id, + visibility: Not(In(['public', 'home', 'followers'])), + }); + const specifiedNoteIds = specifiedNotes.map(note => note.id); + + const keepFileIds = (await Promise.all([...piningNoteIds, ...specifiedNoteIds].map(async (noteId) => { + const note = await this.notesRepository.findOneBy({ id: noteId }); + + return note?.fileIds; + }))).flat().filter((fileId) => fileId !== undefined); + + { // Delete notes + let cursor: MiNote['id'] | null = null; + + while (true) { + const notes = await this.notesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { + id: And(Not(In([...piningNoteIds, ...specifiedNoteIds])), MoreThan(cursor)), + } : { + id: Not(In([...piningNoteIds, ...specifiedNoteIds])), + }), + }, + take: 100, + order: { + id: 1, + }, + }) as MiNote[]; + + if (notes.length === 0) { + break; + } + + cursor = notes.at(-1)?.id ?? null; + + await Promise.all(notes.map((note) => { + return this.noteDeleteService.delete(user, note, false, user); + })); + } + + this.logger.succ(`All of notes deleted: ${job.data.user.id}`); + } + + { // Delete files + let cursor: MiDriveFile['id'] | null = null; + + while (true) { + const files = await this.driveFilesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { + id: And(Not(In(keepFileIds)), MoreThan(cursor)), + } : { + id: Not(In(keepFileIds)), + }), + }, + take: 10, + order: { + id: 1, + }, + }) as MiDriveFile[]; + + if (files.length === 0) { + break; + } + + cursor = files.at(-1)?.id ?? null; + + for (const file of files) { + await this.driveService.deleteFileSync(file); + } + } + + this.logger.succ(`All of files deleted: ${job.data.user.id}`); + } + + { // Send email notification + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, 'Account truncated', + 'Your account has been truncated.', + 'Your account has been truncated.'); + } + } + + return `[${job.data.user.id}] Account notes and drives are truncated`; + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index b9afc7ecc4..5113aa5bf0 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -59,6 +59,7 @@ export type DbJobMap = { importUserLists: DbUserImportJobData; importCustomEmojis: DbUserImportJobData; deleteAccount: DbUserDeleteJobData; + truncateAccount: DbUserTruncateJobData; } export type DbJobDataWithUser = { @@ -80,6 +81,10 @@ export type DbUserDeleteJobData = { soft?: boolean; }; +export type DbUserTruncateJobData = { + user: ThinUser; +}; + export type DbUserImportJobData = { user: ThinUser; fileId: MiDriveFile['id']; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 837658772a..fd177cc931 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -262,6 +262,7 @@ import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; +import * as ep___i_truncateAccount from './endpoints/i/truncate-account.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; @@ -686,6 +687,7 @@ const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes- const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; +const $i_truncateAccount: Provider = { provide: 'ep:i/truncate-account', useClass: ep___i_truncateAccount.default }; const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; @@ -1115,6 +1117,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_registry_set, $i_revokeToken, $i_signinHistory, + $i_truncateAccount, $i_unpin, $i_updateEmail, $i_update, @@ -1536,6 +1539,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $i_registry_set, $i_revokeToken, $i_signinHistory, + $i_truncateAccount, $i_unpin, $i_updateEmail, $i_update, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 1ec54b7b36..3f81ca63a9 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -267,6 +267,7 @@ import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes import * as ep___i_registry_set from './endpoints/i/registry/set.js'; import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; +import * as ep___i_truncateAccount from './endpoints/i/truncate-account.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; @@ -689,6 +690,7 @@ const eps = [ ['i/registry/set', ep___i_registry_set], ['i/revoke-token', ep___i_revokeToken], ['i/signin-history', ep___i_signinHistory], + ['i/truncate-account', ep___i_truncateAccount], ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], diff --git a/packages/backend/src/server/api/endpoints/i/truncate-account.ts b/packages/backend/src/server/api/endpoints/i/truncate-account.ts new file mode 100644 index 0000000000..3f700c5cee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/truncate-account.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//import bcrypt from 'bcryptjs'; +import * as argon2 from 'argon2'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { TruncateAccountService } from '@/core/TruncateAccountService.js'; +import { DI } from '@/di-symbols.js'; +import { UserAuthService } from '@/core/UserAuthService.js'; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + password: { type: 'string' }, + token: { type: 'string', nullable: true }, + }, + required: ['password'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userAuthService: UserAuthService, + private truncateAccountService: TruncateAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const token = ps.token; + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); + + if (profile.twoFactorEnabled) { + if (token == null) { + throw new Error('authentication failed'); + } + + try { + await this.userAuthService.twoFactorAuthenticate(profile, token); + } catch (e) { + throw new Error('authentication failed'); + } + } + + const userDetailed = await this.usersRepository.findOneByOrFail({ id: me.id }); + if (userDetailed.isDeleted) { + return; + } + + const passwordMatched = await argon2.verify(profile.password!, ps.password); + if (!passwordMatched) { + throw new Error('incorrect password'); + } + + await this.truncateAccountService.truncateAccount(me); + }); + } +} diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 7605d0956f..229ce58ce5 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -1662,6 +1662,7 @@ declare namespace entities { IRevokeTokenRequest, ISigninHistoryRequest, ISigninHistoryResponse, + ITruncateAccountRequest, IUnpinRequest, IUnpinResponse, IUpdateEmailRequest, @@ -2505,6 +2506,9 @@ export interface IStream extends EventEmitter { useChannel(channel: C, params?: Channels[C]['params'], name?: string): IChannelConnection; } +// @public (undocumented) +type ITruncateAccountRequest = operations['i___truncate-account']['requestBody']['content']['application/json']; + // @public (undocumented) type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index 8217693c03..aae837b0bd 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -2855,6 +2855,18 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index d4a9840c99..cb3f1e0a3f 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -377,6 +377,7 @@ import type { IRevokeTokenRequest, ISigninHistoryRequest, ISigninHistoryResponse, + ITruncateAccountRequest, IUnpinRequest, IUnpinResponse, IUpdateEmailRequest, @@ -890,6 +891,7 @@ export type Endpoints = { 'i/registry/set': { req: IRegistrySetRequest; res: EmptyResponse }; 'i/revoke-token': { req: IRevokeTokenRequest; res: EmptyResponse }; 'i/signin-history': { req: ISigninHistoryRequest; res: ISigninHistoryResponse }; + 'i/truncate-account': { req: ITruncateAccountRequest; res: EmptyResponse }; 'i/unpin': { req: IUnpinRequest; res: IUnpinResponse }; 'i/update-email': { req: IUpdateEmailRequest; res: IUpdateEmailResponse }; 'i/update': { req: IUpdateRequest; res: IUpdateResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index d51cd141c8..a1e1e04f2f 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -380,6 +380,7 @@ export type IRegistrySetRequest = operations['i___registry___set']['requestBody' export type IRevokeTokenRequest = operations['i___revoke-token']['requestBody']['content']['application/json']; export type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['content']['application/json']; export type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json']; +export type ITruncateAccountRequest = operations['i___truncate-account']['requestBody']['content']['application/json']; export type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; export type IUnpinResponse = operations['i___unpin']['responses']['200']['content']['application/json']; export type IUpdateEmailRequest = operations['i___update-email']['requestBody']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index af80d30769..7b099558bf 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -2466,6 +2466,16 @@ export type paths = { */ post: operations['i___signin-history']; }; + '/i/truncate-account': { + /** + * i/truncate-account + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + post: operations['i___truncate-account']; + }; '/i/unpin': { /** * i/unpin @@ -20717,6 +20727,59 @@ export type operations = { }; }; }; + /** + * i/truncate-account + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the cherrypick mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* + */ + 'i___truncate-account': { + requestBody: { + content: { + 'application/json': { + password: string; + token?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * i/unpin * @description No description provided. diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 2cb5071585..d8cc0a9bfc 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -48,6 +48,18 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + +
+ {{ i18n.ts._accountTruncate.mayTakeTime }} + {{ i18n.ts._accountTruncate.sendEmail }} + {{ i18n.ts._accountTruncate.requestAccountTruncate }} + {{ i18n.ts._accountTruncate.inProgress }} +
+
+ @@ -142,6 +154,28 @@ async function deleteAccount() { await signout(); } +async function truncateAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.truncateAccountConfirm, + }); + if (canceled) return; + } + + const auth = await os.authenticateDialog(); + if (auth.canceled) return; + + await os.apiWithDialog('i/truncate-account', { + password: auth.result.password, + token: auth.result.token, + }); + + await os.alert({ + title: i18n.ts._accountTruncate.started, + }); +} + async function updateRepliesAll(withReplies: boolean) { const { canceled } = await os.confirm({ type: 'warning',