From 72bc78974657b22ab6b1f5a36f6144c294e36de3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
 <46447427+samunohito@users.noreply.github.com>
Date: Mon, 29 Jul 2024 21:31:32 +0900
Subject: [PATCH] =?UTF-8?q?feature:=20=E3=83=A6=E3=83=BC=E3=82=B6=E4=BD=9C?=
 =?UTF-8?q?=E6=88=90=E6=99=82=E3=81=ABSystemWebhook=E3=82=92=E7=99=BA?=
 =?UTF-8?q?=E4=BF=A1=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
 =?UTF-8?q?=E3=81=99=E3=82=8B=20(#14321)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feature: ユーザ作成時にSystemWebhookを発信できるようにする

* fix CHANGELOG.md
---
 CHANGELOG.md                                  |   1 +
 locales/index.d.ts                            |   4 +
 locales/ja-JP.yml                             |   1 +
 .../core/AbuseReportNotificationService.ts    |   2 +-
 packages/backend/src/core/SignupService.ts    |   5 +-
 packages/backend/src/core/UserService.ts      |  24 +++-
 packages/backend/src/models/SystemWebhook.ts  |   2 +
 .../backend/test/e2e/synalio/abuse-report.ts  |  61 ++------
 .../backend/test/e2e/synalio/user-create.ts   | 130 ++++++++++++++++++
 packages/backend/test/utils.ts                |  55 +++++++-
 .../src/components/MkSystemWebhookEditor.vue  |   6 +
 packages/misskey-js/src/autogen/types.ts      |   8 +-
 12 files changed, 237 insertions(+), 62 deletions(-)
 create mode 100644 packages/backend/test/e2e/synalio/user-create.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2e9c8e258..17c233e358 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
 - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
   - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
+- Feat: ユーザ作成時にSystemWebhookを送信可能に #14281
 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 55c65f2aed..2b340ecbb5 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9392,6 +9392,10 @@ export interface Locale extends ILocale {
              * ユーザーからの通報を処理したとき
              */
             "abuseReportResolved": string;
+            /**
+             * ユーザーが作成されたとき
+             */
+            "userCreated": string;
         };
         /**
          * Webhookを削除しますか?
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 3ca4b46682..f0f849fb38 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2491,6 +2491,7 @@ _webhookSettings:
   _systemEvents:
     abuseReport: "ユーザーから通報があったとき"
     abuseReportResolved: "ユーザーからの通報を処理したとき"
+    userCreated: "ユーザーが作成されたとき"
   deleteConfirm: "Webhookを削除しますか?"
 
 _abuseReport:
diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index 42e5931212..7be5335885 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -44,7 +44,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 
 	/**
 	 * 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する.
-	 * 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る.
+	 * 通知先ユーザは{@link getModeratorIds}の取得結果に依る.
 	 *
 	 * @see RoleService.getModeratorIds
 	 * @see GlobalEventService.publishAdminStream
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index 5522ecd6cc..de45898328 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -21,6 +21,7 @@ import { bindThis } from '@/decorators.js';
 import UsersChart from '@/core/chart/charts/users.js';
 import { UtilityService } from '@/core/UtilityService.js';
 import { MetaService } from '@/core/MetaService.js';
+import { UserService } from '@/core/UserService.js';
 
 @Injectable()
 export class SignupService {
@@ -35,6 +36,7 @@ export class SignupService {
 		private usedUsernamesRepository: UsedUsernamesRepository,
 
 		private utilityService: UtilityService,
+		private userService: UserService,
 		private userEntityService: UserEntityService,
 		private idService: IdService,
 		private metaService: MetaService,
@@ -148,7 +150,8 @@ export class SignupService {
 			}));
 		});
 
-		this.usersChart.update(account, true);
+		this.usersChart.update(account, true).then();
+		this.userService.notifySystemWebhook(account, 'userCreated').then();
 
 		return { account, secret };
 	}
diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts
index 72fa4d928d..9b1961c631 100644
--- a/packages/backend/src/core/UserService.ts
+++ b/packages/backend/src/core/UserService.ts
@@ -8,15 +8,18 @@ import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
 import type { MiUser } from '@/models/User.js';
 import { DI } from '@/di-symbols.js';
 import { bindThis } from '@/decorators.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
 
 @Injectable()
 export class UserService {
 	constructor(
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
-
 		@Inject(DI.followingsRepository)
 		private followingsRepository: FollowingsRepository,
+		private systemWebhookService: SystemWebhookService,
+		private userEntityService: UserEntityService,
 	) {
 	}
 
@@ -50,4 +53,23 @@ export class UserService {
 			});
 		}
 	}
+
+	/**
+	 * SystemWebhookを用いてユーザに関する操作内容を管理者各位に通知する.
+	 * ここではJobQueueへのエンキューのみを行うため、即時実行されない.
+	 *
+	 * @see SystemWebhookService.enqueueSystemWebhook
+	 */
+	@bindThis
+	public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
+		const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
+		const recipientWebhookIds = await this.systemWebhookService.fetchSystemWebhooks({ isActive: true, on: [type] });
+		for (const webhookId of recipientWebhookIds) {
+			await this.systemWebhookService.enqueueSystemWebhook(
+				webhookId,
+				type,
+				packedUser,
+			);
+		}
+	}
 }
diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts
index 86fb323d1d..d6c27eae51 100644
--- a/packages/backend/src/models/SystemWebhook.ts
+++ b/packages/backend/src/models/SystemWebhook.ts
@@ -12,6 +12,8 @@ export const systemWebhookEventTypes = [
 	'abuseReport',
 	// 通報を処理したとき
 	'abuseReportResolved',
+	// ユーザが作成された時
+	'userCreated',
 ] as const;
 export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
 
diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts
index b0cc3d13ec..6ce6e47781 100644
--- a/packages/backend/test/e2e/synalio/abuse-report.ts
+++ b/packages/backend/test/e2e/synalio/abuse-report.ts
@@ -5,65 +5,24 @@
 
 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 {
+	api,
+	captureWebhook,
+	randomString,
+	role,
+	signup,
+	startJobQueue,
+	UserToken,
+	WEBHOOK_HOST,
+} 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',
diff --git a/packages/backend/test/e2e/synalio/user-create.ts b/packages/backend/test/e2e/synalio/user-create.ts
new file mode 100644
index 0000000000..cb0f68dfea
--- /dev/null
+++ b/packages/backend/test/e2e/synalio/user-create.ts
@@ -0,0 +1,130 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { setTimeout } from 'node:timers/promises';
+import { entities } from 'misskey-js';
+import { beforeEach, describe, test } from '@jest/globals';
+import {
+	api,
+	captureWebhook,
+	randomString,
+	role,
+	signup,
+	startJobQueue,
+	UserToken,
+	WEBHOOK_HOST,
+} from '../../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
+
+describe('[シナリオ] ユーザ作成', () => {
+	let queue: INestApplicationContext;
+	let admin: entities.SignupResponse;
+
+	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: ['userCreated'],
+				url: WEBHOOK_HOST,
+				secret: randomString(),
+				...args,
+			},
+			credential ?? admin,
+		);
+		return res.body;
+	}
+
+	// -------------------------------------------------------------------------------------------
+
+	beforeAll(async () => {
+		queue = await startJobQueue();
+		admin = await signup({ username: 'admin' });
+
+		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('ユーザが作成された -> userCreatedが送出される', async () => {
+			const webhook = await createSystemWebhook({
+				on: ['userCreated'],
+				isActive: true,
+			});
+
+			let alice: any = null;
+			const webhookBody = await captureWebhook(async () => {
+				alice = await signup({ username: 'alice' });
+			});
+
+			// webhookの送出後にいろいろやってるのでちょっと待つ必要がある
+			await setTimeout(2000);
+
+			console.log(alice);
+			console.log(JSON.stringify(webhookBody, null, 2));
+
+			expect(webhookBody.hookId).toBe(webhook.id);
+			expect(webhookBody.type).toBe('userCreated');
+
+			const body = webhookBody.body as entities.UserLite;
+			expect(alice.id).toBe(body.id);
+			expect(alice.name).toBe(body.name);
+			expect(alice.username).toBe(body.username);
+			expect(alice.host).toBe(body.host);
+			expect(alice.avatarUrl).toBe(body.avatarUrl);
+			expect(alice.avatarBlurhash).toBe(body.avatarBlurhash);
+			expect(alice.avatarDecorations).toEqual(body.avatarDecorations);
+			expect(alice.isBot).toBe(body.isBot);
+			expect(alice.isCat).toBe(body.isCat);
+			expect(alice.instance).toEqual(body.instance);
+			expect(alice.emojis).toEqual(body.emojis);
+			expect(alice.onlineStatus).toBe(body.onlineStatus);
+			expect(alice.badgeRoles).toEqual(body.badgeRoles);
+		});
+
+		test('ユーザ作成 -> userCreatedが未許可の場合は送出されない', async () => {
+			await createSystemWebhook({
+				on: [],
+				isActive: true,
+			});
+
+			let alice: any = null;
+			const webhookBody = await captureWebhook(async () => {
+				alice = await signup({ username: 'alice' });
+			}).catch(e => e.message);
+
+			expect(webhookBody).toBe('timeout');
+			expect(alice.id).not.toBeNull();
+		});
+
+		test('ユーザ作成 -> Webhookが無効の場合は送出されない', async () => {
+			await createSystemWebhook({
+				on: ['userCreated'],
+				isActive: false,
+			});
+
+			let alice: any = null;
+			const webhookBody = await captureWebhook(async () => {
+				alice = await signup({ username: 'alice' });
+			}).catch(e => e.message);
+
+			expect(webhookBody).toBe('timeout');
+			expect(alice.id).not.toBeNull();
+		});
+	});
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index e70befeebe..26de19eaf1 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -12,13 +12,14 @@ import WebSocket, { ClientOptions } from 'ws';
 import fetch, { File, RequestInit, type Headers } from 'node-fetch';
 import { DataSource } from 'typeorm';
 import { JSDOM } from 'jsdom';
-import { DEFAULT_POLICIES } from '@/core/RoleService.js';
-import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
+import { type Response } from 'node-fetch';
+import Fastify from 'fastify';
 import { entities } from '../src/postgres.js';
 import { loadConfig } from '../src/config.js';
 import type * as misskey from 'misskey-js';
-import { type Response } from 'node-fetch';
-import { ApiError } from "@/server/api/error.js";
+import { DEFAULT_POLICIES } from '@/core/RoleService.js';
+import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
+import { ApiError } from '@/server/api/error.js';
 
 export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
 
@@ -27,11 +28,23 @@ export interface UserToken {
 	bearer?: boolean;
 }
 
+export type SystemWebhookPayload = {
+	server: string;
+	hookId: string;
+	eventId: string;
+	createdAt: string;
+	type: string;
+	body: any;
+}
+
 const config = loadConfig();
 export const port = config.port;
 export const origin = config.url;
 export const host = new URL(config.url).host;
 
+export const WEBHOOK_HOST = 'http://localhost:15080';
+export const WEBHOOK_PORT = 15080;
+
 export const cookie = (me: UserToken): string => {
 	return `token=${me.token};`;
 };
@@ -645,3 +658,37 @@ export async function sendEnvResetRequest() {
 export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
 	return obj as { error: ApiError };
 }
+
+export async function captureWebhook<T = SystemWebhookPayload>(postAction: () => Promise<void>, port = WEBHOOK_PORT): 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 });
+
+		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;
+}
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index 3e6a015018..0beb0a8163 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -40,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
 						<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
 					</MkSwitch>
+					<MkSwitch v-model="events.userCreated" :disabled="disabledEvents.userCreated">
+						<template #label>{{ i18n.ts._webhookSettings._systemEvents.userCreated }}</template>
+					</MkSwitch>
 				</div>
 			</MkFolder>
 
@@ -78,6 +81,7 @@ import * as os from '@/os.js';
 type EventType = {
 	abuseReport: boolean;
 	abuseReportResolved: boolean;
+	userCreated: boolean;
 }
 
 const emit = defineEmits<{
@@ -100,12 +104,14 @@ const secret = ref<string>('');
 const events = ref<EventType>({
 	abuseReport: true,
 	abuseReportResolved: true,
+	userCreated: true,
 });
 const isActive = ref<boolean>(true);
 
 const disabledEvents = ref<EventType>({
 	abuseReport: false,
 	abuseReportResolved: false,
+	userCreated: false,
 });
 
 const disableSubmitButton = computed(() => {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index b2b8938baa..bf35d0f421 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4970,7 +4970,7 @@ export type components = {
       latestSentAt: string | null;
       latestStatus: number | null;
       name: string;
-      on: ('abuseReport' | 'abuseReportResolved')[];
+      on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
       url: string;
       secret: string;
     };
@@ -10042,7 +10042,7 @@ export type operations = {
         'application/json': {
           isActive: boolean;
           name: string;
-          on: ('abuseReport' | 'abuseReportResolved')[];
+          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
           url: string;
           secret: string;
         };
@@ -10152,7 +10152,7 @@ export type operations = {
       content: {
         'application/json': {
           isActive?: boolean;
-          on?: ('abuseReport' | 'abuseReportResolved')[];
+          on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
         };
       };
     };
@@ -10265,7 +10265,7 @@ export type operations = {
           id: string;
           isActive: boolean;
           name: string;
-          on: ('abuseReport' | 'abuseReportResolved')[];
+          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
           url: string;
           secret: string;
         };
-- 
GitLab