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