diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f59b7864fa..9fce11441df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - リストTLで、ユーザーが追加・削除されてもTLを初期化しないように - URL取得変数を関数に変更 CURRENT_URL -> Mk:url() - プレビューの表示状態を記憶するように +- 管理者専用の他人を見るwebhookが増えました - Fix: サーバーメトリクスが90度傾いている - Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正 - Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index db7e3e957560..8efbb6175e35 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2178,6 +2178,8 @@ export interface Locale { "renote": string; "reaction": string; "mention": string; + "usersLabel": string; + "usersCaption": string; }; }; } diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index b2fa9c337e20..af7eaad474bb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2093,3 +2093,5 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" + usersLabel: "以下のユーザがnoteしたとき" + usersCaption: "このサーバーのユーザの@に挟まれた部分を改行で区切って指定します" diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 648ff7648332..3c8b89fb8839 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -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, diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 546b4cee1b71..7d836c08bdaf 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -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, diff --git a/packages/backend/src/models/entities/Webhook.ts b/packages/backend/src/models/entities/Webhook.ts index eabb604de94f..39fb04bc5abf 100644 --- a/packages/backend/src/models/entities/Webhook.ts +++ b/packages/backend/src/models/entities/Webhook.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index 51fcce6cf006..c72257d3babf 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -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 { 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 { 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); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index 8ec308eda777..18601c994158 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -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 { 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 { 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, }); diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 3c782973aec6..b76fc7c6af69 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -24,6 +24,11 @@ {{ i18n.ts._webhookSettings._events.renote }} {{ i18n.ts._webhookSettings._events.reaction }} {{ i18n.ts._webhookSettings._events.mention }} + + + + + @@ -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 { - 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 { 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', }); + + diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue index 6eb8a654f55d..b3728e876459 100644 --- a/packages/frontend/src/pages/settings/webhook.new.vue +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -24,6 +24,11 @@ {{ i18n.ts._webhookSettings._events.renote }} {{ i18n.ts._webhookSettings._events.reaction }} {{ i18n.ts._webhookSettings._events.mention }} + + + + + @@ -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 { - 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 { 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,