Merge branch 'develop' into feat-1714
This commit is contained in:
		@@ -10,7 +10,7 @@
 | 
				
			|||||||
		"ghcr.io/devcontainers-contrib/features/corepack:1": {}
 | 
							"ghcr.io/devcontainers-contrib/features/corepack:1": {}
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"forwardPorts": [3000],
 | 
						"forwardPorts": [3000],
 | 
				
			||||||
	"postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh",
 | 
						"postCreateCommand": "/bin/bash .devcontainer/init.sh",
 | 
				
			||||||
	"customizations": {
 | 
						"customizations": {
 | 
				
			||||||
		"vscode": {
 | 
							"vscode": {
 | 
				
			||||||
			"extensions": [
 | 
								"extensions": [
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,6 +8,7 @@ services:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    volumes:
 | 
					    volumes:
 | 
				
			||||||
      - ../:/workspace:cached
 | 
					      - ../:/workspace:cached
 | 
				
			||||||
 | 
					      - node_modules:/workspace/node_modules
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    command: sleep infinity
 | 
					    command: sleep infinity
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -46,6 +47,7 @@ services:
 | 
				
			|||||||
volumes:
 | 
					volumes:
 | 
				
			||||||
  postgres-data:
 | 
					  postgres-data:
 | 
				
			||||||
  redis-data:
 | 
					  redis-data:
 | 
				
			||||||
 | 
					  node_modules:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
networks:
 | 
					networks:
 | 
				
			||||||
  internal_network:
 | 
					  internal_network:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
set -xe
 | 
					set -xe
 | 
				
			||||||
 | 
					
 | 
				
			||||||
sudo chown -R node /workspace
 | 
					sudo chown node node_modules
 | 
				
			||||||
 | 
					git config --global --add safe.directory /workspace
 | 
				
			||||||
git submodule update --init
 | 
					git submodule update --init
 | 
				
			||||||
corepack install
 | 
					corepack install
 | 
				
			||||||
corepack enable
 | 
					corepack enable
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,15 +1,17 @@
 | 
				
			|||||||
## Unreleased
 | 
					## Unreleased
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### General
 | 
					### General
 | 
				
			||||||
 | 
					- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
 | 
				
			||||||
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
 | 
					- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Client
 | 
					### Client
 | 
				
			||||||
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
 | 
					- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
 | 
				
			||||||
  - 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です
 | 
					  - 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です
 | 
				
			||||||
 | 
					- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Server
 | 
					### Server
 | 
				
			||||||
- チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
 | 
					- チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正
 | 
				
			||||||
 | 
					- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## 2024.5.0
 | 
					## 2024.5.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										94
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										94
									
								
								locales/index.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -9309,6 +9309,10 @@ export interface Locale extends ILocale {
 | 
				
			|||||||
         * Webhookを作成
 | 
					         * Webhookを作成
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        "createWebhook": string;
 | 
					        "createWebhook": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * Webhookを編集
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "modifyWebhook": string;
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
         * 名前
 | 
					         * 名前
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
@@ -9355,6 +9359,72 @@ export interface Locale extends ILocale {
 | 
				
			|||||||
             */
 | 
					             */
 | 
				
			||||||
            "mention": string;
 | 
					            "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": {
 | 
					    "_moderationLogTypes": {
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
@@ -9501,6 +9571,30 @@ export interface Locale extends ILocale {
 | 
				
			|||||||
         * ユーザーのバナーを解除
 | 
					         * ユーザーのバナーを解除
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        "unsetUserBanner": string;
 | 
					        "unsetUserBanner": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * SystemWebhookを作成
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "createSystemWebhook": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * SystemWebhookを更新
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "updateSystemWebhook": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * SystemWebhookを削除
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "deleteSystemWebhook": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 通報の通知先を作成
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "createAbuseReportNotificationRecipient": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 通報の通知先を更新
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "updateAbuseReportNotificationRecipient": string;
 | 
				
			||||||
 | 
					        /**
 | 
				
			||||||
 | 
					         * 通報の通知先を削除
 | 
				
			||||||
 | 
					         */
 | 
				
			||||||
 | 
					        "deleteAbuseReportNotificationRecipient": string;
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    "_fileViewer": {
 | 
					    "_fileViewer": {
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2469,6 +2469,7 @@ _drivecleaner:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
_webhookSettings:
 | 
					_webhookSettings:
 | 
				
			||||||
  createWebhook: "Webhookを作成"
 | 
					  createWebhook: "Webhookを作成"
 | 
				
			||||||
 | 
					  modifyWebhook: "Webhookを編集"
 | 
				
			||||||
  name: "名前"
 | 
					  name: "名前"
 | 
				
			||||||
  secret: "シークレット"
 | 
					  secret: "シークレット"
 | 
				
			||||||
  events: "Webhookを実行するタイミング"
 | 
					  events: "Webhookを実行するタイミング"
 | 
				
			||||||
@@ -2481,6 +2482,26 @@ _webhookSettings:
 | 
				
			|||||||
    renote: "Renoteされたとき"
 | 
					    renote: "Renoteされたとき"
 | 
				
			||||||
    reaction: "リアクションがあったとき"
 | 
					    reaction: "リアクションがあったとき"
 | 
				
			||||||
    mention: "メンションされたとき"
 | 
					    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:
 | 
					_moderationLogTypes:
 | 
				
			||||||
  createRole: "ロールを作成"
 | 
					  createRole: "ロールを作成"
 | 
				
			||||||
@@ -2519,6 +2540,12 @@ _moderationLogTypes:
 | 
				
			|||||||
  deleteAvatarDecoration: "アイコンデコレーションを削除"
 | 
					  deleteAvatarDecoration: "アイコンデコレーションを削除"
 | 
				
			||||||
  unsetUserAvatar: "ユーザーのアイコンを解除"
 | 
					  unsetUserAvatar: "ユーザーのアイコンを解除"
 | 
				
			||||||
  unsetUserBanner: "ユーザーのバナーを解除"
 | 
					  unsetUserBanner: "ユーザーのバナーを解除"
 | 
				
			||||||
 | 
					  createSystemWebhook: "SystemWebhookを作成"
 | 
				
			||||||
 | 
					  updateSystemWebhook: "SystemWebhookを更新"
 | 
				
			||||||
 | 
					  deleteSystemWebhook: "SystemWebhookを削除"
 | 
				
			||||||
 | 
					  createAbuseReportNotificationRecipient: "通報の通知先を作成"
 | 
				
			||||||
 | 
					  updateAbuseReportNotificationRecipient: "通報の通知先を更新"
 | 
				
			||||||
 | 
					  deleteAbuseReportNotificationRecipient: "通報の通知先を削除"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_fileViewer:
 | 
					_fileViewer:
 | 
				
			||||||
  title: "ファイルの詳細"
 | 
					  title: "ファイルの詳細"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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";
 | 
				
			||||||
 | 
							`);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -4,7 +4,7 @@
 | 
				
			|||||||
	"private": true,
 | 
						"private": true,
 | 
				
			||||||
	"type": "module",
 | 
						"type": "module",
 | 
				
			||||||
	"engines": {
 | 
						"engines": {
 | 
				
			||||||
		"node": "^20.10.0"
 | 
							"node": "^20.10.0 || ^22.0.0"
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
	"scripts": {
 | 
						"scripts": {
 | 
				
			||||||
		"start": "node ./built/boot/entry.js",
 | 
							"start": "node ./built/boot/entry.js",
 | 
				
			||||||
@@ -159,7 +159,7 @@
 | 
				
			|||||||
		"qrcode": "1.5.3",
 | 
							"qrcode": "1.5.3",
 | 
				
			||||||
		"random-seed": "0.3.0",
 | 
							"random-seed": "0.3.0",
 | 
				
			||||||
		"ratelimiter": "3.4.1",
 | 
							"ratelimiter": "3.4.1",
 | 
				
			||||||
		"re2": "1.20.10",
 | 
							"re2": "1.21.2",
 | 
				
			||||||
		"redis-lock": "0.1.4",
 | 
							"redis-lock": "0.1.4",
 | 
				
			||||||
		"reflect-metadata": "0.2.2",
 | 
							"reflect-metadata": "0.2.2",
 | 
				
			||||||
		"rename": "1.0.4",
 | 
							"rename": "1.0.4",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										406
									
								
								packages/backend/src/core/AbuseReportNotificationService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								packages/backend/src/core/AbuseReportNotificationService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<MiAbuseReportNotificationRecipient[]> {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient[]> {
 | 
				
			||||||
 | 
							return this.fetchRecipients({ method: ['email'] }, { joinUser: true, ...opts });
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Webhookの通知先一覧を取得する.
 | 
				
			||||||
 | 
						 * リレーション先の{@link MiSystemWebhook}も同時に取得する.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						@bindThis
 | 
				
			||||||
 | 
						public fetchWebhookRecipients(): Promise<MiAbuseReportNotificationRecipient[]> {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient> {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient> {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient[]> {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient>();
 | 
				
			||||||
 | 
							const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
 | 
				
			||||||
 | 
							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<void> {
 | 
				
			||||||
 | 
							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();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										128
									
								
								packages/backend/src/core/AbuseReportService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								packages/backend/src/core/AbuseReportService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<MiAbuseUserReport>();
 | 
				
			||||||
 | 
							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'));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,6 +5,13 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { Module } from '@nestjs/common';
 | 
					import { Module } from '@nestjs/common';
 | 
				
			||||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
 | 
					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 { AccountMoveService } from './AccountMoveService.js';
 | 
				
			||||||
import { AccountUpdateService } from './AccountUpdateService.js';
 | 
					import { AccountUpdateService } from './AccountUpdateService.js';
 | 
				
			||||||
import { AiService } from './AiService.js';
 | 
					import { AiService } from './AiService.js';
 | 
				
			||||||
@@ -56,7 +63,7 @@ import { UserMutingService } from './UserMutingService.js';
 | 
				
			|||||||
import { UserSuspendService } from './UserSuspendService.js';
 | 
					import { UserSuspendService } from './UserSuspendService.js';
 | 
				
			||||||
import { UserAuthService } from './UserAuthService.js';
 | 
					import { UserAuthService } from './UserAuthService.js';
 | 
				
			||||||
import { VideoProcessingService } from './VideoProcessingService.js';
 | 
					import { VideoProcessingService } from './VideoProcessingService.js';
 | 
				
			||||||
import { WebhookService } from './WebhookService.js';
 | 
					import { UserWebhookService } from './UserWebhookService.js';
 | 
				
			||||||
import { ProxyAccountService } from './ProxyAccountService.js';
 | 
					import { ProxyAccountService } from './ProxyAccountService.js';
 | 
				
			||||||
import { UtilityService } from './UtilityService.js';
 | 
					import { UtilityService } from './UtilityService.js';
 | 
				
			||||||
import { FileInfoService } from './FileInfoService.js';
 | 
					import { FileInfoService } from './FileInfoService.js';
 | 
				
			||||||
@@ -144,6 +151,8 @@ import type { Provider } from '@nestjs/common';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
					//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
				
			||||||
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
 | 
					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 $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
 | 
				
			||||||
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
 | 
					const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
 | 
				
			||||||
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
 | 
					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 $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
 | 
				
			||||||
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
 | 
					const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
 | 
				
			||||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
 | 
					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 $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
 | 
				
			||||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
 | 
					const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
 | 
				
			||||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
 | 
					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 $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
 | 
				
			||||||
const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
 | 
					const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
 | 
				
			||||||
 | 
					const $AbuseReportNotificationRecipientEntityService: Provider = { provide: 'AbuseReportNotificationRecipientEntityService', useExisting: AbuseReportNotificationRecipientEntityService };
 | 
				
			||||||
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
 | 
					const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
 | 
				
			||||||
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
 | 
					const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
 | 
				
			||||||
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
 | 
					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 $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
 | 
				
			||||||
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
 | 
					const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
 | 
				
			||||||
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
 | 
					const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
 | 
				
			||||||
 | 
					const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
 | 
					const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
 | 
				
			||||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
 | 
					const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
 | 
				
			||||||
@@ -285,6 +297,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
	],
 | 
						],
 | 
				
			||||||
	providers: [
 | 
						providers: [
 | 
				
			||||||
		LoggerService,
 | 
							LoggerService,
 | 
				
			||||||
 | 
							AbuseReportService,
 | 
				
			||||||
 | 
							AbuseReportNotificationService,
 | 
				
			||||||
		AccountMoveService,
 | 
							AccountMoveService,
 | 
				
			||||||
		AccountUpdateService,
 | 
							AccountUpdateService,
 | 
				
			||||||
		AiService,
 | 
							AiService,
 | 
				
			||||||
@@ -337,7 +351,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		UserSuspendService,
 | 
							UserSuspendService,
 | 
				
			||||||
		UserAuthService,
 | 
							UserAuthService,
 | 
				
			||||||
		VideoProcessingService,
 | 
							VideoProcessingService,
 | 
				
			||||||
		WebhookService,
 | 
							UserWebhookService,
 | 
				
			||||||
 | 
							SystemWebhookService,
 | 
				
			||||||
		UtilityService,
 | 
							UtilityService,
 | 
				
			||||||
		FileInfoService,
 | 
							FileInfoService,
 | 
				
			||||||
		SearchService,
 | 
							SearchService,
 | 
				
			||||||
@@ -366,6 +381,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		AbuseUserReportEntityService,
 | 
							AbuseUserReportEntityService,
 | 
				
			||||||
		AnnouncementEntityService,
 | 
							AnnouncementEntityService,
 | 
				
			||||||
 | 
							AbuseReportNotificationRecipientEntityService,
 | 
				
			||||||
		AntennaEntityService,
 | 
							AntennaEntityService,
 | 
				
			||||||
		AppEntityService,
 | 
							AppEntityService,
 | 
				
			||||||
		AuthSessionEntityService,
 | 
							AuthSessionEntityService,
 | 
				
			||||||
@@ -399,6 +415,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		RoleEntityService,
 | 
							RoleEntityService,
 | 
				
			||||||
		ReversiGameEntityService,
 | 
							ReversiGameEntityService,
 | 
				
			||||||
		MetaEntityService,
 | 
							MetaEntityService,
 | 
				
			||||||
 | 
							SystemWebhookEntityService,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		ApAudienceService,
 | 
							ApAudienceService,
 | 
				
			||||||
		ApDbResolverService,
 | 
							ApDbResolverService,
 | 
				
			||||||
@@ -422,6 +439,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
							//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
				
			||||||
		$LoggerService,
 | 
							$LoggerService,
 | 
				
			||||||
 | 
							$AbuseReportService,
 | 
				
			||||||
 | 
							$AbuseReportNotificationService,
 | 
				
			||||||
		$AccountMoveService,
 | 
							$AccountMoveService,
 | 
				
			||||||
		$AccountUpdateService,
 | 
							$AccountUpdateService,
 | 
				
			||||||
		$AiService,
 | 
							$AiService,
 | 
				
			||||||
@@ -474,7 +493,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		$UserSuspendService,
 | 
							$UserSuspendService,
 | 
				
			||||||
		$UserAuthService,
 | 
							$UserAuthService,
 | 
				
			||||||
		$VideoProcessingService,
 | 
							$VideoProcessingService,
 | 
				
			||||||
		$WebhookService,
 | 
							$UserWebhookService,
 | 
				
			||||||
 | 
							$SystemWebhookService,
 | 
				
			||||||
		$UtilityService,
 | 
							$UtilityService,
 | 
				
			||||||
		$FileInfoService,
 | 
							$FileInfoService,
 | 
				
			||||||
		$SearchService,
 | 
							$SearchService,
 | 
				
			||||||
@@ -503,6 +523,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		$AbuseUserReportEntityService,
 | 
							$AbuseUserReportEntityService,
 | 
				
			||||||
		$AnnouncementEntityService,
 | 
							$AnnouncementEntityService,
 | 
				
			||||||
 | 
							$AbuseReportNotificationRecipientEntityService,
 | 
				
			||||||
		$AntennaEntityService,
 | 
							$AntennaEntityService,
 | 
				
			||||||
		$AppEntityService,
 | 
							$AppEntityService,
 | 
				
			||||||
		$AuthSessionEntityService,
 | 
							$AuthSessionEntityService,
 | 
				
			||||||
@@ -536,6 +557,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		$RoleEntityService,
 | 
							$RoleEntityService,
 | 
				
			||||||
		$ReversiGameEntityService,
 | 
							$ReversiGameEntityService,
 | 
				
			||||||
		$MetaEntityService,
 | 
							$MetaEntityService,
 | 
				
			||||||
 | 
							$SystemWebhookEntityService,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$ApAudienceService,
 | 
							$ApAudienceService,
 | 
				
			||||||
		$ApDbResolverService,
 | 
							$ApDbResolverService,
 | 
				
			||||||
@@ -560,6 +582,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
	exports: [
 | 
						exports: [
 | 
				
			||||||
		QueueModule,
 | 
							QueueModule,
 | 
				
			||||||
		LoggerService,
 | 
							LoggerService,
 | 
				
			||||||
 | 
							AbuseReportService,
 | 
				
			||||||
 | 
							AbuseReportNotificationService,
 | 
				
			||||||
		AccountMoveService,
 | 
							AccountMoveService,
 | 
				
			||||||
		AccountUpdateService,
 | 
							AccountUpdateService,
 | 
				
			||||||
		AiService,
 | 
							AiService,
 | 
				
			||||||
@@ -612,7 +636,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		UserSuspendService,
 | 
							UserSuspendService,
 | 
				
			||||||
		UserAuthService,
 | 
							UserAuthService,
 | 
				
			||||||
		VideoProcessingService,
 | 
							VideoProcessingService,
 | 
				
			||||||
		WebhookService,
 | 
							UserWebhookService,
 | 
				
			||||||
 | 
							SystemWebhookService,
 | 
				
			||||||
		UtilityService,
 | 
							UtilityService,
 | 
				
			||||||
		FileInfoService,
 | 
							FileInfoService,
 | 
				
			||||||
		SearchService,
 | 
							SearchService,
 | 
				
			||||||
@@ -640,6 +665,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		AbuseUserReportEntityService,
 | 
							AbuseUserReportEntityService,
 | 
				
			||||||
		AnnouncementEntityService,
 | 
							AnnouncementEntityService,
 | 
				
			||||||
 | 
							AbuseReportNotificationRecipientEntityService,
 | 
				
			||||||
		AntennaEntityService,
 | 
							AntennaEntityService,
 | 
				
			||||||
		AppEntityService,
 | 
							AppEntityService,
 | 
				
			||||||
		AuthSessionEntityService,
 | 
							AuthSessionEntityService,
 | 
				
			||||||
@@ -673,6 +699,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		RoleEntityService,
 | 
							RoleEntityService,
 | 
				
			||||||
		ReversiGameEntityService,
 | 
							ReversiGameEntityService,
 | 
				
			||||||
		MetaEntityService,
 | 
							MetaEntityService,
 | 
				
			||||||
 | 
							SystemWebhookEntityService,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		ApAudienceService,
 | 
							ApAudienceService,
 | 
				
			||||||
		ApDbResolverService,
 | 
							ApDbResolverService,
 | 
				
			||||||
@@ -696,6 +723,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
							//#region 文字列ベースでのinjection用(循環参照対応のため)
 | 
				
			||||||
		$LoggerService,
 | 
							$LoggerService,
 | 
				
			||||||
 | 
							$AbuseReportService,
 | 
				
			||||||
 | 
							$AbuseReportNotificationService,
 | 
				
			||||||
		$AccountMoveService,
 | 
							$AccountMoveService,
 | 
				
			||||||
		$AccountUpdateService,
 | 
							$AccountUpdateService,
 | 
				
			||||||
		$AiService,
 | 
							$AiService,
 | 
				
			||||||
@@ -748,7 +777,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		$UserSuspendService,
 | 
							$UserSuspendService,
 | 
				
			||||||
		$UserAuthService,
 | 
							$UserAuthService,
 | 
				
			||||||
		$VideoProcessingService,
 | 
							$VideoProcessingService,
 | 
				
			||||||
		$WebhookService,
 | 
							$UserWebhookService,
 | 
				
			||||||
 | 
							$SystemWebhookService,
 | 
				
			||||||
		$UtilityService,
 | 
							$UtilityService,
 | 
				
			||||||
		$FileInfoService,
 | 
							$FileInfoService,
 | 
				
			||||||
		$SearchService,
 | 
							$SearchService,
 | 
				
			||||||
@@ -776,6 +806,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		$AbuseUserReportEntityService,
 | 
							$AbuseUserReportEntityService,
 | 
				
			||||||
		$AnnouncementEntityService,
 | 
							$AnnouncementEntityService,
 | 
				
			||||||
 | 
							$AbuseReportNotificationRecipientEntityService,
 | 
				
			||||||
		$AntennaEntityService,
 | 
							$AntennaEntityService,
 | 
				
			||||||
		$AppEntityService,
 | 
							$AppEntityService,
 | 
				
			||||||
		$AuthSessionEntityService,
 | 
							$AuthSessionEntityService,
 | 
				
			||||||
@@ -809,6 +840,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 | 
				
			|||||||
		$RoleEntityService,
 | 
							$RoleEntityService,
 | 
				
			||||||
		$ReversiGameEntityService,
 | 
							$ReversiGameEntityService,
 | 
				
			||||||
		$MetaEntityService,
 | 
							$MetaEntityService,
 | 
				
			||||||
 | 
							$SystemWebhookEntityService,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$ApAudienceService,
 | 
							$ApAudienceService,
 | 
				
			||||||
		$ApDbResolverService,
 | 
							$ApDbResolverService,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
 | 
				
			|||||||
import { LoggerService } from '@/core/LoggerService.js';
 | 
					import { LoggerService } from '@/core/LoggerService.js';
 | 
				
			||||||
import { bindThis } from '@/decorators.js';
 | 
					import { bindThis } from '@/decorators.js';
 | 
				
			||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
 | 
					import { HttpRequestService } from '@/core/HttpRequestService.js';
 | 
				
			||||||
 | 
					import { QueueService } from '@/core/QueueService.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class EmailService {
 | 
					export class EmailService {
 | 
				
			||||||
@@ -32,6 +33,7 @@ export class EmailService {
 | 
				
			|||||||
		private loggerService: LoggerService,
 | 
							private loggerService: LoggerService,
 | 
				
			||||||
		private utilityService: UtilityService,
 | 
							private utilityService: UtilityService,
 | 
				
			||||||
		private httpRequestService: HttpRequestService,
 | 
							private httpRequestService: HttpRequestService,
 | 
				
			||||||
 | 
							private queueService: QueueService,
 | 
				
			||||||
	) {
 | 
						) {
 | 
				
			||||||
		this.logger = this.loggerService.getLogger('email');
 | 
							this.logger = this.loggerService.getLogger('email');
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,7 @@ import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 | 
				
			|||||||
import type { MiSignin } from '@/models/Signin.js';
 | 
					import type { MiSignin } from '@/models/Signin.js';
 | 
				
			||||||
import type { MiPage } from '@/models/Page.js';
 | 
					import type { MiPage } from '@/models/Page.js';
 | 
				
			||||||
import type { MiWebhook } from '@/models/Webhook.js';
 | 
					import type { MiWebhook } from '@/models/Webhook.js';
 | 
				
			||||||
 | 
					import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
 | 
				
			||||||
import type { MiMeta } from '@/models/Meta.js';
 | 
					import type { MiMeta } from '@/models/Meta.js';
 | 
				
			||||||
import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
 | 
					import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
 | 
				
			||||||
import type { Packed } from '@/misc/json-schema.js';
 | 
					import type { Packed } from '@/misc/json-schema.js';
 | 
				
			||||||
@@ -227,6 +228,9 @@ export interface InternalEventTypes {
 | 
				
			|||||||
	webhookCreated: MiWebhook;
 | 
						webhookCreated: MiWebhook;
 | 
				
			||||||
	webhookDeleted: MiWebhook;
 | 
						webhookDeleted: MiWebhook;
 | 
				
			||||||
	webhookUpdated: MiWebhook;
 | 
						webhookUpdated: MiWebhook;
 | 
				
			||||||
 | 
						systemWebhookCreated: MiSystemWebhook;
 | 
				
			||||||
 | 
						systemWebhookDeleted: MiSystemWebhook;
 | 
				
			||||||
 | 
						systemWebhookUpdated: MiSystemWebhook;
 | 
				
			||||||
	antennaCreated: MiAntenna;
 | 
						antennaCreated: MiAntenna;
 | 
				
			||||||
	antennaDeleted: MiAntenna;
 | 
						antennaDeleted: MiAntenna;
 | 
				
			||||||
	antennaUpdated: MiAntenna;
 | 
						antennaUpdated: MiAntenna;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
 | 
				
			|||||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
 | 
					import ActiveUsersChart from '@/core/chart/charts/active-users.js';
 | 
				
			||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
 | 
					import { GlobalEventService } from '@/core/GlobalEventService.js';
 | 
				
			||||||
import { NotificationService } from '@/core/NotificationService.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 { HashtagService } from '@/core/HashtagService.js';
 | 
				
			||||||
import { AntennaService } from '@/core/AntennaService.js';
 | 
					import { AntennaService } from '@/core/AntennaService.js';
 | 
				
			||||||
import { QueueService } from '@/core/QueueService.js';
 | 
					import { QueueService } from '@/core/QueueService.js';
 | 
				
			||||||
@@ -205,7 +205,7 @@ export class NoteCreateService implements OnApplicationShutdown {
 | 
				
			|||||||
		private federatedInstanceService: FederatedInstanceService,
 | 
							private federatedInstanceService: FederatedInstanceService,
 | 
				
			||||||
		private hashtagService: HashtagService,
 | 
							private hashtagService: HashtagService,
 | 
				
			||||||
		private antennaService: AntennaService,
 | 
							private antennaService: AntennaService,
 | 
				
			||||||
		private webhookService: WebhookService,
 | 
							private webhookService: UserWebhookService,
 | 
				
			||||||
		private featuredService: FeaturedService,
 | 
							private featuredService: FeaturedService,
 | 
				
			||||||
		private remoteUserResolveService: RemoteUserResolveService,
 | 
							private remoteUserResolveService: RemoteUserResolveService,
 | 
				
			||||||
		private apDeliverManagerService: ApDeliverManagerService,
 | 
							private apDeliverManagerService: ApDeliverManagerService,
 | 
				
			||||||
@@ -606,7 +606,7 @@ export class NoteCreateService implements OnApplicationShutdown {
 | 
				
			|||||||
			this.webhookService.getActiveWebhooks().then(webhooks => {
 | 
								this.webhookService.getActiveWebhooks().then(webhooks => {
 | 
				
			||||||
				webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
 | 
									webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
 | 
				
			||||||
				for (const webhook of webhooks) {
 | 
									for (const webhook of webhooks) {
 | 
				
			||||||
					this.queueService.webhookDeliver(webhook, 'note', {
 | 
										this.queueService.userWebhookDeliver(webhook, 'note', {
 | 
				
			||||||
						note: noteObj,
 | 
											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'));
 | 
											const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
 | 
				
			||||||
						for (const webhook of webhooks) {
 | 
											for (const webhook of webhooks) {
 | 
				
			||||||
							this.queueService.webhookDeliver(webhook, 'reply', {
 | 
												this.queueService.userWebhookDeliver(webhook, 'reply', {
 | 
				
			||||||
								note: noteObj,
 | 
													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'));
 | 
										const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
 | 
				
			||||||
					for (const webhook of webhooks) {
 | 
										for (const webhook of webhooks) {
 | 
				
			||||||
						this.queueService.webhookDeliver(webhook, 'renote', {
 | 
											this.queueService.userWebhookDeliver(webhook, 'renote', {
 | 
				
			||||||
							note: noteObj,
 | 
												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'));
 | 
								const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
 | 
				
			||||||
			for (const webhook of webhooks) {
 | 
								for (const webhook of webhooks) {
 | 
				
			||||||
				this.queueService.webhookDeliver(webhook, 'mention', {
 | 
									this.queueService.userWebhookDeliver(webhook, 'mention', {
 | 
				
			||||||
					note: detailPackedNote,
 | 
										note: detailPackedNote,
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,10 +7,17 @@ import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
 | 
				
			|||||||
import * as Bull from 'bullmq';
 | 
					import * as Bull from 'bullmq';
 | 
				
			||||||
import { DI } from '@/di-symbols.js';
 | 
					import { DI } from '@/di-symbols.js';
 | 
				
			||||||
import type { Config } from '@/config.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 { 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 { Provider } from '@nestjs/common';
 | 
				
			||||||
import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
 | 
					export type SystemQueue = Bull.Queue<Record<string, unknown>>;
 | 
				
			||||||
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
 | 
					export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
 | 
				
			||||||
@@ -19,7 +26,8 @@ export type InboxQueue = Bull.Queue<InboxJobData>;
 | 
				
			|||||||
export type DbQueue = Bull.Queue;
 | 
					export type DbQueue = Bull.Queue;
 | 
				
			||||||
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
 | 
					export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
 | 
				
			||||||
export type ObjectStorageQueue = Bull.Queue;
 | 
					export type ObjectStorageQueue = Bull.Queue;
 | 
				
			||||||
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
 | 
					export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
 | 
				
			||||||
 | 
					export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $system: Provider = {
 | 
					const $system: Provider = {
 | 
				
			||||||
	provide: 'queue:system',
 | 
						provide: 'queue:system',
 | 
				
			||||||
@@ -63,9 +71,15 @@ const $objectStorage: Provider = {
 | 
				
			|||||||
	inject: [DI.config],
 | 
						inject: [DI.config],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $webhookDeliver: Provider = {
 | 
					const $userWebhookDeliver: Provider = {
 | 
				
			||||||
	provide: 'queue:webhookDeliver',
 | 
						provide: 'queue:userWebhookDeliver',
 | 
				
			||||||
	useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)),
 | 
						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],
 | 
						inject: [DI.config],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +94,8 @@ const $webhookDeliver: Provider = {
 | 
				
			|||||||
		$db,
 | 
							$db,
 | 
				
			||||||
		$relationship,
 | 
							$relationship,
 | 
				
			||||||
		$objectStorage,
 | 
							$objectStorage,
 | 
				
			||||||
		$webhookDeliver,
 | 
							$userWebhookDeliver,
 | 
				
			||||||
 | 
							$systemWebhookDeliver,
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
	exports: [
 | 
						exports: [
 | 
				
			||||||
		$system,
 | 
							$system,
 | 
				
			||||||
@@ -90,7 +105,8 @@ const $webhookDeliver: Provider = {
 | 
				
			|||||||
		$db,
 | 
							$db,
 | 
				
			||||||
		$relationship,
 | 
							$relationship,
 | 
				
			||||||
		$objectStorage,
 | 
							$objectStorage,
 | 
				
			||||||
		$webhookDeliver,
 | 
							$userWebhookDeliver,
 | 
				
			||||||
 | 
							$systemWebhookDeliver,
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class QueueModule implements OnApplicationShutdown {
 | 
					export class QueueModule implements OnApplicationShutdown {
 | 
				
			||||||
@@ -102,7 +118,8 @@ export class QueueModule implements OnApplicationShutdown {
 | 
				
			|||||||
		@Inject('queue:db') public dbQueue: DbQueue,
 | 
							@Inject('queue:db') public dbQueue: DbQueue,
 | 
				
			||||||
		@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
 | 
							@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
 | 
				
			||||||
		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 | 
							@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<void> {
 | 
						public async dispose(): Promise<void> {
 | 
				
			||||||
@@ -117,7 +134,8 @@ export class QueueModule implements OnApplicationShutdown {
 | 
				
			|||||||
			this.dbQueue.close(),
 | 
								this.dbQueue.close(),
 | 
				
			||||||
			this.relationshipQueue.close(),
 | 
								this.relationshipQueue.close(),
 | 
				
			||||||
			this.objectStorageQueue.close(),
 | 
								this.objectStorageQueue.close(),
 | 
				
			||||||
			this.webhookDeliverQueue.close(),
 | 
								this.userWebhookDeliverQueue.close(),
 | 
				
			||||||
 | 
								this.systemWebhookDeliverQueue.close(),
 | 
				
			||||||
		]);
 | 
							]);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,15 +8,33 @@ import { Inject, Injectable } from '@nestjs/common';
 | 
				
			|||||||
import type { IActivity } from '@/core/activitypub/type.js';
 | 
					import type { IActivity } from '@/core/activitypub/type.js';
 | 
				
			||||||
import type { MiDriveFile } from '@/models/DriveFile.js';
 | 
					import type { MiDriveFile } from '@/models/DriveFile.js';
 | 
				
			||||||
import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
 | 
					import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
 | 
				
			||||||
 | 
					import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
 | 
				
			||||||
import type { Config } from '@/config.js';
 | 
					import type { Config } from '@/config.js';
 | 
				
			||||||
import { DI } from '@/di-symbols.js';
 | 
					import { DI } from '@/di-symbols.js';
 | 
				
			||||||
import { bindThis } from '@/decorators.js';
 | 
					import { bindThis } from '@/decorators.js';
 | 
				
			||||||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.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 { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
 | 
				
			||||||
import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.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 httpSignature from '@peertube/http-signature';
 | 
				
			||||||
import type * as Bull from 'bullmq';
 | 
					import type * as Bull from 'bullmq';
 | 
				
			||||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class QueueService {
 | 
					export class QueueService {
 | 
				
			||||||
@@ -31,7 +49,8 @@ export class QueueService {
 | 
				
			|||||||
		@Inject('queue:db') public dbQueue: DbQueue,
 | 
							@Inject('queue:db') public dbQueue: DbQueue,
 | 
				
			||||||
		@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
 | 
							@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
 | 
				
			||||||
		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 | 
							@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', {
 | 
							this.systemQueue.add('tickCharts', {
 | 
				
			||||||
		}, {
 | 
							}, {
 | 
				
			||||||
@@ -431,9 +450,13 @@ export class QueueService {
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * @see UserWebhookDeliverJobData
 | 
				
			||||||
 | 
						 * @see WebhookDeliverProcessorService
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	@bindThis
 | 
						@bindThis
 | 
				
			||||||
	public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
 | 
						public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
 | 
				
			||||||
		const data = {
 | 
							const data: UserWebhookDeliverJobData = {
 | 
				
			||||||
			type,
 | 
								type,
 | 
				
			||||||
			content,
 | 
								content,
 | 
				
			||||||
			webhookId: webhook.id,
 | 
								webhookId: webhook.id,
 | 
				
			||||||
@@ -444,7 +467,33 @@ export class QueueService {
 | 
				
			|||||||
			eventId: randomUUID(),
 | 
								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,
 | 
								attempts: 4,
 | 
				
			||||||
			backoff: {
 | 
								backoff: {
 | 
				
			||||||
				type: 'custom',
 | 
									type: 'custom',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -410,14 +410,32 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	@bindThis
 | 
						@bindThis
 | 
				
			||||||
	public async getModeratorIds(includeAdmins = true): Promise<MiUser['id'][]> {
 | 
						public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
 | 
				
			||||||
		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
 | 
							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 moderatorRoles = includeAdmins
 | 
				
			||||||
		const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
 | 
								? roles.filter(r => r.isModerator || r.isAdministrator)
 | 
				
			||||||
			roleId: In(moderatorRoles.map(r => r.id)),
 | 
								: roles.filter(r => r.isModerator);
 | 
				
			||||||
		}) : [];
 | 
					
 | 
				
			||||||
		// TODO: isRootなアカウントも含める
 | 
							// 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
 | 
						@bindThis
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										233
									
								
								packages/backend/src/core/SystemWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								packages/backend/src/core/SystemWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<MiSystemWebhook[]> {
 | 
				
			||||||
 | 
							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<MiSystemWebhook> {
 | 
				
			||||||
 | 
							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<MiSystemWebhook> {
 | 
				
			||||||
 | 
							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<void> {
 | 
				
			||||||
 | 
							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();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -16,7 +16,7 @@ import Logger from '@/logger.js';
 | 
				
			|||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
					import { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
				
			||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
 | 
					import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
 | 
				
			||||||
import { LoggerService } from '@/core/LoggerService.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 { bindThis } from '@/decorators.js';
 | 
				
			||||||
import { CacheService } from '@/core/CacheService.js';
 | 
					import { CacheService } from '@/core/CacheService.js';
 | 
				
			||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
 | 
					import { UserFollowingService } from '@/core/UserFollowingService.js';
 | 
				
			||||||
@@ -46,7 +46,7 @@ export class UserBlockingService implements OnModuleInit {
 | 
				
			|||||||
		private idService: IdService,
 | 
							private idService: IdService,
 | 
				
			||||||
		private queueService: QueueService,
 | 
							private queueService: QueueService,
 | 
				
			||||||
		private globalEventService: GlobalEventService,
 | 
							private globalEventService: GlobalEventService,
 | 
				
			||||||
		private webhookService: WebhookService,
 | 
							private webhookService: UserWebhookService,
 | 
				
			||||||
		private apRendererService: ApRendererService,
 | 
							private apRendererService: ApRendererService,
 | 
				
			||||||
		private loggerService: LoggerService,
 | 
							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'));
 | 
									const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
 | 
				
			||||||
				for (const webhook of webhooks) {
 | 
									for (const webhook of webhooks) {
 | 
				
			||||||
					this.queueService.webhookDeliver(webhook, 'unfollow', {
 | 
										this.queueService.userWebhookDeliver(webhook, 'unfollow', {
 | 
				
			||||||
						user: packed,
 | 
											user: packed,
 | 
				
			||||||
					});
 | 
										});
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
 | 
				
			|||||||
import type { Packed } from '@/misc/json-schema.js';
 | 
					import type { Packed } from '@/misc/json-schema.js';
 | 
				
			||||||
import InstanceChart from '@/core/chart/charts/instance.js';
 | 
					import InstanceChart from '@/core/chart/charts/instance.js';
 | 
				
			||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.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 { NotificationService } from '@/core/NotificationService.js';
 | 
				
			||||||
import { DI } from '@/di-symbols.js';
 | 
					import { DI } from '@/di-symbols.js';
 | 
				
			||||||
import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
 | 
					import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
 | 
				
			||||||
@@ -82,7 +82,7 @@ export class UserFollowingService implements OnModuleInit {
 | 
				
			|||||||
		private metaService: MetaService,
 | 
							private metaService: MetaService,
 | 
				
			||||||
		private notificationService: NotificationService,
 | 
							private notificationService: NotificationService,
 | 
				
			||||||
		private federatedInstanceService: FederatedInstanceService,
 | 
							private federatedInstanceService: FederatedInstanceService,
 | 
				
			||||||
		private webhookService: WebhookService,
 | 
							private webhookService: UserWebhookService,
 | 
				
			||||||
		private apRendererService: ApRendererService,
 | 
							private apRendererService: ApRendererService,
 | 
				
			||||||
		private accountMoveService: AccountMoveService,
 | 
							private accountMoveService: AccountMoveService,
 | 
				
			||||||
		private fanoutTimelineService: FanoutTimelineService,
 | 
							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'));
 | 
									const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
 | 
				
			||||||
				for (const webhook of webhooks) {
 | 
									for (const webhook of webhooks) {
 | 
				
			||||||
					this.queueService.webhookDeliver(webhook, 'follow', {
 | 
										this.queueService.userWebhookDeliver(webhook, 'follow', {
 | 
				
			||||||
						user: packed,
 | 
											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'));
 | 
									const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
 | 
				
			||||||
				for (const webhook of webhooks) {
 | 
									for (const webhook of webhooks) {
 | 
				
			||||||
					this.queueService.webhookDeliver(webhook, 'followed', {
 | 
										this.queueService.userWebhookDeliver(webhook, 'followed', {
 | 
				
			||||||
						user: packed,
 | 
											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'));
 | 
									const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
 | 
				
			||||||
				for (const webhook of webhooks) {
 | 
									for (const webhook of webhooks) {
 | 
				
			||||||
					this.queueService.webhookDeliver(webhook, 'unfollow', {
 | 
										this.queueService.userWebhookDeliver(webhook, 'unfollow', {
 | 
				
			||||||
						user: packed,
 | 
											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'));
 | 
							const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
 | 
				
			||||||
		for (const webhook of webhooks) {
 | 
							for (const webhook of webhooks) {
 | 
				
			||||||
			this.queueService.webhookDeliver(webhook, 'unfollow', {
 | 
								this.queueService.userWebhookDeliver(webhook, 'unfollow', {
 | 
				
			||||||
				user: packedFollowee,
 | 
									user: packedFollowee,
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										99
									
								
								packages/backend/src/core/UserWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								packages/backend/src/core/UserWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<void> {
 | 
				
			||||||
 | 
							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();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<void> {
 | 
					 | 
				
			||||||
		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();
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -29,6 +29,7 @@ import { bindThis } from '@/decorators.js';
 | 
				
			|||||||
import type { MiRemoteUser } from '@/models/User.js';
 | 
					import type { MiRemoteUser } from '@/models/User.js';
 | 
				
			||||||
import { isNotNull } from '@/misc/is-not-null.js';
 | 
					import { isNotNull } from '@/misc/is-not-null.js';
 | 
				
			||||||
import { GlobalEventService } from '@/core/GlobalEventService.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 { 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 { ApNoteService } from './models/ApNoteService.js';
 | 
				
			||||||
import { ApLoggerService } from './ApLoggerService.js';
 | 
					import { ApLoggerService } from './ApLoggerService.js';
 | 
				
			||||||
@@ -57,9 +58,6 @@ export class ApInboxService {
 | 
				
			|||||||
		@Inject(DI.followingsRepository)
 | 
							@Inject(DI.followingsRepository)
 | 
				
			||||||
		private followingsRepository: FollowingsRepository,
 | 
							private followingsRepository: FollowingsRepository,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		@Inject(DI.abuseUserReportsRepository)
 | 
					 | 
				
			||||||
		private abuseUserReportsRepository: AbuseUserReportsRepository,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		@Inject(DI.followRequestsRepository)
 | 
							@Inject(DI.followRequestsRepository)
 | 
				
			||||||
		private followRequestsRepository: FollowRequestsRepository,
 | 
							private followRequestsRepository: FollowRequestsRepository,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -68,6 +66,7 @@ export class ApInboxService {
 | 
				
			|||||||
		private utilityService: UtilityService,
 | 
							private utilityService: UtilityService,
 | 
				
			||||||
		private idService: IdService,
 | 
							private idService: IdService,
 | 
				
			||||||
		private metaService: MetaService,
 | 
							private metaService: MetaService,
 | 
				
			||||||
 | 
							private abuseReportService: AbuseReportService,
 | 
				
			||||||
		private userFollowingService: UserFollowingService,
 | 
							private userFollowingService: UserFollowingService,
 | 
				
			||||||
		private apAudienceService: ApAudienceService,
 | 
							private apAudienceService: ApAudienceService,
 | 
				
			||||||
		private reactionService: ReactionService,
 | 
							private reactionService: ReactionService,
 | 
				
			||||||
@@ -545,14 +544,13 @@ export class ApInboxService {
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
		if (users.length < 1) return 'skip';
 | 
							if (users.length < 1) return 'skip';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		await this.abuseUserReportsRepository.insert({
 | 
							await this.abuseReportService.report([{
 | 
				
			||||||
			id: this.idService.gen(),
 | 
					 | 
				
			||||||
			targetUserId: users[0].id,
 | 
								targetUserId: users[0].id,
 | 
				
			||||||
			targetUserHost: users[0].host,
 | 
								targetUserHost: users[0].host,
 | 
				
			||||||
			reporterId: actor.id,
 | 
								reporterId: actor.id,
 | 
				
			||||||
			reporterHost: actor.host,
 | 
								reporterHost: actor.host,
 | 
				
			||||||
			comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`,
 | 
								comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`,
 | 
				
			||||||
		});
 | 
							}]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return 'ok';
 | 
							return 'ok';
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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<string, Packed<'UserLite'>>,
 | 
				
			||||||
 | 
								webhooks: Map<string, Packed<'SystemWebhook'>>,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						): Promise<Packed<'AbuseReportNotificationRecipient'>> {
 | 
				
			||||||
 | 
							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<Packed<'AbuseReportNotificationRecipient'>[]> {
 | 
				
			||||||
 | 
							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<string, Packed<'UserLite'>> = (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<string, Packed<'SystemWebhook'>> = (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)));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -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<string, MiSystemWebhook>
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						): Promise<Packed<'SystemWebhook'>> {
 | 
				
			||||||
 | 
							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<Packed<'SystemWebhook'>[]> {
 | 
				
			||||||
 | 
							if (src.length === 0) {
 | 
				
			||||||
 | 
								return [];
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							const webhooks = Array.of<MiSystemWebhook>();
 | 
				
			||||||
 | 
							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)));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,6 +49,7 @@ export const DI = {
 | 
				
			|||||||
	swSubscriptionsRepository: Symbol('swSubscriptionsRepository'),
 | 
						swSubscriptionsRepository: Symbol('swSubscriptionsRepository'),
 | 
				
			||||||
	hashtagsRepository: Symbol('hashtagsRepository'),
 | 
						hashtagsRepository: Symbol('hashtagsRepository'),
 | 
				
			||||||
	abuseUserReportsRepository: Symbol('abuseUserReportsRepository'),
 | 
						abuseUserReportsRepository: Symbol('abuseUserReportsRepository'),
 | 
				
			||||||
 | 
						abuseReportNotificationRecipientRepository: Symbol('abuseReportNotificationRecipientRepository'),
 | 
				
			||||||
	registrationTicketsRepository: Symbol('registrationTicketsRepository'),
 | 
						registrationTicketsRepository: Symbol('registrationTicketsRepository'),
 | 
				
			||||||
	authSessionsRepository: Symbol('authSessionsRepository'),
 | 
						authSessionsRepository: Symbol('authSessionsRepository'),
 | 
				
			||||||
	accessTokensRepository: Symbol('accessTokensRepository'),
 | 
						accessTokensRepository: Symbol('accessTokensRepository'),
 | 
				
			||||||
@@ -70,6 +71,7 @@ export const DI = {
 | 
				
			|||||||
	channelFavoritesRepository: Symbol('channelFavoritesRepository'),
 | 
						channelFavoritesRepository: Symbol('channelFavoritesRepository'),
 | 
				
			||||||
	registryItemsRepository: Symbol('registryItemsRepository'),
 | 
						registryItemsRepository: Symbol('registryItemsRepository'),
 | 
				
			||||||
	webhooksRepository: Symbol('webhooksRepository'),
 | 
						webhooksRepository: Symbol('webhooksRepository'),
 | 
				
			||||||
 | 
						systemWebhooksRepository: Symbol('systemWebhooksRepository'),
 | 
				
			||||||
	adsRepository: Symbol('adsRepository'),
 | 
						adsRepository: Symbol('adsRepository'),
 | 
				
			||||||
	passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
 | 
						passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
 | 
				
			||||||
	retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
 | 
						retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,12 +4,12 @@
 | 
				
			|||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
	packedUserLiteSchema,
 | 
					 | 
				
			||||||
	packedUserDetailedNotMeOnlySchema,
 | 
					 | 
				
			||||||
	packedMeDetailedOnlySchema,
 | 
						packedMeDetailedOnlySchema,
 | 
				
			||||||
	packedUserDetailedNotMeSchema,
 | 
					 | 
				
			||||||
	packedMeDetailedSchema,
 | 
						packedMeDetailedSchema,
 | 
				
			||||||
 | 
						packedUserDetailedNotMeOnlySchema,
 | 
				
			||||||
 | 
						packedUserDetailedNotMeSchema,
 | 
				
			||||||
	packedUserDetailedSchema,
 | 
						packedUserDetailedSchema,
 | 
				
			||||||
 | 
						packedUserLiteSchema,
 | 
				
			||||||
	packedUserSchema,
 | 
						packedUserSchema,
 | 
				
			||||||
} from '@/models/json-schema/user.js';
 | 
					} from '@/models/json-schema/user.js';
 | 
				
			||||||
import { packedNoteSchema } from '@/models/json-schema/note.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 { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
 | 
				
			||||||
import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
 | 
					import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
 | 
				
			||||||
import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.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 { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
 | 
				
			||||||
import { packedChannelSchema } from '@/models/json-schema/channel.js';
 | 
					import { packedChannelSchema } from '@/models/json-schema/channel.js';
 | 
				
			||||||
import { packedAntennaSchema } from '@/models/json-schema/antenna.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 { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
 | 
				
			||||||
import { packedSigninSchema } from '@/models/json-schema/signin.js';
 | 
					import { packedSigninSchema } from '@/models/json-schema/signin.js';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
	packedRoleLiteSchema,
 | 
						packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
 | 
				
			||||||
	packedRoleSchema,
 | 
					 | 
				
			||||||
	packedRolePoliciesSchema,
 | 
					 | 
				
			||||||
	packedRoleCondFormulaLogicsSchema,
 | 
						packedRoleCondFormulaLogicsSchema,
 | 
				
			||||||
	packedRoleCondFormulaValueNot,
 | 
					 | 
				
			||||||
	packedRoleCondFormulaValueIsLocalOrRemoteSchema,
 | 
					 | 
				
			||||||
	packedRoleCondFormulaValueAssignedRoleSchema,
 | 
						packedRoleCondFormulaValueAssignedRoleSchema,
 | 
				
			||||||
	packedRoleCondFormulaValueCreatedSchema,
 | 
						packedRoleCondFormulaValueCreatedSchema,
 | 
				
			||||||
	packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
 | 
						packedRoleCondFormulaValueIsLocalOrRemoteSchema,
 | 
				
			||||||
 | 
						packedRoleCondFormulaValueNot,
 | 
				
			||||||
	packedRoleCondFormulaValueSchema,
 | 
						packedRoleCondFormulaValueSchema,
 | 
				
			||||||
	packedRoleCondFormulaValueUserSettingBooleanSchema,
 | 
						packedRoleCondFormulaValueUserSettingBooleanSchema,
 | 
				
			||||||
 | 
						packedRoleLiteSchema,
 | 
				
			||||||
 | 
						packedRolePoliciesSchema,
 | 
				
			||||||
 | 
						packedRoleSchema,
 | 
				
			||||||
} from '@/models/json-schema/role.js';
 | 
					} from '@/models/json-schema/role.js';
 | 
				
			||||||
import { packedAdSchema } from '@/models/json-schema/ad.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 {
 | 
					import {
 | 
				
			||||||
	packedMetaLiteSchema,
 | 
					 | 
				
			||||||
	packedMetaDetailedOnlySchema,
 | 
						packedMetaDetailedOnlySchema,
 | 
				
			||||||
	packedMetaDetailedSchema,
 | 
						packedMetaDetailedSchema,
 | 
				
			||||||
 | 
						packedMetaLiteSchema,
 | 
				
			||||||
} from '@/models/json-schema/meta.js';
 | 
					} 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 = {
 | 
					export const refs = {
 | 
				
			||||||
	UserLite: packedUserLiteSchema,
 | 
						UserLite: packedUserLiteSchema,
 | 
				
			||||||
@@ -111,6 +113,8 @@ export const refs = {
 | 
				
			|||||||
	MetaLite: packedMetaLiteSchema,
 | 
						MetaLite: packedMetaLiteSchema,
 | 
				
			||||||
	MetaDetailedOnly: packedMetaDetailedOnlySchema,
 | 
						MetaDetailedOnly: packedMetaDetailedOnlySchema,
 | 
				
			||||||
	MetaDetailed: packedMetaDetailedSchema,
 | 
						MetaDetailed: packedMetaDetailedSchema,
 | 
				
			||||||
 | 
						SystemWebhook: packedSystemWebhookSchema,
 | 
				
			||||||
 | 
						AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
 | 
					export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										100
									
								
								packages/backend/src/models/AbuseReportNotificationRecipient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								packages/backend/src/models/AbuseReportNotificationRecipient.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,11 +3,83 @@
 | 
				
			|||||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { Provider } from '@nestjs/common';
 | 
				
			||||||
import { Module } from '@nestjs/common';
 | 
					import { Module } from '@nestjs/common';
 | 
				
			||||||
import { DI } from '@/di-symbols.js';
 | 
					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 { DataSource } from 'typeorm';
 | 
				
			||||||
import type { Provider } from '@nestjs/common';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $usersRepository: Provider = {
 | 
					const $usersRepository: Provider = {
 | 
				
			||||||
	provide: DI.usersRepository,
 | 
						provide: DI.usersRepository,
 | 
				
			||||||
@@ -225,6 +297,12 @@ const $abuseUserReportsRepository: Provider = {
 | 
				
			|||||||
	inject: [DI.db],
 | 
						inject: [DI.db],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const $abuseReportNotificationRecipientRepository: Provider = {
 | 
				
			||||||
 | 
						provide: DI.abuseReportNotificationRecipientRepository,
 | 
				
			||||||
 | 
						useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient),
 | 
				
			||||||
 | 
						inject: [DI.db],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $registrationTicketsRepository: Provider = {
 | 
					const $registrationTicketsRepository: Provider = {
 | 
				
			||||||
	provide: DI.registrationTicketsRepository,
 | 
						provide: DI.registrationTicketsRepository,
 | 
				
			||||||
	useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket).extend(miRepository as MiRepository<MiRegistrationTicket>),
 | 
						useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket).extend(miRepository as MiRepository<MiRegistrationTicket>),
 | 
				
			||||||
@@ -351,6 +429,12 @@ const $webhooksRepository: Provider = {
 | 
				
			|||||||
	inject: [DI.db],
 | 
						inject: [DI.db],
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const $systemWebhooksRepository: Provider = {
 | 
				
			||||||
 | 
						provide: DI.systemWebhooksRepository,
 | 
				
			||||||
 | 
						useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook),
 | 
				
			||||||
 | 
						inject: [DI.db],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const $adsRepository: Provider = {
 | 
					const $adsRepository: Provider = {
 | 
				
			||||||
	provide: DI.adsRepository,
 | 
						provide: DI.adsRepository,
 | 
				
			||||||
	useFactory: (db: DataSource) => db.getRepository(MiAd).extend(miRepository as MiRepository<MiAd>),
 | 
						useFactory: (db: DataSource) => db.getRepository(MiAd).extend(miRepository as MiRepository<MiAd>),
 | 
				
			||||||
@@ -412,8 +496,7 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
	imports: [
 | 
						imports: [],
 | 
				
			||||||
	],
 | 
					 | 
				
			||||||
	providers: [
 | 
						providers: [
 | 
				
			||||||
		$usersRepository,
 | 
							$usersRepository,
 | 
				
			||||||
		$notesRepository,
 | 
							$notesRepository,
 | 
				
			||||||
@@ -451,6 +534,7 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
		$swSubscriptionsRepository,
 | 
							$swSubscriptionsRepository,
 | 
				
			||||||
		$hashtagsRepository,
 | 
							$hashtagsRepository,
 | 
				
			||||||
		$abuseUserReportsRepository,
 | 
							$abuseUserReportsRepository,
 | 
				
			||||||
 | 
							$abuseReportNotificationRecipientRepository,
 | 
				
			||||||
		$registrationTicketsRepository,
 | 
							$registrationTicketsRepository,
 | 
				
			||||||
		$authSessionsRepository,
 | 
							$authSessionsRepository,
 | 
				
			||||||
		$accessTokensRepository,
 | 
							$accessTokensRepository,
 | 
				
			||||||
@@ -472,6 +556,7 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
		$channelFavoritesRepository,
 | 
							$channelFavoritesRepository,
 | 
				
			||||||
		$registryItemsRepository,
 | 
							$registryItemsRepository,
 | 
				
			||||||
		$webhooksRepository,
 | 
							$webhooksRepository,
 | 
				
			||||||
 | 
							$systemWebhooksRepository,
 | 
				
			||||||
		$adsRepository,
 | 
							$adsRepository,
 | 
				
			||||||
		$passwordResetRequestsRepository,
 | 
							$passwordResetRequestsRepository,
 | 
				
			||||||
		$retentionAggregationsRepository,
 | 
							$retentionAggregationsRepository,
 | 
				
			||||||
@@ -520,6 +605,7 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
		$swSubscriptionsRepository,
 | 
							$swSubscriptionsRepository,
 | 
				
			||||||
		$hashtagsRepository,
 | 
							$hashtagsRepository,
 | 
				
			||||||
		$abuseUserReportsRepository,
 | 
							$abuseUserReportsRepository,
 | 
				
			||||||
 | 
							$abuseReportNotificationRecipientRepository,
 | 
				
			||||||
		$registrationTicketsRepository,
 | 
							$registrationTicketsRepository,
 | 
				
			||||||
		$authSessionsRepository,
 | 
							$authSessionsRepository,
 | 
				
			||||||
		$accessTokensRepository,
 | 
							$accessTokensRepository,
 | 
				
			||||||
@@ -541,6 +627,7 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
		$channelFavoritesRepository,
 | 
							$channelFavoritesRepository,
 | 
				
			||||||
		$registryItemsRepository,
 | 
							$registryItemsRepository,
 | 
				
			||||||
		$webhooksRepository,
 | 
							$webhooksRepository,
 | 
				
			||||||
 | 
							$systemWebhooksRepository,
 | 
				
			||||||
		$adsRepository,
 | 
							$adsRepository,
 | 
				
			||||||
		$passwordResetRequestsRepository,
 | 
							$passwordResetRequestsRepository,
 | 
				
			||||||
		$retentionAggregationsRepository,
 | 
							$retentionAggregationsRepository,
 | 
				
			||||||
@@ -553,4 +640,5 @@ const $reversiGamesRepository: Provider = {
 | 
				
			|||||||
		$reversiGamesRepository,
 | 
							$reversiGamesRepository,
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class RepositoryModule {}
 | 
					export class RepositoryModule {
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										98
									
								
								packages/backend/src/models/SystemWebhook.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								packages/backend/src/models/SystemWebhook.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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>): MiSystemWebhook {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								...obj,
 | 
				
			||||||
 | 
								updatedAt: new Date(obj.updatedAt),
 | 
				
			||||||
 | 
								latestSentAt: obj.latestSentAt ? new Date(obj.latestSentAt) : null,
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -11,6 +11,7 @@ import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transfor
 | 
				
			|||||||
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
 | 
					import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
 | 
				
			||||||
import { OrmUtils } from 'typeorm/util/OrmUtils.js';
 | 
					import { OrmUtils } from 'typeorm/util/OrmUtils.js';
 | 
				
			||||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 | 
					import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 | 
				
			||||||
 | 
					import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
 | 
				
			||||||
import { MiAccessToken } from '@/models/AccessToken.js';
 | 
					import { MiAccessToken } from '@/models/AccessToken.js';
 | 
				
			||||||
import { MiAd } from '@/models/Ad.js';
 | 
					import { MiAd } from '@/models/Ad.js';
 | 
				
			||||||
import { MiAnnouncement } from '@/models/Announcement.js';
 | 
					import { MiAnnouncement } from '@/models/Announcement.js';
 | 
				
			||||||
@@ -68,6 +69,7 @@ import { MiUserPublickey } from '@/models/UserPublickey.js';
 | 
				
			|||||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
 | 
					import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
 | 
				
			||||||
import { MiUserMemo } from '@/models/UserMemo.js';
 | 
					import { MiUserMemo } from '@/models/UserMemo.js';
 | 
				
			||||||
import { MiWebhook } from '@/models/Webhook.js';
 | 
					import { MiWebhook } from '@/models/Webhook.js';
 | 
				
			||||||
 | 
					import { MiSystemWebhook } from '@/models/SystemWebhook.js';
 | 
				
			||||||
import { MiChannel } from '@/models/Channel.js';
 | 
					import { MiChannel } from '@/models/Channel.js';
 | 
				
			||||||
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
 | 
					import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
 | 
				
			||||||
import { MiRole } from '@/models/Role.js';
 | 
					import { MiRole } from '@/models/Role.js';
 | 
				
			||||||
@@ -144,6 +146,7 @@ export const miRepository = {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export {
 | 
					export {
 | 
				
			||||||
	MiAbuseUserReport,
 | 
						MiAbuseUserReport,
 | 
				
			||||||
 | 
						MiAbuseReportNotificationRecipient,
 | 
				
			||||||
	MiAccessToken,
 | 
						MiAccessToken,
 | 
				
			||||||
	MiAd,
 | 
						MiAd,
 | 
				
			||||||
	MiAnnouncement,
 | 
						MiAnnouncement,
 | 
				
			||||||
@@ -201,6 +204,7 @@ export {
 | 
				
			|||||||
	MiUserPublickey,
 | 
						MiUserPublickey,
 | 
				
			||||||
	MiUserSecurityKey,
 | 
						MiUserSecurityKey,
 | 
				
			||||||
	MiWebhook,
 | 
						MiWebhook,
 | 
				
			||||||
 | 
						MiSystemWebhook,
 | 
				
			||||||
	MiChannel,
 | 
						MiChannel,
 | 
				
			||||||
	MiRetentionAggregation,
 | 
						MiRetentionAggregation,
 | 
				
			||||||
	MiRole,
 | 
						MiRole,
 | 
				
			||||||
@@ -213,6 +217,7 @@ export {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
 | 
					export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
 | 
				
			||||||
 | 
					export type AbuseReportNotificationRecipientRepository = Repository<MiAbuseReportNotificationRecipient> & MiRepository<MiAbuseReportNotificationRecipient>;
 | 
				
			||||||
export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
 | 
					export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
 | 
				
			||||||
export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
 | 
					export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
 | 
				
			||||||
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;
 | 
					export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;
 | 
				
			||||||
@@ -270,6 +275,7 @@ export type UserProfilesRepository = Repository<MiUserProfile> & MiRepository<Mi
 | 
				
			|||||||
export type UserPublickeysRepository = Repository<MiUserPublickey> & MiRepository<MiUserPublickey>;
 | 
					export type UserPublickeysRepository = Repository<MiUserPublickey> & MiRepository<MiUserPublickey>;
 | 
				
			||||||
export type UserSecurityKeysRepository = Repository<MiUserSecurityKey> & MiRepository<MiUserSecurityKey>;
 | 
					export type UserSecurityKeysRepository = Repository<MiUserSecurityKey> & MiRepository<MiUserSecurityKey>;
 | 
				
			||||||
export type WebhooksRepository = Repository<MiWebhook> & MiRepository<MiWebhook>;
 | 
					export type WebhooksRepository = Repository<MiWebhook> & MiRepository<MiWebhook>;
 | 
				
			||||||
 | 
					export type SystemWebhooksRepository = Repository<MiSystemWebhook> & MiRepository<MiWebhook>;
 | 
				
			||||||
export type ChannelsRepository = Repository<MiChannel> & MiRepository<MiChannel>;
 | 
					export type ChannelsRepository = Repository<MiChannel> & MiRepository<MiChannel>;
 | 
				
			||||||
export type RetentionAggregationsRepository = Repository<MiRetentionAggregation> & MiRepository<MiRetentionAggregation>;
 | 
					export type RetentionAggregationsRepository = Repository<MiRetentionAggregation> & MiRepository<MiRetentionAggregation>;
 | 
				
			||||||
export type RolesRepository = Repository<MiRole> & MiRepository<MiRole>;
 | 
					export type RolesRepository = Repository<MiRole> & MiRepository<MiRole>;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
							
								
								
									
										54
									
								
								packages/backend/src/models/json-schema/system-webhook.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								packages/backend/src/models/json-schema/system-webhook.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
				
			||||||
@@ -5,13 +5,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// https://github.com/typeorm/typeorm/issues/2400
 | 
					// https://github.com/typeorm/typeorm/issues/2400
 | 
				
			||||||
import pg from 'pg';
 | 
					import pg from 'pg';
 | 
				
			||||||
pg.types.setTypeParser(20, Number);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import { DataSource, Logger } from 'typeorm';
 | 
					import { DataSource, Logger } from 'typeorm';
 | 
				
			||||||
import * as highlight from 'cli-highlight';
 | 
					import * as highlight from 'cli-highlight';
 | 
				
			||||||
import { entities as charts } from '@/core/chart/entities.js';
 | 
					import { entities as charts } from '@/core/chart/entities.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 | 
					import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
 | 
				
			||||||
 | 
					import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
 | 
				
			||||||
import { MiAccessToken } from '@/models/AccessToken.js';
 | 
					import { MiAccessToken } from '@/models/AccessToken.js';
 | 
				
			||||||
import { MiAd } from '@/models/Ad.js';
 | 
					import { MiAd } from '@/models/Ad.js';
 | 
				
			||||||
import { MiAnnouncement } from '@/models/Announcement.js';
 | 
					import { MiAnnouncement } from '@/models/Announcement.js';
 | 
				
			||||||
@@ -69,6 +68,7 @@ import { MiUserProfile } from '@/models/UserProfile.js';
 | 
				
			|||||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
 | 
					import { MiUserPublickey } from '@/models/UserPublickey.js';
 | 
				
			||||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
 | 
					import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
 | 
				
			||||||
import { MiWebhook } from '@/models/Webhook.js';
 | 
					import { MiWebhook } from '@/models/Webhook.js';
 | 
				
			||||||
 | 
					import { MiSystemWebhook } from '@/models/SystemWebhook.js';
 | 
				
			||||||
import { MiChannel } from '@/models/Channel.js';
 | 
					import { MiChannel } from '@/models/Channel.js';
 | 
				
			||||||
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
 | 
					import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
 | 
				
			||||||
import { MiRole } from '@/models/Role.js';
 | 
					import { MiRole } from '@/models/Role.js';
 | 
				
			||||||
@@ -83,6 +83,8 @@ import { Config } from '@/config.js';
 | 
				
			|||||||
import MisskeyLogger from '@/logger.js';
 | 
					import MisskeyLogger from '@/logger.js';
 | 
				
			||||||
import { bindThis } from '@/decorators.js';
 | 
					import { bindThis } from '@/decorators.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pg.types.setTypeParser(20, Number);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const dbLogger = new MisskeyLogger('db');
 | 
					export const dbLogger = new MisskeyLogger('db');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
 | 
					const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
 | 
				
			||||||
@@ -167,6 +169,7 @@ export const entities = [
 | 
				
			|||||||
	MiHashtag,
 | 
						MiHashtag,
 | 
				
			||||||
	MiSwSubscription,
 | 
						MiSwSubscription,
 | 
				
			||||||
	MiAbuseUserReport,
 | 
						MiAbuseUserReport,
 | 
				
			||||||
 | 
						MiAbuseReportNotificationRecipient,
 | 
				
			||||||
	MiRegistrationTicket,
 | 
						MiRegistrationTicket,
 | 
				
			||||||
	MiSignin,
 | 
						MiSignin,
 | 
				
			||||||
	MiModerationLog,
 | 
						MiModerationLog,
 | 
				
			||||||
@@ -185,6 +188,7 @@ export const entities = [
 | 
				
			|||||||
	MiPasswordResetRequest,
 | 
						MiPasswordResetRequest,
 | 
				
			||||||
	MiUserPending,
 | 
						MiUserPending,
 | 
				
			||||||
	MiWebhook,
 | 
						MiWebhook,
 | 
				
			||||||
 | 
						MiSystemWebhook,
 | 
				
			||||||
	MiUserIp,
 | 
						MiUserIp,
 | 
				
			||||||
	MiRetentionAggregation,
 | 
						MiRetentionAggregation,
 | 
				
			||||||
	MiRole,
 | 
						MiRole,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,8 @@ import { QueueProcessorService } from './QueueProcessorService.js';
 | 
				
			|||||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 | 
					import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 | 
				
			||||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
 | 
					import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
 | 
				
			||||||
import { InboxProcessorService } from './processors/InboxProcessorService.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 { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
 | 
				
			||||||
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
 | 
					import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
 | 
				
			||||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
 | 
					import { CleanProcessorService } from './processors/CleanProcessorService.js';
 | 
				
			||||||
@@ -71,7 +72,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
 | 
				
			|||||||
		DeleteFileProcessorService,
 | 
							DeleteFileProcessorService,
 | 
				
			||||||
		CleanRemoteFilesProcessorService,
 | 
							CleanRemoteFilesProcessorService,
 | 
				
			||||||
		RelationshipProcessorService,
 | 
							RelationshipProcessorService,
 | 
				
			||||||
		WebhookDeliverProcessorService,
 | 
							UserWebhookDeliverProcessorService,
 | 
				
			||||||
 | 
							SystemWebhookDeliverProcessorService,
 | 
				
			||||||
		EndedPollNotificationProcessorService,
 | 
							EndedPollNotificationProcessorService,
 | 
				
			||||||
		DeliverProcessorService,
 | 
							DeliverProcessorService,
 | 
				
			||||||
		InboxProcessorService,
 | 
							InboxProcessorService,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,7 +10,8 @@ import type { Config } from '@/config.js';
 | 
				
			|||||||
import { DI } from '@/di-symbols.js';
 | 
					import { DI } from '@/di-symbols.js';
 | 
				
			||||||
import type Logger from '@/logger.js';
 | 
					import type Logger from '@/logger.js';
 | 
				
			||||||
import { bindThis } from '@/decorators.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 { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
 | 
				
			||||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 | 
					import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
 | 
				
			||||||
import { InboxProcessorService } from './processors/InboxProcessorService.js';
 | 
					import { InboxProcessorService } from './processors/InboxProcessorService.js';
 | 
				
			||||||
@@ -76,7 +77,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
	private dbQueueWorker: Bull.Worker;
 | 
						private dbQueueWorker: Bull.Worker;
 | 
				
			||||||
	private deliverQueueWorker: Bull.Worker;
 | 
						private deliverQueueWorker: Bull.Worker;
 | 
				
			||||||
	private inboxQueueWorker: Bull.Worker;
 | 
						private inboxQueueWorker: Bull.Worker;
 | 
				
			||||||
	private webhookDeliverQueueWorker: Bull.Worker;
 | 
						private userWebhookDeliverQueueWorker: Bull.Worker;
 | 
				
			||||||
 | 
						private systemWebhookDeliverQueueWorker: Bull.Worker;
 | 
				
			||||||
	private relationshipQueueWorker: Bull.Worker;
 | 
						private relationshipQueueWorker: Bull.Worker;
 | 
				
			||||||
	private objectStorageQueueWorker: Bull.Worker;
 | 
						private objectStorageQueueWorker: Bull.Worker;
 | 
				
			||||||
	private endedPollNotificationQueueWorker: Bull.Worker;
 | 
						private endedPollNotificationQueueWorker: Bull.Worker;
 | 
				
			||||||
@@ -86,7 +88,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
		private config: Config,
 | 
							private config: Config,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		private queueLoggerService: QueueLoggerService,
 | 
							private queueLoggerService: QueueLoggerService,
 | 
				
			||||||
		private webhookDeliverProcessorService: WebhookDeliverProcessorService,
 | 
							private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
 | 
				
			||||||
 | 
							private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
 | 
				
			||||||
		private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
 | 
							private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
 | 
				
			||||||
		private deliverProcessorService: DeliverProcessorService,
 | 
							private deliverProcessorService: DeliverProcessorService,
 | 
				
			||||||
		private inboxProcessorService: InboxProcessorService,
 | 
							private inboxProcessorService: InboxProcessorService,
 | 
				
			||||||
@@ -160,13 +163,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				autorun: false,
 | 
									autorun: false,
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const systemLogger = this.logger.createSubLogger('system');
 | 
								const logger = this.logger.createSubLogger('system');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.systemQueueWorker
 | 
								this.systemQueueWorker
 | 
				
			||||||
				.on('active', (job) => systemLogger.debug(`active id=${job.id}`))
 | 
									.on('active', (job) => logger.debug(`active id=${job.id}`))
 | 
				
			||||||
				.on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`))
 | 
									.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 | 
				
			||||||
				.on('failed', (job, err: Error) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -174,8 +177,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => systemLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -217,13 +220,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				autorun: false,
 | 
									autorun: false,
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const dbLogger = this.logger.createSubLogger('db');
 | 
								const logger = this.logger.createSubLogger('db');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.dbQueueWorker
 | 
								this.dbQueueWorker
 | 
				
			||||||
				.on('active', (job) => dbLogger.debug(`active id=${job.id}`))
 | 
									.on('active', (job) => logger.debug(`active id=${job.id}`))
 | 
				
			||||||
				.on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`))
 | 
									.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 | 
				
			||||||
				.on('failed', (job, err) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -231,8 +234,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => dbLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -257,13 +260,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				},
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const deliverLogger = this.logger.createSubLogger('deliver');
 | 
								const logger = this.logger.createSubLogger('deliver');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.deliverQueueWorker
 | 
								this.deliverQueueWorker
 | 
				
			||||||
				.on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 | 
									.on('active', (job) => logger.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('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
 | 
				
			||||||
				.on('failed', (job, err) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: Deliver: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: Deliver: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -271,8 +274,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => deliverLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -297,13 +300,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				},
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const inboxLogger = this.logger.createSubLogger('inbox');
 | 
								const logger = this.logger.createSubLogger('inbox');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.inboxQueueWorker
 | 
								this.inboxQueueWorker
 | 
				
			||||||
				.on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`))
 | 
									.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`))
 | 
				
			||||||
				.on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
 | 
									.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
 | 
				
			||||||
				.on('failed', (job, err) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: Inbox: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: Inbox: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -311,21 +314,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => inboxLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#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) {
 | 
									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 {
 | 
									} 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,
 | 
									autorun: false,
 | 
				
			||||||
				concurrency: 64,
 | 
									concurrency: 64,
 | 
				
			||||||
				limiter: {
 | 
									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
 | 
								this.userWebhookDeliverQueueWorker
 | 
				
			||||||
				.on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 | 
									.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 | 
				
			||||||
				.on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${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) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: WebhookDeliver: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
							extra: { job, err },
 | 
												extra: { job, err },
 | 
				
			||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => webhookLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`));
 | 
									.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
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -384,13 +427,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				},
 | 
									},
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const relationshipLogger = this.logger.createSubLogger('relationship');
 | 
								const logger = this.logger.createSubLogger('relationship');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.relationshipQueueWorker
 | 
								this.relationshipQueueWorker
 | 
				
			||||||
				.on('active', (job) => relationshipLogger.debug(`active id=${job.id}`))
 | 
									.on('active', (job) => logger.debug(`active id=${job.id}`))
 | 
				
			||||||
				.on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`))
 | 
									.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 | 
				
			||||||
				.on('failed', (job, err) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -398,8 +441,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => relationshipLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -425,13 +468,13 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
				concurrency: 16,
 | 
									concurrency: 16,
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const objectStorageLogger = this.logger.createSubLogger('objectStorage');
 | 
								const logger = this.logger.createSubLogger('objectStorage');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			this.objectStorageQueueWorker
 | 
								this.objectStorageQueueWorker
 | 
				
			||||||
				.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`))
 | 
									.on('active', (job) => logger.debug(`active id=${job.id}`))
 | 
				
			||||||
				.on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`))
 | 
									.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 | 
				
			||||||
				.on('failed', (job, err) => {
 | 
									.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) {
 | 
										if (config.sentryForBackend) {
 | 
				
			||||||
						Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, {
 | 
											Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, {
 | 
				
			||||||
							level: 'error',
 | 
												level: 'error',
 | 
				
			||||||
@@ -439,8 +482,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
						});
 | 
											});
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				})
 | 
									})
 | 
				
			||||||
				.on('error', (err: Error) => objectStorageLogger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
									.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
 | 
				
			||||||
				.on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`));
 | 
									.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		//#endregion
 | 
							//#endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -467,7 +510,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
			this.dbQueueWorker.run(),
 | 
								this.dbQueueWorker.run(),
 | 
				
			||||||
			this.deliverQueueWorker.run(),
 | 
								this.deliverQueueWorker.run(),
 | 
				
			||||||
			this.inboxQueueWorker.run(),
 | 
								this.inboxQueueWorker.run(),
 | 
				
			||||||
			this.webhookDeliverQueueWorker.run(),
 | 
								this.userWebhookDeliverQueueWorker.run(),
 | 
				
			||||||
 | 
								this.systemWebhookDeliverQueueWorker.run(),
 | 
				
			||||||
			this.relationshipQueueWorker.run(),
 | 
								this.relationshipQueueWorker.run(),
 | 
				
			||||||
			this.objectStorageQueueWorker.run(),
 | 
								this.objectStorageQueueWorker.run(),
 | 
				
			||||||
			this.endedPollNotificationQueueWorker.run(),
 | 
								this.endedPollNotificationQueueWorker.run(),
 | 
				
			||||||
@@ -481,7 +525,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
 | 
				
			|||||||
			this.dbQueueWorker.close(),
 | 
								this.dbQueueWorker.close(),
 | 
				
			||||||
			this.deliverQueueWorker.close(),
 | 
								this.deliverQueueWorker.close(),
 | 
				
			||||||
			this.inboxQueueWorker.close(),
 | 
								this.inboxQueueWorker.close(),
 | 
				
			||||||
			this.webhookDeliverQueueWorker.close(),
 | 
								this.userWebhookDeliverQueueWorker.close(),
 | 
				
			||||||
 | 
								this.systemWebhookDeliverQueueWorker.close(),
 | 
				
			||||||
			this.relationshipQueueWorker.close(),
 | 
								this.relationshipQueueWorker.close(),
 | 
				
			||||||
			this.objectStorageQueueWorker.close(),
 | 
								this.objectStorageQueueWorker.close(),
 | 
				
			||||||
			this.endedPollNotificationQueueWorker.close(),
 | 
								this.endedPollNotificationQueueWorker.close(),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,7 +14,8 @@ export const QUEUE = {
 | 
				
			|||||||
	DB: 'db',
 | 
						DB: 'db',
 | 
				
			||||||
	RELATIONSHIP: 'relationship',
 | 
						RELATIONSHIP: 'relationship',
 | 
				
			||||||
	OBJECT_STORAGE: 'objectStorage',
 | 
						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 {
 | 
					export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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<SystemWebhookDeliverJobData>): Promise<string> {
 | 
				
			||||||
 | 
							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;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -13,10 +13,10 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
 | 
				
			|||||||
import { StatusError } from '@/misc/status-error.js';
 | 
					import { StatusError } from '@/misc/status-error.js';
 | 
				
			||||||
import { bindThis } from '@/decorators.js';
 | 
					import { bindThis } from '@/decorators.js';
 | 
				
			||||||
import { QueueLoggerService } from '../QueueLoggerService.js';
 | 
					import { QueueLoggerService } from '../QueueLoggerService.js';
 | 
				
			||||||
import type { WebhookDeliverJobData } from '../types.js';
 | 
					import { UserWebhookDeliverJobData } from '../types.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class WebhookDeliverProcessorService {
 | 
					export class UserWebhookDeliverProcessorService {
 | 
				
			||||||
	private logger: Logger;
 | 
						private logger: Logger;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	constructor(
 | 
						constructor(
 | 
				
			||||||
@@ -33,7 +33,7 @@ export class WebhookDeliverProcessorService {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	@bindThis
 | 
						@bindThis
 | 
				
			||||||
	public async process(job: Bull.Job<WebhookDeliverJobData>): Promise<string> {
 | 
						public async process(job: Bull.Job<UserWebhookDeliverJobData>): Promise<string> {
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			this.logger.debug(`delivering ${job.data.webhookId}`);
 | 
								this.logger.debug(`delivering ${job.data.webhookId}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -106,7 +106,17 @@ export type EndedPollNotificationJobData = {
 | 
				
			|||||||
	noteId: MiNote['id'];
 | 
						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;
 | 
						type: string;
 | 
				
			||||||
	content: unknown;
 | 
						content: unknown;
 | 
				
			||||||
	webhookId: MiWebhook['id'];
 | 
						webhookId: MiWebhook['id'];
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -73,6 +73,16 @@ export class ApiCallService implements OnApplicationShutdown {
 | 
				
			|||||||
				reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
 | 
									reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			statusCode = statusCode ?? 403;
 | 
								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) {
 | 
							} else if (!statusCode) {
 | 
				
			||||||
			statusCode = 500;
 | 
								statusCode = 500;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -308,12 +318,17 @@ export class ApiCallService implements OnApplicationShutdown {
 | 
				
			|||||||
			if (factor > 0) {
 | 
								if (factor > 0) {
 | 
				
			||||||
				// Rate limit
 | 
									// Rate limit
 | 
				
			||||||
				await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
 | 
									await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
 | 
				
			||||||
					throw new ApiError({
 | 
										if ('info' in err) {
 | 
				
			||||||
						message: 'Rate limit exceeded. Please try again later.',
 | 
											// errはLimiter.LimiterInfoであることが期待される
 | 
				
			||||||
						code: 'RATE_LIMIT_EXCEEDED',
 | 
											throw new ApiError({
 | 
				
			||||||
						id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
 | 
												message: 'Rate limit exceeded. Please try again later.',
 | 
				
			||||||
						httpStatusCode: 429,
 | 
												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.');
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,8 +6,13 @@
 | 
				
			|||||||
import { Module } from '@nestjs/common';
 | 
					import { Module } from '@nestjs/common';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { CoreModule } from '@/core/CoreModule.js';
 | 
					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_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_create from './endpoints/admin/accounts/create.js';
 | 
				
			||||||
import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.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';
 | 
					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_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_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_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 from './endpoints/announcements.js';
 | 
				
			||||||
import * as ep___announcements_show from './endpoints/announcements/show.js';
 | 
					import * as ep___announcements_show from './endpoints/announcements/show.js';
 | 
				
			||||||
import * as ep___antennas_create from './endpoints/antennas/create.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_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_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_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_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 };
 | 
					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_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_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_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: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
 | 
				
			||||||
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.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 };
 | 
					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,
 | 
							ApiLoggerService,
 | 
				
			||||||
		$admin_meta,
 | 
							$admin_meta,
 | 
				
			||||||
		$admin_abuseUserReports,
 | 
							$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_create,
 | 
				
			||||||
		$admin_accounts_delete,
 | 
							$admin_accounts_delete,
 | 
				
			||||||
		$admin_accounts_findByEmail,
 | 
							$admin_accounts_findByEmail,
 | 
				
			||||||
@@ -832,6 +857,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 | 
				
			|||||||
		$admin_roles_unassign,
 | 
							$admin_roles_unassign,
 | 
				
			||||||
		$admin_roles_updateDefaultPolicies,
 | 
							$admin_roles_updateDefaultPolicies,
 | 
				
			||||||
		$admin_roles_users,
 | 
							$admin_roles_users,
 | 
				
			||||||
 | 
							$admin_systemWebhook_create,
 | 
				
			||||||
 | 
							$admin_systemWebhook_delete,
 | 
				
			||||||
 | 
							$admin_systemWebhook_list,
 | 
				
			||||||
 | 
							$admin_systemWebhook_show,
 | 
				
			||||||
 | 
							$admin_systemWebhook_update,
 | 
				
			||||||
		$announcements,
 | 
							$announcements,
 | 
				
			||||||
		$announcements_show,
 | 
							$announcements_show,
 | 
				
			||||||
		$antennas_create,
 | 
							$antennas_create,
 | 
				
			||||||
@@ -1129,6 +1159,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 | 
				
			|||||||
	exports: [
 | 
						exports: [
 | 
				
			||||||
		$admin_meta,
 | 
							$admin_meta,
 | 
				
			||||||
		$admin_abuseUserReports,
 | 
							$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_create,
 | 
				
			||||||
		$admin_accounts_delete,
 | 
							$admin_accounts_delete,
 | 
				
			||||||
		$admin_accounts_findByEmail,
 | 
							$admin_accounts_findByEmail,
 | 
				
			||||||
@@ -1203,6 +1238,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
 | 
				
			|||||||
		$admin_roles_unassign,
 | 
							$admin_roles_unassign,
 | 
				
			||||||
		$admin_roles_updateDefaultPolicies,
 | 
							$admin_roles_updateDefaultPolicies,
 | 
				
			||||||
		$admin_roles_users,
 | 
							$admin_roles_users,
 | 
				
			||||||
 | 
							$admin_systemWebhook_create,
 | 
				
			||||||
 | 
							$admin_systemWebhook_delete,
 | 
				
			||||||
 | 
							$admin_systemWebhook_list,
 | 
				
			||||||
 | 
							$admin_systemWebhook_show,
 | 
				
			||||||
 | 
							$admin_systemWebhook_update,
 | 
				
			||||||
		$announcements,
 | 
							$announcements,
 | 
				
			||||||
		$announcements_show,
 | 
							$announcements_show,
 | 
				
			||||||
		$antennas_create,
 | 
							$antennas_create,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,11 +32,13 @@ export class RateLimiterService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	@bindThis
 | 
						@bindThis
 | 
				
			||||||
	public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
 | 
						public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
 | 
				
			||||||
		return new Promise<void>((ok, reject) => {
 | 
							{
 | 
				
			||||||
			if (this.disabled) ok();
 | 
								if (this.disabled) {
 | 
				
			||||||
 | 
									return Promise.resolve();
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Short-term limit
 | 
								// Short-term limit
 | 
				
			||||||
			const min = (): void => {
 | 
								const min = new Promise<void>((ok, reject) => {
 | 
				
			||||||
				const minIntervalLimiter = new Limiter({
 | 
									const minIntervalLimiter = new Limiter({
 | 
				
			||||||
					id: `${actor}:${limitation.key}:min`,
 | 
										id: `${actor}:${limitation.key}:min`,
 | 
				
			||||||
					duration: limitation.minInterval! * factor,
 | 
										duration: limitation.minInterval! * factor,
 | 
				
			||||||
@@ -46,25 +48,25 @@ export class RateLimiterService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
				minIntervalLimiter.get((err, info) => {
 | 
									minIntervalLimiter.get((err, info) => {
 | 
				
			||||||
					if (err) {
 | 
										if (err) {
 | 
				
			||||||
						return reject('ERR');
 | 
											return reject({ code: 'ERR', info });
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
 | 
										this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					if (info.remaining === 0) {
 | 
										if (info.remaining === 0) {
 | 
				
			||||||
						reject('BRIEF_REQUEST_INTERVAL');
 | 
											return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
 | 
				
			||||||
					} else {
 | 
										} else {
 | 
				
			||||||
						if (hasLongTermLimit) {
 | 
											if (hasLongTermLimit) {
 | 
				
			||||||
							max();
 | 
												return max;
 | 
				
			||||||
						} else {
 | 
											} else {
 | 
				
			||||||
							ok();
 | 
												return ok();
 | 
				
			||||||
						}
 | 
											}
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			};
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Long term limit
 | 
								// Long term limit
 | 
				
			||||||
			const max = (): void => {
 | 
								const max = new Promise<void>((ok, reject) => {
 | 
				
			||||||
				const limiter = new Limiter({
 | 
									const limiter = new Limiter({
 | 
				
			||||||
					id: `${actor}:${limitation.key}`,
 | 
										id: `${actor}:${limitation.key}`,
 | 
				
			||||||
					duration: limitation.duration! * factor,
 | 
										duration: limitation.duration! * factor,
 | 
				
			||||||
@@ -74,18 +76,18 @@ export class RateLimiterService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
				limiter.get((err, info) => {
 | 
									limiter.get((err, info) => {
 | 
				
			||||||
					if (err) {
 | 
										if (err) {
 | 
				
			||||||
						return reject('ERR');
 | 
											return reject({ code: 'ERR', info });
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
 | 
										this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					if (info.remaining === 0) {
 | 
										if (info.remaining === 0) {
 | 
				
			||||||
						reject('RATE_LIMIT_EXCEEDED');
 | 
											return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
 | 
				
			||||||
					} else {
 | 
										} else {
 | 
				
			||||||
						ok();
 | 
											return ok();
 | 
				
			||||||
					}
 | 
										}
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			};
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const hasShortTermLimit = typeof limitation.minInterval === 'number';
 | 
								const hasShortTermLimit = typeof limitation.minInterval === 'number';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -94,12 +96,12 @@ export class RateLimiterService {
 | 
				
			|||||||
				typeof limitation.max === 'number';
 | 
									typeof limitation.max === 'number';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (hasShortTermLimit) {
 | 
								if (hasShortTermLimit) {
 | 
				
			||||||
				min();
 | 
									return min;
 | 
				
			||||||
			} else if (hasLongTermLimit) {
 | 
								} else if (hasLongTermLimit) {
 | 
				
			||||||
				max();
 | 
									return max;
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
				ok();
 | 
									return Promise.resolve();
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		});
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,8 +6,18 @@
 | 
				
			|||||||
import { permissions } from 'misskey-js';
 | 
					import { permissions } from 'misskey-js';
 | 
				
			||||||
import type { KeyOf, Schema } from '@/misc/json-schema.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_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_create from './endpoints/admin/accounts/create.js';
 | 
				
			||||||
import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.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';
 | 
					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_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_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_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_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_federation_updateInstance from './endpoints/admin/federation/update-instance.js';
 | 
				
			||||||
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.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_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_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_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 from './endpoints/announcements.js';
 | 
				
			||||||
import * as ep___announcements_show from './endpoints/announcements/show.js';
 | 
					import * as ep___announcements_show from './endpoints/announcements/show.js';
 | 
				
			||||||
import * as ep___antennas_create from './endpoints/antennas/create.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 = [
 | 
					const eps = [
 | 
				
			||||||
	['admin/meta', ep___admin_meta],
 | 
						['admin/meta', ep___admin_meta],
 | 
				
			||||||
	['admin/abuse-user-reports', ep___admin_abuseUserReports],
 | 
						['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/create', ep___admin_accounts_create],
 | 
				
			||||||
	['admin/accounts/delete', ep___admin_accounts_delete],
 | 
						['admin/accounts/delete', ep___admin_accounts_delete],
 | 
				
			||||||
	['admin/accounts/find-by-email', ep___admin_accounts_findByEmail],
 | 
						['admin/accounts/find-by-email', ep___admin_accounts_findByEmail],
 | 
				
			||||||
@@ -453,6 +474,11 @@ const eps = [
 | 
				
			|||||||
	['admin/roles/unassign', ep___admin_roles_unassign],
 | 
						['admin/roles/unassign', ep___admin_roles_unassign],
 | 
				
			||||||
	['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
 | 
						['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
 | 
				
			||||||
	['admin/roles/users', ep___admin_roles_users],
 | 
						['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', ep___announcements],
 | 
				
			||||||
	['announcements/show', ep___announcements_show],
 | 
						['announcements/show', ep___announcements_show],
 | 
				
			||||||
	['antennas/create', ep___antennas_create],
 | 
						['antennas/create', ep___antennas_create],
 | 
				
			||||||
@@ -873,8 +899,12 @@ export interface IEndpoint {
 | 
				
			|||||||
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
 | 
					const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
 | 
				
			||||||
	return {
 | 
						return {
 | 
				
			||||||
		name: name,
 | 
							name: name,
 | 
				
			||||||
		get meta() { return ep.meta ?? {}; },
 | 
							get meta() {
 | 
				
			||||||
		get params() { return ep.paramDef; },
 | 
								return ep.meta ?? {};
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							get params() {
 | 
				
			||||||
 | 
								return ep.paramDef;
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
	};
 | 
						};
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
				
			||||||
 | 
						constructor(
 | 
				
			||||||
 | 
							private abuseReportNotificationService: AbuseReportNotificationService,
 | 
				
			||||||
 | 
						) {
 | 
				
			||||||
 | 
							super(meta, paramDef, async (ps, me) => {
 | 
				
			||||||
 | 
								await this.abuseReportNotificationService.deleteRecipient(
 | 
				
			||||||
 | 
									ps.id,
 | 
				
			||||||
 | 
									me,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -5,7 +5,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { Inject, Injectable } from '@nestjs/common';
 | 
					import { Inject, Injectable } from '@nestjs/common';
 | 
				
			||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
 | 
					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 = {
 | 
					export const meta = {
 | 
				
			||||||
	tags: ['admin'],
 | 
						tags: ['admin'],
 | 
				
			||||||
@@ -53,7 +53,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
				
			|||||||
		@Inject('queue:inbox') public inboxQueue: InboxQueue,
 | 
							@Inject('queue:inbox') public inboxQueue: InboxQueue,
 | 
				
			||||||
		@Inject('queue:db') public dbQueue: DbQueue,
 | 
							@Inject('queue:db') public dbQueue: DbQueue,
 | 
				
			||||||
		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 | 
							@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) => {
 | 
							super(meta, paramDef, async (ps, me) => {
 | 
				
			||||||
			const deliverJobCounts = await this.deliverQueue.getJobCounts();
 | 
								const deliverJobCounts = await this.deliverQueue.getJobCounts();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,12 +5,10 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { Inject, Injectable } from '@nestjs/common';
 | 
					import { Inject, Injectable } from '@nestjs/common';
 | 
				
			||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
 | 
					import { Endpoint } from '@/server/api/endpoint-base.js';
 | 
				
			||||||
import type { UsersRepository, AbuseUserReportsRepository } from '@/models/_.js';
 | 
					import type { AbuseUserReportsRepository } from '@/models/_.js';
 | 
				
			||||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
 | 
					 | 
				
			||||||
import { QueueService } from '@/core/QueueService.js';
 | 
					 | 
				
			||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
 | 
					 | 
				
			||||||
import { DI } from '@/di-symbols.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 = {
 | 
					export const meta = {
 | 
				
			||||||
	tags: ['admin'],
 | 
						tags: ['admin'],
 | 
				
			||||||
@@ -18,6 +16,16 @@ export const meta = {
 | 
				
			|||||||
	requireCredential: true,
 | 
						requireCredential: true,
 | 
				
			||||||
	requireModerator: true,
 | 
						requireModerator: true,
 | 
				
			||||||
	kind: 'write:admin:resolve-abuse-user-report',
 | 
						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;
 | 
					} as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const paramDef = {
 | 
					export const paramDef = {
 | 
				
			||||||
@@ -29,47 +37,20 @@ export const paramDef = {
 | 
				
			|||||||
	required: ['reportId'],
 | 
						required: ['reportId'],
 | 
				
			||||||
} as const;
 | 
					} as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// TODO: ロジックをサービスに切り出す
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
					export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
				
			||||||
	constructor(
 | 
						constructor(
 | 
				
			||||||
		@Inject(DI.usersRepository)
 | 
					 | 
				
			||||||
		private usersRepository: UsersRepository,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		@Inject(DI.abuseUserReportsRepository)
 | 
							@Inject(DI.abuseUserReportsRepository)
 | 
				
			||||||
		private abuseUserReportsRepository: AbuseUserReportsRepository,
 | 
							private abuseUserReportsRepository: AbuseUserReportsRepository,
 | 
				
			||||||
 | 
							private abuseReportService: AbuseReportService,
 | 
				
			||||||
		private queueService: QueueService,
 | 
					 | 
				
			||||||
		private instanceActorService: InstanceActorService,
 | 
					 | 
				
			||||||
		private apRendererService: ApRendererService,
 | 
					 | 
				
			||||||
		private moderationLogService: ModerationLogService,
 | 
					 | 
				
			||||||
	) {
 | 
						) {
 | 
				
			||||||
		super(meta, paramDef, async (ps, me) => {
 | 
							super(meta, paramDef, async (ps, me) => {
 | 
				
			||||||
			const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
 | 
								const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
 | 
				
			||||||
 | 
								if (!report) {
 | 
				
			||||||
			if (report == null) {
 | 
									throw new ApiError(meta.errors.noSuchAbuseReport);
 | 
				
			||||||
				throw new Error('report not found');
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (ps.forward && report.targetUserHost != null) {
 | 
								await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me);
 | 
				
			||||||
				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,
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
				
			||||||
 | 
						constructor(
 | 
				
			||||||
 | 
							private systemWebhookService: SystemWebhookService,
 | 
				
			||||||
 | 
						) {
 | 
				
			||||||
 | 
							super(meta, paramDef, async (ps, me) => {
 | 
				
			||||||
 | 
								await this.systemWebhookService.deleteSystemWebhook(
 | 
				
			||||||
 | 
									ps.id,
 | 
				
			||||||
 | 
									me,
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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<typeof meta, typeof paramDef> { // 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);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -3,17 +3,11 @@
 | 
				
			|||||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import sanitizeHtml from 'sanitize-html';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { Inject, Injectable } from '@nestjs/common';
 | 
					 | 
				
			||||||
import type { AbuseUserReportsRepository } from '@/models/_.js';
 | 
					 | 
				
			||||||
import { IdService } from '@/core/IdService.js';
 | 
					 | 
				
			||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
 | 
					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 { GetterService } from '@/server/api/GetterService.js';
 | 
				
			||||||
import { RoleService } from '@/core/RoleService.js';
 | 
					import { RoleService } from '@/core/RoleService.js';
 | 
				
			||||||
 | 
					import { AbuseReportService } from '@/core/AbuseReportService.js';
 | 
				
			||||||
import { ApiError } from '../../error.js';
 | 
					import { ApiError } from '../../error.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const meta = {
 | 
					export const meta = {
 | 
				
			||||||
@@ -57,60 +51,32 @@ export const paramDef = {
 | 
				
			|||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
					export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 | 
				
			||||||
	constructor(
 | 
						constructor(
 | 
				
			||||||
		@Inject(DI.abuseUserReportsRepository)
 | 
					 | 
				
			||||||
		private abuseUserReportsRepository: AbuseUserReportsRepository,
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		private idService: IdService,
 | 
					 | 
				
			||||||
		private metaService: MetaService,
 | 
					 | 
				
			||||||
		private emailService: EmailService,
 | 
					 | 
				
			||||||
		private getterService: GetterService,
 | 
							private getterService: GetterService,
 | 
				
			||||||
		private roleService: RoleService,
 | 
							private roleService: RoleService,
 | 
				
			||||||
		private globalEventService: GlobalEventService,
 | 
							private abuseReportService: AbuseReportService,
 | 
				
			||||||
	) {
 | 
						) {
 | 
				
			||||||
		super(meta, paramDef, async (ps, me) => {
 | 
							super(meta, paramDef, async (ps, me) => {
 | 
				
			||||||
			// Lookup user
 | 
								// 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);
 | 
									if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
 | 
				
			||||||
				throw err;
 | 
									throw err;
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (user.id === me.id) {
 | 
								if (targetUser.id === me.id) {
 | 
				
			||||||
				throw new ApiError(meta.errors.cannotReportYourself);
 | 
									throw new ApiError(meta.errors.cannotReportYourself);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if (await this.roleService.isAdministrator(user)) {
 | 
								if (await this.roleService.isAdministrator(targetUser)) {
 | 
				
			||||||
				throw new ApiError(meta.errors.cannotReportAdmin);
 | 
									throw new ApiError(meta.errors.cannotReportAdmin);
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const report = await this.abuseUserReportsRepository.insertOne({
 | 
								await this.abuseReportService.report([{
 | 
				
			||||||
				id: this.idService.gen(),
 | 
									targetUserId: targetUser.id,
 | 
				
			||||||
				targetUserId: user.id,
 | 
									targetUserHost: targetUser.host,
 | 
				
			||||||
				targetUserHost: user.host,
 | 
					 | 
				
			||||||
				reporterId: me.id,
 | 
									reporterId: me.id,
 | 
				
			||||||
				reporterHost: null,
 | 
									reporterHost: null,
 | 
				
			||||||
				comment: ps.comment,
 | 
									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));
 | 
					 | 
				
			||||||
				}
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -25,7 +25,16 @@ import { getNoteSummary } from '@/misc/get-note-summary.js';
 | 
				
			|||||||
import { DI } from '@/di-symbols.js';
 | 
					import { DI } from '@/di-symbols.js';
 | 
				
			||||||
import * as Acct from '@/misc/acct.js';
 | 
					import * as Acct from '@/misc/acct.js';
 | 
				
			||||||
import { MetaService } from '@/core/MetaService.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 { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
				
			||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 | 
					import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 | 
				
			||||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
 | 
					import { PageEntityService } from '@/core/entities/PageEntityService.js';
 | 
				
			||||||
@@ -111,7 +120,8 @@ export class ClientServerService {
 | 
				
			|||||||
		@Inject('queue:inbox') public inboxQueue: InboxQueue,
 | 
							@Inject('queue:inbox') public inboxQueue: InboxQueue,
 | 
				
			||||||
		@Inject('queue:db') public dbQueue: DbQueue,
 | 
							@Inject('queue:db') public dbQueue: DbQueue,
 | 
				
			||||||
		@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
 | 
							@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);
 | 
							//this.createServer = this.createServer.bind(this);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -239,7 +249,8 @@ export class ClientServerService {
 | 
				
			|||||||
				this.inboxQueue,
 | 
									this.inboxQueue,
 | 
				
			||||||
				this.dbQueue,
 | 
									this.dbQueue,
 | 
				
			||||||
				this.objectStorageQueue,
 | 
									this.objectStorageQueue,
 | 
				
			||||||
				this.webhookDeliverQueue,
 | 
									this.userWebhookDeliverQueue,
 | 
				
			||||||
 | 
									this.systemWebhookDeliverQueue,
 | 
				
			||||||
			].map(q => new BullMQAdapter(q)),
 | 
								].map(q => new BullMQAdapter(q)),
 | 
				
			||||||
			serverAdapter,
 | 
								serverAdapter,
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -90,6 +90,12 @@ export const moderationLogTypes = [
 | 
				
			|||||||
	'deleteAvatarDecoration',
 | 
						'deleteAvatarDecoration',
 | 
				
			||||||
	'unsetUserAvatar',
 | 
						'unsetUserAvatar',
 | 
				
			||||||
	'unsetUserBanner',
 | 
						'unsetUserBanner',
 | 
				
			||||||
 | 
						'createSystemWebhook',
 | 
				
			||||||
 | 
						'updateSystemWebhook',
 | 
				
			||||||
 | 
						'deleteSystemWebhook',
 | 
				
			||||||
 | 
						'createAbuseReportNotificationRecipient',
 | 
				
			||||||
 | 
						'updateAbuseReportNotificationRecipient',
 | 
				
			||||||
 | 
						'deleteAbuseReportNotificationRecipient',
 | 
				
			||||||
] as const;
 | 
					] as const;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ModerationLogPayloads = {
 | 
					export type ModerationLogPayloads = {
 | 
				
			||||||
@@ -282,6 +288,32 @@ export type ModerationLogPayloads = {
 | 
				
			|||||||
		userHost: string | null;
 | 
							userHost: string | null;
 | 
				
			||||||
		fileId: string;
 | 
							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<T> = {
 | 
					export type Serialized<T> = {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										401
									
								
								packages/backend/test/e2e/synalio/abuse-report.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								packages/backend/test/e2e/synalio/abuse-report.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<T = SystemWebhookPayload>(postAction: () => Promise<void>): Promise<T> {
 | 
				
			||||||
 | 
							const fastify = Fastify();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							let timeoutHandle: NodeJS.Timeout | null = null;
 | 
				
			||||||
 | 
							const result = await new Promise<string>(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<entities.AdminSystemWebhookCreateRequest>, credential?: UserToken): Promise<entities.AdminSystemWebhookCreateResponse> {
 | 
				
			||||||
 | 
							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<entities.AdminAbuseReportNotificationRecipientCreateRequest>, credential?: UserToken): Promise<entities.AdminAbuseReportNotificationRecipientCreateResponse> {
 | 
				
			||||||
 | 
							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<entities.UsersReportAbuseRequest>, credential?: UserToken): Promise<entities.EmptyResponse> {
 | 
				
			||||||
 | 
							const res = await api(
 | 
				
			||||||
 | 
								'users/report-abuse',
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									userId: alice.id,
 | 
				
			||||||
 | 
									comment: randomString(),
 | 
				
			||||||
 | 
									...args,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								credential ?? admin,
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
							return res.body;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async function resolveAbuseReport(args?: Partial<entities.AdminResolveAbuseUserReportRequest>, credential?: UserToken): Promise<entities.EmptyResponse> {
 | 
				
			||||||
 | 
							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');
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										343
									
								
								packages/backend/test/unit/AbuseReportNotificationService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								packages/backend/test/unit/AbuseReportNotificationService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<RoleService>;
 | 
				
			||||||
 | 
						let emailService: jest.Mocked<EmailService>;
 | 
				
			||||||
 | 
						let webhookService: jest.Mocked<SystemWebhookService>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// --------------------------------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						let root: MiUser;
 | 
				
			||||||
 | 
						let alice: MiUser;
 | 
				
			||||||
 | 
						let bob: MiUser;
 | 
				
			||||||
 | 
						let systemWebhook1: MiSystemWebhook;
 | 
				
			||||||
 | 
						let systemWebhook2: MiSystemWebhook;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// --------------------------------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async function createUser(data: Partial<MiUser> = {}) {
 | 
				
			||||||
 | 
							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<MiSystemWebhook> = {}) {
 | 
				
			||||||
 | 
							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<MiAbuseReportNotificationRecipient> = {}) {
 | 
				
			||||||
 | 
							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<RoleService>;
 | 
				
			||||||
 | 
							emailService = app.get<EmailService>(EmailService) as jest.Mocked<EmailService>;
 | 
				
			||||||
 | 
							webhookService = app.get<SystemWebhookService>(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							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]);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -3,8 +3,6 @@
 | 
				
			|||||||
 * SPDX-License-Identifier: AGPL-3.0-only
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
process.env.NODE_ENV = 'test';
 | 
					process.env.NODE_ENV = 'test';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { jest } from '@jest/globals';
 | 
					import { jest } from '@jest/globals';
 | 
				
			||||||
@@ -13,7 +11,14 @@ import { Test } from '@nestjs/testing';
 | 
				
			|||||||
import * as lolex from '@sinonjs/fake-timers';
 | 
					import * as lolex from '@sinonjs/fake-timers';
 | 
				
			||||||
import { GlobalModule } from '@/GlobalModule.js';
 | 
					import { GlobalModule } from '@/GlobalModule.js';
 | 
				
			||||||
import { RoleService } from '@/core/RoleService.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 { DI } from '@/di-symbols.js';
 | 
				
			||||||
import { MetaService } from '@/core/MetaService.js';
 | 
					import { MetaService } from '@/core/MetaService.js';
 | 
				
			||||||
import { genAidx } from '@/misc/id/aidx.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 { secureRndstr } from '@/misc/secure-rndstr.js';
 | 
				
			||||||
import { NotificationService } from '@/core/NotificationService.js';
 | 
					import { NotificationService } from '@/core/NotificationService.js';
 | 
				
			||||||
import { RoleCondFormulaValue } from '@/models/Role.js';
 | 
					import { RoleCondFormulaValue } from '@/models/Role.js';
 | 
				
			||||||
 | 
					import { UserEntityService } from '@/core/entities/UserEntityService.js';
 | 
				
			||||||
import { sleep } from '../utils.js';
 | 
					import { sleep } from '../utils.js';
 | 
				
			||||||
import type { TestingModule } from '@nestjs/testing';
 | 
					import type { TestingModule } from '@nestjs/testing';
 | 
				
			||||||
import type { MockFunctionMetadata } from 'jest-mock';
 | 
					import type { MockFunctionMetadata } from 'jest-mock';
 | 
				
			||||||
@@ -39,27 +45,27 @@ describe('RoleService', () => {
 | 
				
			|||||||
	let notificationService: jest.Mocked<NotificationService>;
 | 
						let notificationService: jest.Mocked<NotificationService>;
 | 
				
			||||||
	let clock: lolex.InstalledClock;
 | 
						let clock: lolex.InstalledClock;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function createUser(data: Partial<MiUser> = {}) {
 | 
						async function createUser(data: Partial<MiUser> = {}) {
 | 
				
			||||||
		const un = secureRndstr(16);
 | 
							const un = secureRndstr(16);
 | 
				
			||||||
		return usersRepository.insert({
 | 
							const x = await usersRepository.insert({
 | 
				
			||||||
			id: genAidx(Date.now()),
 | 
								id: genAidx(Date.now()),
 | 
				
			||||||
			username: un,
 | 
								username: un,
 | 
				
			||||||
			usernameLower: un,
 | 
								usernameLower: un,
 | 
				
			||||||
			...data,
 | 
								...data,
 | 
				
			||||||
		})
 | 
							});
 | 
				
			||||||
			.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
 | 
							return await usersRepository.findOneByOrFail(x.identifiers[0]);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function createRole(data: Partial<MiRole> = {}) {
 | 
						async function createRole(data: Partial<MiRole> = {}) {
 | 
				
			||||||
		return rolesRepository.insert({
 | 
							const x = await rolesRepository.insert({
 | 
				
			||||||
			id: genAidx(Date.now()),
 | 
								id: genAidx(Date.now()),
 | 
				
			||||||
			updatedAt: new Date(),
 | 
								updatedAt: new Date(),
 | 
				
			||||||
			lastUsedAt: new Date(),
 | 
								lastUsedAt: new Date(),
 | 
				
			||||||
			name: '',
 | 
								name: '',
 | 
				
			||||||
			description: '',
 | 
								description: '',
 | 
				
			||||||
			...data,
 | 
								...data,
 | 
				
			||||||
		})
 | 
							});
 | 
				
			||||||
			.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
 | 
							return await rolesRepository.findOneByOrFail(x.identifiers[0]);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial<MiRole> = {}) {
 | 
						function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial<MiRole> = {}) {
 | 
				
			||||||
@@ -71,6 +77,20 @@ describe('RoleService', () => {
 | 
				
			|||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async function assignRole(args: Partial<MiRoleAssignment>) {
 | 
				
			||||||
 | 
							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() {
 | 
						function aidx() {
 | 
				
			||||||
		return genAidx(Date.now());
 | 
							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', () => {
 | 
						describe('conditional role', () => {
 | 
				
			||||||
		test('~かつ~', async () => {
 | 
							test('~かつ~', async () => {
 | 
				
			||||||
			const [user1, user2, user3, user4] = await Promise.all([
 | 
								const [user1, user2, user3, user4] = await Promise.all([
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										515
									
								
								packages/backend/test/unit/SystemWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										515
									
								
								packages/backend/test/unit/SystemWebhookService.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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<QueueService>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// --------------------------------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						let root: MiUser;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// --------------------------------------------------------------------------------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async function createUser(data: Partial<MiUser> = {}) {
 | 
				
			||||||
 | 
							return await usersRepository
 | 
				
			||||||
 | 
								.insert({
 | 
				
			||||||
 | 
									id: idService.gen(),
 | 
				
			||||||
 | 
									...data,
 | 
				
			||||||
 | 
								})
 | 
				
			||||||
 | 
								.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						async function createWebhook(data: Partial<MiSystemWebhook> = {}) {
 | 
				
			||||||
 | 
							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<QueueService>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							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);
 | 
				
			||||||
 | 
									});
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@@ -22,6 +22,66 @@ export function abuseUserReport() {
 | 
				
			|||||||
	};
 | 
						};
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: string | null = 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true'): entities.Channel {
 | 
				
			||||||
 | 
						return {
 | 
				
			||||||
 | 
							id,
 | 
				
			||||||
 | 
							createdAt: '2016-12-28T22:49:51.000Z',
 | 
				
			||||||
 | 
							lastNotedAt: '2016-12-28T22:49:51.000Z',
 | 
				
			||||||
 | 
							name,
 | 
				
			||||||
 | 
							description: null,
 | 
				
			||||||
 | 
							userId: null,
 | 
				
			||||||
 | 
							bannerUrl,
 | 
				
			||||||
 | 
							pinnedNoteIds: [],
 | 
				
			||||||
 | 
							color: '#000',
 | 
				
			||||||
 | 
							isArchived: false,
 | 
				
			||||||
 | 
							usersCount: 1,
 | 
				
			||||||
 | 
							notesCount: 1,
 | 
				
			||||||
 | 
							isSensitive: false,
 | 
				
			||||||
 | 
							allowRenoteToExternal: false,
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
 | 
				
			||||||
 | 
						return {
 | 
				
			||||||
 | 
							id,
 | 
				
			||||||
 | 
							createdAt: '2016-12-28T22:49:51.000Z',
 | 
				
			||||||
 | 
							lastClippedAt: null,
 | 
				
			||||||
 | 
							userId: 'someuserid',
 | 
				
			||||||
 | 
							user: {
 | 
				
			||||||
 | 
								id: 'someuserid',
 | 
				
			||||||
 | 
								name: 'Misskey User',
 | 
				
			||||||
 | 
								username: 'miskist',
 | 
				
			||||||
 | 
								host: 'misskey-hub.net',
 | 
				
			||||||
 | 
								avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
 | 
				
			||||||
 | 
								avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
 | 
				
			||||||
 | 
								avatarDecorations: [],
 | 
				
			||||||
 | 
								emojis: {},
 | 
				
			||||||
 | 
								badgeRoles: [],
 | 
				
			||||||
 | 
								onlineStatus: 'unknown',
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							notesCount: undefined,
 | 
				
			||||||
 | 
							name,
 | 
				
			||||||
 | 
							description: 'Some clip description',
 | 
				
			||||||
 | 
							isPublic: false,
 | 
				
			||||||
 | 
							favoritedCount: 0,
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function emojiDetailed(id = 'someemojiid', name = 'some_emoji'): entities.EmojiDetailed {
 | 
				
			||||||
 | 
						return {
 | 
				
			||||||
 | 
							id,
 | 
				
			||||||
 | 
							aliases: ['alias1', 'alias2'],
 | 
				
			||||||
 | 
							name,
 | 
				
			||||||
 | 
							category: 'emojiCategory',
 | 
				
			||||||
 | 
							host: null,
 | 
				
			||||||
 | 
							url: '/client-assets/about-icon.png',
 | 
				
			||||||
 | 
							license: null,
 | 
				
			||||||
 | 
							isSensitive: false,
 | 
				
			||||||
 | 
							localOnly: false,
 | 
				
			||||||
 | 
							roleIdsThatCanBeUsedThisEmojiAsReaction: ['roleId1', 'roleId2'],
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function galleryPost(isSensitive = false) {
 | 
					export function galleryPost(isSensitive = false) {
 | 
				
			||||||
	return {
 | 
						return {
 | 
				
			||||||
		id: 'somepostid',
 | 
							id: 'somepostid',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -397,7 +397,7 @@ function toStories(component: string): Promise<string> {
 | 
				
			|||||||
	const globs = await Promise.all([
 | 
						const globs = await Promise.all([
 | 
				
			||||||
		glob('src/components/global/Mk*.vue'),
 | 
							glob('src/components/global/Mk*.vue'),
 | 
				
			||||||
		glob('src/components/global/RouterView.vue'),
 | 
							glob('src/components/global/RouterView.vue'),
 | 
				
			||||||
		glob('src/components/Mk{A,B}*.vue'),
 | 
							glob('src/components/Mk[A-C]*.vue'),
 | 
				
			||||||
		glob('src/components/MkDigitalClock.vue'),
 | 
							glob('src/components/MkDigitalClock.vue'),
 | 
				
			||||||
		glob('src/components/MkGalleryPostPreview.vue'),
 | 
							glob('src/components/MkGalleryPostPreview.vue'),
 | 
				
			||||||
		glob('src/components/MkSignupServerRules.vue'),
 | 
							glob('src/components/MkSignupServerRules.vue'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@ const _dirname = fileURLToPath(new URL('.', import.meta.url));
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const config = {
 | 
					const config = {
 | 
				
			||||||
	stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
 | 
						stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
 | 
				
			||||||
 | 
						staticDirs: [{ from: '../assets', to: '/client-assets' }],
 | 
				
			||||||
	addons: [
 | 
						addons: [
 | 
				
			||||||
		getAbsolutePath('@storybook/addon-essentials'),
 | 
							getAbsolutePath('@storybook/addon-essentials'),
 | 
				
			||||||
		getAbsolutePath('@storybook/addon-interactions'),
 | 
							getAbsolutePath('@storybook/addon-interactions'),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,7 +7,7 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
 | 
				
			|||||||
import { addons } from '@storybook/preview-api';
 | 
					import { addons } from '@storybook/preview-api';
 | 
				
			||||||
import { type Preview, setup } from '@storybook/vue3';
 | 
					import { type Preview, setup } from '@storybook/vue3';
 | 
				
			||||||
import isChromatic from 'chromatic/isChromatic';
 | 
					import isChromatic from 'chromatic/isChromatic';
 | 
				
			||||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
 | 
					import { initialize, mswLoader } from 'msw-storybook-addon';
 | 
				
			||||||
import { userDetailed } from './fakes.js';
 | 
					import { userDetailed } from './fakes.js';
 | 
				
			||||||
import locale from './locale.js';
 | 
					import locale from './locale.js';
 | 
				
			||||||
import { commonHandlers, onUnhandledRequest } from './mocks.js';
 | 
					import { commonHandlers, onUnhandledRequest } from './mocks.js';
 | 
				
			||||||
@@ -122,7 +122,6 @@ const preview = {
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
			return story;
 | 
								return story;
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		mswDecorator,
 | 
					 | 
				
			||||||
		(Story, context) => {
 | 
							(Story, context) => {
 | 
				
			||||||
			return {
 | 
								return {
 | 
				
			||||||
				setup() {
 | 
									setup() {
 | 
				
			||||||
@@ -137,6 +136,7 @@ const preview = {
 | 
				
			|||||||
			};
 | 
								};
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
 | 
						loaders: [mswLoader],
 | 
				
			||||||
	parameters: {
 | 
						parameters: {
 | 
				
			||||||
		controls: {
 | 
							controls: {
 | 
				
			||||||
			exclude: /^__/,
 | 
								exclude: /^__/,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -104,6 +104,7 @@
 | 
				
			|||||||
		"@types/node": "20.12.7",
 | 
							"@types/node": "20.12.7",
 | 
				
			||||||
		"@types/punycode": "2.1.4",
 | 
							"@types/punycode": "2.1.4",
 | 
				
			||||||
		"@types/sanitize-html": "2.11.0",
 | 
							"@types/sanitize-html": "2.11.0",
 | 
				
			||||||
 | 
							"@types/seedrandom": "3.0.8",
 | 
				
			||||||
		"@types/throttle-debounce": "5.0.2",
 | 
							"@types/throttle-debounce": "5.0.2",
 | 
				
			||||||
		"@types/tinycolor2": "1.4.6",
 | 
							"@types/tinycolor2": "1.4.6",
 | 
				
			||||||
		"@types/uuid": "9.0.8",
 | 
							"@types/uuid": "9.0.8",
 | 
				
			||||||
@@ -128,6 +129,7 @@
 | 
				
			|||||||
		"prettier": "3.2.5",
 | 
							"prettier": "3.2.5",
 | 
				
			||||||
		"react": "18.3.1",
 | 
							"react": "18.3.1",
 | 
				
			||||||
		"react-dom": "18.3.1",
 | 
							"react-dom": "18.3.1",
 | 
				
			||||||
 | 
							"seedrandom": "3.0.5",
 | 
				
			||||||
		"start-server-and-test": "2.0.3",
 | 
							"start-server-and-test": "2.0.3",
 | 
				
			||||||
		"storybook": "8.0.9",
 | 
							"storybook": "8.0.9",
 | 
				
			||||||
		"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
 | 
							"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
	:type="type"
 | 
						:type="type"
 | 
				
			||||||
	:name="name"
 | 
						:name="name"
 | 
				
			||||||
	:value="value"
 | 
						:value="value"
 | 
				
			||||||
 | 
						:disabled="disabled"
 | 
				
			||||||
	@click="emit('click', $event)"
 | 
						@click="emit('click', $event)"
 | 
				
			||||||
	@mousedown="onMousedown"
 | 
						@mousedown="onMousedown"
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
@@ -55,6 +56,7 @@ const props = defineProps<{
 | 
				
			|||||||
	asLike?: boolean;
 | 
						asLike?: boolean;
 | 
				
			||||||
	name?: string;
 | 
						name?: string;
 | 
				
			||||||
	value?: string;
 | 
						value?: string;
 | 
				
			||||||
 | 
						disabled?: boolean;
 | 
				
			||||||
}>();
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits<{
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,77 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { HttpResponse, http } from 'msw';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { expect, userEvent, within } from '@storybook/test';
 | 
				
			||||||
 | 
					import { channel } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import { commonHandlers } from '../../.storybook/mocks.js';
 | 
				
			||||||
 | 
					import MkChannelFollowButton from './MkChannelFollowButton.vue';
 | 
				
			||||||
 | 
					import { semaphore } from '@/scripts/test-utils.js';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function sleep(ms: number) {
 | 
				
			||||||
 | 
						return new Promise(resolve => setTimeout(resolve, ms));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const s = semaphore();
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkChannelFollowButton,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkChannelFollowButton v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							channel: channel(),
 | 
				
			||||||
 | 
							full: true,
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						async play({ canvasElement }) {
 | 
				
			||||||
 | 
							await s.acquire();
 | 
				
			||||||
 | 
							await sleep(1000);
 | 
				
			||||||
 | 
							const canvas = within(canvasElement);
 | 
				
			||||||
 | 
							const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
 | 
				
			||||||
 | 
							await userEvent.click(buttonElement);
 | 
				
			||||||
 | 
							await sleep(1000);
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
 | 
				
			||||||
 | 
							await sleep(100);
 | 
				
			||||||
 | 
							await userEvent.click(buttonElement);
 | 
				
			||||||
 | 
							s.release();
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
							msw: {
 | 
				
			||||||
 | 
								handlers: [
 | 
				
			||||||
 | 
									...commonHandlers,
 | 
				
			||||||
 | 
									http.post('/api/channels/follow', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/channels/follow')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json({});
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
									http.post('/api/channels/unfollow', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/channels/unfollow')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json({});
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
								],
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChannelFollowButton>;
 | 
				
			||||||
@@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script lang="ts" setup>
 | 
					<script lang="ts" setup>
 | 
				
			||||||
import { ref } from 'vue';
 | 
					import { ref } from 'vue';
 | 
				
			||||||
 | 
					import * as Misskey from 'misskey-js';
 | 
				
			||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
					import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
				
			||||||
import { i18n } from '@/i18n.js';
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<{
 | 
					const props = withDefaults(defineProps<{
 | 
				
			||||||
	channel: Record<string, any>;
 | 
						channel: Misskey.entities.Channel;
 | 
				
			||||||
	full?: boolean;
 | 
						full?: boolean;
 | 
				
			||||||
}>(), {
 | 
					}>(), {
 | 
				
			||||||
	full: false,
 | 
						full: false,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const isFollowing = ref<boolean>(props.channel.isFollowing);
 | 
					const isFollowing = ref(props.channel.isFollowing);
 | 
				
			||||||
const wait = ref(false);
 | 
					const wait = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function onClick() {
 | 
					async function onClick() {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { HttpResponse, http } from 'msw';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { channel } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import { commonHandlers } from '../../.storybook/mocks.js';
 | 
				
			||||||
 | 
					import MkChannelList from './MkChannelList.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkChannelList,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkChannelList v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							pagination: {
 | 
				
			||||||
 | 
								endpoint: 'channels/search',
 | 
				
			||||||
 | 
								limit: 10,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							chromatic: {
 | 
				
			||||||
 | 
								// NOTE: ロードが終わるまで待つ
 | 
				
			||||||
 | 
								delay: 3000,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							layout: 'fullscreen',
 | 
				
			||||||
 | 
							msw: {
 | 
				
			||||||
 | 
								handlers: [
 | 
				
			||||||
 | 
									...commonHandlers,
 | 
				
			||||||
 | 
									http.post('/api/channels/search', async ({ request, params }) => {
 | 
				
			||||||
 | 
										action('POST /api/channels/search')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [
 | 
				
			||||||
 | 
											channel(),
 | 
				
			||||||
 | 
											channel('lastchannel', 'Last Channel', null),
 | 
				
			||||||
 | 
										]);
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
								],
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						decorators: [
 | 
				
			||||||
 | 
							() => ({
 | 
				
			||||||
 | 
								template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
 | 
				
			||||||
 | 
							}),
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChannelList>;
 | 
				
			||||||
@@ -0,0 +1,43 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { channel } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import MkChannelPreview from './MkChannelPreview.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkChannelPreview,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkChannelPreview v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							channel: channel(),
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'fullscreen',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						decorators: [
 | 
				
			||||||
 | 
							() => ({
 | 
				
			||||||
 | 
								template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
 | 
				
			||||||
 | 
							}),
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChannelPreview>;
 | 
				
			||||||
							
								
								
									
										117
									
								
								packages/frontend/src/components/MkChart.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								packages/frontend/src/components/MkChart.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw';
 | 
				
			||||||
 | 
					import seedrandom from 'seedrandom';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { commonHandlers } from '../../.storybook/mocks.js';
 | 
				
			||||||
 | 
					import MkChart from './MkChart.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
 | 
				
			||||||
 | 
						const rng = seedrandom(seed);
 | 
				
			||||||
 | 
						const max = Math.floor(option?.mul ?? 250 * rng());
 | 
				
			||||||
 | 
						let accumulation = 0;
 | 
				
			||||||
 | 
						const array: number[] = [];
 | 
				
			||||||
 | 
						for (let i = 0; i < limit; i++) {
 | 
				
			||||||
 | 
							const num = Math.floor((max + 1) * rng());
 | 
				
			||||||
 | 
							if (option?.accumulate) {
 | 
				
			||||||
 | 
								accumulation += num;
 | 
				
			||||||
 | 
								array.unshift(accumulation);
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								array.push(num);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return array;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getChartResolver(fields: string[], option?: { accumulate?: boolean, mulMap?: Record<string, number> }): HttpResponseResolver<PathParams, DefaultBodyType, JsonBodyType> {
 | 
				
			||||||
 | 
						return ({ request }) => {
 | 
				
			||||||
 | 
							action(`GET ${request.url}`)();
 | 
				
			||||||
 | 
							const limitParam = new URL(request.url).searchParams.get('limit');
 | 
				
			||||||
 | 
							const limit = limitParam ? parseInt(limitParam) : 30;
 | 
				
			||||||
 | 
							const res = {};
 | 
				
			||||||
 | 
							for (const field of fields) {
 | 
				
			||||||
 | 
								const layers = field.split('.');
 | 
				
			||||||
 | 
								let current = res;
 | 
				
			||||||
 | 
								while (layers.length > 1) {
 | 
				
			||||||
 | 
									const currentKey = layers.shift()!;
 | 
				
			||||||
 | 
									if (current[currentKey] == null) current[currentKey] = {};
 | 
				
			||||||
 | 
									current = current[currentKey];
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								current[layers[0]] = getChartArray(field, limit, {
 | 
				
			||||||
 | 
									accumulate: option?.accumulate,
 | 
				
			||||||
 | 
									mul: option?.mulMap != null && field in option.mulMap ? option.mulMap[field] : undefined,
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return HttpResponse.json(res);
 | 
				
			||||||
 | 
						};
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Base = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkChart,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkChart v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							src: 'federation',
 | 
				
			||||||
 | 
							span: 'hour',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
							msw: {
 | 
				
			||||||
 | 
								handlers: [
 | 
				
			||||||
 | 
									...commonHandlers,
 | 
				
			||||||
 | 
									http.get('/api/charts/federation', getChartResolver(
 | 
				
			||||||
 | 
										['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'],
 | 
				
			||||||
 | 
									)),
 | 
				
			||||||
 | 
									http.get('/api/charts/notes', getChartResolver(
 | 
				
			||||||
 | 
										['local.total', 'remote.total'],
 | 
				
			||||||
 | 
										{ accumulate: true },
 | 
				
			||||||
 | 
									)),
 | 
				
			||||||
 | 
									http.get('/api/charts/drive', getChartResolver(
 | 
				
			||||||
 | 
										['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'],
 | 
				
			||||||
 | 
										{ mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } },
 | 
				
			||||||
 | 
									)),
 | 
				
			||||||
 | 
								],
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChart>;
 | 
				
			||||||
 | 
					export const FederationChart = {
 | 
				
			||||||
 | 
						...Base,
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							src: 'federation',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChart>;
 | 
				
			||||||
 | 
					export const NotesTotalChart = {
 | 
				
			||||||
 | 
						...Base,
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							src: 'notes-total',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChart>;
 | 
				
			||||||
 | 
					export const DriveChart = {
 | 
				
			||||||
 | 
						...Base,
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							src: 'drive',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkChart>;
 | 
				
			||||||
@@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
  id-denylist violation when setting it. This is causing about 60+ lint issues.
 | 
					  id-denylist violation when setting it. This is causing about 60+ lint issues.
 | 
				
			||||||
  As this is part of Chart.js's API it makes sense to disable the check here.
 | 
					  As this is part of Chart.js's API it makes sense to disable the check here.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
import { onMounted, ref, shallowRef, watch, PropType } from 'vue';
 | 
					import { onMounted, ref, shallowRef, watch } from 'vue';
 | 
				
			||||||
import { Chart } from 'chart.js';
 | 
					import { Chart } from 'chart.js';
 | 
				
			||||||
 | 
					import * as Misskey from 'misskey-js';
 | 
				
			||||||
import { misskeyApiGet } from '@/scripts/misskey-api.js';
 | 
					import { misskeyApiGet } from '@/scripts/misskey-api.js';
 | 
				
			||||||
import { defaultStore } from '@/store.js';
 | 
					import { defaultStore } from '@/store.js';
 | 
				
			||||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 | 
					import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
 | 
				
			||||||
@@ -34,44 +35,55 @@ import MkChartLegend from '@/components/MkChartLegend.vue';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
initChart();
 | 
					initChart();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					type ChartSrc =
 | 
				
			||||||
	src: {
 | 
						| 'federation'
 | 
				
			||||||
		type: String,
 | 
						| 'ap-request'
 | 
				
			||||||
		required: true,
 | 
						| 'users'
 | 
				
			||||||
	},
 | 
						| 'users-total'
 | 
				
			||||||
	args: {
 | 
						| 'active-users'
 | 
				
			||||||
		type: Object,
 | 
						| 'notes'
 | 
				
			||||||
		required: false,
 | 
						| 'local-notes'
 | 
				
			||||||
	},
 | 
						| 'remote-notes'
 | 
				
			||||||
	limit: {
 | 
						| 'notes-total'
 | 
				
			||||||
		type: Number,
 | 
						| 'drive'
 | 
				
			||||||
		required: false,
 | 
						| 'drive-files'
 | 
				
			||||||
		default: 90,
 | 
						| 'instance-requests'
 | 
				
			||||||
	},
 | 
						| 'instance-users'
 | 
				
			||||||
	span: {
 | 
						| 'instance-users-total'
 | 
				
			||||||
		type: String as PropType<'hour' | 'day'>,
 | 
						| 'instance-notes'
 | 
				
			||||||
		required: true,
 | 
						| 'instance-notes-total'
 | 
				
			||||||
	},
 | 
						| 'instance-ff'
 | 
				
			||||||
	detailed: {
 | 
						| 'instance-ff-total'
 | 
				
			||||||
		type: Boolean,
 | 
						| 'instance-drive-usage'
 | 
				
			||||||
		required: false,
 | 
						| 'instance-drive-usage-total'
 | 
				
			||||||
		default: false,
 | 
						| 'instance-drive-files'
 | 
				
			||||||
	},
 | 
						| 'instance-drive-files-total'
 | 
				
			||||||
	stacked: {
 | 
						| 'per-user-notes'
 | 
				
			||||||
		type: Boolean,
 | 
						| 'per-user-pv'
 | 
				
			||||||
		required: false,
 | 
						| 'per-user-following'
 | 
				
			||||||
		default: false,
 | 
						| 'per-user-followers'
 | 
				
			||||||
	},
 | 
						| 'per-user-drive'
 | 
				
			||||||
	bar: {
 | 
					
 | 
				
			||||||
		type: Boolean,
 | 
					const props = withDefaults(defineProps<{
 | 
				
			||||||
		required: false,
 | 
						src: ChartSrc;
 | 
				
			||||||
		default: false,
 | 
						args?: {
 | 
				
			||||||
	},
 | 
							host?: string;
 | 
				
			||||||
	aspectRatio: {
 | 
							user?: Misskey.entities.UserLite;
 | 
				
			||||||
		type: Number,
 | 
							withoutAll?: boolean;
 | 
				
			||||||
		required: false,
 | 
						};
 | 
				
			||||||
		default: null,
 | 
						limit?: number;
 | 
				
			||||||
	},
 | 
						span: 'hour' | 'day';
 | 
				
			||||||
 | 
						detailed?: boolean;
 | 
				
			||||||
 | 
						stacked?: boolean;
 | 
				
			||||||
 | 
						bar?: boolean;
 | 
				
			||||||
 | 
						aspectRatio?: number | null;
 | 
				
			||||||
 | 
					}>(), {
 | 
				
			||||||
 | 
						args: undefined,
 | 
				
			||||||
 | 
						limit: 90,
 | 
				
			||||||
 | 
						detailed: false,
 | 
				
			||||||
 | 
						stacked: false,
 | 
				
			||||||
 | 
						bar: false,
 | 
				
			||||||
 | 
						aspectRatio: null,
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
 | 
					const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import MkChartLegend from './MkChartLegend.vue';
 | 
				
			||||||
 | 
					void MkChartLegend;
 | 
				
			||||||
@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import MkChartTooltip from './MkChartTooltip.vue';
 | 
				
			||||||
 | 
					void MkChartTooltip;
 | 
				
			||||||
@@ -0,0 +1,79 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { HttpResponse, http } from 'msw';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { expect, within } from '@storybook/test';
 | 
				
			||||||
 | 
					import { commonHandlers } from '../../.storybook/mocks.js';
 | 
				
			||||||
 | 
					import MkClickerGame from './MkClickerGame.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function sleep(ms: number) {
 | 
				
			||||||
 | 
						return new Promise(resolve => setTimeout(resolve, ms));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkClickerGame,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkClickerGame v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						async play({ canvasElement }) {
 | 
				
			||||||
 | 
							await sleep(1000);
 | 
				
			||||||
 | 
							const canvas = within(canvasElement);
 | 
				
			||||||
 | 
							const count = canvas.getByTestId('count');
 | 
				
			||||||
 | 
							// NOTE: flaky なので N/A も通しておく
 | 
				
			||||||
 | 
							await expect(count).toHaveTextContent(/^(0|N\/A)$/);
 | 
				
			||||||
 | 
							// FIXME: flaky
 | 
				
			||||||
 | 
							// const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
 | 
				
			||||||
 | 
							// await userEvent.click(buttonElement);
 | 
				
			||||||
 | 
							// await expect(count).toHaveTextContent('1');
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
							msw: {
 | 
				
			||||||
 | 
								handlers: [
 | 
				
			||||||
 | 
									...commonHandlers,
 | 
				
			||||||
 | 
									http.post('/api/i/registry/get', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/i/registry/get')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json({
 | 
				
			||||||
 | 
											error: {
 | 
				
			||||||
 | 
												message: 'No such key.',
 | 
				
			||||||
 | 
												code: 'NO_SUCH_KEY',
 | 
				
			||||||
 | 
												id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a',
 | 
				
			||||||
 | 
											},
 | 
				
			||||||
 | 
										}, {
 | 
				
			||||||
 | 
											status: 400,
 | 
				
			||||||
 | 
										});
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
									http.post('/api/i/registry/set', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/i/registry/set')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json(undefined, { status: 204 });
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
									http.post('/api/i/claim-achievement', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/i/claim-achievement')(await request.json());
 | 
				
			||||||
 | 
										return HttpResponse.json(undefined, { status: 204 });
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
								],
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkClickerGame>;
 | 
				
			||||||
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
<div>
 | 
					<div>
 | 
				
			||||||
	<div v-if="game.ready" :class="$style.game">
 | 
						<div v-if="game.ready" :class="$style.game">
 | 
				
			||||||
		<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
 | 
							<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
 | 
				
			||||||
		<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
 | 
							<div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
 | 
				
			||||||
		<button v-click-anime class="_button" @click="onClick">
 | 
							<button v-click-anime class="_button" @click="onClick">
 | 
				
			||||||
			<img src="/client-assets/cookie.png" :class="$style.img">
 | 
								<img src="/client-assets/cookie.png" :class="$style.img">
 | 
				
			||||||
		</button>
 | 
							</button>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,43 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { clip } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import MkClipPreview from './MkClipPreview.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkClipPreview,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkClipPreview v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							clip: clip(),
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'fullscreen',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						decorators: [
 | 
				
			||||||
 | 
							() => ({
 | 
				
			||||||
 | 
								template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
 | 
				
			||||||
 | 
							}),
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkClipPreview>;
 | 
				
			||||||
@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import MkCode_core from './MkCode.core.vue';
 | 
				
			||||||
 | 
					void MkCode_core;
 | 
				
			||||||
							
								
								
									
										44
									
								
								packages/frontend/src/components/MkCode.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								packages/frontend/src/components/MkCode.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import MkCode from './MkCode.vue';
 | 
				
			||||||
 | 
					const code = `for (let i, 100) {
 | 
				
			||||||
 | 
						<: if (i % 15 == 0) "FizzBuzz"
 | 
				
			||||||
 | 
							elif (i % 3 == 0) "Fizz"
 | 
				
			||||||
 | 
							elif (i % 5 == 0) "Buzz"
 | 
				
			||||||
 | 
							else i
 | 
				
			||||||
 | 
					}`;
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCode,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCode v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							code,
 | 
				
			||||||
 | 
							lang: 'is',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCode>;
 | 
				
			||||||
@@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import MkCodeEditor from './MkCodeEditor.vue';
 | 
				
			||||||
 | 
					const code = `for (let i, 100) {
 | 
				
			||||||
 | 
						<: if (i % 15 == 0) "FizzBuzz"
 | 
				
			||||||
 | 
							elif (i % 3 == 0) "Fizz"
 | 
				
			||||||
 | 
							elif (i % 5 == 0) "Buzz"
 | 
				
			||||||
 | 
							else i
 | 
				
			||||||
 | 
					}`;
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCodeEditor,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								data() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										code,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									events() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											'change': action('change'),
 | 
				
			||||||
 | 
											'keydown': action('keydown'),
 | 
				
			||||||
 | 
											'enter': action('enter'),
 | 
				
			||||||
 | 
											'update:modelValue': action('update:modelValue'),
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							lang: 'aiscript',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'fullscreen',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						decorators: [
 | 
				
			||||||
 | 
							() => ({
 | 
				
			||||||
 | 
								template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>',
 | 
				
			||||||
 | 
							}),
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCodeEditor>;
 | 
				
			||||||
@@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import MkCodeInline from './MkCodeInline.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCodeInline,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCodeInline v-bind="props"/>',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							code: '<: "Hello, world!"',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCodeInline>;
 | 
				
			||||||
@@ -0,0 +1,50 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import MkColorInput from './MkColorInput.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkColorInput,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								data() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										color: '#cccccc',
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									events() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											'update:modelValue': action('update:modelValue'),
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'fullscreen',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						decorators: [
 | 
				
			||||||
 | 
							() => ({
 | 
				
			||||||
 | 
								template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>',
 | 
				
			||||||
 | 
							}),
 | 
				
			||||||
 | 
						],
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkColorInput>;
 | 
				
			||||||
@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import MkContainer from './MkContainer.vue';
 | 
				
			||||||
 | 
					void MkContainer;
 | 
				
			||||||
@@ -0,0 +1,58 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { userEvent, within } from '@storybook/test';
 | 
				
			||||||
 | 
					import MkContextMenu from './MkContextMenu.vue';
 | 
				
			||||||
 | 
					import * as os from '@/os.js';
 | 
				
			||||||
 | 
					export const Empty = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								methods: {
 | 
				
			||||||
 | 
									onContextmenu(ev: MouseEvent) {
 | 
				
			||||||
 | 
										os.contextMenu(args.items, ev);
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							items: [],
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						async play({ canvasElement }) {
 | 
				
			||||||
 | 
							const canvas = within(canvasElement);
 | 
				
			||||||
 | 
							const target = canvas.getByText('Right Click Here');
 | 
				
			||||||
 | 
							await userEvent.pointer({ keys: '[MouseRight>]', target });
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkContextMenu>;
 | 
				
			||||||
 | 
					export const SomeTabs = {
 | 
				
			||||||
 | 
						...Empty,
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							items: [
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									text: 'Home',
 | 
				
			||||||
 | 
									icon: 'ti ti-home',
 | 
				
			||||||
 | 
									action() {},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
							],
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkContextMenu>;
 | 
				
			||||||
@@ -0,0 +1,75 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { HttpResponse, http } from 'msw';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { file } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import { commonHandlers } from '../../.storybook/mocks.js';
 | 
				
			||||||
 | 
					import MkCropperDialog from './MkCropperDialog.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCropperDialog,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									events() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											'ok': action('ok'),
 | 
				
			||||||
 | 
											'cancel': action('cancel'),
 | 
				
			||||||
 | 
											'closed': action('closed'),
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCropperDialog v-bind="props" v-on="events" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							file: file(),
 | 
				
			||||||
 | 
							aspectRatio: NaN,
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							chromatic: {
 | 
				
			||||||
 | 
								// NOTE: ロードが終わるまで待つ
 | 
				
			||||||
 | 
								delay: 3000,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
							msw: {
 | 
				
			||||||
 | 
								handlers: [
 | 
				
			||||||
 | 
									...commonHandlers,
 | 
				
			||||||
 | 
									http.get('/proxy/image.webp', async ({ request }) => {
 | 
				
			||||||
 | 
										const url = new URL(request.url).searchParams.get('url');
 | 
				
			||||||
 | 
										if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
 | 
				
			||||||
 | 
											const image = await (await fetch('client-assets/fedi.jpg')).blob();
 | 
				
			||||||
 | 
											return new HttpResponse(image, {
 | 
				
			||||||
 | 
												headers: {
 | 
				
			||||||
 | 
													'Content-Type': 'image/jpeg',
 | 
				
			||||||
 | 
												},
 | 
				
			||||||
 | 
											});
 | 
				
			||||||
 | 
										} else {
 | 
				
			||||||
 | 
											return new HttpResponse(null, { status: 404 });
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
									http.post('/api/drive/files/create', async ({ request }) => {
 | 
				
			||||||
 | 
										action('POST /api/drive/files/create')(await request.formData());
 | 
				
			||||||
 | 
										return HttpResponse.json(file());
 | 
				
			||||||
 | 
									}),
 | 
				
			||||||
 | 
								],
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCropperDialog>;
 | 
				
			||||||
@@ -0,0 +1,38 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { emojiDetailed } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCustomEmojiDetailedDialog,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCustomEmojiDetailedDialog v-bind="props" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							emoji: emojiDetailed(),
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>;
 | 
				
			||||||
							
								
								
									
										89
									
								
								packages/frontend/src/components/MkCwButton.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								packages/frontend/src/components/MkCwButton.stories.impl.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,89 @@
 | 
				
			|||||||
 | 
					/*
 | 
				
			||||||
 | 
					 * SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					 * SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/explicit-function-return-type */
 | 
				
			||||||
 | 
					/* eslint-disable import/no-default-export */
 | 
				
			||||||
 | 
					import { StoryObj } from '@storybook/vue3';
 | 
				
			||||||
 | 
					import { action } from '@storybook/addon-actions';
 | 
				
			||||||
 | 
					import { expect, userEvent, within } from '@storybook/test';
 | 
				
			||||||
 | 
					import { file } from '../../.storybook/fakes.js';
 | 
				
			||||||
 | 
					import MkCwButton from './MkCwButton.vue';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					import { semaphore } from '@/scripts/test-utils.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function sleep(ms: number) {
 | 
				
			||||||
 | 
						return new Promise(resolve => setTimeout(resolve, ms));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const s = semaphore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Default = {
 | 
				
			||||||
 | 
						render(args) {
 | 
				
			||||||
 | 
							return {
 | 
				
			||||||
 | 
								components: {
 | 
				
			||||||
 | 
									MkCwButton,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								data() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										showContent: false,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								setup() {
 | 
				
			||||||
 | 
									return {
 | 
				
			||||||
 | 
										args,
 | 
				
			||||||
 | 
									};
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								computed: {
 | 
				
			||||||
 | 
									props() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											...this.args,
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									events() {
 | 
				
			||||||
 | 
										return {
 | 
				
			||||||
 | 
											'update:modelValue': action('update:modelValue'),
 | 
				
			||||||
 | 
										};
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />',
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							text: 'Some CW content',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						async play({ canvasElement }) {
 | 
				
			||||||
 | 
							await s.acquire();
 | 
				
			||||||
 | 
							await sleep(1000);
 | 
				
			||||||
 | 
							const canvas = within(canvasElement);
 | 
				
			||||||
 | 
							const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
 | 
				
			||||||
 | 
							await userEvent.click(buttonElement);
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
 | 
				
			||||||
 | 
							await userEvent.click(buttonElement);
 | 
				
			||||||
 | 
							s.release();
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						parameters: {
 | 
				
			||||||
 | 
							chromatic: {
 | 
				
			||||||
 | 
								// NOTE: テストが終わるまで待つ
 | 
				
			||||||
 | 
								delay: 5000,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
							layout: 'centered',
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCwButton>;
 | 
				
			||||||
 | 
					export const IncludesTextAndDriveFile = {
 | 
				
			||||||
 | 
						...Default,
 | 
				
			||||||
 | 
						args: {
 | 
				
			||||||
 | 
							text: 'Some CW content',
 | 
				
			||||||
 | 
							files: [file()],
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						async play({ canvasElement }) {
 | 
				
			||||||
 | 
							const canvas = within(canvasElement);
 | 
				
			||||||
 | 
							const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(' / ');
 | 
				
			||||||
 | 
							await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 }));
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
					} satisfies StoryObj<typeof MkCwButton>;
 | 
				
			||||||
							
								
								
									
										32
									
								
								packages/frontend/src/components/MkDivider.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								packages/frontend/src/components/MkDivider.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					<!--
 | 
				
			||||||
 | 
					SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					<div
 | 
				
			||||||
 | 
						class="default" :style="[
 | 
				
			||||||
 | 
							marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {},
 | 
				
			||||||
 | 
							marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {},
 | 
				
			||||||
 | 
							borderStyle ? { borderStyle: borderStyle } : {},
 | 
				
			||||||
 | 
							borderWidth ? { borderWidth: borderWidth } : {},
 | 
				
			||||||
 | 
							borderColor ? { borderColor: borderColor } : {},
 | 
				
			||||||
 | 
						]"
 | 
				
			||||||
 | 
					/>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					defineProps<{
 | 
				
			||||||
 | 
						marginTopBottom?: string;
 | 
				
			||||||
 | 
						marginLeftRight?: string;
 | 
				
			||||||
 | 
						borderStyle?: string;
 | 
				
			||||||
 | 
						borderWidth?: string;
 | 
				
			||||||
 | 
						borderColor?: string;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped lang="scss">
 | 
				
			||||||
 | 
					.default {
 | 
				
			||||||
 | 
						border-top: solid 0.5px var(--divider);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
		@keydown.enter="toggle"
 | 
							@keydown.enter="toggle"
 | 
				
			||||||
	>
 | 
						>
 | 
				
			||||||
	<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
 | 
						<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
 | 
				
			||||||
	<span :class="$style.body">
 | 
						<span v-if="!noBody" :class="$style.body">
 | 
				
			||||||
		<!-- TODO: 無名slotの方は廃止 -->
 | 
							<!-- TODO: 無名slotの方は廃止 -->
 | 
				
			||||||
		<span :class="$style.label">
 | 
							<span :class="$style.label">
 | 
				
			||||||
			<span @click="toggle">
 | 
								<span @click="toggle">
 | 
				
			||||||
@@ -34,16 +34,19 @@ const props = defineProps<{
 | 
				
			|||||||
	modelValue: boolean | Ref<boolean>;
 | 
						modelValue: boolean | Ref<boolean>;
 | 
				
			||||||
	disabled?: boolean;
 | 
						disabled?: boolean;
 | 
				
			||||||
	helpText?: string;
 | 
						helpText?: string;
 | 
				
			||||||
 | 
						noBody?: boolean;
 | 
				
			||||||
}>();
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const emit = defineEmits<{
 | 
					const emit = defineEmits<{
 | 
				
			||||||
	(ev: 'update:modelValue', v: boolean): void;
 | 
						(ev: 'update:modelValue', v: boolean): void;
 | 
				
			||||||
 | 
						(ev: 'change', v: boolean): void;
 | 
				
			||||||
}>();
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const checked = toRefs(props).modelValue;
 | 
					const checked = toRefs(props).modelValue;
 | 
				
			||||||
const toggle = () => {
 | 
					const toggle = () => {
 | 
				
			||||||
	if (props.disabled) return;
 | 
						if (props.disabled) return;
 | 
				
			||||||
	emit('update:modelValue', !checked.value);
 | 
						emit('update:modelValue', !checked.value);
 | 
				
			||||||
 | 
						emit('change', !checked.value);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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<MkSystemWebhookResult | null> {
 | 
				
			||||||
 | 
						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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										217
									
								
								packages/frontend/src/components/MkSystemWebhookEditor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								packages/frontend/src/components/MkSystemWebhookEditor.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,217 @@
 | 
				
			|||||||
 | 
					<!--
 | 
				
			||||||
 | 
					SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					<MkModalWindow
 | 
				
			||||||
 | 
						:width="450"
 | 
				
			||||||
 | 
						:height="590"
 | 
				
			||||||
 | 
						:canClose="true"
 | 
				
			||||||
 | 
						:withOkButton="false"
 | 
				
			||||||
 | 
						:okButtonDisabled="false"
 | 
				
			||||||
 | 
						@click="onCancelClicked"
 | 
				
			||||||
 | 
						@close="onCancelClicked"
 | 
				
			||||||
 | 
						@closed="onCancelClicked"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
						<template #header>
 | 
				
			||||||
 | 
							{{ mode === 'create' ? i18n.ts._webhookSettings.createWebhook : i18n.ts._webhookSettings.modifyWebhook }}
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
						<MkSpacer :marginMin="20" :marginMax="28">
 | 
				
			||||||
 | 
							<MkLoading v-if="loading !== 0"/>
 | 
				
			||||||
 | 
							<div v-else :class="$style.root" class="_gaps_m">
 | 
				
			||||||
 | 
								<MkInput v-model="title">
 | 
				
			||||||
 | 
									<template #label>{{ i18n.ts._webhookSettings.name }}</template>
 | 
				
			||||||
 | 
								</MkInput>
 | 
				
			||||||
 | 
								<MkInput v-model="url">
 | 
				
			||||||
 | 
									<template #label>URL</template>
 | 
				
			||||||
 | 
								</MkInput>
 | 
				
			||||||
 | 
								<MkInput v-model="secret">
 | 
				
			||||||
 | 
									<template #label>{{ i18n.ts._webhookSettings.secret }}</template>
 | 
				
			||||||
 | 
								</MkInput>
 | 
				
			||||||
 | 
								<MkFolder :defaultOpen="true">
 | 
				
			||||||
 | 
									<template #label>{{ i18n.ts._webhookSettings.events }}</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<div class="_gaps_s">
 | 
				
			||||||
 | 
										<MkSwitch v-model="events.abuseReport" :disabled="disabledEvents.abuseReport">
 | 
				
			||||||
 | 
											<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReport }}</template>
 | 
				
			||||||
 | 
										</MkSwitch>
 | 
				
			||||||
 | 
										<MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
 | 
				
			||||||
 | 
											<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
 | 
				
			||||||
 | 
										</MkSwitch>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
								</MkFolder>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								<MkSwitch v-model="isActive">
 | 
				
			||||||
 | 
									<template #label>{{ i18n.ts.enable }}</template>
 | 
				
			||||||
 | 
								</MkSwitch>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								<div :class="$style.footer" class="_buttonsCenter">
 | 
				
			||||||
 | 
									<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked">
 | 
				
			||||||
 | 
										<i class="ti ti-check"></i>
 | 
				
			||||||
 | 
										{{ i18n.ts.ok }}
 | 
				
			||||||
 | 
									</MkButton>
 | 
				
			||||||
 | 
									<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</MkSpacer>
 | 
				
			||||||
 | 
					</MkModalWindow>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { computed, onMounted, ref, toRefs } from 'vue';
 | 
				
			||||||
 | 
					import FormSection from '@/components/form/section.vue';
 | 
				
			||||||
 | 
					import MkInput from '@/components/MkInput.vue';
 | 
				
			||||||
 | 
					import MkSwitch from '@/components/MkSwitch.vue';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
						MkSystemWebhookEditorProps,
 | 
				
			||||||
 | 
						MkSystemWebhookResult,
 | 
				
			||||||
 | 
						SystemWebhookEventType,
 | 
				
			||||||
 | 
					} from '@/components/MkSystemWebhookEditor.impl.js';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					import MkButton from '@/components/MkButton.vue';
 | 
				
			||||||
 | 
					import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
				
			||||||
 | 
					import MkModalWindow from '@/components/MkModalWindow.vue';
 | 
				
			||||||
 | 
					import MkFolder from '@/components/MkFolder.vue';
 | 
				
			||||||
 | 
					import * as os from '@/os.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type EventType = {
 | 
				
			||||||
 | 
						abuseReport: boolean;
 | 
				
			||||||
 | 
						abuseReportResolved: boolean;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
						(ev: 'submitted', result: MkSystemWebhookResult): void;
 | 
				
			||||||
 | 
						(ev: 'closed'): void;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<MkSystemWebhookEditorProps>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { mode, id, requiredEvents } = toRefs(props);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loading = ref<number>(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const title = ref<string>('');
 | 
				
			||||||
 | 
					const url = ref<string>('');
 | 
				
			||||||
 | 
					const secret = ref<string>('');
 | 
				
			||||||
 | 
					const events = ref<EventType>({
 | 
				
			||||||
 | 
						abuseReport: true,
 | 
				
			||||||
 | 
						abuseReportResolved: true,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					const isActive = ref<boolean>(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const disabledEvents = ref<EventType>({
 | 
				
			||||||
 | 
						abuseReport: false,
 | 
				
			||||||
 | 
						abuseReportResolved: false,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const disableSubmitButton = computed(() => {
 | 
				
			||||||
 | 
						if (!title.value) {
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (!url.value) {
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (!secret.value) {
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return false;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onSubmitClicked() {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							const params = {
 | 
				
			||||||
 | 
								isActive: isActive.value,
 | 
				
			||||||
 | 
								name: title.value,
 | 
				
			||||||
 | 
								url: url.value,
 | 
				
			||||||
 | 
								secret: secret.value,
 | 
				
			||||||
 | 
								on: Object.keys(events.value).filter(ev => events.value[ev as keyof EventType]) as SystemWebhookEventType[],
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								switch (mode.value) {
 | 
				
			||||||
 | 
									case 'create': {
 | 
				
			||||||
 | 
										const result = await misskeyApi('admin/system-webhook/create', params);
 | 
				
			||||||
 | 
										emit('submitted', result);
 | 
				
			||||||
 | 
										break;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									case 'edit': {
 | 
				
			||||||
 | 
										// eslint-disable-next-line
 | 
				
			||||||
 | 
										const result = await misskeyApi('admin/system-webhook/update', { id: id.value!, ...params });
 | 
				
			||||||
 | 
										emit('submitted', result);
 | 
				
			||||||
 | 
										break;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// eslint-disable-next-line
 | 
				
			||||||
 | 
							} catch (ex: any) {
 | 
				
			||||||
 | 
								const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
 | 
				
			||||||
 | 
								await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
 | 
				
			||||||
 | 
								emit('closed');
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function onCancelClicked() {
 | 
				
			||||||
 | 
						emit('closed');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
 | 
				
			||||||
 | 
						loading.value++;
 | 
				
			||||||
 | 
						try {
 | 
				
			||||||
 | 
							return await fn();
 | 
				
			||||||
 | 
						} finally {
 | 
				
			||||||
 | 
							loading.value--;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							switch (mode.value) {
 | 
				
			||||||
 | 
								case 'edit': {
 | 
				
			||||||
 | 
									if (!id.value) {
 | 
				
			||||||
 | 
										throw new Error('id is required');
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									try {
 | 
				
			||||||
 | 
										const res = await misskeyApi('admin/system-webhook/show', { id: id.value });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										title.value = res.name;
 | 
				
			||||||
 | 
										url.value = res.url;
 | 
				
			||||||
 | 
										secret.value = res.secret;
 | 
				
			||||||
 | 
										isActive.value = res.isActive;
 | 
				
			||||||
 | 
										for (const ev of Object.keys(events.value)) {
 | 
				
			||||||
 | 
											events.value[ev] = res.on.includes(ev as SystemWebhookEventType);
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										// eslint-disable-next-line
 | 
				
			||||||
 | 
									} catch (ex: any) {
 | 
				
			||||||
 | 
										const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
 | 
				
			||||||
 | 
										await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
 | 
				
			||||||
 | 
										emit('closed');
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for (const ev of requiredEvents.value ?? []) {
 | 
				
			||||||
 | 
								disabledEvents.value[ev] = true;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style module lang="scss">
 | 
				
			||||||
 | 
					.root {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: column;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: stretch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.footer {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: flex-end;
 | 
				
			||||||
 | 
						margin-top: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -0,0 +1,307 @@
 | 
				
			|||||||
 | 
					<!--
 | 
				
			||||||
 | 
					SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					<MkModalWindow
 | 
				
			||||||
 | 
						ref="dialog"
 | 
				
			||||||
 | 
						:width="400"
 | 
				
			||||||
 | 
						:height="490"
 | 
				
			||||||
 | 
						:withOkButton="false"
 | 
				
			||||||
 | 
						:okButtonDisabled="false"
 | 
				
			||||||
 | 
						@close="onCancelClicked"
 | 
				
			||||||
 | 
						@closed="emit('closed')"
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
						<template #header>
 | 
				
			||||||
 | 
							{{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
						<div v-if="loading === 0">
 | 
				
			||||||
 | 
							<MkSpacer :marginMin="20" :marginMax="28">
 | 
				
			||||||
 | 
								<div :class="$style.root" class="_gaps_m">
 | 
				
			||||||
 | 
									<MkInput v-model="title">
 | 
				
			||||||
 | 
										<template #label>{{ i18n.ts.title }}</template>
 | 
				
			||||||
 | 
									</MkInput>
 | 
				
			||||||
 | 
									<MkSelect v-model="method">
 | 
				
			||||||
 | 
										<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
 | 
				
			||||||
 | 
										<option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
 | 
				
			||||||
 | 
										<option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
 | 
				
			||||||
 | 
										<template #caption>
 | 
				
			||||||
 | 
											{{ methodCaption }}
 | 
				
			||||||
 | 
										</template>
 | 
				
			||||||
 | 
									</MkSelect>
 | 
				
			||||||
 | 
									<div>
 | 
				
			||||||
 | 
										<MkSelect v-if="method === 'email'" v-model="userId">
 | 
				
			||||||
 | 
											<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
 | 
				
			||||||
 | 
											<option v-for="user in moderators" :key="user.id" :value="user.id">
 | 
				
			||||||
 | 
												{{ user.name ? `${user.name}(${user.username})` : user.username }}
 | 
				
			||||||
 | 
											</option>
 | 
				
			||||||
 | 
										</MkSelect>
 | 
				
			||||||
 | 
										<div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
 | 
				
			||||||
 | 
											<MkSelect v-model="systemWebhookId" style="flex: 1">
 | 
				
			||||||
 | 
												<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
 | 
				
			||||||
 | 
												<option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
 | 
				
			||||||
 | 
													{{ webhook.name }}
 | 
				
			||||||
 | 
												</option>
 | 
				
			||||||
 | 
											</MkSelect>
 | 
				
			||||||
 | 
											<MkButton rounded @click="onEditSystemWebhookClicked">
 | 
				
			||||||
 | 
												<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
 | 
				
			||||||
 | 
												<span v-else class="ti ti-settings" style="line-height: normal"/>
 | 
				
			||||||
 | 
											</MkButton>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
									</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<MkDivider/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									<MkSwitch v-model="isActive">
 | 
				
			||||||
 | 
										<template #label>{{ i18n.ts.enable }}</template>
 | 
				
			||||||
 | 
									</MkSwitch>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</MkSpacer>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							<div :class="$style.footer" class="_buttonsCenter">
 | 
				
			||||||
 | 
								<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
 | 
				
			||||||
 | 
								<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
						<div v-else>
 | 
				
			||||||
 | 
							<MkLoading/>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</MkModalWindow>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script lang="ts" setup>
 | 
				
			||||||
 | 
					import { computed, onMounted, ref, toRefs } from 'vue';
 | 
				
			||||||
 | 
					import { entities } from 'misskey-js';
 | 
				
			||||||
 | 
					import MkButton from '@/components/MkButton.vue';
 | 
				
			||||||
 | 
					import MkModalWindow from '@/components/MkModalWindow.vue';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					import MkInput from '@/components/MkInput.vue';
 | 
				
			||||||
 | 
					import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
				
			||||||
 | 
					import MkSelect from '@/components/MkSelect.vue';
 | 
				
			||||||
 | 
					import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
 | 
				
			||||||
 | 
					import MkSwitch from '@/components/MkSwitch.vue';
 | 
				
			||||||
 | 
					import MkDivider from '@/components/MkDivider.vue';
 | 
				
			||||||
 | 
					import * as os from '@/os.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type NotificationRecipientMethod = 'email' | 'webhook';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
						(ev: 'submitted'): void;
 | 
				
			||||||
 | 
						(ev: 'closed'): void;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<{
 | 
				
			||||||
 | 
						mode: 'create' | 'edit';
 | 
				
			||||||
 | 
						id?: string;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { mode, id } = toRefs(props);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loading = ref<number>(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const title = ref<string>('');
 | 
				
			||||||
 | 
					const method = ref<NotificationRecipientMethod>('email');
 | 
				
			||||||
 | 
					const userId = ref<string | null>(null);
 | 
				
			||||||
 | 
					const systemWebhookId = ref<string | null>(null);
 | 
				
			||||||
 | 
					const isActive = ref<boolean>(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const moderators = ref<entities.User[]>([]);
 | 
				
			||||||
 | 
					const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const methodCaption = computed(() => {
 | 
				
			||||||
 | 
						switch (method.value) {
 | 
				
			||||||
 | 
							case 'email': {
 | 
				
			||||||
 | 
								return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							case 'webhook': {
 | 
				
			||||||
 | 
								return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							default: {
 | 
				
			||||||
 | 
								return '';
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const disableSubmitButton = computed(() => {
 | 
				
			||||||
 | 
						if (!title.value) {
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						switch (method.value) {
 | 
				
			||||||
 | 
							case 'email': {
 | 
				
			||||||
 | 
								return userId.value === null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							case 'webhook': {
 | 
				
			||||||
 | 
								return systemWebhookId.value === null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							default: {
 | 
				
			||||||
 | 
								return true;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onSubmitClicked() {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							const _userId = (method.value === 'email') ? userId.value : null;
 | 
				
			||||||
 | 
							const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null;
 | 
				
			||||||
 | 
							const params = {
 | 
				
			||||||
 | 
								isActive: isActive.value,
 | 
				
			||||||
 | 
								name: title.value,
 | 
				
			||||||
 | 
								method: method.value,
 | 
				
			||||||
 | 
								userId: _userId ?? undefined,
 | 
				
			||||||
 | 
								systemWebhookId: _systemWebhookId ?? undefined,
 | 
				
			||||||
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							try {
 | 
				
			||||||
 | 
								switch (mode.value) {
 | 
				
			||||||
 | 
									case 'create': {
 | 
				
			||||||
 | 
										await misskeyApi('admin/abuse-report/notification-recipient/create', params);
 | 
				
			||||||
 | 
										break;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									case 'edit': {
 | 
				
			||||||
 | 
										// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 | 
				
			||||||
 | 
										await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params });
 | 
				
			||||||
 | 
										break;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								emit('submitted');
 | 
				
			||||||
 | 
								// eslint-disable-next-line
 | 
				
			||||||
 | 
							} catch (ex: any) {
 | 
				
			||||||
 | 
								const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
 | 
				
			||||||
 | 
								await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
 | 
				
			||||||
 | 
								emit('closed');
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function onCancelClicked() {
 | 
				
			||||||
 | 
						emit('closed');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onEditSystemWebhookClicked() {
 | 
				
			||||||
 | 
						let result: MkSystemWebhookResult | null;
 | 
				
			||||||
 | 
						if (systemWebhookId.value === null) {
 | 
				
			||||||
 | 
							result = await showSystemWebhookEditorDialog({
 | 
				
			||||||
 | 
								mode: 'create',
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							result = await showSystemWebhookEditorDialog({
 | 
				
			||||||
 | 
								mode: 'edit',
 | 
				
			||||||
 | 
								id: systemWebhookId.value,
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if (!result) {
 | 
				
			||||||
 | 
							return;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						await fetchSystemWebhooks();
 | 
				
			||||||
 | 
						systemWebhookId.value = result.id ?? null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function fetchSystemWebhooks() {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							systemWebhooks.value = [
 | 
				
			||||||
 | 
								{ id: null, name: i18n.ts.createNew },
 | 
				
			||||||
 | 
								...await misskeyApi('admin/system-webhook/list', { }),
 | 
				
			||||||
 | 
							];
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function fetchModerators() {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							const users = Array.of<entities.User>();
 | 
				
			||||||
 | 
							for (; ;) {
 | 
				
			||||||
 | 
								const res = await misskeyApi('admin/show-users', {
 | 
				
			||||||
 | 
									limit: 100,
 | 
				
			||||||
 | 
									state: 'adminOrModerator',
 | 
				
			||||||
 | 
									origin: 'local',
 | 
				
			||||||
 | 
									offset: users.length,
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if (res.length === 0) {
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								users.push(...res);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							moderators.value = users;
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
 | 
				
			||||||
 | 
						loading.value++;
 | 
				
			||||||
 | 
						try {
 | 
				
			||||||
 | 
							return await fn();
 | 
				
			||||||
 | 
						} finally {
 | 
				
			||||||
 | 
							loading.value--;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
						await loadingScope(async () => {
 | 
				
			||||||
 | 
							await fetchModerators();
 | 
				
			||||||
 | 
							await fetchSystemWebhooks();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if (mode.value === 'edit') {
 | 
				
			||||||
 | 
								if (!id.value) {
 | 
				
			||||||
 | 
									throw new Error('id is required');
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								try {
 | 
				
			||||||
 | 
									const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									title.value = res.name;
 | 
				
			||||||
 | 
									method.value = res.method;
 | 
				
			||||||
 | 
									userId.value = res.userId ?? null;
 | 
				
			||||||
 | 
									systemWebhookId.value = res.systemWebhookId ?? null;
 | 
				
			||||||
 | 
									isActive.value = res.isActive;
 | 
				
			||||||
 | 
									// eslint-disable-next-line
 | 
				
			||||||
 | 
								} catch (ex: any) {
 | 
				
			||||||
 | 
									const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
 | 
				
			||||||
 | 
									await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
 | 
				
			||||||
 | 
									emit('closed');
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								userId.value = moderators.value[0]?.id ?? null;
 | 
				
			||||||
 | 
								systemWebhookId.value = systemWebhooks.value[0]?.id ?? null;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style lang="scss" module>
 | 
				
			||||||
 | 
					.root {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: column;
 | 
				
			||||||
 | 
						justify-content: flex-start;
 | 
				
			||||||
 | 
						align-items: stretch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.footer {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: flex-end;
 | 
				
			||||||
 | 
						margin-top: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.systemWebhook {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: stretch;
 | 
				
			||||||
 | 
						align-items: flex-end;
 | 
				
			||||||
 | 
						gap: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						button {
 | 
				
			||||||
 | 
							width: 2.5em;
 | 
				
			||||||
 | 
							height: 2.5em;
 | 
				
			||||||
 | 
							min-width: 2.5em;
 | 
				
			||||||
 | 
							min-height: 2.5em;
 | 
				
			||||||
 | 
							box-sizing: border-box;
 | 
				
			||||||
 | 
							padding: 6px;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -0,0 +1,114 @@
 | 
				
			|||||||
 | 
					<!--
 | 
				
			||||||
 | 
					SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					<div :class="$style.root" class="_panel _gaps_s">
 | 
				
			||||||
 | 
						<div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
 | 
				
			||||||
 | 
						<div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
 | 
				
			||||||
 | 
						<div :class="$style.rightDivider" style="flex: 1">
 | 
				
			||||||
 | 
							<div v-if="method === 'email' && user">
 | 
				
			||||||
 | 
								{{
 | 
				
			||||||
 | 
									`${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username)
 | 
				
			||||||
 | 
								}}
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
							<div v-if="method === 'webhook' && systemWebhook">
 | 
				
			||||||
 | 
								{{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }}
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
						<div :class="$style.recipientButtons" style="margin-left: auto">
 | 
				
			||||||
 | 
							<button :class="$style.recipientButton" @click="onEditButtonClicked()">
 | 
				
			||||||
 | 
								<span class="ti ti-settings"/>
 | 
				
			||||||
 | 
							</button>
 | 
				
			||||||
 | 
							<button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
 | 
				
			||||||
 | 
								<span class="ti ti-trash"/>
 | 
				
			||||||
 | 
							</button>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { entities } from 'misskey-js';
 | 
				
			||||||
 | 
					import { computed, toRefs } from 'vue';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const emit = defineEmits<{
 | 
				
			||||||
 | 
						(ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void;
 | 
				
			||||||
 | 
						(ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const props = defineProps<{
 | 
				
			||||||
 | 
						entity: entities.AbuseReportNotificationRecipient;
 | 
				
			||||||
 | 
					}>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const { entity } = toRefs(props);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const method = computed(() => entity.value.method);
 | 
				
			||||||
 | 
					const user = computed(() => entity.value.user);
 | 
				
			||||||
 | 
					const systemWebhook = computed(() => entity.value.systemWebhook);
 | 
				
			||||||
 | 
					const methodIcon = computed(() => {
 | 
				
			||||||
 | 
						switch (entity.value.method) {
 | 
				
			||||||
 | 
							case 'email':
 | 
				
			||||||
 | 
								return 'ti-mail';
 | 
				
			||||||
 | 
							case 'webhook':
 | 
				
			||||||
 | 
								return 'ti-webhook';
 | 
				
			||||||
 | 
							default:
 | 
				
			||||||
 | 
								return 'ti-help';
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					const methodName = computed(() => {
 | 
				
			||||||
 | 
						switch (entity.value.method) {
 | 
				
			||||||
 | 
							case 'email':
 | 
				
			||||||
 | 
								return i18n.ts._abuseReport._notificationRecipient._recipientType.mail;
 | 
				
			||||||
 | 
							case 'webhook':
 | 
				
			||||||
 | 
								return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook;
 | 
				
			||||||
 | 
							default:
 | 
				
			||||||
 | 
								return '不明';
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function onEditButtonClicked() {
 | 
				
			||||||
 | 
						emit('edit', entity.value.id);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function onDeleteButtonClicked() {
 | 
				
			||||||
 | 
						emit('delete', entity.value.id);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style module lang="scss">
 | 
				
			||||||
 | 
					.root {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: center;
 | 
				
			||||||
 | 
						padding: 4px 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.rightDivider {
 | 
				
			||||||
 | 
						border-right: 0.5px solid var(--divider);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.recipientButtons {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: center;
 | 
				
			||||||
 | 
						margin-right: -4;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.recipientButton {
 | 
				
			||||||
 | 
						background-color: transparent;
 | 
				
			||||||
 | 
						border: none;
 | 
				
			||||||
 | 
						border-radius: 9999px;
 | 
				
			||||||
 | 
						box-sizing: border-box;
 | 
				
			||||||
 | 
						margin-top: -2px;
 | 
				
			||||||
 | 
						margin-bottom: -2px;
 | 
				
			||||||
 | 
						padding: 8px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						&:hover {
 | 
				
			||||||
 | 
							background-color: var(--buttonBg);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -0,0 +1,176 @@
 | 
				
			|||||||
 | 
					<!--
 | 
				
			||||||
 | 
					SPDX-FileCopyrightText: syuilo and misskey-project
 | 
				
			||||||
 | 
					SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<template>
 | 
				
			||||||
 | 
					<MkStickyContainer>
 | 
				
			||||||
 | 
						<template #header>
 | 
				
			||||||
 | 
							<XHeader :actions="headerActions" :tabs="headerTabs"/>
 | 
				
			||||||
 | 
						</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						<MkSpacer :contentMax="900">
 | 
				
			||||||
 | 
							<div :class="$style.root" class="_gaps_m">
 | 
				
			||||||
 | 
								<div :class="$style.addButton">
 | 
				
			||||||
 | 
									<MkButton primary @click="onAddButtonClicked">
 | 
				
			||||||
 | 
										<span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
 | 
				
			||||||
 | 
									</MkButton>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
								<div :class="$style.subMenus" class="_gaps_s">
 | 
				
			||||||
 | 
									<MkSelect v-model="filterMethod" style="flex: 1">
 | 
				
			||||||
 | 
										<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
 | 
				
			||||||
 | 
										<option :value="null">-</option>
 | 
				
			||||||
 | 
										<option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
 | 
				
			||||||
 | 
										<option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
 | 
				
			||||||
 | 
									</MkSelect>
 | 
				
			||||||
 | 
									<MkInput v-model="filterText" type="search" style="flex: 1">
 | 
				
			||||||
 | 
										<template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
 | 
				
			||||||
 | 
									</MkInput>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								<MkDivider/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								<div :class="$style.recipients" class="_gaps_s">
 | 
				
			||||||
 | 
									<XRecipient
 | 
				
			||||||
 | 
										v-for="r in filteredRecipients"
 | 
				
			||||||
 | 
										:key="r.id"
 | 
				
			||||||
 | 
										:entity="r"
 | 
				
			||||||
 | 
										@edit="onEditButtonClicked"
 | 
				
			||||||
 | 
										@delete="onDeleteButtonClicked"
 | 
				
			||||||
 | 
									/>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</div>
 | 
				
			||||||
 | 
						</MkSpacer>
 | 
				
			||||||
 | 
					</MkStickyContainer>
 | 
				
			||||||
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<script setup lang="ts">
 | 
				
			||||||
 | 
					import { entities } from 'misskey-js';
 | 
				
			||||||
 | 
					import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
 | 
				
			||||||
 | 
					import XRecipient from './notification-recipient.item.vue';
 | 
				
			||||||
 | 
					import XHeader from '@/pages/admin/_header_.vue';
 | 
				
			||||||
 | 
					import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
				
			||||||
 | 
					import MkInput from '@/components/MkInput.vue';
 | 
				
			||||||
 | 
					import MkSelect from '@/components/MkSelect.vue';
 | 
				
			||||||
 | 
					import MkButton from '@/components/MkButton.vue';
 | 
				
			||||||
 | 
					import * as os from '@/os.js';
 | 
				
			||||||
 | 
					import MkDivider from '@/components/MkDivider.vue';
 | 
				
			||||||
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const filterMethod = ref<string | null>(null);
 | 
				
			||||||
 | 
					const filterText = ref<string>('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const filteredRecipients = computed(() => {
 | 
				
			||||||
 | 
						const method = filterMethod.value;
 | 
				
			||||||
 | 
						const text = filterText.value.trim().length === 0 ? null : filterText.value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return recipients.value.filter(it => {
 | 
				
			||||||
 | 
							if (method ?? text) {
 | 
				
			||||||
 | 
								if (text) {
 | 
				
			||||||
 | 
									const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username];
 | 
				
			||||||
 | 
									if (keywords.filter(k => k?.includes(text)).length !== 0) {
 | 
				
			||||||
 | 
										return true;
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if (method) {
 | 
				
			||||||
 | 
									return it.method.includes(method);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								return false;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return true;
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					const headerActions = computed(() => []);
 | 
				
			||||||
 | 
					const headerTabs = computed(() => []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onAddButtonClicked() {
 | 
				
			||||||
 | 
						await showEditor('create');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onEditButtonClicked(id: string) {
 | 
				
			||||||
 | 
						await showEditor('edit', id);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function onDeleteButtonClicked(id: string) {
 | 
				
			||||||
 | 
						const res = await os.confirm({
 | 
				
			||||||
 | 
							type: 'warning',
 | 
				
			||||||
 | 
							title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm,
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
						if (!res.canceled) {
 | 
				
			||||||
 | 
							await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id });
 | 
				
			||||||
 | 
							await fetchRecipients();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function showEditor(mode: 'create' | 'edit', id?: string) {
 | 
				
			||||||
 | 
						const { dispose, needLoad } = await new Promise<{ dispose: () => void, needLoad: boolean }>(async resolve => {
 | 
				
			||||||
 | 
							const res = await os.popup(
 | 
				
			||||||
 | 
								defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									mode,
 | 
				
			||||||
 | 
									id,
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									submitted: async () => {
 | 
				
			||||||
 | 
										resolve({ dispose: res.dispose, needLoad: true });
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
									closed: () => {
 | 
				
			||||||
 | 
										resolve({ dispose: res.dispose, needLoad: false });
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
 | 
								},
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						dispose();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if (needLoad) {
 | 
				
			||||||
 | 
							await fetchRecipients();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function fetchRecipients() {
 | 
				
			||||||
 | 
						const result = await misskeyApi('admin/abuse-report/notification-recipient/list', {
 | 
				
			||||||
 | 
							method: ['email', 'webhook'],
 | 
				
			||||||
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onMounted(async () => {
 | 
				
			||||||
 | 
						await fetchRecipients();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style module lang="scss">
 | 
				
			||||||
 | 
					.root {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: column;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: stretch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.addButton {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						justify-content: flex-end;
 | 
				
			||||||
 | 
						gap: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.subMenus {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: space-between;
 | 
				
			||||||
 | 
						align-items: flex-end;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.recipients {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: column;
 | 
				
			||||||
 | 
						justify-content: flex-start;
 | 
				
			||||||
 | 
						align-items: stretch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
@@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
<MkStickyContainer>
 | 
					<MkStickyContainer>
 | 
				
			||||||
	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
 | 
						<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
 | 
				
			||||||
	<MkSpacer :contentMax="900">
 | 
						<MkSpacer :contentMax="900">
 | 
				
			||||||
		<div>
 | 
							<div :class="$style.root" class="_gaps">
 | 
				
			||||||
			<div class="reports">
 | 
								<div :class="$style.subMenus" class="_gaps">
 | 
				
			||||||
				<div class="">
 | 
									<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton>
 | 
				
			||||||
					<div class="inputs" style="display: flex;">
 | 
								</div>
 | 
				
			||||||
						<MkSelect v-model="state" style="margin: 0; flex: 1;">
 | 
					
 | 
				
			||||||
							<template #label>{{ i18n.ts.state }}</template>
 | 
								<div :class="$style.inputs" class="_gaps">
 | 
				
			||||||
							<option value="all">{{ i18n.ts.all }}</option>
 | 
									<MkSelect v-model="state" style="margin: 0; flex: 1;">
 | 
				
			||||||
							<option value="unresolved">{{ i18n.ts.unresolved }}</option>
 | 
										<template #label>{{ i18n.ts.state }}</template>
 | 
				
			||||||
							<option value="resolved">{{ i18n.ts.resolved }}</option>
 | 
										<option value="all">{{ i18n.ts.all }}</option>
 | 
				
			||||||
						</MkSelect>
 | 
										<option value="unresolved">{{ i18n.ts.unresolved }}</option>
 | 
				
			||||||
						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
 | 
										<option value="resolved">{{ i18n.ts.resolved }}</option>
 | 
				
			||||||
							<template #label>{{ i18n.ts.reporteeOrigin }}</template>
 | 
									</MkSelect>
 | 
				
			||||||
							<option value="combined">{{ i18n.ts.all }}</option>
 | 
									<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
 | 
				
			||||||
							<option value="local">{{ i18n.ts.local }}</option>
 | 
										<template #label>{{ i18n.ts.reporteeOrigin }}</template>
 | 
				
			||||||
							<option value="remote">{{ i18n.ts.remote }}</option>
 | 
										<option value="combined">{{ i18n.ts.all }}</option>
 | 
				
			||||||
						</MkSelect>
 | 
										<option value="local">{{ i18n.ts.local }}</option>
 | 
				
			||||||
						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
 | 
										<option value="remote">{{ i18n.ts.remote }}</option>
 | 
				
			||||||
							<template #label>{{ i18n.ts.reporterOrigin }}</template>
 | 
									</MkSelect>
 | 
				
			||||||
							<option value="combined">{{ i18n.ts.all }}</option>
 | 
									<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
 | 
				
			||||||
							<option value="local">{{ i18n.ts.local }}</option>
 | 
										<template #label>{{ i18n.ts.reporterOrigin }}</template>
 | 
				
			||||||
							<option value="remote">{{ i18n.ts.remote }}</option>
 | 
										<option value="combined">{{ i18n.ts.all }}</option>
 | 
				
			||||||
						</MkSelect>
 | 
										<option value="local">{{ i18n.ts.local }}</option>
 | 
				
			||||||
					</div>
 | 
										<option value="remote">{{ i18n.ts.remote }}</option>
 | 
				
			||||||
					<!-- TODO
 | 
									</MkSelect>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								<!-- TODO
 | 
				
			||||||
			<div class="inputs" style="display: flex; padding-top: 1.2em;">
 | 
								<div class="inputs" style="display: flex; padding-top: 1.2em;">
 | 
				
			||||||
				<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
 | 
									<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
 | 
				
			||||||
					<span>{{ i18n.ts.username }}</span>
 | 
										<span>{{ i18n.ts.username }}</span>
 | 
				
			||||||
@@ -41,11 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
			</div>
 | 
								</div>
 | 
				
			||||||
			-->
 | 
								-->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
					<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
 | 
								<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
 | 
				
			||||||
						<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
 | 
									<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
 | 
				
			||||||
					</MkPagination>
 | 
								</MkPagination>
 | 
				
			||||||
				</div>
 | 
					 | 
				
			||||||
			</div>
 | 
					 | 
				
			||||||
		</div>
 | 
							</div>
 | 
				
			||||||
	</MkSpacer>
 | 
						</MkSpacer>
 | 
				
			||||||
</MkStickyContainer>
 | 
					</MkStickyContainer>
 | 
				
			||||||
@@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue';
 | 
				
			|||||||
import XAbuseReport from '@/components/MkAbuseReport.vue';
 | 
					import XAbuseReport from '@/components/MkAbuseReport.vue';
 | 
				
			||||||
import { i18n } from '@/i18n.js';
 | 
					import { i18n } from '@/i18n.js';
 | 
				
			||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
					import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
				
			||||||
 | 
					import MkButton from '@/components/MkButton.vue';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const reports = shallowRef<InstanceType<typeof MkPagination>>();
 | 
					const reports = shallowRef<InstanceType<typeof MkPagination>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +82,7 @@ const pagination = {
 | 
				
			|||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function resolved(reportId) {
 | 
					function resolved(reportId) {
 | 
				
			||||||
	reports.value.removeItem(reportId);
 | 
						reports.value?.removeItem(reportId);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const headerActions = computed(() => []);
 | 
					const headerActions = computed(() => []);
 | 
				
			||||||
@@ -92,3 +94,26 @@ definePageMetadata(() => ({
 | 
				
			|||||||
	icon: 'ti ti-exclamation-circle',
 | 
						icon: 'ti ti-exclamation-circle',
 | 
				
			||||||
}));
 | 
					}));
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style module lang="scss">
 | 
				
			||||||
 | 
					.root {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: column;
 | 
				
			||||||
 | 
						justify-content: center;
 | 
				
			||||||
 | 
						align-items: stretch;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.subMenus {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: flex-end;
 | 
				
			||||||
 | 
						align-items: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.inputs {
 | 
				
			||||||
 | 
						display: flex;
 | 
				
			||||||
 | 
						flex-direction: row;
 | 
				
			||||||
 | 
						justify-content: space-between;
 | 
				
			||||||
 | 
						align-items: center;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -214,6 +214,11 @@ const menuDef = computed(() => [{
 | 
				
			|||||||
		text: i18n.ts.externalServices,
 | 
							text: i18n.ts.externalServices,
 | 
				
			||||||
		to: '/admin/external-services',
 | 
							to: '/admin/external-services',
 | 
				
			||||||
		active: currentPage.value?.route.name === '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',
 | 
							icon: 'ti ti-adjustments',
 | 
				
			||||||
		text: i18n.ts.other,
 | 
							text: i18n.ts.other,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,9 +8,35 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
	<template #label>
 | 
						<template #label>
 | 
				
			||||||
		<b
 | 
							<b
 | 
				
			||||||
			:class="{
 | 
								:class="{
 | 
				
			||||||
				[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
 | 
									[$style.logGreen]: [
 | 
				
			||||||
				[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
 | 
										'createRole',
 | 
				
			||||||
				[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
 | 
										'addCustomEmoji',
 | 
				
			||||||
 | 
										'createGlobalAnnouncement',
 | 
				
			||||||
 | 
										'createUserAnnouncement',
 | 
				
			||||||
 | 
										'createAd',
 | 
				
			||||||
 | 
										'createInvitation',
 | 
				
			||||||
 | 
										'createAvatarDecoration',
 | 
				
			||||||
 | 
										'createSystemWebhook',
 | 
				
			||||||
 | 
										'createAbuseReportNotificationRecipient',
 | 
				
			||||||
 | 
									].includes(log.type),
 | 
				
			||||||
 | 
									[$style.logYellow]: [
 | 
				
			||||||
 | 
										'markSensitiveDriveFile',
 | 
				
			||||||
 | 
										'resetPassword'
 | 
				
			||||||
 | 
									].includes(log.type),
 | 
				
			||||||
 | 
									[$style.logRed]: [
 | 
				
			||||||
 | 
										'suspend',
 | 
				
			||||||
 | 
										'deleteRole',
 | 
				
			||||||
 | 
										'suspendRemoteInstance',
 | 
				
			||||||
 | 
										'deleteGlobalAnnouncement',
 | 
				
			||||||
 | 
										'deleteUserAnnouncement',
 | 
				
			||||||
 | 
										'deleteCustomEmoji',
 | 
				
			||||||
 | 
										'deleteNote',
 | 
				
			||||||
 | 
										'deleteDriveFile',
 | 
				
			||||||
 | 
										'deleteAd',
 | 
				
			||||||
 | 
										'deleteAvatarDecoration',
 | 
				
			||||||
 | 
										'deleteSystemWebhook',
 | 
				
			||||||
 | 
										'deleteAbuseReportNotificationRecipient',
 | 
				
			||||||
 | 
									].includes(log.type)
 | 
				
			||||||
			}"
 | 
								}"
 | 
				
			||||||
		>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
 | 
							>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
 | 
				
			||||||
		<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 | 
							<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 | 
				
			||||||
@@ -40,6 +66,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
		<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
 | 
							<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
 | 
				
			||||||
		<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
 | 
							<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
 | 
				
			||||||
		<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
 | 
							<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'createSystemWebhook'">: {{ log.info.webhook.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'updateSystemWebhook'">: {{ log.info.before.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'deleteSystemWebhook'">: {{ log.info.webhook.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
 | 
				
			||||||
 | 
							<span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
 | 
				
			||||||
	</template>
 | 
						</template>
 | 
				
			||||||
	<template #icon>
 | 
						<template #icon>
 | 
				
			||||||
		<MkAvatar :user="log.user" :class="$style.avatar"/>
 | 
							<MkAvatar :user="log.user" :class="$style.avatar"/>
 | 
				
			||||||
@@ -116,6 +148,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			|||||||
				<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
 | 
									<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
 | 
				
			||||||
			</div>
 | 
								</div>
 | 
				
			||||||
		</template>
 | 
							</template>
 | 
				
			||||||
 | 
							<template v-else-if="log.type === 'updateSystemWebhook'">
 | 
				
			||||||
 | 
								<div :class="$style.diff">
 | 
				
			||||||
 | 
									<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</template>
 | 
				
			||||||
 | 
							<template v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">
 | 
				
			||||||
 | 
								<div :class="$style.diff">
 | 
				
			||||||
 | 
									<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
 | 
				
			||||||
 | 
								</div>
 | 
				
			||||||
 | 
							</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		<details>
 | 
							<details>
 | 
				
			||||||
			<summary>raw</summary>
 | 
								<summary>raw</summary>
 | 
				
			||||||
 
 | 
				
			|||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user