From dbf9e1194bf5b84ec711c14f27c11d2bfeb37f20 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 6 Jun 2024 10:01:50 +0900 Subject: refactor(backend): remove unused logger option --- packages/backend/src/server/FileServerService.ts | 2 +- packages/backend/src/server/ServerService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 9db3aa1bfb..77a637d895 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -53,7 +53,7 @@ export class FileServerService { private internalStorageService: InternalStorageService, private loggerService: LoggerService, ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); + this.logger = this.loggerService.getLogger('server', 'gray'); //this.createServer = this.createServer.bind(this); } diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 3572f16627..9c849480f2 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -68,7 +68,7 @@ export class ServerService implements OnApplicationShutdown { private loggerService: LoggerService, private oauth2ProviderService: OAuth2ProviderService, ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); + this.logger = this.loggerService.getLogger('server', 'gray'); } @bindThis -- cgit v1.2.3-freya From ab69e113f4921462b72f1f352dfefe52b37862f5 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:20:54 +0900 Subject: enhance(backend): improve sentry integration --- packages/backend/src/boot/worker.ts | 23 ++++++++++++++++++++++ .../backend/src/queue/QueueProcessorService.ts | 4 ++-- packages/backend/src/server/api/ApiCallService.ts | 14 +++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index d4a7cd56e5..5d4a15b29f 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -4,13 +4,36 @@ */ import cluster from 'node:cluster'; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; import { envOption } from '@/env.js'; +import { loadConfig } from '@/config.js'; import { jobQueue, server } from './common.js'; /** * Init worker process */ export async function workerMain() { + const config = loadConfig(); + + if (config.sentryForBackend) { + Sentry.init({ + integrations: [ + ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), + ], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.sentryForBackend.options, + }); + } + if (envOption.onlyServer) { await server(); } else if (envOption.onlyQueue) { diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index eb1901d069..4f333df791 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -165,7 +165,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.systemQueueWorker .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => systemLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('failed', (job, err) => systemLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) .on('error', (err: Error) => systemLogger.error(`error ${err.stack}`, { e: renderError(err) })) .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`)); } @@ -214,7 +214,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.dbQueueWorker .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => dbLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) + .on('failed', (job, err) => dbLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) .on('error', (err: Error) => dbLogger.error(`error ${err.stack}`, { e: renderError(err) })) .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`)); } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 271ef80554..e21a5e90ab 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -93,7 +93,7 @@ export class ApiCallService implements OnApplicationShutdown { } } - #onExecError(ep: IEndpoint, data: any, err: Error): void { + #onExecError(ep: IEndpoint, data: any, err: Error, userId?: MiUser['id']): void { if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { @@ -108,10 +108,12 @@ export class ApiCallService implements OnApplicationShutdown { id: errId, }, }); - console.error(err, errId); if (this.config.sentryForBackend) { Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + user: { + id: userId, + }, extra: { ep: ep.name, ps: data, @@ -410,9 +412,13 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking if (this.config.sentryForBackend) { - return await Sentry.startSpan({ name: 'API: ' + ep.name }, () => ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err))); + return await Sentry.startSpan({ + name: 'API: ' + ep.name, + }, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); } else { - return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err)); + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); } } -- cgit v1.2.3-freya From 859271613958cc4a9bc8fcd9616c3146c07a50a4 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:15:37 +0900 Subject: enhance(backend): improve sentry integration --- packages/backend/src/queue/QueueProcessorService.ts | 7 +++++++ packages/backend/src/server/api/ApiCallService.ts | 1 + 2 files changed, 8 insertions(+) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 6a87be021e..7bfe1f4caa 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -169,6 +169,7 @@ export class QueueProcessorService implements OnApplicationShutdown { systemLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -225,6 +226,7 @@ export class QueueProcessorService implements OnApplicationShutdown { dbLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -264,6 +266,7 @@ export class QueueProcessorService implements OnApplicationShutdown { deliverLogger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -303,6 +306,7 @@ export class QueueProcessorService implements OnApplicationShutdown { inboxLogger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -342,6 +346,7 @@ export class QueueProcessorService implements OnApplicationShutdown { webhookLogger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: WebhookDeliver: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -388,6 +393,7 @@ export class QueueProcessorService implements OnApplicationShutdown { relationshipLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', extra: { job, err }, }); } @@ -428,6 +434,7 @@ export class QueueProcessorService implements OnApplicationShutdown { objectStorageLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', extra: { job, err }, }); } diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index e21a5e90ab..166f9c8675 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -111,6 +111,7 @@ export class ApiCallService implements OnApplicationShutdown { if (this.config.sentryForBackend) { Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + level: 'error', user: { id: userId, }, -- cgit v1.2.3-freya From e0cf5b24022e22179355cf9439d3cfea6036daba Mon Sep 17 00:00:00 2001 From: Porlam Nicla <84321396+GrapeApple0@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:46:46 +0900 Subject: 配信停止したインスタンス一覧が見れなくなる問題を修正 (#13945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 配信停止したインスタンス一覧が見れなくなる問題を修正 * Update CHANGELOG.md --- CHANGELOG.md | 2 +- packages/backend/src/server/api/endpoints/federation/instances.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cccb451d5..f93811c606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 ### Client - diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 4ef4315fb3..36f4bf5aa6 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -117,9 +117,9 @@ export default class extends Endpoint { // eslint- if (typeof ps.suspended === 'boolean') { if (ps.suspended) { - query.andWhere('instance.isSuspended = TRUE'); + query.andWhere('instance.suspensionState != \'none\''); } else { - query.andWhere('instance.isSuspended = FALSE'); + query.andWhere('instance.suspensionState = \'none\''); } } -- cgit v1.2.3-freya From 61fae45390283aee7ac582aa5303aae863de0f7a Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Sat, 8 Jun 2024 15:34:19 +0900 Subject: feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする (#13758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする * モデログに対応&エンドポイントを単一オブジェクトでのサポートに変更(API経由で大量に作るシチュエーションもないと思うので) * fix spdx * fix migration * fix migration * fix models * add e2e webhook * tweak * fix modlog * fix bugs * add tests and fix bugs * add tests and fix bugs * add tests * fix path * regenerate locale * 混入除去 * 混入除去 * add abuseReportResolved * fix pnpm-lock.yaml * add abuseReportResolved test * fix bugs * fix ui * add tests * fix CHANGELOG.md * add tests * add RoleService.getModeratorIds tests * WebhookServiceをUserとSystemに分割 * fix CHANGELOG.md * fix test * insertOneを使う用に * fix * regenerate locales * revert version * separate webhook job queue * fix * :art: * Update QueueProcessorService.ts --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/index.d.ts | 94 +++ locales/ja-JP.yml | 27 + .../1713656541000-abuse-report-notification.js | 62 ++ .../src/core/AbuseReportNotificationService.ts | 406 +++++++++ packages/backend/src/core/AbuseReportService.ts | 128 +++ packages/backend/src/core/CoreModule.ts | 44 +- packages/backend/src/core/EmailService.ts | 2 + packages/backend/src/core/GlobalEventService.ts | 4 + packages/backend/src/core/NoteCreateService.ts | 12 +- packages/backend/src/core/QueueModule.ts | 38 +- packages/backend/src/core/QueueService.ts | 63 +- packages/backend/src/core/RoleService.ts | 30 +- packages/backend/src/core/SystemWebhookService.ts | 233 +++++ packages/backend/src/core/UserBlockingService.ts | 6 +- packages/backend/src/core/UserFollowingService.ts | 12 +- packages/backend/src/core/UserWebhookService.ts | 99 +++ packages/backend/src/core/WebhookService.ts | 97 --- .../backend/src/core/activitypub/ApInboxService.ts | 10 +- ...buseReportNotificationRecipientEntityService.ts | 88 ++ .../core/entities/SystemWebhookEntityService.ts | 74 ++ packages/backend/src/di-symbols.ts | 2 + packages/backend/src/misc/json-schema.ts | 28 +- .../src/models/AbuseReportNotificationRecipient.ts | 100 +++ packages/backend/src/models/RepositoryModule.ts | 98 ++- packages/backend/src/models/SystemWebhook.ts | 98 +++ packages/backend/src/models/_.ts | 6 + .../abuse-report-notification-recipient.ts | 50 ++ .../src/models/json-schema/system-webhook.ts | 54 ++ packages/backend/src/postgres.ts | 8 +- packages/backend/src/queue/QueueProcessorModule.ts | 6 +- .../backend/src/queue/QueueProcessorService.ts | 153 ++-- packages/backend/src/queue/const.ts | 3 +- .../SystemWebhookDeliverProcessorService.ts | 87 ++ .../UserWebhookDeliverProcessorService.ts | 86 ++ .../processors/WebhookDeliverProcessorService.ts | 86 -- packages/backend/src/queue/types.ts | 12 +- packages/backend/src/server/api/EndpointsModule.ts | 42 +- packages/backend/src/server/api/endpoints.ts | 38 +- .../abuse-report/notification-recipient/create.ts | 122 +++ .../abuse-report/notification-recipient/delete.ts | 44 + .../abuse-report/notification-recipient/list.ts | 55 ++ .../abuse-report/notification-recipient/show.ts | 64 ++ .../abuse-report/notification-recipient/update.ts | 128 +++ .../src/server/api/endpoints/admin/queue/stats.ts | 5 +- .../endpoints/admin/resolve-abuse-user-report.ts | 53 +- .../api/endpoints/admin/system-webhook/create.ts | 85 ++ .../api/endpoints/admin/system-webhook/delete.ts | 44 + .../api/endpoints/admin/system-webhook/list.ts | 60 ++ .../api/endpoints/admin/system-webhook/show.ts | 62 ++ .../api/endpoints/admin/system-webhook/update.ts | 91 ++ .../src/server/api/endpoints/users/report-abuse.ts | 54 +- .../backend/src/server/web/ClientServerService.ts | 17 +- packages/backend/src/types.ts | 32 + packages/backend/test/e2e/synalio/abuse-report.ts | 401 +++++++++ .../test/unit/AbuseReportNotificationService.ts | 343 ++++++++ packages/backend/test/unit/RoleService.ts | 132 ++- packages/backend/test/unit/SystemWebhookService.ts | 515 +++++++++++ packages/frontend/src/components/MkButton.vue | 2 + packages/frontend/src/components/MkDivider.vue | 32 + packages/frontend/src/components/MkSwitch.vue | 5 +- .../src/components/MkSystemWebhookEditor.impl.ts | 45 + .../src/components/MkSystemWebhookEditor.vue | 217 +++++ .../abuse-report/notification-recipient.editor.vue | 307 +++++++ .../abuse-report/notification-recipient.item.vue | 114 +++ .../admin/abuse-report/notification-recipient.vue | 176 ++++ packages/frontend/src/pages/admin/abuses.vue | 85 +- packages/frontend/src/pages/admin/index.vue | 5 + .../frontend/src/pages/admin/modlog.ModLog.vue | 48 +- .../src/pages/admin/system-webhook.item.vue | 117 +++ .../frontend/src/pages/admin/system-webhook.vue | 96 +++ packages/frontend/src/router/definition.ts | 8 + packages/misskey-js/etc/misskey-js.api.md | 105 ++- packages/misskey-js/src/autogen/apiClientJSDoc.ts | 120 +++ packages/misskey-js/src/autogen/endpoint.ts | 28 + packages/misskey-js/src/autogen/entities.ts | 18 + packages/misskey-js/src/autogen/models.ts | 2 + packages/misskey-js/src/autogen/types.ts | 939 ++++++++++++++++++--- packages/misskey-js/src/consts.ts | 26 + packages/misskey-js/src/entities.ts | 19 +- 80 files changed, 6733 insertions(+), 575 deletions(-) create mode 100644 packages/backend/migration/1713656541000-abuse-report-notification.js create mode 100644 packages/backend/src/core/AbuseReportNotificationService.ts create mode 100644 packages/backend/src/core/AbuseReportService.ts create mode 100644 packages/backend/src/core/SystemWebhookService.ts create mode 100644 packages/backend/src/core/UserWebhookService.ts delete mode 100644 packages/backend/src/core/WebhookService.ts create mode 100644 packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts create mode 100644 packages/backend/src/core/entities/SystemWebhookEntityService.ts create mode 100644 packages/backend/src/models/AbuseReportNotificationRecipient.ts create mode 100644 packages/backend/src/models/SystemWebhook.ts create mode 100644 packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts create mode 100644 packages/backend/src/models/json-schema/system-webhook.ts create mode 100644 packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts create mode 100644 packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts delete mode 100644 packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts create mode 100644 packages/backend/test/e2e/synalio/abuse-report.ts create mode 100644 packages/backend/test/unit/AbuseReportNotificationService.ts create mode 100644 packages/backend/test/unit/SystemWebhookService.ts create mode 100644 packages/frontend/src/components/MkDivider.vue create mode 100644 packages/frontend/src/components/MkSystemWebhookEditor.impl.ts create mode 100644 packages/frontend/src/components/MkSystemWebhookEditor.vue create mode 100644 packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue create mode 100644 packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue create mode 100644 packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue create mode 100644 packages/frontend/src/pages/admin/system-webhook.item.vue create mode 100644 packages/frontend/src/pages/admin/system-webhook.vue (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index f93811c606..8b70636d82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased ### General +- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 ### Client diff --git a/locales/index.d.ts b/locales/index.d.ts index 0b1b86d373..acdc1fc421 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9305,6 +9305,10 @@ export interface Locale extends ILocale { * Webhookを作成 */ "createWebhook": string; + /** + * Webhookを編集 + */ + "modifyWebhook": string; /** * 名前 */ @@ -9351,6 +9355,72 @@ export interface Locale extends ILocale { */ "mention": string; }; + "_systemEvents": { + /** + * ユーザーから通報があったとき + */ + "abuseReport": string; + /** + * ユーザーからの通報を処理したとき + */ + "abuseReportResolved": string; + }; + /** + * Webhookを削除しますか? + */ + "deleteConfirm": string; + }; + "_abuseReport": { + "_notificationRecipient": { + /** + * 通報の通知先を追加 + */ + "createRecipient": string; + /** + * 通報の通知先を編集 + */ + "modifyRecipient": string; + /** + * 通知先の種類 + */ + "recipientType": string; + "_recipientType": { + /** + * メール + */ + "mail": string; + /** + * Webhook + */ + "webhook": string; + "_captions": { + /** + * モデレーター権限を持つユーザーのメールアドレスに通知を送ります(通報を受けた時のみ) + */ + "mail": string; + /** + * 指定したSystemWebhookに通知を送ります(通報を受けた時と通報を解決した時にそれぞれ発信) + */ + "webhook": string; + }; + }; + /** + * キーワード + */ + "keywords": string; + /** + * 通知先ユーザー + */ + "notifiedUser": string; + /** + * 使用するWebhook + */ + "notifiedWebhook": string; + /** + * 通知先を削除しますか? + */ + "deleteConfirm": string; + }; }; "_moderationLogTypes": { /** @@ -9497,6 +9567,30 @@ export interface Locale extends ILocale { * ユーザーのバナーを解除 */ "unsetUserBanner": string; + /** + * SystemWebhookを作成 + */ + "createSystemWebhook": string; + /** + * SystemWebhookを更新 + */ + "updateSystemWebhook": string; + /** + * SystemWebhookを削除 + */ + "deleteSystemWebhook": string; + /** + * 通報の通知先を作成 + */ + "createAbuseReportNotificationRecipient": string; + /** + * 通報の通知先を更新 + */ + "updateAbuseReportNotificationRecipient": string; + /** + * 通報の通知先を削除 + */ + "deleteAbuseReportNotificationRecipient": string; }; "_fileViewer": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a89cfbd843..3ac1ce82a3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2468,6 +2468,7 @@ _drivecleaner: _webhookSettings: createWebhook: "Webhookを作成" + modifyWebhook: "Webhookを編集" name: "名前" secret: "シークレット" events: "Webhookを実行するタイミング" @@ -2480,6 +2481,26 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" + _systemEvents: + abuseReport: "ユーザーから通報があったとき" + abuseReportResolved: "ユーザーからの通報を処理したとき" + deleteConfirm: "Webhookを削除しますか?" + +_abuseReport: + _notificationRecipient: + createRecipient: "通報の通知先を追加" + modifyRecipient: "通報の通知先を編集" + recipientType: "通知先の種類" + _recipientType: + mail: "メール" + webhook: "Webhook" + _captions: + mail: "モデレーター権限を持つユーザーのメールアドレスに通知を送ります(通報を受けた時のみ)" + webhook: "指定したSystemWebhookに通知を送ります(通報を受けた時と通報を解決した時にそれぞれ発信)" + keywords: "キーワード" + notifiedUser: "通知先ユーザー" + notifiedWebhook: "使用するWebhook" + deleteConfirm: "通知先を削除しますか?" _moderationLogTypes: createRole: "ロールを作成" @@ -2518,6 +2539,12 @@ _moderationLogTypes: deleteAvatarDecoration: "アイコンデコレーションを削除" unsetUserAvatar: "ユーザーのアイコンを解除" unsetUserBanner: "ユーザーのバナーを解除" + createSystemWebhook: "SystemWebhookを作成" + updateSystemWebhook: "SystemWebhookを更新" + deleteSystemWebhook: "SystemWebhookを削除" + createAbuseReportNotificationRecipient: "通報の通知先を作成" + updateAbuseReportNotificationRecipient: "通報の通知先を更新" + deleteAbuseReportNotificationRecipient: "通報の通知先を削除" _fileViewer: title: "ファイルの詳細" diff --git a/packages/backend/migration/1713656541000-abuse-report-notification.js b/packages/backend/migration/1713656541000-abuse-report-notification.js new file mode 100644 index 0000000000..4a754f81e2 --- /dev/null +++ b/packages/backend/migration/1713656541000-abuse-report-notification.js @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AbuseReportNotification1713656541000 { + name = 'AbuseReportNotification1713656541000' + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "system_webhook" ( + "id" varchar(32) NOT NULL, + "isActive" boolean NOT NULL DEFAULT true, + "updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + "latestSentAt" timestamp with time zone NULL DEFAULT NULL, + "latestStatus" integer NULL DEFAULT NULL, + "name" varchar(255) NOT NULL, + "on" varchar(128) [] NOT NULL DEFAULT '{}'::character varying[], + "url" varchar(1024) NOT NULL, + "secret" varchar(1024) NOT NULL, + CONSTRAINT "PK_system_webhook_id" PRIMARY KEY ("id") + ); + CREATE INDEX "IDX_system_webhook_isActive" ON "system_webhook" ("isActive"); + CREATE INDEX "IDX_system_webhook_on" ON "system_webhook" USING gin ("on"); + + CREATE TABLE "abuse_report_notification_recipient" ( + "id" varchar(32) NOT NULL, + "isActive" boolean NOT NULL DEFAULT true, + "updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" varchar(255) NOT NULL, + "method" varchar(64) NOT NULL, + "userId" varchar(32) NULL DEFAULT NULL, + "systemWebhookId" varchar(32) NULL DEFAULT NULL, + CONSTRAINT "PK_abuse_report_notification_recipient_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_abuse_report_notification_recipient_userId1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_abuse_report_notification_recipient_userId2" FOREIGN KEY ("userId") REFERENCES "user_profile"("userId") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId" FOREIGN KEY ("systemWebhookId") REFERENCES "system_webhook"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ); + CREATE INDEX "IDX_abuse_report_notification_recipient_isActive" ON "abuse_report_notification_recipient" ("isActive"); + CREATE INDEX "IDX_abuse_report_notification_recipient_method" ON "abuse_report_notification_recipient" ("method"); + CREATE INDEX "IDX_abuse_report_notification_recipient_userId" ON "abuse_report_notification_recipient" ("userId"); + CREATE INDEX "IDX_abuse_report_notification_recipient_systemWebhookId" ON "abuse_report_notification_recipient" ("systemWebhookId"); + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId1"; + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId2"; + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId"; + DROP INDEX "IDX_abuse_report_notification_recipient_isActive"; + DROP INDEX "IDX_abuse_report_notification_recipient_method"; + DROP INDEX "IDX_abuse_report_notification_recipient_userId"; + DROP INDEX "IDX_abuse_report_notification_recipient_systemWebhookId"; + DROP TABLE "abuse_report_notification_recipient"; + + DROP INDEX "IDX_system_webhook_isActive"; + DROP INDEX "IDX_system_webhook_on"; + DROP TABLE "system_webhook"; + `); + } +} diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts new file mode 100644 index 0000000000..df752afcd8 --- /dev/null +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -0,0 +1,406 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { Brackets, In, IsNull, Not } from 'typeorm'; +import * as Redis from 'ioredis'; +import sanitizeHtml from 'sanitize-html'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import type { + AbuseReportNotificationRecipientRepository, + MiAbuseReportNotificationRecipient, + MiAbuseUserReport, + MiUser, +} from '@/models/_.js'; +import { EmailService } from '@/core/EmailService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { IdService } from './IdService.js'; + +@Injectable() +export class AbuseReportNotificationService implements OnApplicationShutdown { + constructor( + @Inject(DI.abuseReportNotificationRecipientRepository) + private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + private idService: IdService, + private roleService: RoleService, + private systemWebhookService: SystemWebhookService, + private emailService: EmailService, + private metaService: MetaService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + this.redisForSub.on('message', this.onMessage); + } + + /** + * 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する. + * 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る. + * + * @see RoleService.getModeratorIds + * @see GlobalEventService.publishAdminStream + */ + @bindThis + public async notifyAdminStream(abuseReports: MiAbuseUserReport[]) { + if (abuseReports.length <= 0) { + return; + } + + const moderatorIds = await this.roleService.getModeratorIds(true, true); + + for (const moderatorId of moderatorIds) { + for (const abuseReport of abuseReports) { + this.globalEventService.publishAdminStream( + moderatorId, + 'newAbuseUserReport', + { + id: abuseReport.id, + targetUserId: abuseReport.targetUserId, + reporterId: abuseReport.reporterId, + comment: abuseReport.comment, + }, + ); + } + } + } + + /** + * Mailを用いて{@link abuseReports}の内容を管理者各位に通知する. + * メールアドレスの送信先は以下の通り. + * - モデレータ権限所有者ユーザ(設定画面からメールアドレスの設定を行っているユーザに限る) + * - metaテーブルに設定されているメールアドレス + * + * @see EmailService.sendEmail + */ + @bindThis + public async notifyMail(abuseReports: MiAbuseUserReport[]) { + if (abuseReports.length <= 0) { + return; + } + + const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it + .filter(it => it.isActive && it.userProfile?.emailVerified) + .map(it => it.userProfile?.email) + .filter(isNotNull), + ); + + // 送信先の鮮度を保つため、毎回取得する + const meta = await this.metaService.fetch(true); + recipientEMailAddresses.push( + ...(meta.email ? [meta.email] : []), + ); + + if (recipientEMailAddresses.length <= 0) { + return; + } + + for (const mailAddress of recipientEMailAddresses) { + await Promise.all( + abuseReports.map(it => { + // TODO: 送信処理はJobQueue化したい + return this.emailService.sendEmail( + mailAddress, + 'New Abuse Report', + sanitizeHtml(it.comment), + sanitizeHtml(it.comment), + ); + }), + ); + } + } + + /** + * SystemWebhookを用いて{@link abuseReports}の内容を管理者各位に通知する. + * ここではJobQueueへのエンキューのみを行うため、即時実行されない. + * + * @see SystemWebhookService.enqueueSystemWebhook + */ + @bindThis + public async notifySystemWebhook( + abuseReports: MiAbuseUserReport[], + type: 'abuseReport' | 'abuseReportResolved', + ) { + if (abuseReports.length <= 0) { + return; + } + + const recipientWebhookIds = await this.fetchWebhookRecipients() + .then(it => it + .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') + .map(it => it.systemWebhookId) + .filter(isNotNull)); + for (const webhookId of recipientWebhookIds) { + await Promise.all( + abuseReports.map(it => { + return this.systemWebhookService.enqueueSystemWebhook( + webhookId, + type, + it, + ); + }), + ); + } + } + + /** + * 通報の通知先一覧を取得する. + * + * @param {Object} [params] クエリの取得条件 + * @param {Object} [params.method] 取得する通知先の通知方法 + * @param {Object} [opts] 動作時の詳細なオプション + * @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true) + * @param {boolean} [opts.joinUser] 通知先のユーザ情報をJOINするかどうか(default: false) + * @param {boolean} [opts.joinSystemWebhook] 通知先のSystemWebhook情報をJOINするかどうか(default: false) + * @see removeUnauthorizedRecipientUsers + */ + @bindThis + public async fetchRecipients( + params?: { + ids?: MiAbuseReportNotificationRecipient['id'][], + method?: RecipientMethod[], + }, + opts?: { + removeUnauthorized?: boolean, + joinUser?: boolean, + joinSystemWebhook?: boolean, + }, + ): Promise { + const query = this.abuseReportNotificationRecipientRepository.createQueryBuilder('recipient'); + + if (opts?.joinUser) { + query.innerJoinAndSelect('user', 'user', 'recipient.userId = user.id'); + query.innerJoinAndSelect('recipient.userProfile', 'userProfile'); + } + + if (opts?.joinSystemWebhook) { + query.innerJoinAndSelect('recipient.systemWebhook', 'systemWebhook'); + } + + if (params?.ids) { + query.andWhere({ id: In(params.ids) }); + } + + if (params?.method) { + query.andWhere(new Brackets(qb => { + if (params.method?.includes('email')) { + qb.orWhere({ method: 'email', userId: Not(IsNull()) }); + } + if (params.method?.includes('webhook')) { + qb.orWhere({ method: 'webhook', userId: IsNull() }); + } + })); + } + + const recipients = await query.getMany(); + if (recipients.length <= 0) { + return []; + } + + // アサイン有効期限切れはイベントで拾えないので、このタイミングでチェック及び削除(オプション) + return (opts?.removeUnauthorized ?? true) + ? await this.removeUnauthorizedRecipientUsers(recipients) + : recipients; + } + + /** + * EMailの通知先一覧を取得する. + * リレーション先の{@link MiUser}および{@link MiUserProfile}も同時に取得する. + * + * @param {Object} [opts] + * @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true) + * @see removeUnauthorizedRecipientUsers + */ + @bindThis + public async fetchEMailRecipients(opts?: { + removeUnauthorized?: boolean + }): Promise { + return this.fetchRecipients({ method: ['email'] }, { joinUser: true, ...opts }); + } + + /** + * Webhookの通知先一覧を取得する. + * リレーション先の{@link MiSystemWebhook}も同時に取得する. + */ + @bindThis + public fetchWebhookRecipients(): Promise { + return this.fetchRecipients({ method: ['webhook'] }, { joinSystemWebhook: true }); + } + + /** + * 通知先を作成する. + */ + @bindThis + public async createRecipient( + params: { + isActive: MiAbuseReportNotificationRecipient['isActive']; + name: MiAbuseReportNotificationRecipient['name']; + method: MiAbuseReportNotificationRecipient['method']; + userId: MiAbuseReportNotificationRecipient['userId']; + systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId']; + }, + updater: MiUser, + ): Promise { + const id = this.idService.gen(); + await this.abuseReportNotificationRecipientRepository.insert({ + ...params, + id, + }); + + const created = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: id }); + + this.moderationLogService + .log(updater, 'createAbuseReportNotificationRecipient', { + recipientId: id, + recipient: created, + }) + .then(); + + return created; + } + + /** + * 通知先を更新する. + */ + @bindThis + public async updateRecipient( + params: { + id: MiAbuseReportNotificationRecipient['id']; + isActive: MiAbuseReportNotificationRecipient['isActive']; + name: MiAbuseReportNotificationRecipient['name']; + method: MiAbuseReportNotificationRecipient['method']; + userId: MiAbuseReportNotificationRecipient['userId']; + systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId']; + }, + updater: MiUser, + ): Promise { + const beforeEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id }); + + await this.abuseReportNotificationRecipientRepository.update(params.id, { + isActive: params.isActive, + updatedAt: new Date(), + name: params.name, + method: params.method, + userId: params.userId, + systemWebhookId: params.systemWebhookId, + }); + + const afterEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id }); + + this.moderationLogService + .log(updater, 'updateAbuseReportNotificationRecipient', { + recipientId: params.id, + before: beforeEntity, + after: afterEntity, + }) + .then(); + + return afterEntity; + } + + /** + * 通知先を削除する. + */ + @bindThis + public async deleteRecipient( + id: MiAbuseReportNotificationRecipient['id'], + updater: MiUser, + ) { + const entity = await this.abuseReportNotificationRecipientRepository.findBy({ id }); + + await this.abuseReportNotificationRecipientRepository.delete(id); + + this.moderationLogService + .log(updater, 'deleteAbuseReportNotificationRecipient', { + recipientId: id, + recipient: entity, + }) + .then(); + } + + /** + * モデレータ権限を持たない(*1)通知先ユーザを削除する. + * + * *1: 以下の両方を満たすものの事を言う + * - 通知先にユーザIDが設定されている + * - 付与ロールにモデレータ権限がない or アサインの有効期限が切れている + * + * @param recipients 通知先一覧の配列 + * @returns {@lisk recipients}からモデレータ権限を持たない通知先を削除した配列 + */ + @bindThis + private async removeUnauthorizedRecipientUsers(recipients: MiAbuseReportNotificationRecipient[]): Promise { + const userRecipients = recipients.filter(it => it.userId !== null); + const recipientUserIds = new Set(userRecipients.map(it => it.userId).filter(isNotNull)); + if (recipientUserIds.size <= 0) { + // ユーザが通知先として設定されていない場合、この関数での処理を行うべきレコードが無い + return recipients; + } + + // モデレータ権限の有無で通知先設定を振り分ける + const authorizedUserIds = await this.roleService.getModeratorIds(true, true); + const authorizedUserRecipients = Array.of(); + const unauthorizedUserRecipients = Array.of(); + for (const recipient of userRecipients) { + // eslint-disable-next-line + if (authorizedUserIds.includes(recipient.userId!)) { + authorizedUserRecipients.push(recipient); + } else { + unauthorizedUserRecipients.push(recipient); + } + } + + // モデレータ権限を持たない通知先をDBから削除する + if (unauthorizedUserRecipients.length > 0) { + await this.abuseReportNotificationRecipientRepository.delete(unauthorizedUserRecipients.map(it => it.id)); + } + const nonUserRecipients = recipients.filter(it => it.userId === null); + return [...nonUserRecipients, ...authorizedUserRecipients].sort((a, b) => a.id.localeCompare(b.id)); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'roleUpdated': + case 'roleDeleted': + case 'userRoleUnassigned': { + // 場合によってはキャッシュ更新よりも先にここが呼ばれてしまう可能性があるのでnextTickで遅延実行 + process.nextTick(async () => { + const recipients = await this.abuseReportNotificationRecipientRepository.findBy({ + userId: Not(IsNull()), + }); + await this.removeUnauthorizedRecipientUsers(recipients); + }); + break; + } + default: { + break; + } + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts new file mode 100644 index 0000000000..69c51509ba --- /dev/null +++ b/packages/backend/src/core/AbuseReportService.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { IdService } from './IdService.js'; + +@Injectable() +export class AbuseReportService { + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, + private abuseReportNotificationService: AbuseReportNotificationService, + private queueService: QueueService, + private instanceActorService: InstanceActorService, + private apRendererService: ApRendererService, + private moderationLogService: ModerationLogService, + ) { + } + + /** + * ユーザからの通報をDBに記録し、その内容を下記の手段で管理者各位に通知する. + * - 管理者用Redisイベント + * - EMail(モデレータ権限所有者ユーザ+metaテーブルに設定されているメールアドレス) + * - SystemWebhook + * + * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える + * @see AbuseReportNotificationService.notify + */ + @bindThis + public async report(params: { + targetUserId: MiAbuseUserReport['targetUserId'], + targetUserHost: MiAbuseUserReport['targetUserHost'], + reporterId: MiAbuseUserReport['reporterId'], + reporterHost: MiAbuseUserReport['reporterHost'], + comment: string, + }[]) { + const entities = params.map(param => { + return { + id: this.idService.gen(), + targetUserId: param.targetUserId, + targetUserHost: param.targetUserHost, + reporterId: param.reporterId, + reporterHost: param.reporterHost, + comment: param.comment, + }; + }); + + const reports = Array.of(); + for (const entity of entities) { + const report = await this.abuseUserReportsRepository.insertOne(entity); + reports.push(report); + } + + return Promise.all([ + this.abuseReportNotificationService.notifyAdminStream(reports), + this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'), + this.abuseReportNotificationService.notifyMail(reports), + ]); + } + + /** + * 通報を解決し、その内容を下記の手段で管理者各位に通知する. + * - SystemWebhook + * + * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える + * @param operator 通報を処理したユーザ + * @see AbuseReportNotificationService.notify + */ + @bindThis + public async resolve( + params: { + reportId: string; + forward: boolean; + }[], + operator: MiUser, + ) { + const paramsMap = new Map(params.map(it => [it.reportId, it])); + const reports = await this.abuseUserReportsRepository.findBy({ + id: In(params.map(it => it.reportId)), + }); + + for (const report of reports) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ps = paramsMap.get(report.id)!; + + await this.abuseUserReportsRepository.update(report.id, { + resolved: true, + assigneeId: operator.id, + forwarded: ps.forward && report.targetUserHost !== null, + }); + + if (ps.forward && report.targetUserHost != null) { + const actor = await this.instanceActorService.getInstanceActor(); + const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); + + // eslint-disable-next-line + const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); + const contextAssignedFlag = this.apRendererService.addContext(flag); + this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); + } + + this.moderationLogService + .log(operator, 'resolveAbuseReport', { + reportId: report.id, + report: report, + forwarded: ps.forward && report.targetUserHost !== null, + }) + .then(); + } + + return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) + .then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved')); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index be80df6f1c..b5b34487ec 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -5,6 +5,13 @@ import { Module } from '@nestjs/common'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -56,7 +63,7 @@ import { UserMutingService } from './UserMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; -import { WebhookService } from './WebhookService.js'; +import { UserWebhookService } from './UserWebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; @@ -144,6 +151,8 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService }; +const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; @@ -196,7 +205,8 @@ const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; -const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; +const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; +const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; @@ -225,6 +235,7 @@ const $ChartManagementService: Provider = { provide: 'ChartManagementService', u const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; +const $AbuseReportNotificationRecipientEntityService: Provider = { provide: 'AbuseReportNotificationRecipientEntityService', useExisting: AbuseReportNotificationRecipientEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -258,6 +269,7 @@ const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', u const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService }; const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService }; const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService }; +const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService }; const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; @@ -285,6 +297,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AbuseReportService, + AbuseReportNotificationService, AccountMoveService, AccountUpdateService, AiService, @@ -337,7 +351,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserSuspendService, UserAuthService, VideoProcessingService, - WebhookService, + UserWebhookService, + SystemWebhookService, UtilityService, FileInfoService, SearchService, @@ -366,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AbuseUserReportEntityService, AnnouncementEntityService, + AbuseReportNotificationRecipientEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -399,6 +415,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting RoleEntityService, ReversiGameEntityService, MetaEntityService, + SystemWebhookEntityService, ApAudienceService, ApDbResolverService, @@ -422,6 +439,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AbuseReportService, + $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, $AiService, @@ -474,7 +493,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserSuspendService, $UserAuthService, $VideoProcessingService, - $WebhookService, + $UserWebhookService, + $SystemWebhookService, $UtilityService, $FileInfoService, $SearchService, @@ -503,6 +523,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AbuseUserReportEntityService, $AnnouncementEntityService, + $AbuseReportNotificationRecipientEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -536,6 +557,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $RoleEntityService, $ReversiGameEntityService, $MetaEntityService, + $SystemWebhookEntityService, $ApAudienceService, $ApDbResolverService, @@ -560,6 +582,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AbuseReportService, + AbuseReportNotificationService, AccountMoveService, AccountUpdateService, AiService, @@ -612,7 +636,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserSuspendService, UserAuthService, VideoProcessingService, - WebhookService, + UserWebhookService, + SystemWebhookService, UtilityService, FileInfoService, SearchService, @@ -640,6 +665,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AbuseUserReportEntityService, AnnouncementEntityService, + AbuseReportNotificationRecipientEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -673,6 +699,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting RoleEntityService, ReversiGameEntityService, MetaEntityService, + SystemWebhookEntityService, ApAudienceService, ApDbResolverService, @@ -696,6 +723,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AbuseReportService, + $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, $AiService, @@ -748,7 +777,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserSuspendService, $UserAuthService, $VideoProcessingService, - $WebhookService, + $UserWebhookService, + $SystemWebhookService, $UtilityService, $FileInfoService, $SearchService, @@ -776,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AbuseUserReportEntityService, $AnnouncementEntityService, + $AbuseReportNotificationRecipientEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -809,6 +840,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $RoleEntityService, $ReversiGameEntityService, $MetaEntityService, + $SystemWebhookEntityService, $ApAudienceService, $ApDbResolverService, diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 08f8f80a6e..435dbbae28 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -16,6 +16,7 @@ import type { UserProfilesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class EmailService { @@ -32,6 +33,7 @@ export class EmailService { private loggerService: LoggerService, private utilityService: UtilityService, private httpRequestService: HttpRequestService, + private queueService: QueueService, ) { this.logger = this.loggerService.getLogger('email'); } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 90efd63f3a..a70743bed2 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -18,6 +18,7 @@ import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiSignin } from '@/models/Signin.js'; import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -227,6 +228,9 @@ export interface InternalEventTypes { webhookCreated: MiWebhook; webhookDeleted: MiWebhook; webhookUpdated: MiWebhook; + systemWebhookCreated: MiSystemWebhook; + systemWebhookDeleted: MiSystemWebhook; + systemWebhookUpdated: MiSystemWebhook; antennaCreated: MiAntenna; antennaDeleted: MiAntenna; antennaUpdated: MiAntenna; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index e5580f36d1..0c9de117d2 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -38,7 +38,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; import { QueueService } from '@/core/QueueService.js'; @@ -205,7 +205,7 @@ export class NoteCreateService implements OnApplicationShutdown { private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, private antennaService: AntennaService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private featuredService: FeaturedService, private remoteUserResolveService: RemoteUserResolveService, private apDeliverManagerService: ApDeliverManagerService, @@ -606,7 +606,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.webhookService.getActiveWebhooks().then(webhooks => { webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'note', { + this.queueService.userWebhookDeliver(webhook, 'note', { note: noteObj, }); } @@ -633,7 +633,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'reply', { + this.queueService.userWebhookDeliver(webhook, 'reply', { note: noteObj, }); } @@ -656,7 +656,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'renote', { + this.queueService.userWebhookDeliver(webhook, 'renote', { note: noteObj, }); } @@ -788,7 +788,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'mention', { + this.queueService.userWebhookDeliver(webhook, 'mention', { note: detailPackedNote, }); } diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 216734e9e5..b10b8e5899 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -7,10 +7,17 @@ import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { QUEUE, baseQueueOptions } from '@/queue/const.js'; +import { baseQueueOptions, QUEUE } from '@/queue/const.js'; import { allSettled } from '@/misc/promise-tracker.js'; +import { + DeliverJobData, + EndedPollNotificationJobData, + InboxJobData, + RelationshipJobData, + UserWebhookDeliverJobData, + SystemWebhookDeliverJobData, +} from '../queue/types.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; @@ -19,7 +26,8 @@ export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; -export type WebhookDeliverQueue = Bull.Queue; +export type UserWebhookDeliverQueue = Bull.Queue; +export type SystemWebhookDeliverQueue = Bull.Queue; const $system: Provider = { provide: 'queue:system', @@ -63,9 +71,15 @@ const $objectStorage: Provider = { inject: [DI.config], }; -const $webhookDeliver: Provider = { - provide: 'queue:webhookDeliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)), +const $userWebhookDeliver: Provider = { + provide: 'queue:userWebhookDeliver', + useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)), + inject: [DI.config], +}; + +const $systemWebhookDeliver: Provider = { + provide: 'queue:systemWebhookDeliver', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)), inject: [DI.config], }; @@ -80,7 +94,8 @@ const $webhookDeliver: Provider = { $db, $relationship, $objectStorage, - $webhookDeliver, + $userWebhookDeliver, + $systemWebhookDeliver, ], exports: [ $system, @@ -90,7 +105,8 @@ const $webhookDeliver: Provider = { $db, $relationship, $objectStorage, - $webhookDeliver, + $userWebhookDeliver, + $systemWebhookDeliver, ], }) export class QueueModule implements OnApplicationShutdown { @@ -102,7 +118,8 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) {} public async dispose(): Promise { @@ -117,7 +134,8 @@ export class QueueModule implements OnApplicationShutdown { this.dbQueue.close(), this.relationshipQueue.close(), this.objectStorageQueue.close(), - this.webhookDeliverQueue.close(), + this.userWebhookDeliverQueue.close(), + this.systemWebhookDeliverQueue.close(), ]); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index c258a22927..80827a500b 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -8,15 +8,33 @@ import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; +import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; +import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import type { + DbJobData, + DeliverJobData, + RelationshipJobData, + SystemWebhookDeliverJobData, + ThinUser, + UserWebhookDeliverJobData, +} from '../queue/types.js'; +import type { + DbQueue, + DeliverQueue, + EndedPollNotificationQueue, + InboxQueue, + ObjectStorageQueue, + RelationshipQueue, + SystemQueue, + UserWebhookDeliverQueue, + SystemWebhookDeliverQueue, +} from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; -import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; @Injectable() export class QueueService { @@ -31,7 +49,8 @@ export class QueueService { @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { this.systemQueue.add('tickCharts', { }, { @@ -431,9 +450,13 @@ export class QueueService { }); } + /** + * @see UserWebhookDeliverJobData + * @see WebhookDeliverProcessorService + */ @bindThis - public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { - const data = { + public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { + const data: UserWebhookDeliverJobData = { type, content, webhookId: webhook.id, @@ -444,7 +467,33 @@ export class QueueService { eventId: randomUUID(), }; - return this.webhookDeliverQueue.add(webhook.id, data, { + return this.userWebhookDeliverQueue.add(webhook.id, data, { + attempts: 4, + backoff: { + type: 'custom', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + /** + * @see SystemWebhookDeliverJobData + * @see WebhookDeliverProcessorService + */ + @bindThis + public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) { + const data: SystemWebhookDeliverJobData = { + type, + content, + webhookId: webhook.id, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: randomUUID(), + }; + + return this.systemWebhookDeliverQueue.add(webhook.id, data, { attempts: 4, backoff: { type: 'custom', diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index d6eea70297..e2ebecb99f 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -410,14 +410,32 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getModeratorIds(includeAdmins = true): Promise { + public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ - roleId: In(moderatorRoles.map(r => r.id)), - }) : []; + const moderatorRoles = includeAdmins + ? roles.filter(r => r.isModerator || r.isAdministrator) + : roles.filter(r => r.isModerator); + // TODO: isRootなアカウントも含める - return assigns.map(a => a.userId); + const assigns = moderatorRoles.length > 0 + ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) + : []; + + const now = Date.now(); + const result = [ + // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) + ...new Set( + assigns + .filter(it => + (excludeExpire) + ? (it.expiresAt == null || it.expiresAt.getTime() > now) + : true, + ) + .map(a => a.userId), + ), + ]; + + return result.sort((x, y) => x.localeCompare(y)); } @bindThis diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts new file mode 100644 index 0000000000..bc6851f788 --- /dev/null +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { MiUser, SystemWebhooksRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class SystemWebhookService implements OnApplicationShutdown { + private logger: Logger; + private activeSystemWebhooksFetched = false; + private activeSystemWebhooks: MiSystemWebhook[] = []; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + private idService: IdService, + private queueService: QueueService, + private moderationLogService: ModerationLogService, + private loggerService: LoggerService, + private globalEventService: GlobalEventService, + ) { + this.redisForSub.on('message', this.onMessage); + this.logger = this.loggerService.getLogger('webhook'); + } + + @bindThis + public async fetchActiveSystemWebhooks() { + if (!this.activeSystemWebhooksFetched) { + this.activeSystemWebhooks = await this.systemWebhooksRepository.findBy({ + isActive: true, + }); + this.activeSystemWebhooksFetched = true; + } + + return this.activeSystemWebhooks; + } + + /** + * SystemWebhook の一覧を取得する. + */ + @bindThis + public async fetchSystemWebhooks(params?: { + ids?: MiSystemWebhook['id'][]; + isActive?: MiSystemWebhook['isActive']; + on?: MiSystemWebhook['on']; + }): Promise { + const query = this.systemWebhooksRepository.createQueryBuilder('systemWebhook'); + if (params) { + if (params.ids && params.ids.length > 0) { + query.andWhere('systemWebhook.id IN (:...ids)', { ids: params.ids }); + } + if (params.isActive !== undefined) { + query.andWhere('systemWebhook.isActive = :isActive', { isActive: params.isActive }); + } + if (params.on && params.on.length > 0) { + query.andWhere(':on <@ systemWebhook.on', { on: params.on }); + } + } + + return query.getMany(); + } + + /** + * SystemWebhook を作成する. + */ + @bindThis + public async createSystemWebhook( + params: { + isActive: MiSystemWebhook['isActive']; + name: MiSystemWebhook['name']; + on: MiSystemWebhook['on']; + url: MiSystemWebhook['url']; + secret: MiSystemWebhook['secret']; + }, + updater: MiUser, + ): Promise { + const id = this.idService.gen(); + await this.systemWebhooksRepository.insert({ + ...params, + id, + }); + + const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id }); + this.globalEventService.publishInternalEvent('systemWebhookCreated', webhook); + this.moderationLogService + .log(updater, 'createSystemWebhook', { + systemWebhookId: webhook.id, + webhook: webhook, + }) + .then(); + + return webhook; + } + + /** + * SystemWebhook を更新する. + */ + @bindThis + public async updateSystemWebhook( + params: { + id: MiSystemWebhook['id']; + isActive: MiSystemWebhook['isActive']; + name: MiSystemWebhook['name']; + on: MiSystemWebhook['on']; + url: MiSystemWebhook['url']; + secret: MiSystemWebhook['secret']; + }, + updater: MiUser, + ): Promise { + const beforeEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: params.id }); + await this.systemWebhooksRepository.update(beforeEntity.id, { + updatedAt: new Date(), + isActive: params.isActive, + name: params.name, + on: params.on, + url: params.url, + secret: params.secret, + }); + + const afterEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: beforeEntity.id }); + this.globalEventService.publishInternalEvent('systemWebhookUpdated', afterEntity); + this.moderationLogService + .log(updater, 'updateSystemWebhook', { + systemWebhookId: beforeEntity.id, + before: beforeEntity, + after: afterEntity, + }) + .then(); + + return afterEntity; + } + + /** + * SystemWebhook を削除する. + */ + @bindThis + public async deleteSystemWebhook(id: MiSystemWebhook['id'], updater: MiUser) { + const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id }); + await this.systemWebhooksRepository.delete(id); + + this.globalEventService.publishInternalEvent('systemWebhookDeleted', webhook); + this.moderationLogService + .log(updater, 'deleteSystemWebhook', { + systemWebhookId: webhook.id, + webhook, + }) + .then(); + } + + /** + * SystemWebhook をWebhook配送キューに追加する + * @see QueueService.systemWebhookDeliver + */ + @bindThis + public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) { + const webhookEntity = typeof webhook === 'string' + ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) + : webhook; + if (!webhookEntity || !webhookEntity.isActive) { + this.logger.info(`Webhook is not active or not found : ${webhook}`); + return; + } + + if (!webhookEntity.on.includes(type)) { + this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`); + return; + } + + return this.queueService.systemWebhookDeliver(webhookEntity, type, content); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'systemWebhookCreated': { + if (body.isActive) { + this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body)); + } + break; + } + case 'systemWebhookUpdated': { + if (body.isActive) { + const i = this.activeSystemWebhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.activeSystemWebhooks[i] = MiSystemWebhook.deserialize(body); + } else { + this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body)); + } + } else { + this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id); + } + break; + } + case 'systemWebhookDeleted': { + this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id); + break; + } + default: + break; + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 96f389b54c..2f1310b8ef 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -16,7 +16,7 @@ import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -46,7 +46,7 @@ export class UserBlockingService implements OnModuleInit { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private apRendererService: ApRendererService, private loggerService: LoggerService, ) { @@ -121,7 +121,7 @@ export class UserBlockingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packed, }); } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 406ea04031..267a6a3f1b 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -16,7 +16,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; @@ -82,7 +82,7 @@ export class UserFollowingService implements OnModuleInit { private metaService: MetaService, private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private apRendererService: ApRendererService, private accountMoveService: AccountMoveService, private fanoutTimelineService: FanoutTimelineService, @@ -331,7 +331,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'follow', { + this.queueService.userWebhookDeliver(webhook, 'follow', { user: packed, }); } @@ -345,7 +345,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'followed', { + this.queueService.userWebhookDeliver(webhook, 'followed', { user: packed, }); } @@ -398,7 +398,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packed, }); } @@ -740,7 +740,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packedFollowee, }); } diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts new file mode 100644 index 0000000000..e96bfeea95 --- /dev/null +++ b/packages/backend/src/core/UserWebhookService.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { WebhooksRepository } from '@/models/_.js'; +import type { MiWebhook } from '@/models/Webhook.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class UserWebhookService implements OnApplicationShutdown { + private activeWebhooksFetched = false; + private activeWebhooks: MiWebhook[] = []; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + public async getActiveWebhooks() { + if (!this.activeWebhooksFetched) { + this.activeWebhooks = await this.webhooksRepository.findBy({ + active: true, + }); + this.activeWebhooksFetched = true; + } + + return this.activeWebhooks; + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'webhookCreated': { + if (body.active) { + this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }); + } + break; + } + case 'webhookUpdated': { + if (body.active) { + const i = this.activeWebhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.activeWebhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }; + } else { + this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }); + } + } else { + this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id); + } + break; + } + case 'webhookDeleted': { + this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id); + break; + } + default: + break; + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts deleted file mode 100644 index 6be34977b0..0000000000 --- a/packages/backend/src/core/WebhookService.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { WebhooksRepository } from '@/models/_.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class WebhookService implements OnApplicationShutdown { - private webhooksFetched = false; - private webhooks: MiWebhook[] = []; - - constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - ) { - //this.onMessage = this.onMessage.bind(this); - this.redisForSub.on('message', this.onMessage); - } - - @bindThis - public async getActiveWebhooks() { - if (!this.webhooksFetched) { - this.webhooks = await this.webhooksRepository.findBy({ - active: true, - }); - this.webhooksFetched = true; - } - - return this.webhooks; - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'webhookCreated': - if (body.active) { - this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }); - } - break; - case 'webhookUpdated': - if (body.active) { - const i = this.webhooks.findIndex(a => a.id === body.id); - if (i > -1) { - this.webhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }; - } else { - this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }); - } - } else { - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - } - break; - case 'webhookDeleted': - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - break; - default: - break; - } - } - } - - @bindThis - public dispose(): void { - this.redisForSub.off('message', this.onMessage); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index d0d206760c..de3178b482 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -29,6 +29,7 @@ import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -57,9 +58,6 @@ export class ApInboxService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -68,6 +66,7 @@ export class ApInboxService { private utilityService: UtilityService, private idService: IdService, private metaService: MetaService, + private abuseReportService: AbuseReportService, private userFollowingService: UserFollowingService, private apAudienceService: ApAudienceService, private reactionService: ReactionService, @@ -545,14 +544,13 @@ export class ApInboxService { }); if (users.length < 1) return 'skip'; - await this.abuseUserReportsRepository.insert({ - id: this.idService.gen(), + await this.abuseReportService.report([{ targetUserId: users[0].id, targetUserHost: users[0].host, reporterId: actor.id, reporterHost: actor.host, comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, - }); + }]); return 'ok'; } diff --git a/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts new file mode 100644 index 0000000000..6819afafd9 --- /dev/null +++ b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; + +@Injectable() +export class AbuseReportNotificationRecipientEntityService { + constructor( + @Inject(DI.abuseReportNotificationRecipientRepository) + private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + private userEntityService: UserEntityService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + } + + @bindThis + public async pack( + src: MiAbuseReportNotificationRecipient['id'] | MiAbuseReportNotificationRecipient, + opts?: { + users: Map>, + webhooks: Map>, + }, + ): Promise> { + const recipient = typeof src === 'object' + ? src + : await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: src }); + const user = recipient.userId + ? (opts?.users.get(recipient.userId) ?? await this.userEntityService.pack<'UserLite'>(recipient.userId)) + : undefined; + const webhook = recipient.systemWebhookId + ? (opts?.webhooks.get(recipient.systemWebhookId) ?? await this.systemWebhookEntityService.pack(recipient.systemWebhookId)) + : undefined; + + return { + id: recipient.id, + isActive: recipient.isActive, + updatedAt: recipient.updatedAt.toISOString(), + name: recipient.name, + method: recipient.method, + userId: recipient.userId ?? undefined, + user: user, + systemWebhookId: recipient.systemWebhookId ?? undefined, + systemWebhook: webhook, + }; + } + + @bindThis + public async packMany( + src: MiAbuseReportNotificationRecipient['id'][] | MiAbuseReportNotificationRecipient[], + ): Promise[]> { + const objs = src.filter((it): it is MiAbuseReportNotificationRecipient => typeof it === 'object'); + const ids = src.filter((it): it is MiAbuseReportNotificationRecipient['id'] => typeof it === 'string'); + if (ids.length > 0) { + objs.push( + ...await this.abuseReportNotificationRecipientRepository.findBy({ id: In(ids) }), + ); + } + + const userIds = objs.map(it => it.userId).filter(isNotNull); + const users: Map> = (userIds.length > 0) + ? await this.userEntityService.packMany(userIds) + .then(it => new Map(it.map(it => [it.id, it]))) + : new Map(); + + const systemWebhookIds = objs.map(it => it.systemWebhookId).filter(isNotNull); + const systemWebhooks: Map> = (systemWebhookIds.length > 0) + ? await this.systemWebhookEntityService.packMany(systemWebhookIds) + .then(it => new Map(it.map(it => [it.id, it]))) + : new Map(); + + return Promise + .all( + objs.map(it => this.pack(it, { users: users, webhooks: systemWebhooks })), + ) + .then(it => it.sort((a, b) => a.id.localeCompare(b.id))); + } +} + diff --git a/packages/backend/src/core/entities/SystemWebhookEntityService.ts b/packages/backend/src/core/entities/SystemWebhookEntityService.ts new file mode 100644 index 0000000000..e18734091c --- /dev/null +++ b/packages/backend/src/core/entities/SystemWebhookEntityService.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { MiSystemWebhook, SystemWebhooksRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; + +@Injectable() +export class SystemWebhookEntityService { + constructor( + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + ) { + } + + @bindThis + public async pack( + src: MiSystemWebhook['id'] | MiSystemWebhook, + opts?: { + webhooks: Map + }, + ): Promise> { + const webhook = typeof src === 'object' + ? src + : opts?.webhooks.get(src) ?? await this.systemWebhooksRepository.findOneByOrFail({ id: src }); + + return { + id: webhook.id, + isActive: webhook.isActive, + updatedAt: webhook.updatedAt.toISOString(), + latestSentAt: webhook.latestSentAt?.toISOString() ?? null, + latestStatus: webhook.latestStatus, + name: webhook.name, + on: webhook.on, + url: webhook.url, + secret: webhook.secret, + }; + } + + @bindThis + public async packMany(src: MiSystemWebhook['id'][] | MiSystemWebhook[]): Promise[]> { + if (src.length === 0) { + return []; + } + + const webhooks = Array.of(); + webhooks.push( + ...src.filter((it): it is MiSystemWebhook => typeof it === 'object'), + ); + + const ids = src.filter((it): it is MiSystemWebhook['id'] => typeof it === 'string'); + if (ids.length > 0) { + webhooks.push( + ...await this.systemWebhooksRepository.findBy({ id: In(ids) }), + ); + } + + return Promise + .all( + webhooks.map(x => + this.pack(x, { + webhooks: new Map(webhooks.map(x => [x.id, x])), + }), + ), + ) + .then(it => it.sort((a, b) => a.id.localeCompare(b.id))); + } +} + diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 919f4794a3..271082b4ff 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -49,6 +49,7 @@ export const DI = { swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), abuseUserReportsRepository: Symbol('abuseUserReportsRepository'), + abuseReportNotificationRecipientRepository: Symbol('abuseReportNotificationRecipientRepository'), registrationTicketsRepository: Symbol('registrationTicketsRepository'), authSessionsRepository: Symbol('authSessionsRepository'), accessTokensRepository: Symbol('accessTokensRepository'), @@ -70,6 +71,7 @@ export const DI = { channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), + systemWebhooksRepository: Symbol('systemWebhooksRepository'), adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 41e5bfe9e4..a721b8663c 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -4,12 +4,12 @@ */ import { - packedUserLiteSchema, - packedUserDetailedNotMeOnlySchema, packedMeDetailedOnlySchema, - packedUserDetailedNotMeSchema, packedMeDetailedSchema, + packedUserDetailedNotMeOnlySchema, + packedUserDetailedNotMeSchema, packedUserDetailedSchema, + packedUserLiteSchema, packedUserSchema, } from '@/models/json-schema/user.js'; import { packedNoteSchema } from '@/models/json-schema/note.js'; @@ -25,7 +25,7 @@ import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js'; -import { packedPageSchema, packedPageBlockSchema } from '@/models/json-schema/page.js'; +import { packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedChannelSchema } from '@/models/json-schema/channel.js'; import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; @@ -38,25 +38,27 @@ import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; import { - packedRoleLiteSchema, - packedRoleSchema, - packedRolePoliciesSchema, + packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaLogicsSchema, - packedRoleCondFormulaValueNot, - packedRoleCondFormulaValueIsLocalOrRemoteSchema, packedRoleCondFormulaValueAssignedRoleSchema, packedRoleCondFormulaValueCreatedSchema, - packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, + packedRoleCondFormulaValueIsLocalOrRemoteSchema, + packedRoleCondFormulaValueNot, packedRoleCondFormulaValueSchema, packedRoleCondFormulaValueUserSettingBooleanSchema, + packedRoleLiteSchema, + packedRolePoliciesSchema, + packedRoleSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; -import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; +import { packedReversiGameDetailedSchema, packedReversiGameLiteSchema } from '@/models/json-schema/reversi-game.js'; import { - packedMetaLiteSchema, packedMetaDetailedOnlySchema, packedMetaDetailedSchema, + packedMetaLiteSchema, } from '@/models/json-schema/meta.js'; +import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; +import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -111,6 +113,8 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + SystemWebhook: packedSystemWebhookSchema, + AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts new file mode 100644 index 0000000000..fbff880afc --- /dev/null +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; +import { MiUserProfile } from '@/models/UserProfile.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +/** + * 通報受信時に通知を送信する方法. + */ +export type RecipientMethod = 'email' | 'webhook'; + +@Entity('abuse_report_notification_recipient') +export class MiAbuseReportNotificationRecipient { + @PrimaryColumn(id()) + public id: string; + + /** + * 有効かどうか. + */ + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + /** + * 更新日時. + */ + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + /** + * 通知設定名. + */ + @Column('varchar', { + length: 255, + }) + public name: string; + + /** + * 通知方法. + */ + @Index() + @Column('varchar', { + length: 64, + }) + public method: RecipientMethod; + + /** + * 通知先のユーザID. + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userId: MiUser['id'] | null; + + /** + * 通知先のユーザ. + */ + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId1' }) + public user: MiUser | null; + + /** + * 通知先のユーザプロフィール. + */ + @ManyToOne(type => MiUserProfile, {}) + @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) + public userProfile: MiUserProfile | null; + + /** + * 通知先のシステムWebhookId. + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public systemWebhookId: string | null; + + /** + * 通知先のシステムWebhook. + */ + @ManyToOne(type => MiSystemWebhook, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public systemWebhook: MiSystemWebhook | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index d3062d6b36..ea0f88baba 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -3,11 +3,83 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiRepository, MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame, miRepository } from './_.js'; +import { + MiAbuseReportNotificationRecipient, + MiAbuseUserReport, + MiAccessToken, + MiAd, + MiAnnouncement, + MiAnnouncementRead, + MiAntenna, + MiApp, + MiAuthSession, + MiAvatarDecoration, + MiBlocking, + MiBubbleGameRecord, + MiChannel, + MiChannelFavorite, + MiChannelFollowing, + MiClip, + MiClipFavorite, + MiClipNote, + MiDriveFile, + MiDriveFolder, + MiEmoji, + MiFlash, + MiFlashLike, + MiFollowing, + MiFollowRequest, + MiGalleryLike, + MiGalleryPost, + MiHashtag, + MiInstance, + MiMeta, + MiModerationLog, + MiMuting, + MiNote, + MiNoteFavorite, + MiNoteReaction, + MiNoteThreadMuting, + MiNoteUnread, + MiPage, + MiPageLike, + MiPasswordResetRequest, + MiPoll, + MiPollVote, + MiPromoNote, + MiPromoRead, + MiRegistrationTicket, + MiRegistryItem, + MiRelay, + MiRenoteMuting, + MiRepository, + miRepository, + MiRetentionAggregation, + MiReversiGame, + MiRole, + MiRoleAssignment, + MiSignin, + MiSwSubscription, + MiSystemWebhook, + MiUsedUsername, + MiUser, + MiUserIp, + MiUserKeypair, + MiUserList, + MiUserListFavorite, + MiUserListMembership, + MiUserMemo, + MiUserNotePining, + MiUserPending, + MiUserProfile, + MiUserPublickey, + MiUserSecurityKey, + MiWebhook +} from './_.js'; import type { DataSource } from 'typeorm'; -import type { Provider } from '@nestjs/common'; const $usersRepository: Provider = { provide: DI.usersRepository, @@ -225,6 +297,12 @@ const $abuseUserReportsRepository: Provider = { inject: [DI.db], }; +const $abuseReportNotificationRecipientRepository: Provider = { + provide: DI.abuseReportNotificationRecipientRepository, + useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient), + inject: [DI.db], +}; + const $registrationTicketsRepository: Provider = { provide: DI.registrationTicketsRepository, useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket).extend(miRepository as MiRepository), @@ -351,6 +429,12 @@ const $webhooksRepository: Provider = { inject: [DI.db], }; +const $systemWebhooksRepository: Provider = { + provide: DI.systemWebhooksRepository, + useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook), + inject: [DI.db], +}; + const $adsRepository: Provider = { provide: DI.adsRepository, useFactory: (db: DataSource) => db.getRepository(MiAd).extend(miRepository as MiRepository), @@ -412,8 +496,7 @@ const $reversiGamesRepository: Provider = { }; @Module({ - imports: [ - ], + imports: [], providers: [ $usersRepository, $notesRepository, @@ -451,6 +534,7 @@ const $reversiGamesRepository: Provider = { $swSubscriptionsRepository, $hashtagsRepository, $abuseUserReportsRepository, + $abuseReportNotificationRecipientRepository, $registrationTicketsRepository, $authSessionsRepository, $accessTokensRepository, @@ -472,6 +556,7 @@ const $reversiGamesRepository: Provider = { $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, + $systemWebhooksRepository, $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, @@ -520,6 +605,7 @@ const $reversiGamesRepository: Provider = { $swSubscriptionsRepository, $hashtagsRepository, $abuseUserReportsRepository, + $abuseReportNotificationRecipientRepository, $registrationTicketsRepository, $authSessionsRepository, $accessTokensRepository, @@ -541,6 +627,7 @@ const $reversiGamesRepository: Provider = { $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, + $systemWebhooksRepository, $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, @@ -553,4 +640,5 @@ const $reversiGamesRepository: Provider = { $reversiGamesRepository, ], }) -export class RepositoryModule {} +export class RepositoryModule { +} diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts new file mode 100644 index 0000000000..86fb323d1d --- /dev/null +++ b/packages/backend/src/models/SystemWebhook.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { Serialized } from '@/types.js'; +import { id } from './util/id.js'; + +export const systemWebhookEventTypes = [ + // ユーザからの通報を受けたとき + 'abuseReport', + // 通報を処理したとき + 'abuseReportResolved', +] as const; +export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; + +@Entity('system_webhook') +export class MiSystemWebhook { + @PrimaryColumn(id()) + public id: string; + + /** + * 有効かどうか. + */ + @Index('IDX_system_webhook_isActive', { synchronize: false }) + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + /** + * 更新日時. + */ + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + /** + * 最後に送信された日時. + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestSentAt: Date | null; + + /** + * 最後に送信されたステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; + + /** + * 通知設定名. + */ + @Column('varchar', { + length: 255, + }) + public name: string; + + /** + * イベント種別. + */ + @Index('IDX_system_webhook_on', { synchronize: false }) + @Column('varchar', { + length: 128, + array: true, + default: '{}', + }) + public on: SystemWebhookEventType[]; + + /** + * Webhook送信先のURL. + */ + @Column('varchar', { + length: 1024, + }) + public url: string; + + /** + * Webhook検証用の値. + */ + @Column('varchar', { + length: 1024, + }) + public secret: string; + + static deserialize(obj: Serialized): MiSystemWebhook { + return { + ...obj, + updatedAt: new Date(obj.updatedAt), + latestSentAt: obj.latestSentAt ? new Date(obj.latestSentAt) : null, + }; + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 2e6a41586e..d366ce48d0 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -11,6 +11,7 @@ import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transfor import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; @@ -68,6 +69,7 @@ import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiWebhook } from '@/models/Webhook.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; import { MiRole } from '@/models/Role.js'; @@ -144,6 +146,7 @@ export const miRepository = { export { MiAbuseUserReport, + MiAbuseReportNotificationRecipient, MiAccessToken, MiAd, MiAnnouncement, @@ -201,6 +204,7 @@ export { MiUserPublickey, MiUserSecurityKey, MiWebhook, + MiSystemWebhook, MiChannel, MiRetentionAggregation, MiRole, @@ -213,6 +217,7 @@ export { }; export type AbuseUserReportsRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; export type AccessTokensRepository = Repository & MiRepository; export type AdsRepository = Repository & MiRepository; export type AnnouncementsRepository = Repository & MiRepository; @@ -270,6 +275,7 @@ export type UserProfilesRepository = Repository & MiRepository & MiRepository; export type UserSecurityKeysRepository = Repository & MiRepository; export type WebhooksRepository = Repository & MiRepository; +export type SystemWebhooksRepository = Repository & MiRepository; export type ChannelsRepository = Repository & MiRepository; export type RetentionAggregationsRepository = Repository & MiRepository; export type RolesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts b/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts new file mode 100644 index 0000000000..6215f0f5a2 --- /dev/null +++ b/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedAbuseReportNotificationRecipientSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + method: { + type: 'string', + optional: false, nullable: false, + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + optional: true, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + systemWebhookId: { + type: 'string', + optional: true, nullable: false, + }, + systemWebhook: { + type: 'object', + optional: true, nullable: false, + ref: 'SystemWebhook', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/system-webhook.ts b/packages/backend/src/models/json-schema/system-webhook.ts new file mode 100644 index 0000000000..d83065a743 --- /dev/null +++ b/packages/backend/src/models/json-schema/system-webhook.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; + +export const packedSystemWebhookSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + latestSentAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + latestStatus: { + type: 'number', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + on: { + type: 'array', + items: { + type: 'string', + optional: false, nullable: false, + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + secret: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index aa2aa5e373..251a03c303 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,13 +5,12 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -pg.types.setTypeParser(20, Number); - import { DataSource, Logger } from 'typeorm'; import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; @@ -69,6 +68,7 @@ import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiWebhook } from '@/models/Webhook.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; import { MiRole } from '@/models/Role.js'; @@ -83,6 +83,8 @@ import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +pg.types.setTypeParser(20, Number); + export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); @@ -167,6 +169,7 @@ export const entities = [ MiHashtag, MiSwSubscription, MiAbuseUserReport, + MiAbuseReportNotificationRecipient, MiRegistrationTicket, MiSignin, MiModerationLog, @@ -185,6 +188,7 @@ export const entities = [ MiPasswordResetRequest, MiUserPending, MiWebhook, + MiSystemWebhook, MiUserIp, MiRetentionAggregation, MiRole, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 8086158997..a1fd38fcc5 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -11,7 +11,8 @@ import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; +import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; @@ -71,7 +72,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeleteFileProcessorService, CleanRemoteFilesProcessorService, RelationshipProcessorService, - WebhookDeliverProcessorService, + UserWebhookDeliverProcessorService, + SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, InboxProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7bfe1f4caa..7bd74f3210 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -10,7 +10,8 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; +import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; @@ -76,7 +77,8 @@ export class QueueProcessorService implements OnApplicationShutdown { private dbQueueWorker: Bull.Worker; private deliverQueueWorker: Bull.Worker; private inboxQueueWorker: Bull.Worker; - private webhookDeliverQueueWorker: Bull.Worker; + private userWebhookDeliverQueueWorker: Bull.Worker; + private systemWebhookDeliverQueueWorker: Bull.Worker; private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; @@ -86,7 +88,8 @@ export class QueueProcessorService implements OnApplicationShutdown { private config: Config, private queueLoggerService: QueueLoggerService, - private webhookDeliverProcessorService: WebhookDeliverProcessorService, + private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, + private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, @@ -160,13 +163,13 @@ export class QueueProcessorService implements OnApplicationShutdown { autorun: false, }); - const systemLogger = this.logger.createSubLogger('system'); + const logger = this.logger.createSubLogger('system'); this.systemQueueWorker - .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { - systemLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { level: 'error', @@ -174,8 +177,8 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => systemLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -217,13 +220,13 @@ export class QueueProcessorService implements OnApplicationShutdown { autorun: false, }); - const dbLogger = this.logger.createSubLogger('db'); + const logger = this.logger.createSubLogger('db'); this.dbQueueWorker - .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - dbLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { level: 'error', @@ -231,8 +234,8 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => dbLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -257,13 +260,13 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const deliverLogger = this.logger.createSubLogger('deliver'); + const logger = this.logger.createSubLogger('deliver'); this.deliverQueueWorker - .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - deliverLogger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { level: 'error', @@ -271,8 +274,8 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => deliverLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -297,13 +300,13 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const inboxLogger = this.logger.createSubLogger('inbox'); + const logger = this.logger.createSubLogger('inbox'); this.inboxQueueWorker - .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) - .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { - inboxLogger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { level: 'error', @@ -311,21 +314,21 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => inboxLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion - //#region webhook deliver + //#region user-webhook deliver { - this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => { + this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { if (this.config.sentryForBackend) { - return Sentry.startSpan({ name: 'Queue: WebhookDeliver' }, () => this.webhookDeliverProcessorService.process(job)); + return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); } else { - return this.webhookDeliverProcessorService.process(job); + return this.userWebhookDeliverProcessorService.process(job); } }, { - ...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER), + ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), autorun: false, concurrency: 64, limiter: { @@ -337,22 +340,62 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const webhookLogger = this.logger.createSubLogger('webhook'); + const logger = this.logger.createSubLogger('user-webhook'); - this.webhookDeliverQueueWorker - .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + this.userWebhookDeliverQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - webhookLogger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: WebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => webhookLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } + //#endregion + + //#region system-webhook deliver + { + this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); + } else { + return this.systemWebhookDeliverProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + autorun: false, + concurrency: 16, + limiter: { + max: 16, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const logger = this.logger.createSubLogger('system-webhook'); + + this.systemWebhookDeliverQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -384,13 +427,13 @@ export class QueueProcessorService implements OnApplicationShutdown { }, }); - const relationshipLogger = this.logger.createSubLogger('relationship'); + const logger = this.logger.createSubLogger('relationship'); this.relationshipQueueWorker - .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - relationshipLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { level: 'error', @@ -398,8 +441,8 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => relationshipLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -425,13 +468,13 @@ export class QueueProcessorService implements OnApplicationShutdown { concurrency: 16, }); - const objectStorageLogger = this.logger.createSubLogger('objectStorage'); + const logger = this.logger.createSubLogger('objectStorage'); this.objectStorageQueueWorker - .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - objectStorageLogger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); if (config.sentryForBackend) { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { level: 'error', @@ -439,8 +482,8 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } }) - .on('error', (err: Error) => objectStorageLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`)); + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -467,7 +510,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.dbQueueWorker.run(), this.deliverQueueWorker.run(), this.inboxQueueWorker.run(), - this.webhookDeliverQueueWorker.run(), + this.userWebhookDeliverQueueWorker.run(), + this.systemWebhookDeliverQueueWorker.run(), this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), @@ -481,7 +525,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.dbQueueWorker.close(), this.deliverQueueWorker.close(), this.inboxQueueWorker.close(), - this.webhookDeliverQueueWorker.close(), + this.userWebhookDeliverQueueWorker.close(), + this.systemWebhookDeliverQueueWorker.close(), this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 132e916612..67f689b618 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -14,7 +14,8 @@ export const QUEUE = { DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', - WEBHOOK_DELIVER: 'webhookDeliver', + USER_WEBHOOK_DELIVER: 'userWebhookDeliver', + SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', }; export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { diff --git a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts new file mode 100644 index 0000000000..f6bef52684 --- /dev/null +++ b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Bull from 'bullmq'; +import { DI } from '@/di-symbols.js'; +import type { SystemWebhooksRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { SystemWebhookDeliverJobData } from '../types.js'; + +@Injectable() +export class SystemWebhookDeliverProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + + private httpRequestService: HttpRequestService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + try { + this.logger.debug(`delivering ${job.data.webhookId}`); + + const res = await this.httpRequestService.send(job.data.to, { + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': this.config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + server: this.config.url, + hookId: job.data.webhookId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + this.systemWebhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + this.logger.error(res as Error); + + this.systemWebhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (!res.isRetryable) { + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); + } + + // 5xx etc. + throw new Error(`${res.statusCode} ${res.statusMessage}`); + } else { + // DNS error, socket error, timeout ... + throw res; + } + } + } +} diff --git a/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts new file mode 100644 index 0000000000..9ec630ef70 --- /dev/null +++ b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Bull from 'bullmq'; +import { DI } from '@/di-symbols.js'; +import type { WebhooksRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { UserWebhookDeliverJobData } from '../types.js'; + +@Injectable() +export class UserWebhookDeliverProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + + private httpRequestService: HttpRequestService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + try { + this.logger.debug(`delivering ${job.data.webhookId}`); + + const res = await this.httpRequestService.send(job.data.to, { + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': this.config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + server: this.config.url, + hookId: job.data.webhookId, + userId: job.data.userId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + this.webhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + this.webhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (!res.isRetryable) { + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); + } + + // 5xx etc. + throw new Error(`${res.statusCode} ${res.statusMessage}`); + } else { + // DNS error, socket error, timeout ... + throw res; + } + } + } +} diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts deleted file mode 100644 index 8c260c0137..0000000000 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Bull from 'bullmq'; -import { DI } from '@/di-symbols.js'; -import type { WebhooksRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type Logger from '@/logger.js'; -import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { StatusError } from '@/misc/status-error.js'; -import { bindThis } from '@/decorators.js'; -import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { WebhookDeliverJobData } from '../types.js'; - -@Injectable() -export class WebhookDeliverProcessorService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - - private httpRequestService: HttpRequestService, - private queueLoggerService: QueueLoggerService, - ) { - this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); - } - - @bindThis - public async process(job: Bull.Job): Promise { - try { - this.logger.debug(`delivering ${job.data.webhookId}`); - - const res = await this.httpRequestService.send(job.data.to, { - method: 'POST', - headers: { - 'User-Agent': 'Misskey-Hooks', - 'X-Misskey-Host': this.config.host, - 'X-Misskey-Hook-Id': job.data.webhookId, - 'X-Misskey-Hook-Secret': job.data.secret, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - server: this.config.url, - hookId: job.data.webhookId, - userId: job.data.userId, - eventId: job.data.eventId, - createdAt: job.data.createdAt, - type: job.data.type, - body: job.data.content, - }), - }); - - this.webhooksRepository.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res.status, - }); - - return 'Success'; - } catch (res) { - this.webhooksRepository.update({ id: job.data.webhookId }, { - latestSentAt: new Date(), - latestStatus: res instanceof StatusError ? res.statusCode : 1, - }); - - if (res instanceof StatusError) { - // 4xx - if (!res.isRetryable) { - throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); - } - - // 5xx etc. - throw new Error(`${res.statusCode} ${res.statusMessage}`); - } else { - // DNS error, socket error, timeout ... - throw res; - } - } - } -} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index ce57ba745e..a4077a0547 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -106,7 +106,17 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; -export type WebhookDeliverJobData = { +export type SystemWebhookDeliverJobData = { + type: string; + content: unknown; + webhookId: MiWebhook['id']; + to: string; + secret: string; + createdAt: number; + eventId: string; +}; + +export type UserWebhookDeliverJobData = { type: string; content: unknown; webhookId: MiWebhook['id']; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index c645f4bcc6..41576bedaa 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -6,8 +6,13 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; +import * as ep___admin_abuseReport_notificationRecipient_show from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; +import * as ep___admin_abuseReport_notificationRecipient_create from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; +import * as ep___admin_abuseReport_notificationRecipient_update from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; +import * as ep___admin_abuseReport_notificationRecipient_delete from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; +import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; @@ -82,6 +87,11 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; +import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; +import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; +import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; +import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; +import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -381,6 +391,11 @@ import type { Provider } from '@nestjs/common'; const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; +const $admin_abuseReport_notificationRecipient_list: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/list', useClass: ep___admin_abuseReport_notificationRecipient_list.default }; +const $admin_abuseReport_notificationRecipient_show: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/show', useClass: ep___admin_abuseReport_notificationRecipient_show.default }; +const $admin_abuseReport_notificationRecipient_create: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/create', useClass: ep___admin_abuseReport_notificationRecipient_create.default }; +const $admin_abuseReport_notificationRecipient_update: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/update', useClass: ep___admin_abuseReport_notificationRecipient_update.default }; +const $admin_abuseReport_notificationRecipient_delete: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/delete', useClass: ep___admin_abuseReport_notificationRecipient_delete.default }; const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; @@ -455,6 +470,11 @@ const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useCla const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; +const $admin_systemWebhook_create: Provider = { provide: 'ep:admin/system-webhook/create', useClass: ep___admin_systemWebhook_create.default }; +const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhook/delete', useClass: ep___admin_systemWebhook_delete.default }; +const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; +const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; +const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; @@ -758,6 +778,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ ApiLoggerService, $admin_meta, $admin_abuseUserReports, + $admin_abuseReport_notificationRecipient_list, + $admin_abuseReport_notificationRecipient_show, + $admin_abuseReport_notificationRecipient_create, + $admin_abuseReport_notificationRecipient_update, + $admin_abuseReport_notificationRecipient_delete, $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, @@ -832,6 +857,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_unassign, $admin_roles_updateDefaultPolicies, $admin_roles_users, + $admin_systemWebhook_create, + $admin_systemWebhook_delete, + $admin_systemWebhook_list, + $admin_systemWebhook_show, + $admin_systemWebhook_update, $announcements, $announcements_show, $antennas_create, @@ -1129,6 +1159,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ exports: [ $admin_meta, $admin_abuseUserReports, + $admin_abuseReport_notificationRecipient_list, + $admin_abuseReport_notificationRecipient_show, + $admin_abuseReport_notificationRecipient_create, + $admin_abuseReport_notificationRecipient_update, + $admin_abuseReport_notificationRecipient_delete, $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, @@ -1203,6 +1238,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_unassign, $admin_roles_updateDefaultPolicies, $admin_roles_users, + $admin_systemWebhook_create, + $admin_systemWebhook_delete, + $admin_systemWebhook_list, + $admin_systemWebhook_show, + $admin_systemWebhook_update, $announcements, $announcements_show, $antennas_create, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a38c62f35a..3dfb7fdad4 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -6,8 +6,18 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReport_notificationRecipient_list + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; +import * as ep___admin_abuseReport_notificationRecipient_show + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; +import * as ep___admin_abuseReport_notificationRecipient_create + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; +import * as ep___admin_abuseReport_notificationRecipient_update + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; +import * as ep___admin_abuseReport_notificationRecipient_delete + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; +import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; @@ -44,7 +54,8 @@ import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-c import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; +import * as ep___admin_federation_refreshRemoteInstanceMetadata + from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; @@ -82,6 +93,11 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; +import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; +import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; +import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; +import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; +import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; @@ -379,6 +395,11 @@ import * as ep___reversi_verify from './endpoints/reversi/verify.js'; const eps = [ ['admin/meta', ep___admin_meta], ['admin/abuse-user-reports', ep___admin_abuseUserReports], + ['admin/abuse-report/notification-recipient/list', ep___admin_abuseReport_notificationRecipient_list], + ['admin/abuse-report/notification-recipient/show', ep___admin_abuseReport_notificationRecipient_show], + ['admin/abuse-report/notification-recipient/create', ep___admin_abuseReport_notificationRecipient_create], + ['admin/abuse-report/notification-recipient/update', ep___admin_abuseReport_notificationRecipient_update], + ['admin/abuse-report/notification-recipient/delete', ep___admin_abuseReport_notificationRecipient_delete], ['admin/accounts/create', ep___admin_accounts_create], ['admin/accounts/delete', ep___admin_accounts_delete], ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], @@ -453,6 +474,11 @@ const eps = [ ['admin/roles/unassign', ep___admin_roles_unassign], ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], ['admin/roles/users', ep___admin_roles_users], + ['admin/system-webhook/create', ep___admin_systemWebhook_create], + ['admin/system-webhook/delete', ep___admin_systemWebhook_delete], + ['admin/system-webhook/list', ep___admin_systemWebhook_list], + ['admin/system-webhook/show', ep___admin_systemWebhook_show], + ['admin/system-webhook/update', ep___admin_systemWebhook_update], ['announcements', ep___announcements], ['announcements/show', ep___announcements_show], ['antennas/create', ep___antennas_create], @@ -873,8 +899,12 @@ export interface IEndpoint { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { return { name: name, - get meta() { return ep.meta ?? {}; }, - get params() { return ep.paramDef; }, + get meta() { + return ep.meta ?? {}; + }, + get params() { + return ep.paramDef; + }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts new file mode 100644 index 0000000000..bdfbcba518 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import type { UserProfilesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + correlationCheckEmail: { + message: 'If "method" is email, "userId" must be set.', + code: 'CORRELATION_CHECK_EMAIL', + id: '348bb8ae-575a-6fe9-4327-5811999def8f', + httpStatusCode: 400, + }, + correlationCheckWebhook: { + message: 'If "method" is webhook, "systemWebhookId" must be set.', + code: 'CORRELATION_CHECK_WEBHOOK', + id: 'b0c15051-de2d-29ef-260c-9585cddd701a', + httpStatusCode: 400, + }, + emailAddressNotSet: { + message: 'Email address is not set.', + code: 'EMAIL_ADDRESS_NOT_SET', + id: '7cc1d85e-2f58-fc31-b644-3de8d0d3421f', + httpStatusCode: 400, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + method: { + type: 'string', + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + format: 'misskey:id', + }, + systemWebhookId: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'isActive', + 'name', + 'method', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.method === 'email') { + const userProfile = await this.userProfilesRepository.findOneBy({ userId: ps.userId }); + if (!ps.userId || !userProfile) { + throw new ApiError(meta.errors.correlationCheckEmail); + } + + if (!userProfile.email || !userProfile.emailVerified) { + throw new ApiError(meta.errors.emailAddressNotSet); + } + } + + if (ps.method === 'webhook' && !ps.systemWebhookId) { + throw new ApiError(meta.errors.correlationCheckWebhook); + } + + const userId = ps.method === 'email' ? ps.userId : null; + const systemWebhookId = ps.method === 'webhook' ? ps.systemWebhookId : null; + const result = await this.abuseReportNotificationService.createRecipient( + { + isActive: ps.isActive, + name: ps.name, + method: ps.method, + userId: userId ?? null, + systemWebhookId: systemWebhookId ?? null, + }, + me, + ); + + return this.abuseReportNotificationRecipientEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts new file mode 100644 index 0000000000..b6dc44e09c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.abuseReportNotificationService.deleteRecipient( + ps.id, + me, + ); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts new file mode 100644 index 0000000000..dad9161a8a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'read:admin:abuse-report:notification-recipient', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + method: { + type: 'array', + items: { + type: 'string', + enum: ['email', 'webhook'], + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps) => { + const recipients = await this.abuseReportNotificationService.fetchRecipients({ method: ps.method }); + return this.abuseReportNotificationRecipientEntityService.packMany(recipients); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts new file mode 100644 index 0000000000..557798f946 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'read:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + noSuchRecipient: { + message: 'No such recipient.', + code: 'NO_SUCH_RECIPIENT', + id: '013de6a8-f757-04cb-4d73-cc2a7e3368e4', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps) => { + const recipients = await this.abuseReportNotificationService.fetchRecipients({ ids: [ps.id] }); + if (recipients.length === 0) { + throw new ApiError(meta.errors.noSuchRecipient); + } + + return this.abuseReportNotificationRecipientEntityService.pack(recipients[0]); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts new file mode 100644 index 0000000000..bd4b485217 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import type { UserProfilesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + correlationCheckEmail: { + message: 'If "method" is email, "userId" must be set.', + code: 'CORRELATION_CHECK_EMAIL', + id: '348bb8ae-575a-6fe9-4327-5811999def8f', + httpStatusCode: 400, + }, + correlationCheckWebhook: { + message: 'If "method" is webhook, "systemWebhookId" must be set.', + code: 'CORRELATION_CHECK_WEBHOOK', + id: 'b0c15051-de2d-29ef-260c-9585cddd701a', + httpStatusCode: 400, + }, + emailAddressNotSet: { + message: 'Email address is not set.', + code: 'EMAIL_ADDRESS_NOT_SET', + id: '7cc1d85e-2f58-fc31-b644-3de8d0d3421f', + httpStatusCode: 400, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + method: { + type: 'string', + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + format: 'misskey:id', + }, + systemWebhookId: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + 'isActive', + 'name', + 'method', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.method === 'email') { + const userProfile = await this.userProfilesRepository.findOneBy({ userId: ps.userId }); + if (!ps.userId || !userProfile) { + throw new ApiError(meta.errors.correlationCheckEmail); + } + + if (!userProfile.email || !userProfile.emailVerified) { + throw new ApiError(meta.errors.emailAddressNotSet); + } + } + + if (ps.method === 'webhook' && !ps.systemWebhookId) { + throw new ApiError(meta.errors.correlationCheckWebhook); + } + + const userId = ps.method === 'email' ? ps.userId : null; + const systemWebhookId = ps.method === 'webhook' ? ps.systemWebhookId : null; + const result = await this.abuseReportNotificationService.updateRecipient( + { + id: ps.id, + isActive: ps.isActive, + name: ps.name, + method: ps.method, + userId: userId ?? null, + systemWebhookId: systemWebhookId ?? null, + }, + me, + ); + + return this.abuseReportNotificationRecipientEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index 9694b3fa40..d7f9e4eaa3 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -53,7 +53,8 @@ export default class extends Endpoint { // eslint- @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { super(meta, paramDef, async (ps, me) => { const deliverJobCounts = await this.deliverQueue.getJobCounts(); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 8b0456068b..9b79100fcf 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -5,12 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, AbuseUserReportsRepository } from '@/models/_.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; export const meta = { tags: ['admin'], @@ -18,6 +16,16 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: 'ac3794dd-2ce4-d878-e546-73c60c06b398', + kind: 'server', + httpStatusCode: 404, + }, + }, } as const; export const paramDef = { @@ -29,47 +37,20 @@ export const paramDef = { required: ['reportId'], } as const; -// TODO: ロジックをサービスに切り出す - @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, - - private queueService: QueueService, - private instanceActorService: InstanceActorService, - private apRendererService: ApRendererService, - private moderationLogService: ModerationLogService, + private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); - - if (report == null) { - throw new Error('report not found'); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); } - if (ps.forward && report.targetUserHost != null) { - const actor = await this.instanceActorService.getInstanceActor(); - const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); - } - - await this.abuseUserReportsRepository.update(report.id, { - resolved: true, - assigneeId: me.id, - forwarded: ps.forward && report.targetUserHost != null, - }); - - this.moderationLogService.log(me, 'resolveAbuseReport', { - reportId: report.id, - report: report, - forwarded: ps.forward && report.targetUserHost != null, - }); + await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts new file mode 100644 index 0000000000..28071e7a33 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + secret: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + }, + required: [ + 'isActive', + 'name', + 'on', + 'url', + 'secret', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.systemWebhookService.createSystemWebhook( + { + isActive: ps.isActive, + name: ps.name, + on: ps.on, + url: ps.url, + secret: ps.secret, + }, + me, + ); + + return this.systemWebhookEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts new file mode 100644 index 0000000000..9cdfc7e70f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.systemWebhookService.deleteSystemWebhook( + ps.id, + me, + ); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts new file mode 100644 index 0000000000..7a440a774e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'SystemWebhook', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps) => { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ + isActive: ps.isActive, + on: ps.on, + }); + return this.systemWebhookEntityService.packMany(webhooks); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts new file mode 100644 index 0000000000..75862c96a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { ApiError } from '@/server/api/error.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, + + errors: { + noSuchSystemWebhook: { + message: 'No such SystemWebhook.', + code: 'NO_SUCH_SYSTEM_WEBHOOK', + id: '38dd1ffe-04b4-6ff5-d8ba-4e6a6ae22c9d', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps) => { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [ps.id] }); + if (webhooks.length === 0) { + throw new ApiError(meta.errors.noSuchSystemWebhook); + } + + return this.systemWebhookEntityService.pack(webhooks[0]); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts new file mode 100644 index 0000000000..8d68bb8f87 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + secret: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + }, + required: [ + 'id', + 'isActive', + 'name', + 'on', + 'url', + 'secret', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.systemWebhookService.updateSystemWebhook( + { + id: ps.id, + isActive: ps.isActive, + name: ps.name, + on: ps.on, + url: ps.url, + secret: ps.secret, + }, + me, + ); + + return this.systemWebhookEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 48e14b68cc..5ff6de37d2 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -3,17 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import sanitizeHtml from 'sanitize-html'; -import { Inject, Injectable } from '@nestjs/common'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -57,60 +51,32 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - private idService: IdService, - private metaService: MetaService, - private emailService: EmailService, private getterService: GetterService, private roleService: RoleService, - private globalEventService: GlobalEventService, + private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { + const targetUser = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; }); - if (user.id === me.id) { + if (targetUser.id === me.id) { throw new ApiError(meta.errors.cannotReportYourself); } - if (await this.roleService.isAdministrator(user)) { + if (await this.roleService.isAdministrator(targetUser)) { throw new ApiError(meta.errors.cannotReportAdmin); } - const report = await this.abuseUserReportsRepository.insertOne({ - id: this.idService.gen(), - targetUserId: user.id, - targetUserHost: user.host, + await this.abuseReportService.report([{ + targetUserId: targetUser.id, + targetUserHost: targetUser.host, reporterId: me.id, reporterHost: null, comment: ps.comment, - }); - - // Publish event to moderators - setImmediate(async () => { - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { - id: report.id, - targetUserId: report.targetUserId, - reporterId: report.reporterId, - comment: report.comment, - }); - } - - const meta = await this.metaService.fetch(); - if (meta.email) { - this.emailService.sendEmail(meta.email, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - }); + }]); }); } } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index ab03489c0d..f55790b636 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -25,7 +25,16 @@ import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; import { MetaService } from '@/core/MetaService.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { + DbQueue, + DeliverQueue, + EndedPollNotificationQueue, + InboxQueue, + ObjectStorageQueue, + SystemQueue, + UserWebhookDeliverQueue, + SystemWebhookDeliverQueue, +} from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; @@ -111,7 +120,8 @@ export class ClientServerService { @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { //this.createServer = this.createServer.bind(this); } @@ -239,7 +249,8 @@ export class ClientServerService { this.inboxQueue, this.dbQueue, this.objectStorageQueue, - this.webhookDeliverQueue, + this.userWebhookDeliverQueue, + this.systemWebhookDeliverQueue, ].map(q => new BullMQAdapter(q)), serverAdapter, }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 929070d0d2..ecbbee4eff 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -90,6 +90,12 @@ export const moderationLogTypes = [ 'deleteAvatarDecoration', 'unsetUserAvatar', 'unsetUserBanner', + 'createSystemWebhook', + 'updateSystemWebhook', + 'deleteSystemWebhook', + 'createAbuseReportNotificationRecipient', + 'updateAbuseReportNotificationRecipient', + 'deleteAbuseReportNotificationRecipient', ] as const; export type ModerationLogPayloads = { @@ -282,6 +288,32 @@ export type ModerationLogPayloads = { userHost: string | null; fileId: string; }; + createSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + updateSystemWebhook: { + systemWebhookId: string; + before: any; + after: any; + }; + deleteSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + createAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; + updateAbuseReportNotificationRecipient: { + recipientId: string; + before: any; + after: any; + }; + deleteAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; }; export type Serialized = { diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts new file mode 100644 index 0000000000..b0cc3d13ec --- /dev/null +++ b/packages/backend/test/e2e/synalio/abuse-report.ts @@ -0,0 +1,401 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { entities } from 'misskey-js'; +import { beforeEach, describe, test } from '@jest/globals'; +import Fastify from 'fastify'; +import { api, randomString, role, signup, startJobQueue, UserToken } from '../../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +const WEBHOOK_HOST = 'http://localhost:15080'; +const WEBHOOK_PORT = 15080; +process.env.NODE_ENV = 'test'; + +describe('[シナリオ] ユーザ通報', () => { + let queue: INestApplicationContext; + let admin: entities.SignupResponse; + let alice: entities.SignupResponse; + let bob: entities.SignupResponse; + + type SystemWebhookPayload = { + server: string; + hookId: string; + eventId: string; + createdAt: string; + type: string; + body: any; + } + + // ------------------------------------------------------------------------------------------- + + async function captureWebhook(postAction: () => Promise): Promise { + const fastify = Fastify(); + + let timeoutHandle: NodeJS.Timeout | null = null; + const result = await new Promise(async (resolve, reject) => { + fastify.all('/', async (req, res) => { + timeoutHandle && clearTimeout(timeoutHandle); + + const body = JSON.stringify(req.body); + res.status(200).send('ok'); + await fastify.close(); + resolve(body); + }); + + await fastify.listen({ port: WEBHOOK_PORT }); + + timeoutHandle = setTimeout(async () => { + await fastify.close(); + reject(new Error('timeout')); + }, 3000); + + try { + await postAction(); + } catch (e) { + await fastify.close(); + reject(e); + } + }); + + await fastify.close(); + + return JSON.parse(result) as T; + } + + async function createSystemWebhook(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/system-webhook/create', + { + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: WEBHOOK_HOST, + secret: randomString(), + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function createAbuseReportNotificationRecipient(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/abuse-report/notification-recipient/create', + { + isActive: true, + name: randomString(), + method: 'webhook', + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function createAbuseReport(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'users/report-abuse', + { + userId: alice.id, + comment: randomString(), + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function resolveAbuseReport(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/resolve-abuse-user-report', + { + reportId: admin.id, + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + // ------------------------------------------------------------------------------------------- + + beforeAll(async () => { + queue = await startJobQueue(); + admin = await signup({ username: 'admin' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + await role(admin, { isAdministrator: true }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await queue.close(); + }); + + // ------------------------------------------------------------------------------------------- + + describe('SystemWebhook', () => { + beforeEach(async () => { + const webhooks = await api('admin/system-webhook/list', {}, admin); + for (const webhook of webhooks.body) { + await api('admin/system-webhook/delete', { id: webhook.id }, admin); + } + }); + + test('通報を受けた -> abuseReportが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody, null, 2)); + + expect(webhookBody.hookId).toBe(webhook.id); + expect(webhookBody.type).toBe('abuseReport'); + expect(webhookBody.body.targetUserId).toBe(alice.id); + expect(webhookBody.body.reporterId).toBe(bob.id); + expect(webhookBody.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが送出される -> 解決 -> abuseReportResolvedが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody1, null, 2)); + expect(webhookBody1.hookId).toBe(webhook.id); + expect(webhookBody1.type).toBe('abuseReport'); + expect(webhookBody1.body.targetUserId).toBe(alice.id); + expect(webhookBody1.body.reporterId).toBe(bob.id); + expect(webhookBody1.body.assigneeId).toBeNull(); + expect(webhookBody1.body.resolved).toBe(false); + expect(webhookBody1.body.comment).toBe(abuse.comment); + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: webhookBody1.body.id, + forward: false, + }, admin); + }); + + console.log(JSON.stringify(webhookBody2, null, 2)); + expect(webhookBody2.hookId).toBe(webhook.id); + expect(webhookBody2.type).toBe('abuseReportResolved'); + expect(webhookBody2.body.targetUserId).toBe(alice.id); + expect(webhookBody2.body.reporterId).toBe(bob.id); + expect(webhookBody2.body.assigneeId).toBe(admin.id); + expect(webhookBody2.body.resolved).toBe(true); + expect(webhookBody2.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: [], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody).toBe('timeout'); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない -> 解決 -> abuseReportResolvedが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }); + + console.log(JSON.stringify(webhookBody2, null, 2)); + expect(webhookBody2.hookId).toBe(webhook.id); + expect(webhookBody2.type).toBe('abuseReportResolved'); + expect(webhookBody2.body.targetUserId).toBe(alice.id); + expect(webhookBody2.body.reporterId).toBe(bob.id); + expect(webhookBody2.body.assigneeId).toBe(admin.id); + expect(webhookBody2.body.resolved).toBe(true); + expect(webhookBody2.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが送出される -> 解決 -> abuseReportResolvedが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody1, null, 2)); + expect(webhookBody1.hookId).toBe(webhook.id); + expect(webhookBody1.type).toBe('abuseReport'); + expect(webhookBody1.body.targetUserId).toBe(alice.id); + expect(webhookBody1.body.reporterId).toBe(bob.id); + expect(webhookBody1.body.assigneeId).toBeNull(); + expect(webhookBody1.body.resolved).toBe(false); + expect(webhookBody1.body.comment).toBe(abuse.comment); + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: webhookBody1.body.id, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない -> 解決 -> abuseReportResolvedが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: [], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> Webhookが無効の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: false, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> 通知設定が無効の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id, isActive: false }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + }); +}); diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts new file mode 100644 index 0000000000..e971659070 --- /dev/null +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -0,0 +1,343 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { + AbuseReportNotificationRecipientRepository, + MiAbuseReportNotificationRecipient, + MiSystemWebhook, + MiUser, + SystemWebhooksRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { IdService } from '@/core/IdService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { randomString } from '../utils.js'; + +process.env.NODE_ENV = 'test'; + +describe('AbuseReportNotificationService', () => { + let app: TestingModule; + let service: AbuseReportNotificationService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let systemWebhooksRepository: SystemWebhooksRepository; + let abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository; + let idService: IdService; + let roleService: jest.Mocked; + let emailService: jest.Mocked; + let webhookService: jest.Mocked; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + let alice: MiUser; + let bob: MiUser; + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createWebhook(data: Partial = {}) { + return systemWebhooksRepository + .insert({ + id: idService.gen(), + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + ...data, + }) + .then(x => systemWebhooksRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createRecipient(data: Partial = {}) { + return abuseReportNotificationRecipientRepository + .insert({ + id: idService.gen(), + isActive: true, + name: randomString(), + ...data, + }) + .then(x => abuseReportNotificationRecipientRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + AbuseReportNotificationService, + IdService, + { + provide: RoleService, useFactory: () => ({ getModeratorIds: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ enqueueSystemWebhook: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: ModerationLogService, useFactory: () => ({ log: () => Promise.resolve() }), + }, + { + provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + systemWebhooksRepository = app.get(DI.systemWebhooksRepository); + abuseReportNotificationRecipientRepository = app.get(DI.abuseReportNotificationRecipientRepository); + + service = app.get(AbuseReportNotificationService); + idService = app.get(IdService); + roleService = app.get(RoleService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + webhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + systemWebhook1 = await createWebhook(); + systemWebhook2 = await createWebhook(); + + roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]); + }); + + afterEach(async () => { + emailService.sendEmail.mockClear(); + webhookService.enqueueSystemWebhook.mockClear(); + + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + await systemWebhooksRepository.delete({}); + await abuseReportNotificationRecipientRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('createRecipient', () => { + test('作成成功1', async () => { + const params = { + isActive: true, + name: randomString(), + method: 'email' as RecipientMethod, + userId: alice.id, + systemWebhookId: null, + }; + + const recipient1 = await service.createRecipient(params, root); + expect(recipient1).toMatchObject(params); + }); + + test('作成成功2', async () => { + const params = { + isActive: true, + name: randomString(), + method: 'webhook' as RecipientMethod, + userId: null, + systemWebhookId: systemWebhook1.id, + }; + + const recipient1 = await service.createRecipient(params, root); + expect(recipient1).toMatchObject(params); + }); + }); + + describe('updateRecipient', () => { + test('更新成功1', async () => { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + + const params = { + id: recipient1.id, + isActive: false, + name: randomString(), + method: 'email' as RecipientMethod, + userId: bob.id, + systemWebhookId: null, + }; + + const recipient2 = await service.updateRecipient(params, root); + expect(recipient2).toMatchObject(params); + }); + + test('更新成功2', async () => { + const recipient1 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook1.id, + }); + + const params = { + id: recipient1.id, + isActive: false, + name: randomString(), + method: 'webhook' as RecipientMethod, + userId: null, + systemWebhookId: systemWebhook2.id, + }; + + const recipient2 = await service.updateRecipient(params, root); + expect(recipient2).toMatchObject(params); + }); + }); + + describe('deleteRecipient', () => { + test('削除成功1', async () => { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + + await service.deleteRecipient(recipient1.id, root); + + await expect(abuseReportNotificationRecipientRepository.findOneBy({ id: recipient1.id })).resolves.toBeNull(); + }); + }); + + describe('fetchRecipients', () => { + async function create() { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + const recipient2 = await createRecipient({ + method: 'email', + userId: bob.id, + }); + + const recipient3 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook1.id, + }); + const recipient4 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook2.id, + }); + + return [recipient1, recipient2, recipient3, recipient4]; + } + + test('フィルタなし', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('フィルタなし(非モデレータは除外される)', async () => { + roleService.getModeratorIds.mockClear(); + roleService.getModeratorIds.mockResolvedValue([root.id, bob.id]); + + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}); + // aliceはモデレータではないので除外される + expect(recipients).toEqual([recipient2, recipient3, recipient4]); + }); + + test('フィルタなし(非モデレータでも除外されないオプション設定)', async () => { + roleService.getModeratorIds.mockClear(); + roleService.getModeratorIds.mockResolvedValue([root.id, bob.id]); + + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}, { removeUnauthorized: false }); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('emailのみ', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['email'] }); + expect(recipients).toEqual([recipient1, recipient2]); + }); + + test('webhookのみ', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['webhook'] }); + expect(recipients).toEqual([recipient3, recipient4]); + }); + + test('すべて', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['email', 'webhook'] }); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('ID指定', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id] }); + expect(recipients).toEqual([recipient1, recipient3]); + }); + + test('ID指定(method=emailではないIDが混ざりこまない)', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id], method: ['email'] }); + expect(recipients).toEqual([recipient1]); + }); + + test('ID指定(method=webhookではないIDが混ざりこまない)', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id], method: ['webhook'] }); + expect(recipients).toEqual([recipient3]); + }); + }); +}); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index ec441735d7..69fa4162fb 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; @@ -13,7 +11,14 @@ import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; -import type { MiRole, MiUser, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/_.js'; +import { + MiRole, + MiRoleAssignment, + MiUser, + RoleAssignmentsRepository, + RolesRepository, + UsersRepository, +} from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { genAidx } from '@/misc/id/aidx.js'; @@ -23,6 +28,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; import { RoleCondFormulaValue } from '@/models/Role.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -39,27 +45,27 @@ describe('RoleService', () => { let notificationService: jest.Mocked; let clock: lolex.InstalledClock; - function createUser(data: Partial = {}) { + async function createUser(data: Partial = {}) { const un = secureRndstr(16); - return usersRepository.insert({ + const x = await usersRepository.insert({ id: genAidx(Date.now()), username: un, usernameLower: un, ...data, - }) - .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + }); + return await usersRepository.findOneByOrFail(x.identifiers[0]); } - function createRole(data: Partial = {}) { - return rolesRepository.insert({ + async function createRole(data: Partial = {}) { + const x = await rolesRepository.insert({ id: genAidx(Date.now()), updatedAt: new Date(), lastUsedAt: new Date(), name: '', description: '', ...data, - }) - .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); + }); + return await rolesRepository.findOneByOrFail(x.identifiers[0]); } function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial = {}) { @@ -71,6 +77,20 @@ describe('RoleService', () => { }); } + async function assignRole(args: Partial) { + const id = genAidx(Date.now()); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + + await roleAssignmentsRepository.insert({ + id, + expiresAt, + ...args, + }); + + return await roleAssignmentsRepository.findOneByOrFail({ id }); + } + function aidx() { return genAidx(Date.now()); } @@ -265,6 +285,96 @@ describe('RoleService', () => { }); }); + describe('getModeratorIds', () => { + test('includeAdmins = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(false, false); + expect(result).toEqual([modeUser1.id, modeUser2.id]); + }); + + test('includeAdmins = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(false, true); + expect(result).toEqual([modeUser1.id]); + }); + + test('includeAdmins = true, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(true, false); + expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); + }); + + test('includeAdmins = true, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(true, true); + expect(result).toEqual([adminUser1.id, modeUser1.id]); + }); + }); + describe('conditional role', () => { test('~かつ~', async () => { const [user1, user2, user3, user4] = await Promise.all([ diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts new file mode 100644 index 0000000000..41b7f977ca --- /dev/null +++ b/packages/backend/test/unit/SystemWebhookService.ts @@ -0,0 +1,515 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MiUser } from '@/models/User.js'; +import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { randomString, sleep } from '../utils.js'; + +describe('SystemWebhookService', () => { + let app: TestingModule; + let service: SystemWebhookService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let systemWebhooksRepository: SystemWebhooksRepository; + let idService: IdService; + let queueService: jest.Mocked; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}) { + return await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createWebhook(data: Partial = {}) { + return systemWebhooksRepository + .insert({ + id: idService.gen(), + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + ...data, + }) + .then(x => systemWebhooksRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + async function beforeAllImpl() { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + SystemWebhookService, + IdService, + LoggerService, + GlobalEventService, + { + provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }), + }, + { + provide: ModerationLogService, useFactory: () => ({ log: () => Promise.resolve() }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + systemWebhooksRepository = app.get(DI.systemWebhooksRepository); + + service = app.get(SystemWebhookService); + idService = app.get(IdService); + queueService = app.get(QueueService) as jest.Mocked; + + app.enableShutdownHooks(); + } + + async function afterAllImpl() { + await app.close(); + } + + async function beforeEachImpl() { + root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + } + + async function afterEachImpl() { + await usersRepository.delete({}); + await systemWebhooksRepository.delete({}); + } + + // -------------------------------------------------------------------------------------- + + describe('アプリを毎回作り直す必要のないグループ', () => { + beforeAll(beforeAllImpl); + afterAll(afterAllImpl); + beforeEach(beforeEachImpl); + afterEach(afterEachImpl); + + describe('fetchSystemWebhooks', () => { + test('フィルタなし', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]); + }); + + test('activeのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1, webhook3]); + }); + + test('特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ on: ['abuseReport'] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook2]); + }); + + test('activeな特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ on: ['abuseReport'], isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1]); + }); + + test('ID指定', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ ids: [webhook1.id, webhook4.id] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook4]); + }); + + test('ID指定(他条件とANDになるか見たい)', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false }); + expect(fetchedWebhooks).toEqual([webhook4]); + }); + }); + + describe('createSystemWebhook', () => { + test('作成成功 ', async () => { + const params = { + isActive: true, + name: randomString(), + on: ['abuseReport'] as SystemWebhookEventType[], + url: 'https://example.com', + secret: randomString(), + }; + + const webhook = await service.createSystemWebhook(params, root); + expect(webhook).toMatchObject(params); + }); + }); + + describe('updateSystemWebhook', () => { + test('更新成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + + const params = { + id: webhook.id, + isActive: false, + name: randomString(), + on: ['abuseReport'] as SystemWebhookEventType[], + url: randomString(), + secret: randomString(), + }; + + const updatedWebhook = await service.updateSystemWebhook(params, root); + expect(updatedWebhook).toMatchObject(params); + }); + }); + + describe('deleteSystemWebhook', () => { + test('削除成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + + await service.deleteSystemWebhook(webhook.id, root); + + await expect(systemWebhooksRepository.findOneBy({ id: webhook.id })).resolves.toBeNull(); + }); + }); + }); + + describe('アプリを毎回作り直す必要があるグループ', () => { + beforeEach(async () => { + await beforeAllImpl(); + await beforeEachImpl(); + }); + + afterEach(async () => { + await afterEachImpl(); + await afterAllImpl(); + }); + + describe('enqueueSystemWebhook', () => { + test('キューに追加成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).toHaveBeenCalled(); + }); + + test('非アクティブなWebhookはキューに追加されない', async () => { + const webhook = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); + }); + + test('未許可のイベント種別が渡された場合はWebhookはキューに追加されない', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: [], + }); + const webhook2 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' }); + await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); + }); + }); + + describe('fetchActiveSystemWebhooks', () => { + describe('systemWebhookCreated', () => { + test('ActiveなWebhookが追加された時、キャッシュに追加されている', async () => { + const webhook = await service.createSystemWebhook( + { + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook]); + }); + + test('NotActiveなWebhookが追加された時、キャッシュに追加されていない', async () => { + const webhook = await service.createSystemWebhook( + { + isActive: false, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([]); + }); + }); + + describe('systemWebhookUpdated', () => { + test('ActiveなWebhookが編集された時、キャッシュに反映されている', async () => { + const id = idService.gen(); + await createWebhook({ id }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.updateSystemWebhook( + { + id, + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook2]); + }); + + test('NotActiveなWebhookが編集された時、キャッシュに追加されない', async () => { + const id = idService.gen(); + await createWebhook({ id, isActive: false }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていないことをチェック + expect(webhook1.length).toEqual(0); + + const webhook2 = await service.updateSystemWebhook( + { + id, + isActive: false, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + + test('NotActiveなWebhookがActiveにされた時、キャッシュに追加されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: false }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていないことをチェック + expect(webhook1.length).toEqual(0); + + const webhook2 = await service.updateSystemWebhook( + { + ...baseWebhook, + isActive: true, + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook2]); + }); + + test('ActiveなWebhookがNotActiveにされた時、キャッシュから削除されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: true }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.updateSystemWebhook( + { + ...baseWebhook, + isActive: false, + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + }); + + describe('systemWebhookDeleted', () => { + test('キャッシュから削除されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: true }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.deleteSystemWebhook( + id, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await sleep(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + }); + }); + }); +}); diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 3489255b91..25b003ba5a 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :type="type" :name="name" :value="value" + :disabled="disabled" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -55,6 +56,7 @@ const props = defineProps<{ asLike?: boolean; name?: string; value?: string; + disabled?: boolean; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue new file mode 100644 index 0000000000..e4e3af99e4 --- /dev/null +++ b/packages/frontend/src/components/MkDivider.vue @@ -0,0 +1,32 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index a19b45448b..721ac357f4 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only @keydown.enter="toggle" > - + @@ -34,16 +34,19 @@ const props = defineProps<{ modelValue: boolean | Ref; disabled?: boolean; helpText?: string; + noBody?: boolean; }>(); const emit = defineEmits<{ (ev: 'update:modelValue', v: boolean): void; + (ev: 'change', v: boolean): void; }>(); const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); + emit('change', !checked.value); }; diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts new file mode 100644 index 0000000000..1222d3261d --- /dev/null +++ b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import * as os from '@/os.js'; + +export type SystemWebhookEventType = 'abuseReport' | 'abuseReportResolved'; + +export type MkSystemWebhookEditorProps = { + mode: 'create' | 'edit'; + id?: string; + requiredEvents?: SystemWebhookEventType[]; +}; + +export type MkSystemWebhookResult = { + id?: string; + isActive: boolean; + name: string; + on: SystemWebhookEventType[]; + url: string; + secret: string; +}; + +export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise { + const { dispose, result } = await new Promise<{ dispose: () => void, result: MkSystemWebhookResult | null }>(async resolve => { + const res = await os.popup( + defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')), + props, + { + submitted: (ev: MkSystemWebhookResult) => { + resolve({ dispose: res.dispose, result: ev }); + }, + closed: () => { + resolve({ dispose: res.dispose, result: null }); + }, + }, + ); + }); + + dispose(); + + return result; +} diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue new file mode 100644 index 0000000000..007d841f00 --- /dev/null +++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue @@ -0,0 +1,217 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue new file mode 100644 index 0000000000..ffe9c620d6 --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -0,0 +1,307 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue new file mode 100644 index 0000000000..0b86808faf --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue new file mode 100644 index 0000000000..a52f8eb7af --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -0,0 +1,176 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index d2f4a4b531..9a9fa472a5 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
-
-
- - - - - - - - - - - - - - - - - - -
- - - - -
-
+ + +
@@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkButton from '@/components/MkButton.vue'; const reports = shallowRef>(); @@ -80,7 +82,7 @@ const pagination = { }; function resolved(reportId) { - reports.value.removeItem(reportId); + reports.value?.removeItem(reportId); } const headerActions = computed(() => []); @@ -92,3 +94,26 @@ definePageMetadata(() => ({ icon: 'ti ti-exclamation-circle', })); + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 794feae202..292f10da1a 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -214,6 +214,11 @@ const menuDef = computed(() => [{ text: i18n.ts.externalServices, to: '/admin/external-services', active: currentPage.value?.route.name === 'external-services', + }, { + icon: 'ti ti-webhook', + text: 'Webhook', + to: '/admin/system-webhook', + active: currentPage.value?.route.name === 'system-webhook', }, { icon: 'ti ti-adjustments', text: i18n.ts.other, diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index e33c882721..91f1c7c5e6 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -8,9 +8,35 @@ SPDX-License-Identifier: AGPL-3.0-only + +
raw diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue new file mode 100644 index 0000000000..0c07122af3 --- /dev/null +++ b/packages/frontend/src/pages/admin/system-webhook.item.vue @@ -0,0 +1,117 @@ + + + + + + + diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue new file mode 100644 index 0000000000..7a40eec944 --- /dev/null +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts index c12ae0fa57..8a443f627b 100644 --- a/packages/frontend/src/router/definition.ts +++ b/packages/frontend/src/router/definition.ts @@ -471,6 +471,14 @@ const routes: RouteDef[] = [{ path: '/invites', name: 'invites', component: page(() => import('@/pages/admin/invites.vue')), + }, { + path: '/abuse-report-notification-recipient', + name: 'abuse-report-notification-recipient', + component: page(() => import('@/pages/admin/abuse-report/notification-recipient.vue')), + }, { + path: '/system-webhook', + name: 'system-webhook', + component: page(() => import('@/pages/admin/system-webhook.vue')), }, { path: '/', component: page(() => import('@/pages/_empty_.vue')), diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 6ff711cabb..bea89f2a7c 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -6,6 +6,11 @@ import { EventEmitter } from 'eventemitter3'; +// Warning: (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient']; + // @public (undocumented) export type Acct = { username: string; @@ -21,13 +26,38 @@ declare namespace acct { } export { acct } -// Warning: (ae-forgotten-export) The symbol "components" needs to be exported by the entry point index.d.ts -// // @public (undocumented) type Ad = components['schemas']['Ad']; // Warning: (ae-forgotten-export) The symbol "operations" needs to be exported by the entry point index.d.ts // +// @public (undocumented) +type AdminAbuseReportNotificationRecipientCreateRequest = operations['admin___abuse-report___notification-recipient___create']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientCreateResponse = operations['admin___abuse-report___notification-recipient___create']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientDeleteRequest = operations['admin___abuse-report___notification-recipient___delete']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientListRequest = operations['admin___abuse-report___notification-recipient___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientListResponse = operations['admin___abuse-report___notification-recipient___list']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientShowRequest = operations['admin___abuse-report___notification-recipient___show']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientShowResponse = operations['admin___abuse-report___notification-recipient___show']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientUpdateRequest = operations['admin___abuse-report___notification-recipient___update']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminAbuseReportNotificationRecipientUpdateResponse = operations['admin___abuse-report___notification-recipient___update']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminAbuseUserReportsRequest = operations['admin___abuse-user-reports']['requestBody']['content']['application/json']; @@ -307,6 +337,33 @@ type AdminShowUsersResponse = operations['admin___show-users']['responses']['200 // @public (undocumented) type AdminSuspendUserRequest = operations['admin___suspend-user']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminSystemWebhookCreateRequest = operations['admin___system-webhook___create']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookCreateResponse = operations['admin___system-webhook___create']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookDeleteRequest = operations['admin___system-webhook___delete']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookListRequest = operations['admin___system-webhook___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookListResponse = operations['admin___system-webhook___list']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookShowRequest = operations['admin___system-webhook___show']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookShowResponse = operations['admin___system-webhook___show']['responses']['200']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookUpdateRequest = operations['admin___system-webhook___update']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminSystemWebhookUpdateResponse = operations['admin___system-webhook___update']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminUnsetUserAvatarRequest = operations['admin___unset-user-avatar']['requestBody']['content']['application/json']; @@ -1133,6 +1190,15 @@ declare namespace entities { AdminMetaResponse, AdminAbuseUserReportsRequest, AdminAbuseUserReportsResponse, + AdminAbuseReportNotificationRecipientListRequest, + AdminAbuseReportNotificationRecipientListResponse, + AdminAbuseReportNotificationRecipientShowRequest, + AdminAbuseReportNotificationRecipientShowResponse, + AdminAbuseReportNotificationRecipientCreateRequest, + AdminAbuseReportNotificationRecipientCreateResponse, + AdminAbuseReportNotificationRecipientUpdateRequest, + AdminAbuseReportNotificationRecipientUpdateResponse, + AdminAbuseReportNotificationRecipientDeleteRequest, AdminAccountsCreateRequest, AdminAccountsCreateResponse, AdminAccountsDeleteRequest, @@ -1228,6 +1294,15 @@ declare namespace entities { AdminRolesUpdateDefaultPoliciesRequest, AdminRolesUsersRequest, AdminRolesUsersResponse, + AdminSystemWebhookCreateRequest, + AdminSystemWebhookCreateResponse, + AdminSystemWebhookDeleteRequest, + AdminSystemWebhookListRequest, + AdminSystemWebhookListResponse, + AdminSystemWebhookShowRequest, + AdminSystemWebhookShowResponse, + AdminSystemWebhookUpdateRequest, + AdminSystemWebhookUpdateResponse, AnnouncementsRequest, AnnouncementsResponse, AnnouncementsShowRequest, @@ -1733,7 +1808,9 @@ declare namespace entities { ReversiGameDetailed, MetaLite, MetaDetailedOnly, - MetaDetailed + MetaDetailed, + SystemWebhook, + AbuseReportNotificationRecipient } } export { entities } @@ -2380,8 +2457,23 @@ type ModerationLog = { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; } | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; + type: 'createSystemWebhook'; + info: ModerationLogPayloads['createSystemWebhook']; +} | { + type: 'updateSystemWebhook'; + info: ModerationLogPayloads['updateSystemWebhook']; +} | { + type: 'deleteSystemWebhook'; + info: ModerationLogPayloads['deleteSystemWebhook']; +} | { + type: 'createAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; +} | { + type: 'updateAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; +} | { + type: 'deleteAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; }); // @public (undocumented) @@ -2921,6 +3013,9 @@ type SwUpdateRegistrationRequest = operations['sw___update-registration']['reque // @public (undocumented) type SwUpdateRegistrationResponse = operations['sw___update-registration']['responses']['200']['content']['application/json']; +// @public (undocumented) +type SystemWebhook = components['schemas']['SystemWebhook']; + // @public (undocumented) type TestRequest = operations['test']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 181f7274b7..e799d4a0c5 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -25,6 +25,66 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * @@ -840,6 +900,66 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index ab3baf1670..20c8509d4c 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -4,6 +4,15 @@ import type { AdminMetaResponse, AdminAbuseUserReportsRequest, AdminAbuseUserReportsResponse, + AdminAbuseReportNotificationRecipientListRequest, + AdminAbuseReportNotificationRecipientListResponse, + AdminAbuseReportNotificationRecipientShowRequest, + AdminAbuseReportNotificationRecipientShowResponse, + AdminAbuseReportNotificationRecipientCreateRequest, + AdminAbuseReportNotificationRecipientCreateResponse, + AdminAbuseReportNotificationRecipientUpdateRequest, + AdminAbuseReportNotificationRecipientUpdateResponse, + AdminAbuseReportNotificationRecipientDeleteRequest, AdminAccountsCreateRequest, AdminAccountsCreateResponse, AdminAccountsDeleteRequest, @@ -99,6 +108,15 @@ import type { AdminRolesUpdateDefaultPoliciesRequest, AdminRolesUsersRequest, AdminRolesUsersResponse, + AdminSystemWebhookCreateRequest, + AdminSystemWebhookCreateResponse, + AdminSystemWebhookDeleteRequest, + AdminSystemWebhookListRequest, + AdminSystemWebhookListResponse, + AdminSystemWebhookShowRequest, + AdminSystemWebhookShowResponse, + AdminSystemWebhookUpdateRequest, + AdminSystemWebhookUpdateResponse, AnnouncementsRequest, AnnouncementsResponse, AnnouncementsShowRequest, @@ -558,6 +576,11 @@ import type { export type Endpoints = { 'admin/meta': { req: EmptyRequest; res: AdminMetaResponse }; 'admin/abuse-user-reports': { req: AdminAbuseUserReportsRequest; res: AdminAbuseUserReportsResponse }; + 'admin/abuse-report/notification-recipient/list': { req: AdminAbuseReportNotificationRecipientListRequest; res: AdminAbuseReportNotificationRecipientListResponse }; + 'admin/abuse-report/notification-recipient/show': { req: AdminAbuseReportNotificationRecipientShowRequest; res: AdminAbuseReportNotificationRecipientShowResponse }; + 'admin/abuse-report/notification-recipient/create': { req: AdminAbuseReportNotificationRecipientCreateRequest; res: AdminAbuseReportNotificationRecipientCreateResponse }; + 'admin/abuse-report/notification-recipient/update': { req: AdminAbuseReportNotificationRecipientUpdateRequest; res: AdminAbuseReportNotificationRecipientUpdateResponse }; + 'admin/abuse-report/notification-recipient/delete': { req: AdminAbuseReportNotificationRecipientDeleteRequest; res: EmptyResponse }; 'admin/accounts/create': { req: AdminAccountsCreateRequest; res: AdminAccountsCreateResponse }; 'admin/accounts/delete': { req: AdminAccountsDeleteRequest; res: EmptyResponse }; 'admin/accounts/find-by-email': { req: AdminAccountsFindByEmailRequest; res: AdminAccountsFindByEmailResponse }; @@ -632,6 +655,11 @@ export type Endpoints = { 'admin/roles/unassign': { req: AdminRolesUnassignRequest; res: EmptyResponse }; 'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse }; 'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse }; + 'admin/system-webhook/create': { req: AdminSystemWebhookCreateRequest; res: AdminSystemWebhookCreateResponse }; + 'admin/system-webhook/delete': { req: AdminSystemWebhookDeleteRequest; res: EmptyResponse }; + 'admin/system-webhook/list': { req: AdminSystemWebhookListRequest; res: AdminSystemWebhookListResponse }; + 'admin/system-webhook/show': { req: AdminSystemWebhookShowRequest; res: AdminSystemWebhookShowResponse }; + 'admin/system-webhook/update': { req: AdminSystemWebhookUpdateRequest; res: AdminSystemWebhookUpdateResponse }; 'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse }; 'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse }; 'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 02ca932d8a..357b5e9eaf 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -7,6 +7,15 @@ export type EmptyResponse = Record | undefined; export type AdminMetaResponse = operations['admin___meta']['responses']['200']['content']['application/json']; export type AdminAbuseUserReportsRequest = operations['admin___abuse-user-reports']['requestBody']['content']['application/json']; export type AdminAbuseUserReportsResponse = operations['admin___abuse-user-reports']['responses']['200']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientListRequest = operations['admin___abuse-report___notification-recipient___list']['requestBody']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientListResponse = operations['admin___abuse-report___notification-recipient___list']['responses']['200']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientShowRequest = operations['admin___abuse-report___notification-recipient___show']['requestBody']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientShowResponse = operations['admin___abuse-report___notification-recipient___show']['responses']['200']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientCreateRequest = operations['admin___abuse-report___notification-recipient___create']['requestBody']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientCreateResponse = operations['admin___abuse-report___notification-recipient___create']['responses']['200']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientUpdateRequest = operations['admin___abuse-report___notification-recipient___update']['requestBody']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientUpdateResponse = operations['admin___abuse-report___notification-recipient___update']['responses']['200']['content']['application/json']; +export type AdminAbuseReportNotificationRecipientDeleteRequest = operations['admin___abuse-report___notification-recipient___delete']['requestBody']['content']['application/json']; export type AdminAccountsCreateRequest = operations['admin___accounts___create']['requestBody']['content']['application/json']; export type AdminAccountsCreateResponse = operations['admin___accounts___create']['responses']['200']['content']['application/json']; export type AdminAccountsDeleteRequest = operations['admin___accounts___delete']['requestBody']['content']['application/json']; @@ -102,6 +111,15 @@ export type AdminRolesUnassignRequest = operations['admin___roles___unassign'][' export type AdminRolesUpdateDefaultPoliciesRequest = operations['admin___roles___update-default-policies']['requestBody']['content']['application/json']; export type AdminRolesUsersRequest = operations['admin___roles___users']['requestBody']['content']['application/json']; export type AdminRolesUsersResponse = operations['admin___roles___users']['responses']['200']['content']['application/json']; +export type AdminSystemWebhookCreateRequest = operations['admin___system-webhook___create']['requestBody']['content']['application/json']; +export type AdminSystemWebhookCreateResponse = operations['admin___system-webhook___create']['responses']['200']['content']['application/json']; +export type AdminSystemWebhookDeleteRequest = operations['admin___system-webhook___delete']['requestBody']['content']['application/json']; +export type AdminSystemWebhookListRequest = operations['admin___system-webhook___list']['requestBody']['content']['application/json']; +export type AdminSystemWebhookListResponse = operations['admin___system-webhook___list']['responses']['200']['content']['application/json']; +export type AdminSystemWebhookShowRequest = operations['admin___system-webhook___show']['requestBody']['content']['application/json']; +export type AdminSystemWebhookShowResponse = operations['admin___system-webhook___show']['responses']['200']['content']['application/json']; +export type AdminSystemWebhookUpdateRequest = operations['admin___system-webhook___update']['requestBody']['content']['application/json']; +export type AdminSystemWebhookUpdateResponse = operations['admin___system-webhook___update']['responses']['200']['content']['application/json']; export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json']; export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json']; export type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts index a6e5fbe689..04574849d4 100644 --- a/packages/misskey-js/src/autogen/models.ts +++ b/packages/misskey-js/src/autogen/models.ts @@ -51,3 +51,5 @@ export type ReversiGameDetailed = components['schemas']['ReversiGameDetailed']; export type MetaLite = components['schemas']['MetaLite']; export type MetaDetailedOnly = components['schemas']['MetaDetailedOnly']; export type MetaDetailed = components['schemas']['MetaDetailed']; +export type SystemWebhook = components['schemas']['SystemWebhook']; +export type AbuseReportNotificationRecipient = components['schemas']['AbuseReportNotificationRecipient']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 2c80676f3e..bdcc1dfd77 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -30,6 +30,56 @@ export type paths = { */ post: operations['admin___abuse-user-reports']; }; + '/admin/abuse-report/notification-recipient/list': { + /** + * admin/abuse-report/notification-recipient/list + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* + */ + post: operations['admin___abuse-report___notification-recipient___list']; + }; + '/admin/abuse-report/notification-recipient/show': { + /** + * admin/abuse-report/notification-recipient/show + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* + */ + post: operations['admin___abuse-report___notification-recipient___show']; + }; + '/admin/abuse-report/notification-recipient/create': { + /** + * admin/abuse-report/notification-recipient/create + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + post: operations['admin___abuse-report___notification-recipient___create']; + }; + '/admin/abuse-report/notification-recipient/update': { + /** + * admin/abuse-report/notification-recipient/update + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + post: operations['admin___abuse-report___notification-recipient___update']; + }; + '/admin/abuse-report/notification-recipient/delete': { + /** + * admin/abuse-report/notification-recipient/delete + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* + */ + post: operations['admin___abuse-report___notification-recipient___delete']; + }; '/admin/accounts/create': { /** * admin/accounts/create @@ -697,6 +747,56 @@ export type paths = { */ post: operations['admin___roles___users']; }; + '/admin/system-webhook/create': { + /** + * admin/system-webhook/create + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + post: operations['admin___system-webhook___create']; + }; + '/admin/system-webhook/delete': { + /** + * admin/system-webhook/delete + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + post: operations['admin___system-webhook___delete']; + }; + '/admin/system-webhook/list': { + /** + * admin/system-webhook/list + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + post: operations['admin___system-webhook___list']; + }; + '/admin/system-webhook/show': { + /** + * admin/system-webhook/show + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + post: operations['admin___system-webhook___show']; + }; + '/admin/system-webhook/update': { + /** + * admin/system-webhook/update + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + post: operations['admin___system-webhook___update']; + }; '/announcements': { /** * announcements @@ -4859,6 +4959,32 @@ export type components = { cacheRemoteSensitiveFiles: boolean; }; MetaDetailed: components['schemas']['MetaLite'] & components['schemas']['MetaDetailedOnly']; + SystemWebhook: { + id: string; + isActive: boolean; + /** Format: date-time */ + updatedAt: string; + /** Format: date-time */ + latestSentAt: string | null; + latestStatus: number | null; + name: string; + on: ('abuseReport' | 'abuseReportResolved')[]; + url: string; + secret: string; + }; + AbuseReportNotificationRecipient: { + id: string; + isActive: boolean; + /** Format: date-time */ + updatedAt: string; + name: string; + /** @enum {string} */ + method: 'email' | 'webhook'; + userId?: string; + user?: components['schemas']['UserLite']; + systemWebhookId?: string; + systemWebhook?: components['schemas']['SystemWebhook']; + }; }; responses: never; parameters: never; @@ -5126,17 +5252,17 @@ export type operations = { }; }; /** - * admin/accounts/create + * admin/abuse-report/notification-recipient/list * @description No description provided. * - * **Credential required**: *No* + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* */ - admin___accounts___create: { + 'admin___abuse-report___notification-recipient___list': { requestBody: { content: { 'application/json': { - username: string; - password: string; + method?: ('email' | 'webhook')[]; }; }; }; @@ -5144,7 +5270,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['MeDetailed']; + 'application/json': components['schemas']['AbuseReportNotificationRecipient'][]; }; }; /** @description Client error */ @@ -5180,24 +5306,27 @@ export type operations = { }; }; /** - * admin/accounts/delete + * admin/abuse-report/notification-recipient/show * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:account* + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *read:admin:abuse-report:notification-recipient* */ - admin___accounts___delete: { + 'admin___abuse-report___notification-recipient___show': { requestBody: { content: { 'application/json': { /** Format: misskey:id */ - userId: string; + id: string; }; }; }; responses: { - /** @description OK (without any results) */ - 204: { - content: never; + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['AbuseReportNotificationRecipient']; + }; }; /** @description Client error */ 400: { @@ -5232,16 +5361,24 @@ export type operations = { }; }; /** - * admin/accounts/find-by-email + * admin/abuse-report/notification-recipient/create * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:admin:account* + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ - 'admin___accounts___find-by-email': { + 'admin___abuse-report___notification-recipient___create': { requestBody: { content: { 'application/json': { - email: string; + isActive: boolean; + name: string; + /** @enum {string} */ + method: 'email' | 'webhook'; + /** Format: misskey:id */ + userId?: string; + /** Format: misskey:id */ + systemWebhookId?: string; }; }; }; @@ -5249,7 +5386,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['UserDetailedNotMe']; + 'application/json': components['schemas']['AbuseReportNotificationRecipient']; }; }; /** @description Client error */ @@ -5285,24 +5422,26 @@ export type operations = { }; }; /** - * admin/ad/create + * admin/abuse-report/notification-recipient/update * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:ad* + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ - admin___ad___create: { + 'admin___abuse-report___notification-recipient___update': { requestBody: { content: { 'application/json': { - url: string; - memo: string; - place: string; - priority: string; - ratio: number; - expiresAt: number; - startsAt: number; - imageUrl: string; - dayOfWeek: number; + /** Format: misskey:id */ + id: string; + isActive: boolean; + name: string; + /** @enum {string} */ + method: 'email' | 'webhook'; + /** Format: misskey:id */ + userId?: string; + /** Format: misskey:id */ + systemWebhookId?: string; }; }; }; @@ -5310,7 +5449,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['Ad']; + 'application/json': components['schemas']['AbuseReportNotificationRecipient']; }; }; /** @description Client error */ @@ -5346,12 +5485,13 @@ export type operations = { }; }; /** - * admin/ad/delete + * admin/abuse-report/notification-recipient/delete * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:ad* + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:abuse-report:notification-recipient* */ - admin___ad___delete: { + 'admin___abuse-report___notification-recipient___delete': { requestBody: { content: { 'application/json': { @@ -5398,23 +5538,17 @@ export type operations = { }; }; /** - * admin/ad/list + * admin/accounts/create * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:admin:ad* + * **Credential required**: *No* */ - admin___ad___list: { + admin___accounts___create: { requestBody: { content: { 'application/json': { - /** @default 10 */ - limit?: number; - /** Format: misskey:id */ - sinceId?: string; - /** Format: misskey:id */ - untilId?: string; - /** @default null */ - publishing?: boolean | null; + username: string; + password: string; }; }; }; @@ -5422,7 +5556,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': components['schemas']['Ad'][]; + 'application/json': components['schemas']['MeDetailed']; }; }; /** @description Client error */ @@ -5458,26 +5592,17 @@ export type operations = { }; }; /** - * admin/ad/update + * admin/accounts/delete * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:ad* + * **Credential required**: *Yes* / **Permission**: *write:admin:account* */ - admin___ad___update: { + admin___accounts___delete: { requestBody: { content: { 'application/json': { /** Format: misskey:id */ - id: string; - memo: string; - url: string; - imageUrl: string; - place: string; - priority: string; - ratio: number; - expiresAt: number; - startsAt: number; - dayOfWeek: number; + userId: string; }; }; }; @@ -5519,39 +5644,16 @@ export type operations = { }; }; /** - * admin/announcements/create + * admin/accounts/find-by-email * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* + * **Credential required**: *Yes* / **Permission**: *read:admin:account* */ - admin___announcements___create: { + 'admin___accounts___find-by-email': { requestBody: { content: { 'application/json': { - title: string; - text: string; - imageUrl: string | null; - /** - * @default info - * @enum {string} - */ - icon?: 'info' | 'warning' | 'error' | 'success'; - /** - * @default normal - * @enum {string} - */ - display?: 'normal' | 'banner' | 'dialog'; - /** @default false */ - forExistingUsers?: boolean; - /** @default false */ - silence?: boolean; - /** @default false */ - needConfirmationToRead?: boolean; - /** - * Format: misskey:id - * @default null - */ - userId?: string | null; + email: string; }; }; }; @@ -5559,20 +5661,7 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': { - /** - * Format: id - * @example xxxxxxxxxx - */ - id: string; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string | null; - title: string; - text: string; - imageUrl: string | null; - }; + 'application/json': components['schemas']['UserDetailedNotMe']; }; }; /** @description Client error */ @@ -5608,24 +5697,33 @@ export type operations = { }; }; /** - * admin/announcements/delete + * admin/ad/create * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* + * **Credential required**: *Yes* / **Permission**: *write:admin:ad* */ - admin___announcements___delete: { + admin___ad___create: { requestBody: { content: { 'application/json': { - /** Format: misskey:id */ - id: string; + url: string; + memo: string; + place: string; + priority: string; + ratio: number; + expiresAt: number; + startsAt: number; + imageUrl: string; + dayOfWeek: number; }; }; }; responses: { - /** @description OK (without any results) */ - 204: { - content: never; + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Ad']; + }; }; /** @description Client error */ 400: { @@ -5660,32 +5758,346 @@ export type operations = { }; }; /** - * admin/announcements/list + * admin/ad/delete * @description No description provided. * - * **Credential required**: *Yes* / **Permission**: *read:admin:announcements* + * **Credential required**: *Yes* / **Permission**: *write:admin:ad* */ - admin___announcements___list: { + admin___ad___delete: { requestBody: { content: { 'application/json': { - /** @default 10 */ - limit?: number; - /** Format: misskey:id */ - sinceId?: string; - /** Format: misskey:id */ - untilId?: string; /** Format: misskey:id */ - userId?: string | null; + id: string; }; }; }; responses: { - /** @description OK (with results) */ - 200: { - content: { - 'application/json': ({ - /** + /** @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']; + }; + }; + }; + }; + /** + * admin/ad/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:ad* + */ + admin___ad___list: { + requestBody: { + content: { + 'application/json': { + /** @default 10 */ + limit?: number; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** @default null */ + publishing?: boolean | null; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Ad'][]; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * admin/ad/update + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:ad* + */ + admin___ad___update: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + memo: string; + url: string; + imageUrl: string; + place: string; + priority: string; + ratio: number; + expiresAt: number; + startsAt: number; + dayOfWeek: number; + }; + }; + }; + 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']; + }; + }; + }; + }; + /** + * admin/announcements/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* + */ + admin___announcements___create: { + requestBody: { + content: { + 'application/json': { + title: string; + text: string; + imageUrl: string | null; + /** + * @default info + * @enum {string} + */ + icon?: 'info' | 'warning' | 'error' | 'success'; + /** + * @default normal + * @enum {string} + */ + display?: 'normal' | 'banner' | 'dialog'; + /** @default false */ + forExistingUsers?: boolean; + /** @default false */ + silence?: boolean; + /** @default false */ + needConfirmationToRead?: boolean; + /** + * Format: misskey:id + * @default null + */ + userId?: string | null; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': { + /** + * Format: id + * @example xxxxxxxxxx + */ + id: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string | null; + title: string; + text: string; + imageUrl: string | null; + }; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * admin/announcements/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:announcements* + */ + admin___announcements___delete: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + }; + }; + }; + 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']; + }; + }; + }; + }; + /** + * admin/announcements/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:admin:announcements* + */ + admin___announcements___list: { + requestBody: { + content: { + 'application/json': { + /** @default 10 */ + limit?: number; + /** Format: misskey:id */ + sinceId?: string; + /** Format: misskey:id */ + untilId?: string; + /** Format: misskey:id */ + userId?: string | null; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': ({ + /** * Format: id * @example xxxxxxxxxx */ @@ -9615,6 +10027,287 @@ export type operations = { }; }; }; + /** + * admin/system-webhook/create + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + 'admin___system-webhook___create': { + requestBody: { + content: { + 'application/json': { + isActive: boolean; + name: string; + on: ('abuseReport' | 'abuseReportResolved')[]; + url: string; + secret: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['SystemWebhook']; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * admin/system-webhook/delete + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + 'admin___system-webhook___delete': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + }; + }; + }; + 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']; + }; + }; + }; + }; + /** + * admin/system-webhook/list + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + 'admin___system-webhook___list': { + requestBody: { + content: { + 'application/json': { + isActive?: boolean; + on?: ('abuseReport' | 'abuseReportResolved')[]; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['SystemWebhook'][]; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * admin/system-webhook/show + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + 'admin___system-webhook___show': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['SystemWebhook']; + }; + }; + /** @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']; + }; + }; + }; + }; + /** + * admin/system-webhook/update + * @description No description provided. + * + * **Internal Endpoint**: This endpoint is an API for the misskey mainframe and is not intended for use by third parties. + * **Credential required**: *Yes* / **Permission**: *write:admin:system-webhook* + */ + 'admin___system-webhook___update': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + id: string; + isActive: boolean; + name: string; + on: ('abuseReport' | 'abuseReportResolved')[]; + url: string; + secret: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['SystemWebhook']; + }; + }; + /** @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']; + }; + }; + }; + }; /** * announcements * @description No description provided. diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index fd6ef4d68d..03b9069290 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -325,4 +325,30 @@ export type ModerationLogPayloads = { userHost: string | null; fileId: string; }; + createSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + updateSystemWebhook: { + systemWebhookId: string; + before: any; + after: any; + }; + deleteSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + createAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; + updateAbuseReportNotificationRecipient: { + recipientId: string; + before: any; + after: any; + }; + deleteAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 35503d6d6f..7a84cb6a1a 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -132,8 +132,23 @@ export type ModerationLog = { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; } | { - type: 'unsetUserBanner'; - info: ModerationLogPayloads['unsetUserBanner']; + type: 'createSystemWebhook'; + info: ModerationLogPayloads['createSystemWebhook']; +} | { + type: 'updateSystemWebhook'; + info: ModerationLogPayloads['updateSystemWebhook']; +} | { + type: 'deleteSystemWebhook'; + info: ModerationLogPayloads['deleteSystemWebhook']; +} | { + type: 'createAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['createAbuseReportNotificationRecipient']; +} | { + type: 'updateAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['updateAbuseReportNotificationRecipient']; +} | { + type: 'deleteAbuseReportNotificationRecipient'; + info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; }); export type ServerStats = { -- cgit v1.2.3-freya From dc3629e732e5aefd792452f0b43a7bb7fdaf103e Mon Sep 17 00:00:00 2001 From: Kisaragi <48310258+KisaragiEffective@users.noreply.github.com> Date: Thu, 13 Jun 2024 10:56:26 +0900 Subject: feat(backend): report `Retry-After` if client hit rate limit (#13949) * feat(backend): report `Retry-After` if client hit rate limit * refactor(backend): fix lint error --- packages/backend/src/server/api/ApiCallService.ts | 27 ++++++++++++---- .../backend/src/server/api/RateLimiterService.ts | 36 ++++++++++++---------- 2 files changed, 40 insertions(+), 23 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 166f9c8675..47f64f6609 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -73,6 +73,16 @@ export class ApiCallService implements OnApplicationShutdown { reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); } statusCode = statusCode ?? 403; + } else if (err.code === 'RATE_LIMIT_EXCEEDED') { + const info: unknown = err.info; + const unixEpochInSeconds = Date.now(); + if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') { + const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000); + // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく + reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); + } else { + this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); + } } else if (!statusCode) { statusCode = 500; } @@ -308,12 +318,17 @@ export class ApiCallService implements OnApplicationShutdown { if (factor > 0) { // Rate limit await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor, factor).catch(err => { - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }); + if ('info' in err) { + // errはLimiter.LimiterInfoであることが期待される + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }, err.info); + } else { + throw new TypeError('information must be a rate-limiter information.'); + } }); } } diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index 0439cdfe5e..cae106c273 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -32,11 +32,13 @@ export class RateLimiterService { @bindThis public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string, factor = 1) { - return new Promise((ok, reject) => { - if (this.disabled) ok(); + { + if (this.disabled) { + return Promise.resolve(); + } // Short-term limit - const min = (): void => { + const min = new Promise((ok, reject) => { const minIntervalLimiter = new Limiter({ id: `${actor}:${limitation.key}:min`, duration: limitation.minInterval! * factor, @@ -46,25 +48,25 @@ export class RateLimiterService { minIntervalLimiter.get((err, info) => { if (err) { - return reject('ERR'); + return reject({ code: 'ERR', info }); } this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); if (info.remaining === 0) { - reject('BRIEF_REQUEST_INTERVAL'); + return reject({ code: 'BRIEF_REQUEST_INTERVAL', info }); } else { if (hasLongTermLimit) { - max(); + return max; } else { - ok(); + return ok(); } } }); - }; + }); // Long term limit - const max = (): void => { + const max = new Promise((ok, reject) => { const limiter = new Limiter({ id: `${actor}:${limitation.key}`, duration: limitation.duration! * factor, @@ -74,18 +76,18 @@ export class RateLimiterService { limiter.get((err, info) => { if (err) { - return reject('ERR'); + return reject({ code: 'ERR', info }); } this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); if (info.remaining === 0) { - reject('RATE_LIMIT_EXCEEDED'); + return reject({ code: 'RATE_LIMIT_EXCEEDED', info }); } else { - ok(); + return ok(); } }); - }; + }); const hasShortTermLimit = typeof limitation.minInterval === 'number'; @@ -94,12 +96,12 @@ export class RateLimiterService { typeof limitation.max === 'number'; if (hasShortTermLimit) { - min(); + return min; } else if (hasLongTermLimit) { - max(); + return max; } else { - ok(); + return Promise.resolve(); } - }); + } } } -- cgit v1.2.3-freya From 220e112c833ec9b382658caee99eabdb6ec330ef Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Sat, 15 Jun 2024 08:42:13 +0900 Subject: fix rate limit check never ends (#13994) --- packages/backend/src/server/api/RateLimiterService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index cae106c273..52d73baa0a 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -57,7 +57,7 @@ export class RateLimiterService { return reject({ code: 'BRIEF_REQUEST_INTERVAL', info }); } else { if (hasLongTermLimit) { - return max; + return max.then(ok, reject); } else { return ok(); } -- cgit v1.2.3-freya From d0ee0203e12d41515b05d7d99ae09f70eeae5874 Mon Sep 17 00:00:00 2001 From: Ryu jongheon Date: Tue, 18 Jun 2024 12:18:04 +0900 Subject: Fix(backend): Limit antenna/webhook/list to exact amount (#14036) ... not +1 * Update antennas/clips e2e test --- packages/backend/src/core/ClipService.ts | 4 ++-- packages/backend/src/core/UserListService.ts | 2 +- packages/backend/src/server/api/endpoints/antennas/create.ts | 2 +- packages/backend/src/server/api/endpoints/i/import-antennas.ts | 2 +- packages/backend/src/server/api/endpoints/i/webhooks/create.ts | 2 +- .../src/server/api/endpoints/users/lists/create-from-public.ts | 2 +- packages/backend/src/server/api/endpoints/users/lists/create.ts | 2 +- packages/backend/test/e2e/antennas.ts | 3 +-- packages/backend/test/e2e/clips.ts | 7 +++---- 9 files changed, 12 insertions(+), 14 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts index 9fd1ebad87..929a9db064 100644 --- a/packages/backend/src/core/ClipService.ts +++ b/packages/backend/src/core/ClipService.ts @@ -41,7 +41,7 @@ export class ClipService { const currentCount = await this.clipsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ClipService.TooManyClipsError(); } @@ -102,7 +102,7 @@ export class ClipService { const currentCount = await this.clipNotesRepository.countBy({ clipId: clip.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { throw new ClipService.TooManyClipNotesError(); } diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bbdcfed738..6333356fe9 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -95,7 +95,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { const currentCount = await this.userListMembershipsRepository.countBy({ userListId: list.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index ec08198514..577b9e1b1f 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -93,7 +93,7 @@ export default class extends Endpoint { // eslint- const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); - if (currentAntennasCount > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index b4661a93e2..bc46163e3d 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -78,7 +78,7 @@ export default class extends Endpoint { if (file.size === 0) throw new ApiError(meta.errors.emptyFile); const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); - if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount + antennas.length >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } this.queueService.createImportAntennasJob(me, antennas); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index c692380288..9eb7f5b3a0 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -85,7 +85,7 @@ export default class extends Endpoint { // eslint- const currentWebhooksCount = await this.webhooksRepository.countBy({ userId: me.id, }); - if (currentWebhooksCount > (await this.roleService.getUserPolicies(me.id)).webhookLimit) { + if (currentWebhooksCount >= (await this.roleService.getUserPolicies(me.id)).webhookLimit) { throw new ApiError(meta.errors.tooManyWebhooks); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index 8504da0209..7e44d501ab 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -100,7 +100,7 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index 9378bde5cb..7daf05ba4e 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 101238b601..6ac14cd8dc 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -163,8 +163,7 @@ describe('アンテナ', () => { }); test('が上限いっぱいまで作成できること', async () => { - // antennaLimit + 1まで作れるのがキモ - const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ + const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit)].map(() => successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index ba6f9d6a65..a229ec06f9 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -153,8 +153,7 @@ describe('クリップ', () => { }); test('の作成はポリシーで定められた数以上はできない。', async () => { - // ポリシー + 1まで作れるという所がミソ - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; for (let i = 0; i < clipLimit; i++) { await create(); } @@ -327,7 +326,7 @@ describe('クリップ', () => { }); test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; const clips = await createMany({}, clipLimit); const res = await list({ parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる @@ -705,7 +704,7 @@ describe('クリップ', () => { // TODO: 17000msくらいかかる... test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { - const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit; const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { text: `test ${i}`, }) as unknown)) as Misskey.entities.Note[]; -- cgit v1.2.3-freya From bf33382082c41199b631c06ac20ea369b0747470 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 21 Jun 2024 13:03:51 +0900 Subject: refactor(backend): remove unnecessary isNotNull sugar --- packages/backend/src/core/AbuseReportNotificationService.ts | 7 +++---- packages/backend/src/core/NoteCreateService.ts | 3 +-- packages/backend/src/core/activitypub/ApAudienceService.ts | 3 +-- packages/backend/src/core/activitypub/ApInboxService.ts | 3 +-- packages/backend/src/core/activitypub/ApRendererService.ts | 5 ++--- .../backend/src/core/activitypub/models/ApMentionService.ts | 3 +-- .../backend/src/core/activitypub/models/ApNoteService.ts | 3 +-- .../backend/src/core/activitypub/models/ApPersonService.ts | 3 +-- .../src/core/activitypub/models/ApQuestionService.ts | 3 +-- packages/backend/src/core/activitypub/models/tag.ts | 3 +-- .../AbuseReportNotificationRecipientEntityService.ts | 5 ++--- .../src/core/entities/AbuseUserReportEntityService.ts | 3 +-- .../backend/src/core/entities/DriveFileEntityService.ts | 7 +++---- .../backend/src/core/entities/InviteCodeEntityService.ts | 5 ++--- packages/backend/src/core/entities/NoteEntityService.ts | 9 ++++----- .../backend/src/core/entities/NotificationEntityService.ts | 13 ++++++------- packages/backend/src/core/entities/PageEntityService.ts | 3 +-- packages/backend/src/core/entities/UserEntityService.ts | 3 +-- packages/backend/src/misc/is-not-null.ts | 8 -------- .../src/server/api/endpoints/gallery/posts/create.ts | 3 +-- .../src/server/api/endpoints/gallery/posts/update.ts | 3 +-- packages/backend/src/server/api/endpoints/pinned-users.ts | 3 +-- 22 files changed, 36 insertions(+), 65 deletions(-) delete mode 100644 packages/backend/src/misc/is-not-null.ts (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index df752afcd8..42e5931212 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -10,7 +10,6 @@ import sanitizeHtml from 'sanitize-html'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import type { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient, @@ -91,7 +90,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it .filter(it => it.isActive && it.userProfile?.emailVerified) .map(it => it.userProfile?.email) - .filter(isNotNull), + .filter(x => x != null), ); // 送信先の鮮度を保つため、毎回取得する @@ -138,7 +137,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .then(it => it .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') .map(it => it.systemWebhookId) - .filter(isNotNull)); + .filter(x => x != null)); for (const webhookId of recipientWebhookIds) { await Promise.all( abuseReports.map(it => { @@ -340,7 +339,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { @bindThis private async removeUnauthorizedRecipientUsers(recipients: MiAbuseReportNotificationRecipient[]): Promise { const userRecipients = recipients.filter(it => it.userId !== null); - const recipientUserIds = new Set(userRecipients.map(it => it.userId).filter(isNotNull)); + const recipientUserIds = new Set(userRecipients.map(it => it.userId).filter(x => x != null)); if (recipientUserIds.size <= 0) { // ユーザが通知先として設定されていない場合、この関数での処理を行うべきレコードが無い return recipients; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 0c9de117d2..a2c3aaa701 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -59,7 +59,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -839,7 +838,7 @@ export class NoteCreateService implements OnApplicationShutdown { const mentions = extractMentions(tokens); let mentionedUsers = (await Promise.all(mentions.map(m => this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), - ))).filter(isNotNull); + ))).filter(x => x != null); // Drop duplicate users mentionedUsers = mentionedUsers.filter((u, i, self) => diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts index 0fccc7b950..5a5a76f7d6 100644 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -8,7 +8,6 @@ import promiseLimit from 'promise-limit'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import { concat, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getApIds } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { ApObject } from './type.js'; @@ -41,7 +40,7 @@ export class ApAudienceService { const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), - )).filter(isNotNull); + )).filter(x => x != null); if (toGroups.public.length > 0) { return { diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index de3178b482..e2164fec1d 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -27,7 +27,6 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; @@ -538,7 +537,7 @@ export class ApInboxService { const userIds = uris .filter(uri => uri.startsWith(this.config.url + '/users/')) .map(uri => uri.split('/').at(-1)) - .filter(isNotNull); + .filter(x => x != null); const users = await this.usersRepository.findBy({ id: In(userIds), }); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 4fc724b548..98e944f347 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -26,7 +26,6 @@ import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; @@ -317,7 +316,7 @@ export class ApRendererService { const getPromisedFiles = async (ids: string[]): Promise => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter(isNotNull); + return ids.map(id => items.find(item => item.id === id)).filter(x => x != null); }; let inReplyTo; @@ -686,7 +685,7 @@ export class ApRendererService { if (names.length === 0) return []; const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); - const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); + const emojis = names.map(name => allEmojis.get(name)).filter(x => x != null); return emojis; } diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts index 0ced7e88af..2cd151fa04 100644 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -8,7 +8,6 @@ import promiseLimit from 'promise-limit'; import type { MiUser } from '@/models/_.js'; import { toArray, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isMention } from '../type.js'; import { Resolver } from '../ApResolverService.js'; import { ApPersonService } from './ApPersonService.js'; @@ -28,7 +27,7 @@ export class ApMentionService { const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), - )).filter(isNotNull); + )).filter(x => x != null); return mentionedUsers; } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index c6e6b3a1e8..fc7aa1e0b9 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -24,7 +24,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; @@ -253,7 +252,7 @@ export class ApNoteService { } }; - const uris = unique([note._misskey_quote, note.quoteUrl].filter(isNotNull)); + const uris = unique([note._misskey_quote, note.quoteUrl].filter(x => x != null)); const results = await Promise.all(uris.map(tryResolveNote)); quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 744b1ea683..398c8695d2 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -38,7 +38,6 @@ import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -637,7 +636,7 @@ export class ApPersonService implements OnModuleInit { // とりあえずidを別の時間で生成して順番を維持 let td = 0; - for (const note of featuredNotes.filter(isNotNull)) { + for (const note of featuredNotes.filter(x => x != null)) { td -= 1000; transactionalEntityManager.insert(MiUserNotePining, { id: this.idService.gen(Date.now() + td), diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index d1936cfe1d..4fae1e897b 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -10,7 +10,6 @@ import type { Config } from '@/config.js'; import type { IPoll } from '@/models/Poll.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isQuestion } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; @@ -52,7 +51,7 @@ export class ApQuestionService { const choices = question[multiple ? 'anyOf' : 'oneOf'] ?.map((x) => x.name) - .filter(isNotNull) + .filter(x => x != null) ?? []; const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0); diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts index e7ceec3262..f75cc45f7e 100644 --- a/packages/backend/src/core/activitypub/models/tag.ts +++ b/packages/backend/src/core/activitypub/models/tag.ts @@ -4,7 +4,6 @@ */ import { toArray } from '@/misc/prelude/array.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isHashtag } from '../type.js'; import type { IObject, IApHashtag } from '../type.js'; @@ -16,7 +15,7 @@ export function extractApHashtags(tags: IObject | IObject[] | null | undefined): return hashtags.map(tag => { const m = tag.name.match(/^#(.+)/); return m ? m[1] : null; - }).filter(isNotNull); + }).filter(x => x != null); } export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { diff --git a/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts index 6819afafd9..1e23c194c5 100644 --- a/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts +++ b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts @@ -11,7 +11,6 @@ import { bindThis } from '@/decorators.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { Packed } from '@/misc/json-schema.js'; import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class AbuseReportNotificationRecipientEntityService { @@ -66,13 +65,13 @@ export class AbuseReportNotificationRecipientEntityService { ); } - const userIds = objs.map(it => it.userId).filter(isNotNull); + const userIds = objs.map(it => it.userId).filter(x => x != null); const users: Map> = (userIds.length > 0) ? await this.userEntityService.packMany(userIds) .then(it => new Map(it.map(it => [it.id, it]))) : new Map(); - const systemWebhookIds = objs.map(it => it.systemWebhookId).filter(isNotNull); + const systemWebhookIds = objs.map(it => it.systemWebhookId).filter(x => x != null); const systemWebhooks: Map> = (systemWebhookIds.length > 0) ? await this.systemWebhookEntityService.packMany(systemWebhookIds) .then(it => new Map(it.map(it => [it.id, it]))) diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index b0e1d1ab36..a13c244c19 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -10,7 +10,6 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @@ -63,7 +62,7 @@ export class AbuseUserReportEntityService { ) { const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); - const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(isNotNull); + const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null); const _userMap = await this.userEntityService.packMany( [..._reporters, ..._targetUsers, ..._assignees], null, diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 02ff2e7754..c485555f90 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -16,7 +16,6 @@ import { appendQuery, query } from '@/misc/prelude/url.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; @@ -261,11 +260,11 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise[]> { - const _user = files.map(({ user, userId }) => user ?? userId).filter(isNotNull); + const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null); const _userMap = await this.userEntityService.packMany(_user) .then(users => new Map(users.map(user => [user.id, user]))); const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); - return items.filter(isNotNull); + return items.filter(x => x != null); } @bindThis @@ -290,6 +289,6 @@ export class DriveFileEntityService { ): Promise[]> { if (fileIds.length === 0) return []; const filesMap = await this.packManyByIdsMap(fileIds, options); - return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + return fileIds.map(id => filesMap.get(id)).filter(x => x != null); } } diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts index 26f57e1299..5d3e823a2a 100644 --- a/packages/backend/src/core/entities/InviteCodeEntityService.ts +++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts @@ -12,7 +12,6 @@ import type { MiUser } from '@/models/User.js'; import type { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -59,8 +58,8 @@ export class InviteCodeEntityService { tickets: MiRegistrationTicket[], me: { id: MiUser['id'] }, ) { - const _createdBys = tickets.map(({ createdBy, createdById }) => createdBy ?? createdById).filter(isNotNull); - const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(isNotNull); + const _createdBys = tickets.map(({ createdBy, createdById }) => createdBy ?? createdById).filter(x => x != null); + const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null); const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me) .then(users => new Map(users.map(u => [u.id, u]))); return Promise.all( diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ce72c50b8..2cd092231c 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -14,7 +14,6 @@ import type { MiNote } from '@/models/Note.js'; import type { MiNoteReaction } from '@/models/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -276,7 +275,7 @@ export class NoteEntityService implements OnModuleInit { packedFiles.set(k, v); } } - return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + return fileIds.map(id => packedFiles.get(id)).filter(x => x != null); } @bindThis @@ -449,12 +448,12 @@ export class NoteEntityService implements OnModuleInit { await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく - const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); const users = [ ...notes.map(({ user, userId }) => user ?? userId), - ...notes.map(({ replyUserId }) => replyUserId).filter(isNotNull), - ...notes.map(({ renoteUserId }) => renoteUserId).filter(isNotNull), + ...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null), + ...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null), ]; const packedUsers = await this.userEntityService.packMany(users, me) .then(users => new Map(users.map(u => [u.id, u]))); diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 94d56c883b..f393513510 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -13,7 +13,6 @@ import type { MiGroupedNotification, MiNotification } from '@/models/Notificatio import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; @@ -103,7 +102,7 @@ export class NotificationEntityService implements OnModuleInit { user, reaction: reaction.reaction, }; - }))).filter(r => isNotNull(r.user)); + }))).filter(r => r.user != null); // if all users have been deleted, don't show this notification if (reactions.length === 0) { return null; @@ -124,7 +123,7 @@ export class NotificationEntityService implements OnModuleInit { } return this.userEntityService.pack(userId, { id: meId }); - }))).filter(isNotNull); + }))).filter(x => x != null); // if all users have been deleted, don't show this notification if (users.length === 0) { return null; @@ -181,7 +180,7 @@ export class NotificationEntityService implements OnModuleInit { validNotifications = await this.#filterValidNotifier(validNotifications, meId); - const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(x => x != null); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], @@ -223,7 +222,7 @@ export class NotificationEntityService implements OnModuleInit { ); }); - return (await Promise.all(packPromises)).filter(isNotNull); + return (await Promise.all(packPromises)).filter(x => x != null); } @bindThis @@ -305,7 +304,7 @@ export class NotificationEntityService implements OnModuleInit { this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)), ]); - const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull); + const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(x => x != null); const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({ where: { id: In(notifierIds) }, }) : []; @@ -313,7 +312,7 @@ export class NotificationEntityService implements OnModuleInit { const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => { const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers); return isValid ? notification : null; - }))) as [T | null] ).filter(isNotNull); + }))) as [T | null] ).filter(x => x != null); return filteredNotifications; } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 142d9e81db..46bf51bb6d 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -14,7 +14,6 @@ import type { MiPage } from '@/models/Page.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -106,7 +105,7 @@ export class PageEntityService { script: page.script, eyeCatchingImageId: page.eyeCatchingImageId, eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, - attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(isNotNull)), + attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(x => x != null)), likedCount: page.likedCount, isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined, }); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b80a1ec206..da96878713 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -47,7 +47,6 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -514,7 +513,7 @@ export class UserEntityService implements OnModuleInit { movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, alsoKnownAs: user.alsoKnownAs ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) - .then(xs => xs.length === 0 ? null : xs.filter(isNotNull)) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null)) : null, createdAt: this.idService.parse(user.id).date.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts deleted file mode 100644 index 8d9dc8bb39..0000000000 --- a/packages/backend/src/misc/is-not-null.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function isNotNull>(input: T | undefined | null): input is T { - return input != null; -} diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index 46f8998810..504a9c789e 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -12,7 +12,6 @@ import type { MiDriveFile } from '@/models/DriveFile.js'; import { IdService } from '@/core/IdService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['gallery'], @@ -70,7 +69,7 @@ export default class extends Endpoint { // eslint- id: fileId, userId: me.id, }), - ))).filter(isNotNull); + ))).filter(x => x != null); if (files.length === 0) { throw new Error(); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index 8bd83ff5ba..2f977784ec 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -10,7 +10,6 @@ import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/_.js import type { MiDriveFile } from '@/models/DriveFile.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['gallery'], @@ -68,7 +67,7 @@ export default class extends Endpoint { // eslint- id: fileId, userId: me.id, }), - ))).filter(isNotNull); + ))).filter(x => x != null); if (files.length === 0) { throw new Error(); diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 784766bcb5..15832ef7f8 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -12,7 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['users'], @@ -53,7 +52,7 @@ export default class extends Endpoint { // eslint- host: acct.host ?? IsNull(), }))); - return await this.userEntityService.packMany(users.filter(isNotNull), me, { schema: 'UserDetailed' }); + return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: 'UserDetailed' }); }); } } -- cgit v1.2.3-freya From e88f08ad7deeba74f5a196fe3afd52da6982f251 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 21 Jun 2024 13:31:02 +0900 Subject: refactor --- .../backend/src/server/api/endpoints/drive/folders/update.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index 52b8b335b5..62b04e1df3 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -95,15 +95,14 @@ export default class extends Endpoint { // eslint- // Check if the circular reference will occur const checkCircle = async (folderId: string): Promise => { - // Fetch folder - const folder2 = await this.driveFoldersRepository.findOneBy({ + const folder2 = await this.driveFoldersRepository.findOneByOrFail({ id: folderId, }); - if (folder2!.id === folder!.id) { + if (folder2.id === folder.id) { return true; - } else if (folder2!.parentId) { - return await checkCircle(folder2!.parentId); + } else if (folder2.parentId) { + return await checkCircle(folder2.parentId); } else { return false; } -- cgit v1.2.3-freya From ac12ab8629f0a0172250f949a98ee1efb1d0890d Mon Sep 17 00:00:00 2001 From: Kisaragi <48310258+KisaragiEffective@users.noreply.github.com> Date: Sat, 22 Jun 2024 12:51:02 +0900 Subject: fix(backend): フィードのノートのMFMはHTMLにレンダーしてから返す (#14006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): フィードのノートのMFMはHTMLにレンダーしてから返す (test wip) * chore: beforeEachを使う? * fix: プレーンテキストにフォールバックしてMFMが含まれていないか調べる方針を実装 * fix: application/jsonだとパースされるのでその作用をキャンセル * build: fix lint error * docs: update CHANGELOG.md --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/backend/src/server/web/FeedService.ts | 6 +++++- packages/backend/test/e2e/fetch-resource.ts | 17 +++++++++++++++++ packages/backend/test/utils.ts | 5 +++-- 4 files changed, 26 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index c5eb698385..ab9f5f8000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Server - チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) +- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 10e3ed2682..9d810ddc84 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -14,6 +14,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { MfmService } from "@/core/MfmService.js"; +import { parse as mfmParse } from 'mfm-js'; @Injectable() export class FeedService { @@ -33,6 +35,7 @@ export class FeedService { private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, + private mfmService: MfmService, ) { } @@ -76,13 +79,14 @@ export class FeedService { id: In(note.fileIds), }) : []; const file = files.find(file => file.type.startsWith('image/')); + const text = note.text; feed.addItem({ title: `New note by ${author.name}`, link: `${this.config.url}/notes/${note.id}`, date: this.idService.parse(note.id).date, description: note.cw ?? undefined, - content: note.text ?? undefined, + content: text ? this.mfmService.toHtml(mfmParse(text), JSON.parse(note.mentionedRemoteUsers)) ?? undefined : undefined, image: file ? this.driveFileEntityService.getPublicUrl(file) : undefined, }); } diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 4851ed14be..7efd688ec2 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -153,6 +153,23 @@ describe('Webリソース', () => { path: path('nonexisting'), status: 404, })); + + describe(' has entry such ', () => { + beforeEach(() => { + post(alice, { text: "**a**" }) + }); + + test('MFMを含まない。', async () => { + const content = await simpleGet(path(alice.username), "*/*", undefined, res => res.text()); + const _body: unknown = content.body; + // JSONフィードのときは改めて文字列化する + const body: string = typeof (_body) === "object" ? JSON.stringify(_body) : _body as string; + + if (body.includes("**a**")) { + throw new Error("MFM shouldn't be included"); + } + }); + }) }); describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 86814fffe0..aad4ab37c9 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -17,6 +17,7 @@ import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/val import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; +import { type Response } from 'node-fetch'; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; @@ -454,7 +455,7 @@ export type SimpleGetResponse = { type: string | null, location: string | null }; -export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { +export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined, bodyExtractor: (res: Response) => Promise = _ => Promise.resolve(null)): Promise => { const res = await relativeFetch(path, { headers: { Accept: accept, @@ -482,7 +483,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde const body = jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : - null; + await bodyExtractor(res); return { status: res.status, -- cgit v1.2.3-freya From b50eb511b0cf6fb05d37c3370726f940c1438a99 Mon Sep 17 00:00:00 2001 From: yupix Date: Sat, 22 Jun 2024 14:52:27 +0900 Subject: refactor: api/*/update系の必須キーを最低限に (#13824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: clips/updateの必須キーをclipIdのみに * refactor: admin/roles/update の必須キーをroleIdのみに * feat: pages/update の必須キーをpageIdのみに * refactor: gallery/posts/update の必須キーをpostidのみに * feat: misskey-jsの型を更新 * feat: i/webhooks/updateの必須キーをwebhookIdのみに * feat: admin/ad/updateの必須キーをidのみに * feat: misskey-jsの型を更新 * chore: update CHANGELOG.md * docs: update CHANGELOG.md * fix: secretが更新できなくなる場合がある Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com> * Update packages/backend/src/server/api/endpoints/gallery/posts/update.ts --------- Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 6 ++ .../src/server/api/endpoints/admin/ad/update.ts | 6 +- .../src/server/api/endpoints/admin/roles/update.ts | 14 ----- .../src/server/api/endpoints/clips/update.ts | 4 +- .../server/api/endpoints/gallery/posts/update.ts | 26 ++++---- .../src/server/api/endpoints/i/webhooks/update.ts | 6 +- .../src/server/api/endpoints/pages/update.ts | 23 +++---- packages/misskey-js/src/autogen/types.ts | 71 +++++++++++----------- 8 files changed, 71 insertions(+), 85 deletions(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9f5f8000..c1af63ad23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) - Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) +- Enhance: エンドポイント`clips/update`の必須項目を`clipId`のみに +- Enhance: エンドポイント`admin/roles/update`の必須項目を`roleId`のみに +- Enhance: エンドポイント`pages/update`の必須項目を`pageId`のみに +- Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに +- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに +- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 62358457ff..4e3d731aca 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -40,7 +40,7 @@ export const paramDef = { startsAt: { type: 'integer' }, dayOfWeek: { type: 'integer' }, }, - required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'dayOfWeek'], + required: ['id'], } as const; @Injectable() @@ -63,8 +63,8 @@ export default class extends Endpoint { // eslint- ratio: ps.ratio, memo: ps.memo, imageUrl: ps.imageUrl, - expiresAt: new Date(ps.expiresAt), - startsAt: new Date(ps.startsAt), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined, + startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined, dayOfWeek: ps.dayOfWeek, }); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 5242e0be2f..465ad7aaaf 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -6,7 +6,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RolesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { RoleService } from '@/core/RoleService.js'; @@ -50,19 +49,6 @@ export const paramDef = { }, required: [ 'roleId', - 'name', - 'description', - 'color', - 'iconUrl', - 'target', - 'condFormula', - 'isPublic', - 'isModerator', - 'isAdministrator', - 'asBadge', - 'canEditMembersByModerator', - 'displayOrder', - 'policies', ], } as const; diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 3b44ba81b3..603a3ccf3d 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipService } from '@/core/ClipService.js'; @@ -41,7 +41,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, }, - required: ['clipId', 'name'], + required: ['clipId'], } as const; @Injectable() diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index 2f977784ec..5243ee9603 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -47,7 +47,7 @@ export const paramDef = { } }, isSensitive: { type: 'boolean', default: false }, }, - required: ['postId', 'title', 'fileIds'], + required: ['postId'], } as const; @Injectable() @@ -62,15 +62,19 @@ export default class extends Endpoint { // eslint- private galleryPostEntityService: GalleryPostEntityService, ) { super(meta, paramDef, async (ps, me) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - this.driveFilesRepository.findOneBy({ - id: fileId, - userId: me.id, - }), - ))).filter(x => x != null); - - if (files.length === 0) { - throw new Error(); + let files: Array | undefined; + + if (ps.fileIds) { + files = (await Promise.all(ps.fileIds.map(fileId => + this.driveFilesRepository.findOneBy({ + id: fileId, + userId: me.id, + }), + ))).filter(x => x != null); + + if (files.length === 0) { + throw new Error(); + } } await this.galleryPostsRepository.update({ @@ -81,7 +85,7 @@ export default class extends Endpoint { // eslint- title: ps.title, description: ps.description, isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), + fileIds: files ? files.map(file => file.id) : undefined, }); const post = await this.galleryPostsRepository.findOneByOrFail({ id: ps.postId }); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index 6e380d76f8..07a25bd82a 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -34,13 +34,13 @@ export const paramDef = { webhookId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, url: { type: 'string', minLength: 1, maxLength: 1024 }, - secret: { type: 'string', maxLength: 1024, default: '' }, + secret: { type: 'string', nullable: true, maxLength: 1024 }, on: { type: 'array', items: { type: 'string', enum: webhookEventTypes, } }, active: { type: 'boolean' }, }, - required: ['webhookId', 'name', 'url', 'on', 'active'], + required: ['webhookId'], } as const; // TODO: ロジックをサービスに切り出す @@ -66,7 +66,7 @@ export default class extends Endpoint { // eslint- await this.webhooksRepository.update(webhook.id, { name: ps.name, url: ps.url, - secret: ps.secret, + secret: ps.secret === null ? '' : ps.secret, on: ps.on, active: ps.active, }); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index b8e5e70a25..f11bbbcb1a 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -70,7 +70,7 @@ export const paramDef = { alignCenter: { type: 'boolean' }, hideTitleWhenPinned: { type: 'boolean' }, }, - required: ['pageId', 'title', 'name', 'content', 'variables', 'script'], + required: ['pageId'], } as const; @Injectable() @@ -91,9 +91,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.accessDenied); } - let eyeCatchingImage = null; if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await this.driveFilesRepository.findOneBy({ + const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ id: ps.eyeCatchingImageId, userId: me.id, }); @@ -116,23 +115,15 @@ export default class extends Endpoint { // eslint- await this.pagesRepository.update(page.id, { updatedAt: new Date(), title: ps.title, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - name: ps.name === undefined ? page.name : ps.name, + name: ps.name, summary: ps.summary === undefined ? page.summary : ps.summary, content: ps.content, variables: ps.variables, script: ps.script, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - font: ps.font === undefined ? page.font : ps.font, - eyeCatchingImageId: ps.eyeCatchingImageId === null - ? null - : ps.eyeCatchingImageId === undefined - ? page.eyeCatchingImageId - : eyeCatchingImage!.id, + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId, }); }); } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index bdcc1dfd77..72aca4dee2 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -5881,15 +5881,15 @@ export type operations = { 'application/json': { /** Format: misskey:id */ id: string; - memo: string; - url: string; - imageUrl: string; - place: string; - priority: string; - ratio: number; - expiresAt: number; - startsAt: number; - dayOfWeek: number; + memo?: string; + url?: string; + imageUrl?: string; + place?: string; + priority?: string; + ratio?: number; + expiresAt?: number; + startsAt?: number; + dayOfWeek?: number; }; }; }; @@ -9744,21 +9744,21 @@ export type operations = { 'application/json': { /** Format: misskey:id */ roleId: string; - name: string; - description: string; - color: string | null; - iconUrl: string | null; + name?: string; + description?: string; + color?: string | null; + iconUrl?: string | null; /** @enum {string} */ - target: 'manual' | 'conditional'; - condFormula: Record; - isPublic: boolean; - isModerator: boolean; - isAdministrator: boolean; + target?: 'manual' | 'conditional'; + condFormula?: Record; + isPublic?: boolean; + isModerator?: boolean; + isAdministrator?: boolean; isExplorable?: boolean; - asBadge: boolean; - canEditMembersByModerator: boolean; - displayOrder: number; - policies: Record; + asBadge?: boolean; + canEditMembersByModerator?: boolean; + displayOrder?: number; + policies?: Record; }; }; }; @@ -13400,7 +13400,7 @@ export type operations = { 'application/json': { /** Format: misskey:id */ clipId: string; - name: string; + name?: string; isPublic?: boolean; description?: string | null; }; @@ -16247,9 +16247,9 @@ export type operations = { 'application/json': { /** Format: misskey:id */ postId: string; - title: string; + title?: string; description?: string | null; - fileIds: string[]; + fileIds?: string[]; /** @default false */ isSensitive?: boolean; }; @@ -20030,12 +20030,11 @@ export type operations = { 'application/json': { /** Format: misskey:id */ webhookId: string; - name: string; - url: string; - /** @default */ - secret?: string; - on: ('mention' | 'unfollow' | 'follow' | 'followed' | 'note' | 'reply' | 'renote' | 'reaction')[]; - active: boolean; + name?: string; + url?: string; + secret?: string | null; + on?: ('mention' | 'unfollow' | 'follow' | 'followed' | 'note' | 'reply' | 'renote' | 'reaction')[]; + active?: boolean; }; }; }; @@ -23404,16 +23403,16 @@ export type operations = { 'application/json': { /** Format: misskey:id */ pageId: string; - title: string; - name: string; + title?: string; + name?: string; summary?: string | null; - content: { + content?: { [key: string]: unknown; }[]; - variables: { + variables?: { [key: string]: unknown; }[]; - script: string; + script?: string; /** Format: misskey:id */ eyeCatchingImageId?: string | null; /** @enum {string} */ -- cgit v1.2.3-freya From 961cb6c5eeb7745dc156327d2041241b70098b70 Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Sat, 22 Jun 2024 19:49:38 +0900 Subject: fix(backend): fix creating reactions bugs (#13901) * fix(backend): add fallback for empty string when creating reaction * fix(backend): prohibit reactions to Renote * test(backend): add some tests for `notes/reactions/create` endpoint * Update CHANGELOG.md * lint * Update CHANGELOG.md --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 2 + packages/backend/src/core/ReactionService.ts | 8 ++- .../server/api/endpoints/notes/reactions/create.ts | 7 +++ packages/backend/test/e2e/endpoints.ts | 61 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index ca74d71719..354bbd20fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 - Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正 +- Fix: 空文字列のリアクションはフォールバックされるように +- Fix: リノートにリアクションできないように ## 2024.5.0 diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index cb0b079df0..64c7b2ed03 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -29,6 +29,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { RoleService } from '@/core/RoleService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; const FALLBACK = '\u2764'; const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; @@ -117,11 +118,16 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } + // Check if note is Renote + if (isRenote(note) && !isQuote(note)) { + throw new IdentifiableError('12c35529-3c79-4327-b1cc-e2cf63a71925', 'You cannot react to Renote.'); + } + let reaction = _reaction ?? FALLBACK; if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { reaction = '\u2764'; - } else if (_reaction) { + } else if (_reaction != null) { const custom = reaction.match(isCustomEmojiRegexp); if (custom) { const reacterHost = this.utilityService.toPunyNullable(user.host); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index b9899608bf..0f0dcca605 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -36,6 +36,12 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: '20ef5475-9f38-4e4c-bd33-de6d979498ec', }, + + cannotReactToRenote: { + message: 'You cannot react to Renote.', + code: 'CANNOT_REACT_TO_RENOTE', + id: 'eaccdc08-ddef-43fe-908f-d108faad57f5', + }, }, } as const; @@ -62,6 +68,7 @@ export default class extends Endpoint { // eslint- await this.reactionService.create(me, note, ps.reaction).catch(err => { if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + if (err.id === '12c35529-3c79-4327-b1cc-e2cf63a71925') throw new ApiError(meta.errors.cannotReactToRenote); throw err; }); return; diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index bc89dc37f4..de5e8ba95e 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -266,6 +266,67 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 400); }); + test('リノートにリアクションできない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const bobRenote = await post(bob, { renoteId: bobNote.id }); + + const res = await api('notes/reactions/create', { + noteId: bobRenote.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'CANNOT_REACT_TO_RENOTE'); + }); + + test('引用にリアクションできる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const bobRenote = await post(bob, { text: 'hi again', renoteId: bobNote.id }); + + const res = await api('notes/reactions/create', { + noteId: bobRenote.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('空文字列のリアクションは\u2764にフォールバックされる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const res = await api('notes/reactions/create', { + noteId: bobNote.id, + reaction: '', + }, alice); + + assert.strictEqual(res.status, 204); + + const reaction = await api('notes/reactions', { + noteId: bobNote.id, + }); + + assert.strictEqual(reaction.body.length, 1); + assert.strictEqual(reaction.body[0].type, '\u2764'); + }); + + test('絵文字ではない文字列のリアクションは\u2764にフォールバックされる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const res = await api('notes/reactions/create', { + noteId: bobNote.id, + reaction: 'Hello!', + }, alice); + + assert.strictEqual(res.status, 204); + + const reaction = await api('notes/reactions', { + noteId: bobNote.id, + }); + + assert.strictEqual(reaction.body.length, 1); + assert.strictEqual(reaction.body[0].type, '\u2764'); + }); + test('空のパラメータで怒られる', async () => { // @ts-expect-error param must not be empty const res = await api('notes/reactions/create', {}, alice); -- cgit v1.2.3-freya From eafae79869204e6de4a3fd4835eda3eb23b53974 Mon Sep 17 00:00:00 2001 From: Kisaragi <48310258+KisaragiEffective@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:29:44 +0900 Subject: test(backend): goodbye, Lenna (#14111) --- packages/backend/src/models/json-schema/drive-file.ts | 2 +- .../src/server/api/endpoints/admin/drive/show-file.ts | 2 +- packages/backend/test/e2e/drive.ts | 6 +++--- packages/backend/test/e2e/endpoints.ts | 2 +- packages/backend/test/e2e/note.ts | 4 ++-- packages/backend/test/e2e/user-notes.ts | 4 ++-- packages/backend/test/resources/192.jpg | Bin 0 -> 5131 bytes packages/backend/test/resources/192.png | Bin 0 -> 26568 bytes packages/backend/test/resources/Lenna.jpg | Bin 25360 -> 0 bytes packages/backend/test/resources/Lenna.png | Bin 473831 -> 0 bytes packages/backend/test/unit/FileInfoService.ts | 10 +++++----- packages/backend/test/utils.ts | 2 +- packages/misskey-js/src/autogen/types.ts | 4 ++-- 13 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 packages/backend/test/resources/192.jpg create mode 100644 packages/backend/test/resources/192.png delete mode 100644 packages/backend/test/resources/Lenna.jpg delete mode 100644 packages/backend/test/resources/Lenna.png (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/models/json-schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts index ca88cc0e39..5ee1561c50 100644 --- a/packages/backend/src/models/json-schema/drive-file.ts +++ b/packages/backend/src/models/json-schema/drive-file.ts @@ -20,7 +20,7 @@ export const packedDriveFileSchema = { name: { type: 'string', optional: false, nullable: false, - example: 'lenna.jpg', + example: '192.jpg', }, type: { type: 'string', diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 459d8880fa..a7136d8c8c 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -61,7 +61,7 @@ export const meta = { name: { type: 'string', optional: false, nullable: false, - example: 'lenna.jpg', + example: '192.jpg', }, type: { type: 'string', diff --git a/packages/backend/test/e2e/drive.ts b/packages/backend/test/e2e/drive.ts index 828c5200ef..43a73163eb 100644 --- a/packages/backend/test/e2e/drive.ts +++ b/packages/backend/test/e2e/drive.ts @@ -23,7 +23,7 @@ describe('Drive', () => { const marker = Math.random().toString(); - const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'; + const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'; const catcher = makeStreamCatcher( alice, @@ -41,14 +41,14 @@ describe('Drive', () => { const file = await catcher; assert.strictEqual(res.status, 204); - assert.strictEqual(file.name, 'Lenna.jpg'); + assert.strictEqual(file.name, '192.jpg'); assert.strictEqual(file.type, 'image/jpeg'); }); test('ローカルからアップロードできる', async () => { // APIレスポンスを直接使用するので utils.js uploadFile が通過することで成功とする - const res = await uploadFile(alice, { path: 'Lenna.jpg', name: 'テスト画像' }); + const res = await uploadFile(alice, { path: '192.jpg', name: 'テスト画像' }); assert.strictEqual(res.body?.name, 'テスト画像.jpg'); assert.strictEqual(res.body.type, 'image/jpeg'); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index de5e8ba95e..d5583ea8bb 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -584,7 +584,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body!.name, 'Lenna.jpg'); + assert.strictEqual(res.body!.name, '192.jpg'); }); test('ファイルに名前を付けられる', async () => { diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index bda31d9640..7ce9f47bc3 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -41,7 +41,7 @@ describe('Note', () => { }); test('ファイルを添付できる', async () => { - const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); const res = await api('notes/create', { fileIds: [file.id], @@ -53,7 +53,7 @@ describe('Note', () => { }, 1000 * 10); test('他人のファイルで怒られる', async () => { - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); const res = await api('notes/create', { text: 'test', diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 331e053935..cc07c5ae71 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -17,8 +17,8 @@ describe('users/notes', () => { beforeAll(async () => { alice = await signup({ username: 'alice' }); - const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); + const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); + const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.png'); jpgNote = await post(alice, { fileIds: [jpg.id], }); diff --git a/packages/backend/test/resources/192.jpg b/packages/backend/test/resources/192.jpg new file mode 100644 index 0000000000..76374628e0 Binary files /dev/null and b/packages/backend/test/resources/192.jpg differ diff --git a/packages/backend/test/resources/192.png b/packages/backend/test/resources/192.png new file mode 100644 index 0000000000..15fd1e3731 Binary files /dev/null and b/packages/backend/test/resources/192.png differ diff --git a/packages/backend/test/resources/Lenna.jpg b/packages/backend/test/resources/Lenna.jpg deleted file mode 100644 index 6b5b32281c..0000000000 Binary files a/packages/backend/test/resources/Lenna.jpg and /dev/null differ diff --git a/packages/backend/test/resources/Lenna.png b/packages/backend/test/resources/Lenna.png deleted file mode 100644 index 59ef68aabd..0000000000 Binary files a/packages/backend/test/resources/Lenna.png and /dev/null differ diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 40d187f5a8..aa9b34b706 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -83,21 +83,21 @@ describe('FileInfoService', () => { describe('IMAGE', () => { test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; + const path = `${resources}/192.jpg`; const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; delete info.warnings; delete info.blurhash; delete info.sensitive; delete info.porn; assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', + size: 5131, + md5: '8c9ed0677dd2b8f9f7472c3af247e5e3', type: { mime: 'image/jpeg', ext: 'jpg', }, - width: 512, - height: 512, + width: 192, + height: 192, orientation: undefined, }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index aad4ab37c9..bf14d05eca 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -297,7 +297,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO body: misskey.entities.DriveFile | null }> => { const absPath = path == null - ? new URL('resources/Lenna.jpg', import.meta.url) + ? new URL('resources/192.jpg', import.meta.url) : isAbsolute(path.toString()) ? new URL(path) : new URL(path, new URL('resources/', import.meta.url)); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 72aca4dee2..b59f8dcbe3 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4299,7 +4299,7 @@ export type components = { id: string; /** Format: date-time */ createdAt: string; - /** @example lenna.jpg */ + /** @example 192.jpg */ name: string; /** @example image/jpeg */ type: string; @@ -6799,7 +6799,7 @@ export type operations = { * @example 15eca7fba0480996e2245f5185bf39f2 */ md5: string; - /** @example lenna.jpg */ + /** @example 192.jpg */ name: string; /** @example image/jpeg */ type: string; -- cgit v1.2.3-freya From 984d582796f41f200d417b2d7647e9cb25b8dcec Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 7 Jul 2024 09:56:09 +0900 Subject: fix(frontend): サーバーサイドbootでエラー画面の描画時にDOMが初期化できていないことがあるのを修正 (#14139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/web/boot.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server') diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 396536948e..4275dc9527 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -29,7 +29,8 @@ let forceError = localStorage.getItem('forceError'); if (forceError != null) { - renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') + renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); + return; } //#region Detect language & fetch translations @@ -155,7 +156,12 @@ document.head.appendChild(css); } - function renderError(code, details) { + async function renderError(code, details) { + // Cannot set property 'innerHTML' of null を回避 + if (document.readyState === 'loading') { + await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); + } + let errorsElement = document.getElementById('errors'); if (!errorsElement) { @@ -314,6 +320,6 @@ #errorInfo { width: 50%; } - `) + }`) } })(); -- cgit v1.2.3-freya From 9ef6c4716ca25d8adeb5344c82a433370a086f88 Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Sun, 7 Jul 2024 14:19:00 +0900 Subject: fix(backend): 名前を空白文字列だけにできる問題を修正 (#14119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): 名前を空白文字列だけにできる問題を修正 * Update Changelog * fix test * Unicodeを含める * fix * ユーザー名がUnicode制御文字とスペースのみで構成される場合はnullに * Revert "ユーザー名がUnicode制御文字とスペースのみで構成される場合はnullに" This reverts commit 6c752a69c0d3649072e7e4ed30025183bceb48f9. * [ci skip] changelog typo --- CHANGELOG.md | 2 ++ packages/backend/src/server/api/endpoints/i/update.ts | 9 ++++++++- packages/backend/test/e2e/endpoints.ts | 13 +++++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index 23de5957fd..f62f966451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ - Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正 - Fix: 空文字列のリアクションはフォールバックされるように - Fix: リノートにリアクションできないように +- Fix: ユーザー名の前後に空白文字列がある場合は省略するように +- Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正 ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index a8e702f328..b39b52bc41 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -257,7 +257,14 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (ps.name !== undefined) updates.name = ps.name; + if (ps.name !== undefined) { + if (ps.name === null) { + updates.name = null; + } else { + const trimmedName = ps.name.trim(); + updates.name = trimmedName === '' ? null : trimmedName; + } + } if (ps.description !== undefined) profileUpdates.description = ps.description; if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index d5583ea8bb..2b2699ecd9 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -117,12 +117,21 @@ describe('Endpoints', () => { assert.strictEqual(res.body.birthday, myBirthday); }); - test('名前を空白にできる', async () => { + test('名前を空白のみにした場合nullになる', async () => { const res = await api('i/update', { name: ' ', }, alice); assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.name, ' '); + assert.strictEqual(res.body.name, null); + }); + + test('名前の前後に空白(ホワイトスペース)を入れてもトリムされる', async () => { + const res = await api('i/update', { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#white_space + name: ' あ い う \u0009\u000b\u000c\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\ufeff', + }, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, 'あ い う'); }); test('誕生日の設定を削除できる', async () => { -- cgit v1.2.3-freya From 600f16d625d8b4c8e7f8a82fb4dd1a77f80bfcbc Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Tue, 9 Jul 2024 21:57:19 +0900 Subject: fix(backend): api-docをScalarに変更 (#14152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(backend): api-docをScalarに変更 * Update Changelog --- CHANGELOG.md | 1 + packages/backend/assets/api-doc.html | 20 ++++++++++++++++++ packages/backend/assets/redoc.html | 24 ---------------------- .../src/server/api/openapi/OpenApiServerService.ts | 2 +- .../backend/src/server/api/openapi/gen-spec.ts | 1 - 5 files changed, 22 insertions(+), 26 deletions(-) create mode 100644 packages/backend/assets/api-doc.html delete mode 100644 packages/backend/assets/redoc.html (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bcf721676..b99a2cbc53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 ### Client +- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) - Fix: リバーシの対局を正しく共有できないことがある問題を修正 diff --git a/packages/backend/assets/api-doc.html b/packages/backend/assets/api-doc.html new file mode 100644 index 0000000000..19e0349d47 --- /dev/null +++ b/packages/backend/assets/api-doc.html @@ -0,0 +1,20 @@ + + + + Misskey API + + + + + + + + + diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html deleted file mode 100644 index 2557b4532e..0000000000 --- a/packages/backend/assets/redoc.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - Misskey API - - - - - - - - - - - - - diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts index 5210e4d2bc..f124aa9f39 100644 --- a/packages/backend/src/server/api/openapi/OpenApiServerService.ts +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -25,7 +25,7 @@ export class OpenApiServerService { public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.get('/api-doc', async (_request, reply) => { reply.header('Cache-Control', 'public, max-age=86400'); - return await reply.sendFile('/redoc.html', staticAssets); + return await reply.sendFile('/api-doc.html', staticAssets); }); fastify.get('/api.json', (_request, reply) => { reply.header('Cache-Control', 'public, max-age=600'); diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 2a14270a24..efa47a6986 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -15,7 +15,6 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { info: { version: config.version, title: 'Misskey API', - 'x-logo': { url: '/static-assets/api-doc.png' }, }, externalDocs: { -- cgit v1.2.3-freya From 6cd15275bbcd17498166cda3455370d5f009e0bb Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Fri, 12 Jul 2024 21:14:09 +0900 Subject: fix: サジェストされるユーザのリストアップ方法を見直し (#14180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: サジェストされるユーザのリストアップ方法を見直し * fix comment * fix CHANGELOG.md * ノートの無いユーザ(updatedAtが無いユーザ)は含めないらしい * fix test --- CHANGELOG.md | 5 + packages/backend/src/core/CoreModule.ts | 6 + packages/backend/src/core/UserSearchService.ts | 205 ++++++++++++++++ .../endpoints/users/search-by-username-and-host.ts | 101 +------- packages/backend/test/unit/UserSearchService.ts | 265 +++++++++++++++++++++ 5 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 packages/backend/src/core/UserSearchService.ts create mode 100644 packages/backend/test/unit/UserSearchService.ts (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f48684b9..060ddd0e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,11 @@ - Fix: リノートにリアクションできないように - Fix: ユーザー名の前後に空白文字列がある場合は省略するように - Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正 +- Fix: ユーザ名のサジェスト時に表示される内容と順番を調整(以下の順番になります) #14149 + 1. フォロー中かつアクティブなユーザ + 2. フォロー中かつ非アクティブなユーザ + 3. フォローしていないアクティブなユーザ + 4. フォローしていない非アクティブなユーザ ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index b5b34487ec..0208540afa 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -12,6 +12,7 @@ import { } from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -202,6 +203,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; @@ -348,6 +350,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -490,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, @@ -633,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -774,6 +779,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts new file mode 100644 index 0000000000..0d03cf6ee0 --- /dev/null +++ b/packages/backend/src/core/UserSearchService.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { Config } from '@/config.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; + +function defaultActiveThreshold() { + return new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); +} + +@Injectable() +export class UserSearchService { + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private userEntityService: UserEntityService, + ) { + } + + /** + * ユーザ名とホスト名によるユーザ検索を行う. + * + * - 検索結果には優先順位がつけられており、以下の順序で検索が行われる. + * 1. フォローしているユーザのうち、一定期間以内(※)に更新されたユーザ + * 2. フォローしているユーザのうち、一定期間以内に更新されていないユーザ + * 3. フォローしていないユーザのうち、一定期間以内に更新されたユーザ + * 4. フォローしていないユーザのうち、一定期間以内に更新されていないユーザ + * - ログインしていない場合は、以下の順序で検索が行われる. + * 1. 一定期間以内に更新されたユーザ + * 2. 一定期間以内に更新されていないユーザ + * - それぞれの検索結果はユーザ名の昇順でソートされる. + * - 動作的には先に登場した検索結果の登場位置が優先される(条件的にユーザIDが重複することはないが). + * (1で既にヒットしていた場合、2, 3, 4でヒットしても無視される) + * - ユーザ名とホスト名の検索条件はそれぞれ前方一致で検索される. + * - ユーザ名の検索は大文字小文字を区別しない. + * - ホスト名の検索は大文字小文字を区別しない. + * - 検索結果は最大で {@link opts.limit} 件までとなる. + * + * ※一定期間とは {@link params.activeThreshold} で指定された日時から現在までの期間を指す. + * + * @param params 検索条件. + * @param opts 関数の動作を制御するオプション. + * @param me 検索を実行するユーザの情報. 未ログインの場合は指定しない. + * @see {@link UserSearchService#buildSearchUserQueries} + * @see {@link UserSearchService#buildSearchUserNoLoginQueries} + */ + @bindThis + public async search( + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + opts?: { + limit?: number, + detail?: boolean, + }, + me?: MiUser | null, + ): Promise[]> { + const queries = me ? this.buildSearchUserQueries(me, params) : this.buildSearchUserNoLoginQueries(params); + + let resultSet = new Set(); + const limit = opts?.limit ?? 10; + for (const query of queries) { + const ids = await query + .select('user.id') + .limit(limit - resultSet.size) + .orderBy('user.usernameLower', 'ASC') + .getRawMany<{ user_id: MiUser['id'] }>() + .then(res => res.map(x => x.user_id)); + + resultSet = new Set([...resultSet, ...ids]); + if (resultSet.size >= limit) { + break; + } + } + + return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>( + [...resultSet].slice(0, limit), + me, + { schema: opts?.detail ? 'UserDetailed' : 'UserLite' }, + ); + } + + /** + * ログイン済みユーザによる検索実行時のクエリ一覧を構築する. + * @param me + * @param params + * @private + */ + @bindThis + private buildSearchUserQueries( + me: MiUser, + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + ) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const followingUserQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const activeFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + })); + inactiveFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + // 自分自身がヒットするとしたらここ + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeUserQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + inactiveUserQuery.setParameters(followingUserQuery.getParameters()); + + return [activeFollowingUsersQuery, inactiveFollowingUsersQuery, activeUserQuery, inactiveUserQuery]; + } + + /** + * ログインしていないユーザによる検索実行時のクエリ一覧を構築する. + * @param params + * @private + */ + @bindThis + private buildSearchUserNoLoginQueries(params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + })); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + + return [activeUserQuery, inactiveUserQuery]; + } + + /** + * ユーザ検索クエリで共通する抽出条件をあらかじめ設定したクエリビルダを生成する. + * @param params + * @private + */ + @bindThis + private generateUserQueryBuilder(params: { + username?: string | null, + host?: string | null, + }): SelectQueryBuilder { + const userQuery = this.usersRepository.createQueryBuilder('user'); + + if (params.username) { + userQuery.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(params.username.toLowerCase()) + '%' }); + } + + if (params.host) { + if (params.host === this.config.hostname || params.host === '.') { + userQuery.andWhere('user.host IS NULL'); + } else { + userQuery.andWhere('user.host LIKE :host', { + host: sqlLikeEscape(params.host.toLowerCase()) + '%', + }); + } + } + + userQuery.andWhere('user.isSuspended = FALSE'); + + return userQuery; + } +} diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 7b3bdab327..8ff952dcb5 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -3,15 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; export const meta = { tags: ['users'], @@ -49,89 +43,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { - super(meta, paramDef, async (ps, me) => { - const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { - if (ps.username) { - query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); - } - - if (ps.host) { - if (ps.host === this.config.hostname || ps.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host LIKE :host', { - host: sqlLikeEscape(ps.host.toLowerCase()) + '%', - }); - } - } - - return query; - }; - - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - let users: MiUser[] = []; - - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = setUsernameAndHostQuery() - .andWhere(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .limit(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = setUsernameAndHostQuery() - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); - } - } else { - const query = setUsernameAndHostQuery() - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - users = await query - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - } - - return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); + super(meta, paramDef, (ps, me) => { + return this.userSearchService.search({ + username: ps.username, + host: ps.host, + }, { + limit: ps.limit, + detail: ps.detail, + }, me); }); } } diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts new file mode 100644 index 0000000000..7ea325d420 --- /dev/null +++ b/packages/backend/test/unit/UserSearchService.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { describe, jest, test } from '@jest/globals'; +import { In } from 'typeorm'; +import { UserSearchService } from '@/core/UserSearchService.js'; +import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +describe('UserSearchService', () => { + let app: TestingModule; + let service: UserSearchService; + + let usersRepository: UsersRepository; + let followingsRepository: FollowingsRepository; + let idService: IdService; + let userProfilesRepository: UserProfilesRepository; + + let root: MiUser; + let alice: MiUser; + let alyce: MiUser; + let alycia: MiUser; + let alysha: MiUser; + let alyson: MiUser; + let alyssa: MiUser; + let bob: MiUser; + let bobbi: MiUser; + let bobbie: MiUser; + let bobby: MiUser; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createFollowings(follower: MiUser, followees: MiUser[]) { + for (const followee of followees) { + await followingsRepository.insert({ + id: idService.gen(), + followerId: follower.id, + followeeId: followee.id, + }); + } + } + + async function setActive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(), + }); + } + } + + async function setInactive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(0), + }); + } + } + + async function setSuspended(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + isSuspended: true, + }); + } + } + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + UserSearchService, + { + provide: UserEntityService, useFactory: jest.fn(() => ({ + // とりあえずIDが返れば確認が出来るので + packMany: (value: any) => value, + })), + }, + IdService, + ], + }) + .compile(); + + await app.init(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + followingsRepository = app.get(DI.followingsRepository); + + service = app.get(UserSearchService); + idService = app.get(IdService); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'Alice', usernameLower: 'alice' }); + alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' }); + alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' }); + alysha = await createUser({ username: 'Alysha', usernameLower: 'alysha' }); + alyson = await createUser({ username: 'Alyson', usernameLower: 'alyson', host: 'example.com' }); + alyssa = await createUser({ username: 'Alyssa', usernameLower: 'alyssa', host: 'example.com' }); + bob = await createUser({ username: 'Bob', usernameLower: 'bob' }); + bobbi = await createUser({ username: 'Bobbi', usernameLower: 'bobbi' }); + bobbie = await createUser({ username: 'Bobbie', usernameLower: 'bobbie', host: 'example.com' }); + bobby = await createUser({ username: 'Bobby', usernameLower: 'bobby', host: 'example.com' }); + }); + + afterEach(async () => { + await usersRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('search', () => { + test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alycia, alysha, alyson]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alycia, alysha, alysonは非アクティブなので後ろに行く + expect(result).toEqual([alice, alyce, alyssa, alycia, alysha, alyson].map(x => x.id)); + }); + + test('フォロー中の非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyceはフォローしていないので後ろに行く + expect(result).toEqual([alycia, alysha, alyson, alyssa, alice, alyce].map(x => x.id)); + }); + + test('フォローしていないアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー(アクティブ)、フォロー(非アクティブ)、非フォロー(アクティブ)、非フォロー(非アクティブ)混在時の優先順位度確認', async () => { + await createFollowings(root, [alyson, alyssa, bob, bobbi, bobbie]); + await setActive([root, alyssa, bob, bobbi, alyce, alycia]); + await setInactive([alyson, alice, alysha, bobbie, bobby]); + + const result = await service.search( + { }, + { limit: 100 }, + root, + ); + + // 見る用 + // const users = await usersRepository.findBy({ id: In(result) }).then(it => new Map(it.map(x => [x.id, x]))); + // console.log(result.map(x => users.get(x as any)).map(it => it?.username)); + + // フォローしててアクティブなので先頭: alyssa, bob, bobbi + // フォローしてて非アクティブなので次: alyson, bobbie + // フォローしてないけどアクティブなので次: alyce, alycia, root(アルファベット順的にここになる) + // フォローしてないし非アクティブなので最後: alice, alysha, bobby + expect(result).toEqual([alyssa, bob, bobbi, alyson, bobbie, alyce, alycia, root, alice, alysha, bobby].map(x => x.id)); + }); + + test('[非ログイン] アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー中のアクティブユーザのうち、"al"から始まり"example.com"にいる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al', host: 'exam' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alyson, alyssa].map(x => x.id)); + }); + + test('サスペンド済みユーザは出ない', async () => { + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setSuspended([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alysha, alyson, alyssa].map(x => x.id)); + }); + }); +}); -- cgit v1.2.3-freya From 7afa593d114335368d9031b6e1d5b1edbc4ea9c9 Mon Sep 17 00:00:00 2001 From: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com> Date: Sun, 14 Jul 2024 09:31:05 +0900 Subject: Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に (#14078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement role policy "canUpdateBioMedia" * docs(changelog): update changelog * docs(changelog): update changelog * chore: regenerate misskey-js type definitions * chore: Apply suggestion from code review Co-authored-by: anatawa12 * chore: fix unnecessarily strict inequality check * chore: policies should be gotten only once --------- Co-authored-by: anatawa12 --- CHANGELOG.md | 2 ++ locales/index.d.ts | 4 ++++ locales/ja-JP.yml | 1 + packages/backend/src/core/RoleService.ts | 3 +++ .../src/core/activitypub/models/ApPersonService.ts | 8 ++++++++ packages/backend/src/models/json-schema/role.ts | 4 ++++ .../backend/src/server/api/endpoints/i/update.ts | 23 ++++++++++++++++------ packages/frontend/src/const.ts | 1 + packages/frontend/src/pages/admin/roles.editor.vue | 20 +++++++++++++++++++ packages/frontend/src/pages/admin/roles.vue | 8 ++++++++ packages/misskey-js/src/autogen/types.ts | 1 + 11 files changed, 69 insertions(+), 6 deletions(-) (limited to 'packages/backend/src/server') diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2a9e32e5..bcc2aa29c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 +- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に + - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 5089f7802e..c2f8e944dd 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6594,6 +6594,10 @@ export interface Locale extends ILocale { * ファイルにNSFWを常に付与 */ "alwaysMarkNsfw": string; + /** + * アイコンとバナーの更新を許可 + */ + "canUpdateBioMedia": string; /** * ノートのピン留めの最大数 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a03d792140..8d117e6dc8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1705,6 +1705,7 @@ _role: canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" + canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index e2ebecb99f..94026fd503 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -47,6 +47,7 @@ export type RolePolicies = { canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; + canUpdateBioMedia: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, + canUpdateBioMedia: true, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), + canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 398c8695d2..457205e023 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -34,6 +34,7 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; @@ -100,6 +101,8 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + private roleService: RoleService, ) { } @@ -238,6 +241,11 @@ export class ApPersonService implements OnModuleInit { return this.apImageService.resolveImage(user, img).catch(() => null); })); + if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null)) + && !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) { + return {}; + } + /* we don't want to return nulls on errors! if the database fields are already null, nothing changes; if the database has old diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index d9987a70c3..7366f05356 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canUpdateBioMedia: { + type: 'boolean', + optional: false, nullable: false, + }, pinLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b39b52bc41..a1e2fa5e4c 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { RolePolicies, RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; @@ -256,6 +256,7 @@ export default class extends Endpoint { // eslint- const profileUpdates = {} as Partial; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + let policies: RolePolicies | null = null; if (ps.name !== undefined) { if (ps.name === null) { @@ -296,14 +297,16 @@ export default class extends Endpoint { // eslint- } if (ps.mutedWords !== undefined) { - checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.mutedWords); profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } if (ps.hardMutedWords !== undefined) { - checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.hardMutedWords); profileUpdates.hardMutedWords = ps.hardMutedWords; } @@ -322,13 +325,17 @@ export default class extends Endpoint { // eslint- if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') { - if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); + policies ??= await this.roleService.getUserPolicies(user.id); + if (policies.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; } if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.avatarId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); @@ -344,6 +351,9 @@ export default class extends Endpoint { // eslint- } if (ps.bannerId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); @@ -359,14 +369,15 @@ export default class extends Endpoint { // eslint- } if (ps.avatarDecorations) { + policies ??= await this.roleService.getUserPolicies(user.id); const decorations = await this.avatarDecorationService.getAll(true); - const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); + const myRoles = await this.roleService.getUserRoles(user.id); const allRoles = await this.roleService.getRoles(); const decorationIds = decorations .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .map(d => d.id); - if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); + if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ id: d.id, diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 9e41926a97..e135bc69a0 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -87,6 +87,7 @@ export const ROLE_POLICIES = [ 'canHideAds', 'driveCapacityMb', 'alwaysMarkNsfw', + 'canUpdateBioMedia', 'pinLimit', 'antennaLimit', 'wordMuteLimit', diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index eb8a59b34f..3e948abdf1 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+