Merge pull request #9 from anatawa12/admin-webhook

Admin webhook
This commit is contained in:
anatawa12 2023-07-27 19:33:22 +09:00 committed by GitHub
commit 90d53bacfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 75 additions and 12 deletions

View File

@ -42,6 +42,7 @@
- リストTLで、ユーザーが追加・削除されてもTLを初期化しないように
- URL取得変数を関数に変更 CURRENT_URL -> Mk:url()
- プレビューの表示状態を記憶するように
- 管理者専用の他人を見るwebhookが増えました
- Fix: サーバーメトリクスが90度傾いている
- Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
- Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正

2
locales/index.d.ts vendored
View File

@ -2178,6 +2178,8 @@ export interface Locale {
"renote": string;
"reaction": string;
"mention": string;
"usersLabel": string;
"usersCaption": string;
};
};
}

View File

@ -2093,3 +2093,5 @@ _webhookSettings:
renote: "Renoteされたとき"
reaction: "リアクションがあったとき"
mention: "メンションされたとき"
usersLabel: "以下のユーザがnoteしたとき"
usersCaption: "このサーバーのユーザの@に挟まれた部分を改行で区切って指定します"

View File

@ -554,7 +554,8 @@ export class NoteCreateService implements OnApplicationShutdown {
this.roleService.addNoteToRoleTimeline(noteObj);
this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
const userNoteEvent = `note@${user.username}` as const;
webhooks = webhooks.filter(x => (x.userId === user.id && x.on.includes('note')) || x.on.includes(userNoteEvent));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'note', {
note: noteObj,

View File

@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { IActivity } from '@/core/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
import type { Webhook, WebhookEventType } from '@/models/entities/Webhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
@ -407,7 +407,7 @@ export class QueueService {
}
@bindThis
public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) {
public webhookDeliver(webhook: Webhook, type: WebhookEventType, content: unknown) {
const data = {
type,
content,

View File

@ -3,6 +3,7 @@ import { id } from '../id.js';
import { User } from './User.js';
export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const;
export type WebhookEventType = (typeof webhookEventTypes)[number] | `note@${string}`;
@Entity()
export class Webhook {
@ -37,7 +38,7 @@ export class Webhook {
@Column('varchar', {
length: 128, array: true, default: '{}',
})
public on: (typeof webhookEventTypes)[number][];
public on: WebhookEventType[];
@Column('varchar', {
length: 1024,

View File

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { IdService } from '@/core/IdService.js';
import type { WebhooksRepository } from '@/models/index.js';
import { webhookEventTypes } from '@/models/entities/Webhook.js';
import { webhookEventTypes, WebhookEventType } from '@/models/entities/Webhook.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
@ -21,6 +21,11 @@ export const meta = {
code: 'TOO_MANY_WEBHOOKS',
id: '87a9bb19-111e-4e37-81d3-a3e7426453b0',
},
adminWebhookDenied: {
message: 'You cannot create webhook for other users.',
code: 'ADMIN_WEBHOOK_DENIED',
id: '0d3321b1-6f66-41aa-9fbe-233c60ce19b0',
},
},
} as const;
@ -31,7 +36,10 @@ export const paramDef = {
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
oneOf: [
{ type: 'string', enum: webhookEventTypes },
{ type: 'string', pattern: '^note@[a-zA-Z0-9]{1,20}$' },
],
} },
},
required: ['name', 'url', 'secret', 'on'],
@ -58,6 +66,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.tooManyWebhooks);
}
if (ps.on.some(x => !(webhookEventTypes as readonly string[]).includes(x))) {
if (!await this.roleService.isAdministrator(me)) {
throw new ApiError(meta.errors.adminWebhookDenied);
}
}
const webhook = await this.webhooksRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
@ -65,7 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: ps.name,
url: ps.url,
secret: ps.secret,
on: ps.on,
on: ps.on as WebhookEventType[],
}).then(x => this.webhooksRepository.findOneByOrFail(x.identifiers[0]));
this.globalEventService.publishInternalEvent('webhookCreated', webhook);

View File

@ -1,9 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { WebhooksRepository } from '@/models/index.js';
import { webhookEventTypes } from '@/models/entities/Webhook.js';
import { webhookEventTypes, WebhookEventType } from '@/models/entities/Webhook.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -19,6 +20,11 @@ export const meta = {
code: 'NO_SUCH_WEBHOOK',
id: 'fb0fea69-da18-45b1-828d-bd4fd1612518',
},
adminWebhookDenied: {
message: 'You cannot create webhook for other users.',
code: 'UPDATE_ADMIN_WEBHOOK_DENIED',
id: 'eb43c0c4-24a3-487d-b139-f3e4e58f87a4',
},
},
} as const;
@ -31,7 +37,10 @@ export const paramDef = {
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
oneOf: [
{ type: 'string', enum: webhookEventTypes },
{ type: 'string', pattern: '^note@[a-zA-Z0-9]{1,20}$' },
],
} },
active: { type: 'boolean' },
},
@ -48,6 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private webhooksRepository: WebhooksRepository,
private globalEventService: GlobalEventService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const webhook = await this.webhooksRepository.findOneBy({
@ -59,11 +69,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchWebhook);
}
if (ps.on.some(x => !(webhookEventTypes as readonly string[]).includes(x))) {
if (!await this.roleService.isAdministrator(me)) {
throw new ApiError(meta.errors.adminWebhookDenied);
}
}
await this.webhooksRepository.update(webhook.id, {
name: ps.name,
url: ps.url,
secret: ps.secret,
on: ps.on,
on: ps.on as WebhookEventType[],
active: ps.active,
});

View File

@ -24,6 +24,11 @@
<MkSwitch v-model="event_renote">{{ i18n.ts._webhookSettings._events.renote }}</MkSwitch>
<MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch>
<MkSwitch v-model="event_mention">{{ i18n.ts._webhookSettings._events.mention }}</MkSwitch>
<MkTextarea v-if="$i?.isAdmin" v-model="users">
<template #label>{{ i18n.ts._webhookSettings._events.usersLabel }}</template>
<template #caption>{{ i18n.ts._webhookSettings._events.usersCaption }}</template>
</MkTextarea>
</div>
</FormSection>
@ -46,6 +51,8 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { useRouter } from '@/router';
import { $i } from '@/account';
import MkTextarea from '@/components/MkTextarea.vue';
const router = useRouter();
@ -69,9 +76,10 @@ let event_reply = $ref(webhook.on.includes('reply'));
let event_renote = $ref(webhook.on.includes('renote'));
let event_reaction = $ref(webhook.on.includes('reaction'));
let event_mention = $ref(webhook.on.includes('mention'));
let users = $ref((webhook.on as string[]).filter(x => x.startsWith('note@')).map(x => x.substring('note@'.length)).join('\n'));
async function save(): Promise<void> {
const events = [];
const events: string[] = [];
if (event_follow) events.push('follow');
if (event_followed) events.push('followed');
if (event_note) events.push('note');
@ -79,6 +87,7 @@ async function save(): Promise<void> {
if (event_renote) events.push('renote');
if (event_reaction) events.push('reaction');
if (event_mention) events.push('mention');
if (users !== '') events.push(...users.split('\n').filter(x => x).map(x => `note@${x}`));
os.apiWithDialog('i/webhooks/update', {
name,
@ -112,3 +121,11 @@ definePageMetadata({
icon: 'ti ti-webhook',
});
</script>
<style lang="scss" module>
.userItem {
display: flex;
}
</style>

View File

@ -24,6 +24,11 @@
<MkSwitch v-model="event_renote">{{ i18n.ts._webhookSettings._events.renote }}</MkSwitch>
<MkSwitch v-model="event_reaction">{{ i18n.ts._webhookSettings._events.reaction }}</MkSwitch>
<MkSwitch v-model="event_mention">{{ i18n.ts._webhookSettings._events.mention }}</MkSwitch>
<MkTextarea v-if="$i?.isAdmin" v-model="users">
<template #label>{{ i18n.ts._webhookSettings._events.usersLabel }}</template>
<template #caption>{{ i18n.ts._webhookSettings._events.usersCaption }}</template>
</MkTextarea>
</div>
</FormSection>
@ -42,6 +47,8 @@ import MkButton from '@/components/MkButton.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { $i } from '@/account';
import MkTextarea from '@/components/MkTextarea.vue';
let name = $ref('');
let url = $ref('');
@ -54,9 +61,10 @@ let event_reply = $ref(true);
let event_renote = $ref(true);
let event_reaction = $ref(true);
let event_mention = $ref(true);
let users = $ref('');
async function create(): Promise<void> {
const events = [];
const events: string[] = [];
if (event_follow) events.push('follow');
if (event_followed) events.push('followed');
if (event_note) events.push('note');
@ -64,6 +72,7 @@ async function create(): Promise<void> {
if (event_renote) events.push('renote');
if (event_reaction) events.push('reaction');
if (event_mention) events.push('mention');
if (users !== '') events.push(...users.split('\n').filter(x => x).map(x => `note@${x}`));
os.apiWithDialog('i/webhooks/create', {
name,