feature: ユーザ作成時にSystemWebhookを発信できるようにする (#14321)

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

* fix CHANGELOG.md
This commit is contained in:
おさむのひと 2024-07-29 21:31:32 +09:00 committed by GitHub
parent 0f0660d49e
commit 72bc789746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 237 additions and 62 deletions

View File

@ -8,6 +8,7 @@
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に - Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Feat: ユーザ作成時にSystemWebhookを送信可能に #14281
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正

4
locales/index.d.ts vendored
View File

@ -9392,6 +9392,10 @@ export interface Locale extends ILocale {
* *
*/ */
"abuseReportResolved": string; "abuseReportResolved": string;
/**
*
*/
"userCreated": string;
}; };
/** /**
* Webhookを削除しますか * Webhookを削除しますか

View File

@ -2491,6 +2491,7 @@ _webhookSettings:
_systemEvents: _systemEvents:
abuseReport: "ユーザーから通報があったとき" abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき" abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
deleteConfirm: "Webhookを削除しますか" deleteConfirm: "Webhookを削除しますか"
_abuseReport: _abuseReport:

View File

@ -44,7 +44,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
/** /**
* Redisイベントを用いて{@link abuseReports}. * Redisイベントを用いて{@link abuseReports}.
* {@link RoleService.getModeratorIds}. * {@link getModeratorIds}.
* *
* @see RoleService.getModeratorIds * @see RoleService.getModeratorIds
* @see GlobalEventService.publishAdminStream * @see GlobalEventService.publishAdminStream

View File

@ -21,6 +21,7 @@ import { bindThis } from '@/decorators.js';
import UsersChart from '@/core/chart/charts/users.js'; import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { UserService } from '@/core/UserService.js';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
@ -35,6 +36,7 @@ export class SignupService {
private usedUsernamesRepository: UsedUsernamesRepository, private usedUsernamesRepository: UsedUsernamesRepository,
private utilityService: UtilityService, private utilityService: UtilityService,
private userService: UserService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private metaService: MetaService, 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 }; return { account, secret };
} }

View File

@ -8,15 +8,18 @@ import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor( constructor(
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: 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,
);
}
}
} }

View File

@ -12,6 +12,8 @@ export const systemWebhookEventTypes = [
'abuseReport', 'abuseReport',
// 通報を処理したとき // 通報を処理したとき
'abuseReportResolved', 'abuseReportResolved',
// ユーザが作成された時
'userCreated',
] as const; ] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

View File

@ -5,65 +5,24 @@
import { entities } from 'misskey-js'; import { entities } from 'misskey-js';
import { beforeEach, describe, test } from '@jest/globals'; import { beforeEach, describe, test } from '@jest/globals';
import Fastify from 'fastify'; import {
import { api, randomString, role, signup, startJobQueue, UserToken } from '../../utils.js'; api,
captureWebhook,
randomString,
role,
signup,
startJobQueue,
UserToken,
WEBHOOK_HOST,
} from '../../utils.js';
import type { INestApplicationContext } from '@nestjs/common'; import type { INestApplicationContext } from '@nestjs/common';
const WEBHOOK_HOST = 'http://localhost:15080';
const WEBHOOK_PORT = 15080;
process.env.NODE_ENV = 'test';
describe('[シナリオ] ユーザ通報', () => { describe('[シナリオ] ユーザ通報', () => {
let queue: INestApplicationContext; let queue: INestApplicationContext;
let admin: entities.SignupResponse; let admin: entities.SignupResponse;
let alice: entities.SignupResponse; let alice: entities.SignupResponse;
let bob: 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> { async function createSystemWebhook(args?: Partial<entities.AdminSystemWebhookCreateRequest>, credential?: UserToken): Promise<entities.AdminSystemWebhookCreateResponse> {
const res = await api( const res = await api(
'admin/system-webhook/create', 'admin/system-webhook/create',

View File

@ -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();
});
});
});

View File

@ -12,13 +12,14 @@ import WebSocket, { ClientOptions } from 'ws';
import fetch, { File, RequestInit, type Headers } from 'node-fetch'; import fetch, { File, RequestInit, type Headers } from 'node-fetch';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { type Response } from 'node-fetch';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import Fastify from 'fastify';
import { entities } from '../src/postgres.js'; import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js'; import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
import { type Response } from 'node-fetch'; import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { ApiError } from "@/server/api/error.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'; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
@ -27,11 +28,23 @@ export interface UserToken {
bearer?: boolean; bearer?: boolean;
} }
export type SystemWebhookPayload = {
server: string;
hookId: string;
eventId: string;
createdAt: string;
type: string;
body: any;
}
const config = loadConfig(); const config = loadConfig();
export const port = config.port; export const port = config.port;
export const origin = config.url; export const origin = config.url;
export const host = new URL(config.url).host; 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 => { export const cookie = (me: UserToken): string => {
return `token=${me.token};`; return `token=${me.token};`;
}; };
@ -645,3 +658,37 @@ export async function sendEnvResetRequest() {
export function castAsError(obj: Record<string, unknown>): { error: ApiError } { export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
return obj as { 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;
}

View File

@ -40,6 +40,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved"> <MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template> <template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="events.userCreated" :disabled="disabledEvents.userCreated">
<template #label>{{ i18n.ts._webhookSettings._systemEvents.userCreated }}</template>
</MkSwitch>
</div> </div>
</MkFolder> </MkFolder>
@ -78,6 +81,7 @@ import * as os from '@/os.js';
type EventType = { type EventType = {
abuseReport: boolean; abuseReport: boolean;
abuseReportResolved: boolean; abuseReportResolved: boolean;
userCreated: boolean;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@ -100,12 +104,14 @@ const secret = ref<string>('');
const events = ref<EventType>({ const events = ref<EventType>({
abuseReport: true, abuseReport: true,
abuseReportResolved: true, abuseReportResolved: true,
userCreated: true,
}); });
const isActive = ref<boolean>(true); const isActive = ref<boolean>(true);
const disabledEvents = ref<EventType>({ const disabledEvents = ref<EventType>({
abuseReport: false, abuseReport: false,
abuseReportResolved: false, abuseReportResolved: false,
userCreated: false,
}); });
const disableSubmitButton = computed(() => { const disableSubmitButton = computed(() => {

View File

@ -4970,7 +4970,7 @@ export type components = {
latestSentAt: string | null; latestSentAt: string | null;
latestStatus: number | null; latestStatus: number | null;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
url: string; url: string;
secret: string; secret: string;
}; };
@ -10042,7 +10042,7 @@ export type operations = {
'application/json': { 'application/json': {
isActive: boolean; isActive: boolean;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
url: string; url: string;
secret: string; secret: string;
}; };
@ -10152,7 +10152,7 @@ export type operations = {
content: { content: {
'application/json': { 'application/json': {
isActive?: boolean; isActive?: boolean;
on?: ('abuseReport' | 'abuseReportResolved')[]; on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
}; };
}; };
}; };
@ -10265,7 +10265,7 @@ export type operations = {
id: string; id: string;
isActive: boolean; isActive: boolean;
name: string; name: string;
on: ('abuseReport' | 'abuseReportResolved')[]; on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
url: string; url: string;
secret: string; secret: string;
}; };