Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { SpaceModule } from './features/space/space.module';
import { TrashModule } from './features/trash/trash.module';
import { UndoRedoModule } from './features/undo-redo/open-api/undo-redo.module';
import { UserModule } from './features/user/user.module';
import { WebhookModule } from './features/webhook/webhook.module';
import { GlobalModule } from './global/global.module';
import { InitBootstrapProvider } from './global/init-bootstrap.provider';
import { LoggerModule } from './logger/logger.module';
Expand Down Expand Up @@ -70,6 +71,7 @@ export const appModules = {
PluginModule,
DashboardModule,
CommentOpenApiModule,
WebhookModule,
OrganizationModule,
AiModule,
],
Expand Down
2 changes: 2 additions & 0 deletions apps/nestjs-backend/src/configs/threshold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const thresholdConfig = registerAs('threshold', () => ({
maxOpenapiAttachmentUploadSize: Number(
process.env.MAX_OPENAPI_ATTACHMENT_UPLOAD_SIZE ?? Infinity
),
// Maximum limit for creating webhooks, If the environment variable is not set, it defaults to 10.
maxCreateWebhookLimit: Number(process.env.MAX_CREATE_WEBHOOK_LIMIT ?? 10),
}));

export const ThresholdConfig = () => Inject(thresholdConfig.KEY);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { WebhookFactory } from '../../features/webhook/webhook.factory';
import { CoreEvent } from '../events';

// type IViewEvent = ViewUpdateEvent;
// type IRecordEvent = RecordCreateEvent | RecordDeleteEvent | RecordUpdateEvent;
// type IListenerEvent = IViewEvent | IRecordEvent;

@Injectable()
export class WebhookListener {
constructor(private readonly webhookFactory: WebhookFactory) {}

@OnEvent('base.*', { async: true })
@OnEvent('table.*', { async: true })
async listener(event: CoreEvent): Promise<void> {
// event.context
this.webhookFactory.run('', event);
}
}
71 changes: 71 additions & 0 deletions apps/nestjs-backend/src/features/webhook/webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import type {
IWebhookListVo,
IWebhookRunHistoriesVo,
IWebhookRunHistoryVo,
IWebhookVo,
} from '@teable/openapi';
import {
createWebhookRoSchema,
getWebhookRunHistoryListQuerySchema,
ICreateWebhookRo,
IGetWebhookRunHistoryListQuery,
IUpdateWebhookRo,
updateWebhookRoSchema,
} from '@teable/openapi';
import { ZodValidationPipe } from '../../zod.validation.pipe';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { WebhookService } from './webhook.service';

@Controller('api/:spaceId/webhook')
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}

@Get()
@Permissions('space|create')
async getWebhookList(@Param('spaceId') spaceId: string): Promise<IWebhookListVo> {
return await this.webhookService.getWebhookList(spaceId);
}

@Get(':webhookId')
async getWebhookById(@Param('webhookId') webhookId: string): Promise<IWebhookVo> {
return await this.webhookService.getWebhookById(webhookId);
}

@Post()
async createWebhook(
@Body(new ZodValidationPipe(createWebhookRoSchema)) body: ICreateWebhookRo
): Promise<IWebhookVo> {
return await this.webhookService.createWebhook(body);
}

@Delete(':webhookId')
async deleteWebhook(@Param('webhookId') webhookId: string) {
return await this.webhookService.deleteWebhook(webhookId);
}

@Put(':webhookId')
async updateWebhook(
@Param('webhookId') webhookId: string,
@Body(new ZodValidationPipe(updateWebhookRoSchema)) body: IUpdateWebhookRo
): Promise<IWebhookVo> {
return await this.webhookService.updateWebhook(webhookId, body);
}

@Get(':webhookId/run-history')
async getWebhookRunHistoryList(
@Param('webhookId') webhookId: string,
@Query(new ZodValidationPipe(getWebhookRunHistoryListQuerySchema))
query: IGetWebhookRunHistoryListQuery
): Promise<IWebhookRunHistoriesVo> {
return await this.webhookService.getWebhookRunHistoryList(webhookId, query);
}

@Get('/run-history/:runHistoryId')
async getWebhookRunHistoryById(
@Param('runHistoryId') runHistoryId: string
): Promise<IWebhookRunHistoryVo> {
return await this.webhookService.getWebhookRunHistoryById(runHistoryId);
}
}
73 changes: 73 additions & 0 deletions apps/nestjs-backend/src/features/webhook/webhook.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/naming-convention */
import crypto from 'crypto';
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import type { IWebhookVo } from '@teable/openapi';
import { ContentType } from '@teable/openapi';
import { filter, from, mergeMap } from 'rxjs';
import { match } from 'ts-pattern';
import type { CoreEvent } from '../../event-emitter/events';
import { WebhookService } from './webhook.service';

type IWebhook = IWebhookVo & { secret: string | null };

@Injectable()
export class WebhookFactory {
private logger = new Logger(WebhookFactory.name);

constructor(
private readonly httpService: HttpService,
private readonly webHookService: WebhookService
) {}

async run(spaceId: string, event: CoreEvent) {
const webHookList = await this.webHookService.getWebhookListBySpaceId(spaceId);

// 10s
// event.payload

from(webHookList).pipe(
filter((webhookContext) => {
return true;
}),
mergeMap((value) => {
return this.sendHttpRequest(value, event);
}, 10)
);
}

private sendHttpRequest(webhookContext: IWebhook, event: CoreEvent) {
const body = match(webhookContext.contentType)
.with(ContentType.Json, () => {
return JSON.stringify(event.payload);
})
.with(ContentType.FormUrlencoded, () => {
return '';
})
.exhaustive();

const headers: { [key: string]: string } = {};
headers['User-Agent'] = 'teable/1.0.0';
headers['Content-Type'] = webhookContext.contentType;
headers['X-Event'] = event.name;
headers['X-Hook-ID'] = '';

if (webhookContext.secret) {
headers['X-Signature-256'] = this.signature(webhookContext.secret, body);
}

return this.httpService
.post(webhookContext.url, body, {
headers: headers,
})
.pipe();
}

private signature(secret: string, body: unknown): string {
const signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(body))
.digest('hex');
return `sha256=${signature}`;
}
}
17 changes: 17 additions & 0 deletions apps/nestjs-backend/src/features/webhook/webhook.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { WebhookController } from './webhook.controller';
import { WebhookFactory } from './webhook.factory';
import { WebhookService } from './webhook.service';

@Module({
imports: [
HttpModule.register({
timeout: 5000,
}),
],
controllers: [WebhookController],
exports: [WebhookService],
providers: [WebhookService, WebhookFactory],
})
export class WebhookModule {}
Loading
Loading