From 8b2a0a5773839a3d62a48bc0f1e0beb0b0212b4d Mon Sep 17 00:00:00 2001 From: syuilo Date: Sat, 2 Apr 2022 15:28:49 +0900 Subject: [PATCH] feat: Webhook (#8457) * feat: introduce webhook * wip * wip * wip * Update CHANGELOG.md --- CHANGELOG.md | 1 + .../migration/1648548247382-webhook.js | 19 ++++ .../migration/1648816172177-webhook-2.js | 14 +++ packages/backend/src/db/postgre.ts | 2 + packages/backend/src/misc/webhook-cache.ts | 49 ++++++++++ .../backend/src/models/entities/webhook.ts | 73 +++++++++++++++ packages/backend/src/models/index.ts | 2 + packages/backend/src/queue/index.ts | 33 ++++++- .../src/queue/processors/webhook-deliver.ts | 56 ++++++++++++ packages/backend/src/queue/queues.ts | 4 +- packages/backend/src/queue/types.ts | 8 ++ packages/backend/src/server/api/endpoints.ts | 10 +++ .../server/api/endpoints/i/webhooks/create.ts | 43 +++++++++ .../server/api/endpoints/i/webhooks/delete.ts | 44 +++++++++ .../server/api/endpoints/i/webhooks/list.ts | 25 ++++++ .../server/api/endpoints/i/webhooks/show.ts | 41 +++++++++ .../server/api/endpoints/i/webhooks/update.ts | 59 ++++++++++++ .../backend/src/server/api/stream/types.ts | 4 + .../backend/src/services/blocking/create.ts | 22 ++++- .../backend/src/services/following/create.ts | 24 ++++- .../backend/src/services/following/delete.ts | 13 ++- .../backend/src/services/following/reject.ts | 11 ++- packages/backend/src/services/note/create.ts | 36 ++++++++ packages/client/src/components/form/link.vue | 2 +- packages/client/src/pages/settings/index.vue | 8 ++ .../src/pages/settings/webhook.edit.vue | 89 +++++++++++++++++++ .../client/src/pages/settings/webhook.new.vue | 81 +++++++++++++++++ .../client/src/pages/settings/webhook.vue | 52 +++++++++++ 28 files changed, 815 insertions(+), 10 deletions(-) create mode 100644 packages/backend/migration/1648548247382-webhook.js create mode 100644 packages/backend/migration/1648816172177-webhook-2.js create mode 100644 packages/backend/src/misc/webhook-cache.ts create mode 100644 packages/backend/src/models/entities/webhook.ts create mode 100644 packages/backend/src/queue/processors/webhook-deliver.ts create mode 100644 packages/backend/src/server/api/endpoints/i/webhooks/create.ts create mode 100644 packages/backend/src/server/api/endpoints/i/webhooks/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/i/webhooks/list.ts create mode 100644 packages/backend/src/server/api/endpoints/i/webhooks/show.ts create mode 100644 packages/backend/src/server/api/endpoints/i/webhooks/update.ts create mode 100644 packages/client/src/pages/settings/webhook.edit.vue create mode 100644 packages/client/src/pages/settings/webhook.new.vue create mode 100644 packages/client/src/pages/settings/webhook.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2f2ec1f..9326fb845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ You should also include the user name that made the change. ## 12.x.x (unreleased) ### Improvements +- Webhooks @syuilo - Bull Dashboardを組み込み、ジョブキューの確認や操作を行えるように @syuilo - Bull Dashboardを開くには、最初だけ一旦ログアウトしてから再度管理者権限を持つアカウントでログインする必要があります - Check that installed Node.js version fulfills version requirement @ThatOneCalculator diff --git a/packages/backend/migration/1648548247382-webhook.js b/packages/backend/migration/1648548247382-webhook.js new file mode 100644 index 000000000..aea369a5c --- /dev/null +++ b/packages/backend/migration/1648548247382-webhook.js @@ -0,0 +1,19 @@ +export class webhook1648548247382 { + name = 'webhook1648548247382' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "webhook" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "on" character varying(128) array NOT NULL DEFAULT '{}', "url" character varying(1024) NOT NULL, "secret" character varying(1024) NOT NULL, "active" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id")); COMMENT ON COLUMN "webhook"."createdAt" IS 'The created date of the Antenna.'; COMMENT ON COLUMN "webhook"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "webhook"."name" IS 'The name of the Antenna.'`); + await queryRunner.query(`CREATE INDEX "IDX_f272c8c8805969e6a6449c77b3" ON "webhook" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_8063a0586ed1dfbe86e982d961" ON "webhook" ("on") `); + await queryRunner.query(`CREATE INDEX "IDX_5a056076f76b2efe08216ba655" ON "webhook" ("active") `); + await queryRunner.query(`ALTER TABLE "webhook" ADD CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "webhook" DROP CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5a056076f76b2efe08216ba655"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8063a0586ed1dfbe86e982d961"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f272c8c8805969e6a6449c77b3"`); + await queryRunner.query(`DROP TABLE "webhook"`); + } +} diff --git a/packages/backend/migration/1648816172177-webhook-2.js b/packages/backend/migration/1648816172177-webhook-2.js new file mode 100644 index 000000000..2feb68d61 --- /dev/null +++ b/packages/backend/migration/1648816172177-webhook-2.js @@ -0,0 +1,14 @@ + +export class webhook21648816172177 { + name = 'webhook21648816172177' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "webhook" ADD "latestSentAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "webhook" ADD "latestStatus" integer`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestStatus"`); + await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestSentAt"`); + } +} diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 491c1a174..f7638a53d 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -73,6 +73,7 @@ import { PasswordResetRequest } from '@/models/entities/password-reset-request.j import { UserPending } from '@/models/entities/user-pending.js'; import { entities as charts } from '@/services/chart/entities.js'; +import { Webhook } from '@/models/entities/webhook.js'; const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); @@ -171,6 +172,7 @@ export const entities = [ Ad, PasswordResetRequest, UserPending, + Webhook, ...charts, ]; diff --git a/packages/backend/src/misc/webhook-cache.ts b/packages/backend/src/misc/webhook-cache.ts new file mode 100644 index 000000000..4bd233366 --- /dev/null +++ b/packages/backend/src/misc/webhook-cache.ts @@ -0,0 +1,49 @@ +import { Webhooks } from '@/models/index.js'; +import { Webhook } from '@/models/entities/webhook.js'; +import { subsdcriber } from '../db/redis.js'; + +let webhooksFetched = false; +let webhooks: Webhook[] = []; + +export async function getActiveWebhooks() { + if (!webhooksFetched) { + webhooks = await Webhooks.findBy({ + active: true, + }); + webhooksFetched = true; + } + + return webhooks; +} + +subsdcriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'webhookCreated': + if (body.active) { + webhooks.push(body); + } + break; + case 'webhookUpdated': + if (body.active) { + const i = webhooks.findIndex(a => a.id === body.id); + if (i > -1) { + webhooks[i] = body; + } else { + webhooks.push(body); + } + } else { + webhooks = webhooks.filter(a => a.id !== body.id); + } + break; + case 'webhookDeleted': + webhooks = webhooks.filter(a => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/models/entities/webhook.ts b/packages/backend/src/models/entities/webhook.ts new file mode 100644 index 000000000..56b411f87 --- /dev/null +++ b/packages/backend/src/models/entities/webhook.ts @@ -0,0 +1,73 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user.js'; +import { id } from '../id.js'; + +export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const; + +@Entity() +export class Webhook { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Antenna.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The owner ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + comment: 'The name of the Antenna.', + }) + public name: string; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}', + }) + public on: (typeof webhookEventTypes)[number][]; + + @Column('varchar', { + length: 1024, + }) + public url: string; + + @Column('varchar', { + length: 1024, + }) + public secret: string; + + @Index() + @Column('boolean', { + default: true, + }) + public active: boolean; + + /** + * 直近のリクエスト送信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 54582347c..814b37d44 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -64,6 +64,7 @@ import { Ad } from './entities/ad.js'; import { PasswordResetRequest } from './entities/password-reset-request.js'; import { UserPending } from './entities/user-pending.js'; import { InstanceRepository } from './repositories/instance.js'; +import { Webhook } from './entities/webhook.js'; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -125,5 +126,6 @@ export const Channels = (ChannelRepository); export const ChannelFollowings = db.getRepository(ChannelFollowing); export const ChannelNotePinings = db.getRepository(ChannelNotePining); export const RegistryItems = db.getRepository(RegistryItem); +export const Webhooks = db.getRepository(Webhook); export const Ads = db.getRepository(Ad); export const PasswordResetRequests = db.getRepository(PasswordResetRequest); diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts index b679a552b..a570400b7 100644 --- a/packages/backend/src/queue/index.ts +++ b/packages/backend/src/queue/index.ts @@ -8,13 +8,15 @@ import processInbox from './processors/inbox.js'; import processDb from './processors/db/index.js'; import processObjectStorage from './processors/object-storage/index.js'; import processSystemQueue from './processors/system/index.js'; +import processWebhookDeliver from './processors/webhook-deliver.js'; import { endedPollNotification } from './processors/ended-poll-notification.js'; import { queueLogger } from './logger.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { getJobInfo } from './get-job-info.js'; -import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue } from './queues.js'; +import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js'; import { ThinUser } from './types.js'; import { IActivity } from '@/remote/activitypub/type.js'; +import { Webhook } from '@/models/entities/webhook.js'; function renderError(e: Error): any { return { @@ -26,6 +28,7 @@ function renderError(e: Error): any { const systemLogger = queueLogger.createSubLogger('system'); const deliverLogger = queueLogger.createSubLogger('deliver'); +const webhookLogger = queueLogger.createSubLogger('webhook'); const inboxLogger = queueLogger.createSubLogger('inbox'); const dbLogger = queueLogger.createSubLogger('db'); const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); @@ -70,6 +73,14 @@ objectStorageQueue .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); +webhookDeliverQueue + .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + export function deliver(user: ThinUser, content: unknown, to: string | null) { if (content == null) return null; if (to == null) return null; @@ -251,12 +262,32 @@ export function createCleanRemoteFilesJob() { }); } +export function webhookDeliver(webhook: Webhook, content: unknown) { + const data = { + content, + webhookId: webhook.id, + to: webhook.url, + secret: webhook.secret, + }; + + return webhookDeliverQueue.add(data, { + attempts: 4, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff', + }, + removeOnComplete: true, + removeOnFail: true, + }); +} + export default function() { if (envOption.onlyServer) return; deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); endedPollNotificationQueue.process(endedPollNotification); + webhookDeliverQueue.process(64, processWebhookDeliver); processDb(dbQueue); processObjectStorage(objectStorageQueue); diff --git a/packages/backend/src/queue/processors/webhook-deliver.ts b/packages/backend/src/queue/processors/webhook-deliver.ts new file mode 100644 index 000000000..a4d39d86e --- /dev/null +++ b/packages/backend/src/queue/processors/webhook-deliver.ts @@ -0,0 +1,56 @@ +import { URL } from 'node:url'; +import Bull from 'bull'; +import Logger from '@/services/logger.js'; +import { WebhookDeliverJobData } from '../types.js'; +import { getResponse, StatusError } from '@/misc/fetch.js'; +import { Webhooks } from '@/models/index.js'; +import config from '@/config/index.js'; + +const logger = new Logger('webhook'); + +let latest: string | null = null; + +export default async (job: Bull.Job) => { + try { + if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { + logger.debug(`delivering ${latest}`); + } + + const res = await getResponse({ + url: job.data.to, + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + }, + body: JSON.stringify(job.data.content), + }); + + Webhooks.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + Webhooks.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; diff --git a/packages/backend/src/queue/queues.ts b/packages/backend/src/queue/queues.ts index d612dee45..f3a267790 100644 --- a/packages/backend/src/queue/queues.ts +++ b/packages/backend/src/queue/queues.ts @@ -1,6 +1,6 @@ import config from '@/config/index.js'; import { initialize as initializeQueue } from './initialize.js'; -import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData } from './types.js'; +import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from './types.js'; export const systemQueue = initializeQueue>('system'); export const endedPollNotificationQueue = initializeQueue('endedPollNotification'); @@ -8,6 +8,7 @@ export const deliverQueue = initializeQueue('deliver', config.de export const inboxQueue = initializeQueue('inbox', config.inboxJobPerSec || 16); export const dbQueue = initializeQueue('db'); export const objectStorageQueue = initializeQueue('objectStorage'); +export const webhookDeliverQueue = initializeQueue('webhookDeliver', 64); export const queues = [ systemQueue, @@ -16,4 +17,5 @@ export const queues = [ inboxQueue, dbQueue, objectStorageQueue, + webhookDeliverQueue, ]; diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 5191caea4..8aeacf462 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -1,6 +1,7 @@ import { DriveFile } from '@/models/entities/drive-file.js'; import { Note } from '@/models/entities/note'; import { User } from '@/models/entities/user.js'; +import { Webhook } from '@/models/entities/webhook'; import { IActivity } from '@/remote/activitypub/type.js'; import httpSignature from 'http-signature'; @@ -46,6 +47,13 @@ export type EndedPollNotificationJobData = { noteId: Note['id']; }; +export type WebhookDeliverJobData = { + content: unknown; + webhookId: Webhook['id']; + to: string; + secret: string; +}; + export type ThinUser = { id: User['id']; }; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b58ee8e8d..e2db03f13 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -202,6 +202,11 @@ import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; import * as ep___i_userGroupInvites from './endpoints/i/user-group-invites.js'; +import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; +import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; +import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; +import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; +import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___messaging_history from './endpoints/messaging/history.js'; import * as ep___messaging_messages from './endpoints/messaging/messages.js'; import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; @@ -507,6 +512,11 @@ const eps = [ ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], ['i/user-group-invites', ep___i_userGroupInvites], + ['i/webhooks/create', ep___i_webhooks_create], + ['i/webhooks/list', ep___i_webhooks_list], + ['i/webhooks/show', ep___i_webhooks_show], + ['i/webhooks/update', ep___i_webhooks_update], + ['i/webhooks/delete', ep___i_webhooks_delete], ['messaging/history', ep___messaging_history], ['messaging/messages', ep___messaging_messages], ['messaging/messages/create', ep___messaging_messages_create], diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts new file mode 100644 index 000000000..2e2fd00b8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -0,0 +1,43 @@ +import define from '../../../define.js'; +import { genId } from '@/misc/gen-id.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; +import { webhookEventTypes } from '@/models/entities/webhook.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + + kind: 'write:account', +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + url: { type: 'string', minLength: 1, maxLength: 1024 }, + secret: { type: 'string', minLength: 1, maxLength: 1024 }, + on: { type: 'array', items: { + type: 'string', enum: webhookEventTypes, + } }, + }, + required: ['name', 'url', 'secret', 'on'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + }).then(x => Webhooks.findOneByOrFail(x.identifiers[0])); + + publishInternalEvent('webhookCreated', webhook); + + return webhook; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts new file mode 100644 index 000000000..2821eaa5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts @@ -0,0 +1,44 @@ +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchWebhook: { + message: 'No such webhook.', + code: 'NO_SUCH_WEBHOOK', + id: 'bae73e5a-5522-4965-ae19-3a8688e71d82', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + webhookId: { type: 'string', format: 'misskey:id' }, + }, + required: ['webhookId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await Webhooks.delete(webhook.id); + + publishInternalEvent('webhookDeleted', webhook); +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts new file mode 100644 index 000000000..54e456373 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -0,0 +1,25 @@ +import define from '../../../define.js'; +import { Webhooks } from '@/models/index.js'; + +export const meta = { + tags: ['webhooks', 'account'], + + requireCredential: true, + + kind: 'read:account', +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const webhooks = await Webhooks.findBy({ + userId: me.id, + }); + + return webhooks; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts new file mode 100644 index 000000000..02fa1edb5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -0,0 +1,41 @@ +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + + kind: 'read:account', + + errors: { + noSuchWebhook: { + message: 'No such webhook.', + code: 'NO_SUCH_WEBHOOK', + id: '50f614d9-3047-4f7e-90d8-ad6b2d5fb098', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + webhookId: { type: 'string', format: 'misskey:id' }, + }, + required: ['webhookId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + return 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 new file mode 100644 index 000000000..f87b9753f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -0,0 +1,59 @@ +import define from '../../../define.js'; +import { ApiError } from '../../../error.js'; +import { Webhooks } from '@/models/index.js'; +import { publishInternalEvent } from '@/services/stream.js'; +import { webhookEventTypes } from '@/models/entities/webhook.js'; + +export const meta = { + tags: ['webhooks'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchWebhook: { + message: 'No such webhook.', + code: 'NO_SUCH_WEBHOOK', + id: 'fb0fea69-da18-45b1-828d-bd4fd1612518', + }, + }, + +} as const; + +export const paramDef = { + type: 'object', + properties: { + webhookId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1, maxLength: 100 }, + url: { type: 'string', minLength: 1, maxLength: 1024 }, + secret: { type: 'string', minLength: 1, maxLength: 1024 }, + on: { type: 'array', items: { + type: 'string', enum: webhookEventTypes, + } }, + active: { type: 'boolean' }, + }, + required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await Webhooks.update(webhook.id, { + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + active: ps.active, + }); + + publishInternalEvent('webhookUpdated', webhook); +}); diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index bea863eb7..3b0a75d79 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -15,6 +15,7 @@ import { AbuseUserReport } from '@/models/entities/abuse-user-report.js'; import { Signin } from '@/models/entities/signin.js'; import { Page } from '@/models/entities/page.js'; import { Packed } from '@/misc/schema.js'; +import { Webhook } from '@/models/entities/webhook'; //#region Stream type-body definitions export interface InternalStreamTypes { @@ -23,6 +24,9 @@ export interface InternalStreamTypes { userChangeModeratorState: { id: User['id']; isModerator: User['isModerator']; }; userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; remoteUserUpdated: { id: User['id']; }; + webhookCreated: Webhook; + webhookDeleted: Webhook; + webhookUpdated: Webhook; antennaCreated: Antenna; antennaDeleted: Antenna; antennaUpdated: Antenna; diff --git a/packages/backend/src/services/blocking/create.ts b/packages/backend/src/services/blocking/create.ts index 86c7d7967..5c6719007 100644 --- a/packages/backend/src/services/blocking/create.ts +++ b/packages/backend/src/services/blocking/create.ts @@ -10,6 +10,8 @@ import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLis import { perUserFollowingChart } from '@/services/chart/index.js'; import { genId } from '@/misc/gen-id.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; +import { webhookDeliver } from '@/queue/index.js'; export default async function(blocker: User, blockee: User) { await Promise.all([ @@ -57,9 +59,17 @@ async function cancelRequest(follower: User, followee: User) { if (Users.isLocalUser(follower)) { Users.pack(followee, follower, { detail: true, - }).then(packed => { + }).then(async packed => { publishUserEvent(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'unfollow', + user: packed, + }); + } }); } @@ -102,9 +112,17 @@ async function unFollow(follower: User, followee: User) { if (Users.isLocalUser(follower)) { Users.pack(followee, follower, { detail: true, - }).then(packed => { + }).then(async packed => { publishUserEvent(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'unfollow', + user: packed, + }); + } }); } diff --git a/packages/backend/src/services/following/create.ts b/packages/backend/src/services/following/create.ts index 0daf30dda..d243317d9 100644 --- a/packages/backend/src/services/following/create.ts +++ b/packages/backend/src/services/following/create.ts @@ -15,6 +15,8 @@ import { genId } from '@/misc/gen-id.js'; import { createNotification } from '../create-notification.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { Packed } from '@/misc/schema.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; +import { webhookDeliver } from '@/queue/index.js'; const logger = new Logger('following/create'); @@ -89,15 +91,33 @@ export async function insertFollowingDoc(followee: { id: User['id']; host: User[ if (Users.isLocalUser(follower)) { Users.pack(followee.id, follower, { detail: true, - }).then(packed => { + }).then(async packed => { publishUserEvent(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); publishMainStream(follower.id, 'follow', packed as Packed<"UserDetailedNotMe">); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'follow', + user: packed, + }); + } }); } // Publish followed event if (Users.isLocalUser(followee)) { - Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'followed', packed)); + Users.pack(follower.id, followee).then(async packed => { + publishMainStream(followee.id, 'followed', packed) + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'followed', + user: packed, + }); + } + }); // 通知を作成 createNotification(followee.id, 'follow', { diff --git a/packages/backend/src/services/following/delete.ts b/packages/backend/src/services/following/delete.ts index 35fd664b5..85e40f136 100644 --- a/packages/backend/src/services/following/delete.ts +++ b/packages/backend/src/services/following/delete.ts @@ -3,12 +3,13 @@ import { renderActivity } from '@/remote/activitypub/renderer/index.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderUndo from '@/remote/activitypub/renderer/undo.js'; import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { deliver } from '@/queue/index.js'; +import { deliver, webhookDeliver } from '@/queue/index.js'; import Logger from '../logger.js'; import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js'; import { User } from '@/models/entities/user.js'; import { Followings, Users, Instances } from '@/models/index.js'; import { instanceChart, perUserFollowingChart } from '@/services/chart/index.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; const logger = new Logger('following/delete'); @@ -31,9 +32,17 @@ export default async function(follower: { id: User['id']; host: User['host']; ur if (!silent && Users.isLocalUser(follower)) { Users.pack(followee.id, follower, { detail: true, - }).then(packed => { + }).then(async packed => { publishUserEvent(follower.id, 'unfollow', packed); publishMainStream(follower.id, 'unfollow', packed); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'unfollow', + user: packed, + }); + } }); } diff --git a/packages/backend/src/services/following/reject.ts b/packages/backend/src/services/following/reject.ts index 2d1db3c34..e1744e05b 100644 --- a/packages/backend/src/services/following/reject.ts +++ b/packages/backend/src/services/following/reject.ts @@ -1,11 +1,12 @@ import { renderActivity } from '@/remote/activitypub/renderer/index.js'; import renderFollow from '@/remote/activitypub/renderer/follow.js'; import renderReject from '@/remote/activitypub/renderer/reject.js'; -import { deliver } from '@/queue/index.js'; +import { deliver, webhookDeliver } from '@/queue/index.js'; import { publishMainStream, publishUserEvent } from '@/services/stream.js'; import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; import { Users, FollowRequests, Followings } from '@/models/index.js'; import { decrementFollowing } from './delete.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; type Local = ILocalUser | { id: ILocalUser['id']; @@ -111,4 +112,12 @@ async function publishUnfollow(followee: Both, follower: Local) { publishUserEvent(follower.id, 'unfollow', packedFollowee); publishMainStream(follower.id, 'unfollow', packedFollowee); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'unfollow', + user: packedFollowee, + }); + } } diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index 2ed194b7e..6f373aaf4 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -35,9 +35,11 @@ import { Channel } from '@/models/entities/channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { getAntennas } from '@/misc/antenna-cache.js'; import { endedPollNotificationQueue } from '@/queue/queues.js'; +import { webhookDeliver } from '@/queue/index.js'; import { Cache } from '@/misc/cache.js'; import { UserProfile } from '@/models/entities/user-profile.js'; import { db } from '@/db/postgre.js'; +import { getActiveWebhooks } from '@/misc/webhook-cache.js'; const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -345,6 +347,16 @@ export default async (user: { id: User['id']; username: User['username']; host: publishNotesStream(noteObj); + getActiveWebhooks().then(webhooks => { + webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'note', + note: noteObj, + }); + } + }); + const nm = new NotificationManager(user, note); const nmRelatedPromises = []; @@ -365,6 +377,14 @@ export default async (user: { id: User['id']; username: User['username']; host: if (!threadMuted) { nm.push(data.reply.userId, 'reply'); publishMainStream(data.reply.userId, 'reply', noteObj); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'reply', + note: noteObj, + }); + } } } } @@ -384,6 +404,14 @@ export default async (user: { id: User['id']; username: User['username']; host: // Publish event if ((user.id !== data.renote.userId) && data.renote.userHost === null) { publishMainStream(data.renote.userId, 'renote', noteObj); + + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'renote', + note: noteObj, + }); + } } } @@ -620,6 +648,14 @@ async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, publishMainStream(u.id, 'mention', detailPackedNote); + const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); + for (const webhook of webhooks) { + webhookDeliver(webhook, { + type: 'mention', + note: detailPackedNote, + }); + } + // Create notification nm.push(u.id, 'mention'); } diff --git a/packages/client/src/components/form/link.vue b/packages/client/src/components/form/link.vue index 3eb74425b..b74e9bd68 100644 --- a/packages/client/src/components/form/link.vue +++ b/packages/client/src/components/form/link.vue @@ -80,7 +80,7 @@ export default defineComponent({ margin-right: 0.75em; flex-shrink: 0; text-align: center; - opacity: 0.8; + color: var(--fgTransparentWeak); &:empty { display: none; diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index 42e40c5ac..44c3be62f 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -148,6 +148,11 @@ const menuDef = computed(() => [{ text: 'API', to: '/settings/api', active: page.value === 'api', + }, { + icon: 'fas fa-bolt', + text: 'Webhook', + to: '/settings/webhook', + active: page.value === 'webhook', }, { icon: 'fas fa-ellipsis-h', text: i18n.ts.other, @@ -192,6 +197,9 @@ const component = computed(() => { case 'security': return defineAsyncComponent(() => import('./security.vue')); case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); case 'api': return defineAsyncComponent(() => import('./api.vue')); + case 'webhook': return defineAsyncComponent(() => import('./webhook.vue')); + case 'webhook/new': return defineAsyncComponent(() => import('./webhook.new.vue')); + case 'webhook/edit': return defineAsyncComponent(() => import('./webhook.edit.vue')); case 'apps': return defineAsyncComponent(() => import('./apps.vue')); case 'other': return defineAsyncComponent(() => import('./other.vue')); case 'general': return defineAsyncComponent(() => import('./general.vue')); diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue new file mode 100644 index 000000000..bb3a25407 --- /dev/null +++ b/packages/client/src/pages/settings/webhook.edit.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue new file mode 100644 index 000000000..9bb492c49 --- /dev/null +++ b/packages/client/src/pages/settings/webhook.new.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue new file mode 100644 index 000000000..c9af8b676 --- /dev/null +++ b/packages/client/src/pages/settings/webhook.vue @@ -0,0 +1,52 @@ + + +