feat: support webhook (#1298)

This commit is contained in:
Innei
2023-12-24 21:13:07 +08:00
committed by GitHub
parent 309184c5c6
commit c6d037db1d
28 changed files with 758 additions and 26 deletions

View File

@@ -61,6 +61,7 @@ import { SyncModule } from './modules/sync/sync.module'
import { TopicModule } from './modules/topic/topic.module' import { TopicModule } from './modules/topic/topic.module'
import { UpdateModule } from './modules/update/update.module' import { UpdateModule } from './modules/update/update.module'
import { UserModule } from './modules/user/user.module' import { UserModule } from './modules/user/user.module'
import { WebhookModule } from './modules/webhook/webhook.module'
import { DatabaseModule } from './processors/database/database.module' import { DatabaseModule } from './processors/database/database.module'
import { GatewayModule } from './processors/gateway/gateway.module' import { GatewayModule } from './processors/gateway/gateway.module'
import { HelperModule } from './processors/helper/helper.module' import { HelperModule } from './processors/helper/helper.module'
@@ -109,6 +110,7 @@ import { RedisModule } from './processors/redis/redis.module'
TopicModule, TopicModule,
UpdateModule, UpdateModule,
UserModule, UserModule,
WebhookModule,
PageProxyModule, PageProxyModule,
RenderEjsModule, RenderEjsModule,

View File

@@ -53,15 +53,15 @@ export enum BusinessEvents {
STDOUT = 'STDOUT', STDOUT = 'STDOUT',
// activity // activity
ACTIVITY_LIKE = 'activity_like', ACTIVITY_LIKE = 'ACTIVITY_LIKE',
} }
export enum EventScope { export enum EventScope {
ALL, TO_VISITOR = 1 << 0,
TO_VISITOR, TO_ADMIN = 1 << 1,
TO_ADMIN, TO_SYSTEM = 1 << 2,
TO_SYSTEM, TO_VISITOR_ADMIN = (1 << 0) | (1 << 1),
TO_VISITOR_ADMIN, TO_SYSTEM_VISITOR = (1 << 0) | (1 << 2),
TO_SYSTEM_VISITOR, TO_SYSTEM_ADMIN = (1 << 1) | (1 << 2),
TO_SYSTEM_ADMIN, ALL = (1 << 0) | (1 << 1) | (1 << 2),
} }

View File

@@ -12,6 +12,9 @@ export const CATEGORY_COLLECTION_NAME = 'categories'
export const COMMENT_COLLECTION_NAME = 'comments' export const COMMENT_COLLECTION_NAME = 'comments'
export const RECENTLY_COLLECTION_NAME = 'recentlies' export const RECENTLY_COLLECTION_NAME = 'recentlies'
export const Analyze_COLLECTION_NAME = 'analyzes'
export const WEBHOOK_EVENT_COLLECTION_NAME = 'webhook_events'
export enum CollectionRefTypes { export enum CollectionRefTypes {
Post = POST_COLLECTION_NAME, Post = POST_COLLECTION_NAME,
Note = NOTE_COLLECTION_NAME, Note = NOTE_COLLECTION_NAME,

View File

@@ -3,6 +3,7 @@ import { UAParser } from 'ua-parser-js'
import { index, modelOptions, prop, Severity } from '@typegoose/typegoose' import { index, modelOptions, prop, Severity } from '@typegoose/typegoose'
import { Analyze_COLLECTION_NAME } from '~/constants/db.constant'
import { BaseModel } from '~/shared/model/base.model' import { BaseModel } from '~/shared/model/base.model'
@modelOptions({ @modelOptions({
@@ -13,7 +14,7 @@ import { BaseModel } from '~/shared/model/base.model'
}, },
}, },
options: { options: {
customName: 'Analyze', customName: Analyze_COLLECTION_NAME,
allowMixed: Severity.ALLOW, allowMixed: Severity.ALLOW,
}, },
}) })

View File

@@ -1,6 +1,7 @@
import { existsSync, statSync } from 'fs' import { existsSync, statSync } from 'fs'
import { readdir, readFile, rm, writeFile } from 'fs/promises' import { readdir, readFile, rm, writeFile } from 'fs/promises'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import { flatten } from 'lodash'
import { mkdirp } from 'mkdirp' import { mkdirp } from 'mkdirp'
import { import {
@@ -15,6 +16,10 @@ import { DEMO_MODE, MONGO_DB } from '~/app.config'
import { CronDescription } from '~/common/decorators/cron-description.decorator' import { CronDescription } from '~/common/decorators/cron-description.decorator'
import { CronOnce } from '~/common/decorators/cron-once.decorator' import { CronOnce } from '~/common/decorators/cron-once.decorator'
import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { BusinessEvents, EventScope } from '~/constants/business-event.constant'
import {
Analyze_COLLECTION_NAME,
WEBHOOK_EVENT_COLLECTION_NAME,
} from '~/constants/db.constant'
import { BACKUP_DIR, DATA_DIR } from '~/constants/path.constant' import { BACKUP_DIR, DATA_DIR } from '~/constants/path.constant'
import { migrateDatabase } from '~/migration/migrate' import { migrateDatabase } from '~/migration/migrate'
import { EventManagerService } from '~/processors/helper/helper.event.service' import { EventManagerService } from '~/processors/helper/helper.event.service'
@@ -25,6 +30,10 @@ import { getFolderSize, installPKG } from '~/utils/system.util'
import { ConfigsService } from '../configs/configs.service' import { ConfigsService } from '../configs/configs.service'
const excludeCollections = [
Analyze_COLLECTION_NAME,
WEBHOOK_EVENT_COLLECTION_NAME,
]
@Injectable() @Injectable()
export class BackupService { export class BackupService {
private logger: Logger private logger: Logger
@@ -79,7 +88,14 @@ export class BackupService {
const backupDirPath = join(BACKUP_DIR, dateDir) const backupDirPath = join(BACKUP_DIR, dateDir)
mkdirp.sync(backupDirPath) mkdirp.sync(backupDirPath)
try { try {
await $`mongodump -h ${MONGO_DB.host} --port ${MONGO_DB.port} -d ${MONGO_DB.dbName} --excludeCollection analyzes -o ${backupDirPath} >/dev/null 2>&1` await $`mongodump -h ${MONGO_DB.host} --port ${MONGO_DB.port} -d ${
MONGO_DB.dbName
} ${flatten(
excludeCollections.map((collection) => [
'--excludeCollection',
`${collection}`,
]),
)} -o ${backupDirPath} >/dev/null 2>&1`
// 打包 DB // 打包 DB
cd(backupDirPath) cd(backupDirPath)
await $`mv ${MONGO_DB.dbName} mx-space`.quiet().nothrow() await $`mv ${MONGO_DB.dbName} mx-space`.quiet().nothrow()

View File

@@ -40,9 +40,8 @@ import { NoteMusic } from './models/music.model'
export class NoteModel extends WriteBaseModel { export class NoteModel extends WriteBaseModel {
@prop() @prop()
@IsString() @IsString()
@IsOptional()
@Transform(({ value: title }) => (title.length === 0 ? '无题' : title)) @Transform(({ value: title }) => (title.length === 0 ? '无题' : title))
title: string declare title: string
@prop({ required: false, unique: true }) @prop({ required: false, unique: true })
public nid: number public nid: number

View File

@@ -0,0 +1,6 @@
import type { TopicModel } from '../topic/topic.model'
import type { NoteModel } from './note.model'
export type NormalizedNote = Omit<NoteModel, 'password' | 'topic'> & {
topic: TopicModel
}

View File

@@ -0,0 +1,6 @@
import type { CategoryModel } from '../category/category.model'
import type { PostModel } from './post.model'
export type NormalizedPost = Omit<PostModel, 'category'> & {
category: CategoryModel
}

View File

@@ -0,0 +1,60 @@
import Paginate from 'mongoose-paginate-v2'
import { modelOptions, plugin, prop, Ref } from '@typegoose/typegoose'
import { WEBHOOK_EVENT_COLLECTION_NAME } from '~/constants/db.constant'
import { mongooseLeanId } from '~/shared/model/plugins/lean-id'
import { WebhookModel } from './webhook.model'
type JSON = string
const JSONProps = {
type: String,
set(value) {
if (typeof value === 'object' && value) {
return JSON.stringify(value)
}
return value
},
}
@modelOptions({
schemaOptions: {
timestamps: {
createdAt: 'timestamp',
updatedAt: false,
},
},
options: {
customName: WEBHOOK_EVENT_COLLECTION_NAME,
},
})
@plugin(Paginate)
@plugin(mongooseLeanId)
export class WebhookEventModel {
@prop(JSONProps)
headers: JSON
@prop(JSONProps)
payload: JSON
@prop()
event: string
@prop({ type: String })
response: JSON
@prop()
success: boolean
@prop({
ref: () => WebhookModel,
required: true,
})
hookId: Ref<WebhookModel>
@prop({
default: 0,
})
status: number
}

View File

@@ -0,0 +1,64 @@
import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'
import { ApiController } from '~/common/decorators/api-controller.decorator'
import { Auth } from '~/common/decorators/auth.decorator'
import { HTTPDecorators } from '~/common/decorators/http.decorator'
import { BusinessEvents } from '~/constants/business-event.constant'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { PagerDto } from '~/shared/dto/pager.dto'
import { WebhookDtoPartial, WebhookModel } from './webhook.model'
import { WebhookService } from './webhook.service'
@ApiController('/webhooks')
@Auth()
export class WebhookController {
constructor(private readonly service: WebhookService) {}
@Post('/')
create(@Body() body: WebhookModel) {
body.events = this.service.transformEvents(body.events)
return this.service.createWebhook(body)
}
@Get('/')
async getAll() {
return this.service.getAllWebhooks().then((data) => {
Reflect.deleteProperty(data, 'secret')
return data
})
}
@Patch('/:id')
async update(@Body() body: WebhookDtoPartial, @Param() { id }: MongoIdDto) {
if (body.events) body.events = this.service.transformEvents(body.events)
return this.service.updateWebhook(id, body)
}
@Delete('/:id')
async delete(@Param() { id }: MongoIdDto) {
return this.service.deleteWebhook(id)
}
@Get('/:id')
@HTTPDecorators.Paginator
async getEventsByHookId(
@Param() { id }: MongoIdDto,
@Query() query: PagerDto,
) {
return this.service.getEventsByHookId(id, query)
}
@Get('/events')
async getEventsEnum() {
return Object.values(BusinessEvents)
}
@Post('/redispatch/:id')
async redispatch(@Param() { id }: MongoIdDto) {
return this.service.redispatch(id)
}
}

View File

@@ -0,0 +1,46 @@
import { IsBoolean, IsEnum, IsString, IsUrl } from 'class-validator'
import { PartialType } from '@nestjs/mapped-types'
import { modelOptions, plugin, prop } from '@typegoose/typegoose'
import { EventScope } from '~/constants/business-event.constant'
import { mongooseLeanId } from '~/shared/model/plugins/lean-id'
@modelOptions({
schemaOptions: {
timestamps: {
createdAt: 'timestamp',
},
},
options: {
customName: 'webhooks',
},
})
@plugin(mongooseLeanId)
export class WebhookModel {
@prop({ required: true })
@IsUrl({
require_protocol: true,
})
payloadUrl: string
@prop({ required: true, type: String })
@IsString({ each: true })
events: string[]
@prop({ required: true })
@IsBoolean()
enabled: boolean
id: string
@prop({ required: true, select: false })
@IsString()
secret: string
@prop({ enum: EventScope })
@IsEnum(EventScope)
scope: EventScope
}
export class WebhookDtoPartial extends PartialType(WebhookModel) {}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'
import { WebhookController } from './webhook.controller'
import { WebhookService } from './webhook.service'
@Module({
controllers: [WebhookController],
providers: [WebhookService],
})
export class WebhookModule {}

View File

@@ -0,0 +1,200 @@
import { createHmac } from 'crypto'
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import type { IEventManagerHandlerDisposer } from '~/processors/helper/helper.event.service'
import type { PagerDto } from '~/shared/dto/pager.dto'
import { BadRequestException, Injectable } from '@nestjs/common'
import { ReturnModelType } from '@typegoose/typegoose'
import { BusinessEvents, EventScope } from '~/constants/business-event.constant'
import { EventManagerService } from '~/processors/helper/helper.event.service'
import { HttpService } from '~/processors/helper/helper.http.service'
import { InjectModel } from '~/transformers/model.transformer'
import { WebhookEventModel } from './webhook-event.model'
import { WebhookModel } from './webhook.model'
const ACCEPT_EVENTS = new Set(Object.values(BusinessEvents))
@Injectable()
export class WebhookService implements OnModuleInit, OnModuleDestroy {
constructor(
@InjectModel(WebhookModel)
private readonly webhookModel: ReturnModelType<typeof WebhookModel>,
@InjectModel(WebhookEventModel)
private readonly webhookEventModel: MongooseModel<WebhookEventModel>,
private readonly httpService: HttpService,
private readonly eventService: EventManagerService,
) {}
private eventListenerDisposer: IEventManagerHandlerDisposer
onModuleDestroy() {
this.eventListenerDisposer()
}
onModuleInit() {
this.eventListenerDisposer = this.eventService.registerHandler(
(type: BusinessEvents, data, scope) => {
if (!ACCEPT_EVENTS.has(type)) {
return
}
this.sendWebhook(type, data, scope)
},
)
}
createWebhook(model: WebhookModel) {
return this.webhookModel.create(model)
}
transformEvents(events: string[]) {
let nextEvents = [] as string[]
for (const event of events) {
if (event === 'all') {
nextEvents = ['all']
break
}
if (ACCEPT_EVENTS.has(event as any)) {
nextEvents.push(event)
}
}
return nextEvents
}
async deleteWebhook(id: string) {
await this.webhookModel.deleteOne({
_id: id,
})
await this.webhookEventModel.deleteMany({
hookId: id,
})
}
async updateWebhook(id: string, model: Partial<WebhookModel>) {
await this.webhookModel.updateOne(
{
_id: id,
},
model,
)
}
async getAllWebhooks() {
return this.webhookModel.find().lean()
}
async sendWebhook(event: string, payload: any, scope: EventScope) {
const stringifyPayload = JSON.stringify(payload)
const clonedPayload = JSON.parse(stringifyPayload)
const enabledWebHooks = await this.webhookModel
.find({
events: {
$in: [event, 'all'],
},
enabled: true,
})
.select('+secret')
.lean()
const scopedWebhooks = enabledWebHooks.filter((webhook) => {
return (webhook.scope || EventScope.ALL & scope) === scope
})
await Promise.all(
scopedWebhooks.map(async (webhook) => {
const headers = {
'X-Webhook-Signature': generateSha1Signature(
webhook.secret,
stringifyPayload,
),
'X-Webhook-Event': event,
'X-Webhook-Id': webhook.id,
'X-Webhook-Timestamp': Date.now().toString(),
'X-Webhook-Signature256': generateSha256Signature(
webhook.secret,
stringifyPayload,
),
}
const webhookEvent = await this.webhookEventModel.create({
event,
headers,
success: false,
payload: stringifyPayload,
hookId: webhook.id,
response: null,
})
this.httpService.axiosRef
.post(webhook.payloadUrl, clonedPayload, {
headers,
})
.then(async (response) => {
webhookEvent.response = JSON.stringify({
headers: response.headers,
data: response.data,
timestamp: Date.now(),
})
webhookEvent.status = response.status
webhookEvent.success = true
await webhookEvent.save()
})
.catch((err) => {
if (!err.response) {
return
}
webhookEvent.response = JSON.stringify({
headers: err.response.headers,
data: err.response.data,
timestamp: Date.now(),
})
webhookEvent.status = err.response.status
webhookEvent.success = false
webhookEvent.save()
})
}),
)
}
async redispatch(id: string) {
const record = await this.webhookEventModel.findById(id)
if (!record) {
throw new BadRequestException('Webhook event not found')
}
const hook = await this.webhookModel.findById(record.hookId)
if (!hook) {
throw new BadRequestException('Webhook not found')
}
const scope = hook.scope
await this.sendWebhook(record.event, JSON.parse(record.payload), scope)
}
async getEventsByHookId(hookId: string, query: PagerDto) {
const { page, size } = query
const skip = (page - 1) * size
return await this.webhookEventModel.paginate(
{
hookId,
},
{
limit: size,
skip,
sort: {
timestamp: -1,
},
},
)
}
}
function generateSha1Signature(secret: string, payload: string): string {
const hmac = createHmac('sha1', secret)
hmac.update(payload)
return hmac.digest('hex')
}
function generateSha256Signature(secret: string, payload: string): string {
const hmac = createHmac('sha256', secret)
hmac.update(payload)
return hmac.digest('hex')
}

View File

@@ -17,6 +17,8 @@ import { SubscribeModel } from '~/modules/subscribe/subscribe.model'
import { SyncUpdateModel } from '~/modules/sync-update/sync-update.model' import { SyncUpdateModel } from '~/modules/sync-update/sync-update.model'
import { TopicModel } from '~/modules/topic/topic.model' import { TopicModel } from '~/modules/topic/topic.model'
import { UserModel } from '~/modules/user/user.model' import { UserModel } from '~/modules/user/user.model'
import { WebhookEventModel } from '~/modules/webhook/webhook-event.model'
import { WebhookModel } from '~/modules/webhook/webhook.model'
import { getProviderByTypegooseClass } from '~/transformers/model.transformer' import { getProviderByTypegooseClass } from '~/transformers/model.transformer'
export const databaseModels = [ export const databaseModels = [
@@ -39,4 +41,7 @@ export const databaseModels = [
SubscribeModel, SubscribeModel,
UserModel, UserModel,
SyncUpdateModel, SyncUpdateModel,
WebhookModel,
WebhookEventModel,
].map((model) => getProviderByTypegooseClass(model)) ].map((model) => getProviderByTypegooseClass(model))

View File

@@ -19,6 +19,12 @@ export type EventManagerOptions = {
nextTick?: boolean nextTick?: boolean
} }
type EventHandler = {
event: string
payload: any
scope: EventScope
}
export type IEventManagerHandlerDisposer = () => void export type IEventManagerHandlerDisposer = () => void
@Injectable() @Injectable()
@@ -40,8 +46,6 @@ export class EventManagerService {
this.logger = new Logger(EventManagerService.name) this.logger = new Logger(EventManagerService.name)
this.listenSystemEvents() this.listenSystemEvents()
this.logger.debug('EventManagerService is ready')
} }
private mapScopeToInstance: Record< private mapScopeToInstance: Record<
@@ -59,10 +63,15 @@ export class EventManagerService {
this.emitter2, this.emitter2,
this.systemGateway, this.systemGateway,
], ],
[EventScope.TO_VISITOR]: [this.webGateway], [EventScope.TO_VISITOR]: [this.webGateway, this.emitter2],
[EventScope.TO_ADMIN]: [this.adminGateway], [EventScope.TO_ADMIN]: [this.adminGateway, this.emitter2],
[EventScope.TO_SYSTEM]: [this.emitter2, this.systemGateway], [EventScope.TO_SYSTEM]: [this.emitter2, this.systemGateway],
[EventScope.TO_VISITOR_ADMIN]: [this.webGateway, this.adminGateway], [EventScope.TO_VISITOR_ADMIN]: [
this.webGateway,
this.adminGateway,
this.emitter2,
],
[EventScope.TO_SYSTEM_VISITOR]: [ [EventScope.TO_SYSTEM_VISITOR]: [
this.emitter2, this.emitter2,
this.webGateway, this.webGateway,
@@ -109,6 +118,7 @@ export class EventManagerService {
return instance.emit(this.#key, { return instance.emit(this.#key, {
event, event,
payload, payload,
scope,
}) })
} else if (instance instanceof BroadcastBaseGateway) { } else if (instance instanceof BroadcastBaseGateway) {
return instance.broadcast(event as any, data) return instance.broadcast(event as any, data)
@@ -151,13 +161,13 @@ export class EventManagerService {
} }
} }
#handlers: ((type: string, data: any) => void)[] = [] #handlers: ((type: string, data: any, scope: EventScope) => void)[] = []
registerHandler( registerHandler(
handler: (type: EventBusEvents, data: any) => void, handler: (type: EventBusEvents, data: any, scope: EventScope) => void,
): IEventManagerHandlerDisposer ): IEventManagerHandlerDisposer
registerHandler( registerHandler(
handler: (type: BusinessEvents, data: any) => void, handler: (type: BusinessEvents, data: any, scope: EventScope) => void,
): IEventManagerHandlerDisposer ): IEventManagerHandlerDisposer
registerHandler(handler: Function) { registerHandler(handler: Function) {
this.#handlers.push(handler as any) this.#handlers.push(handler as any)
@@ -169,11 +179,13 @@ export class EventManagerService {
private listenSystemEvents() { private listenSystemEvents() {
this.emitter2.on(this.#key, (data) => { this.emitter2.on(this.#key, (data) => {
const { event, payload } = data const { event, payload, scope } = data
console.debug(`Received event: [${event}]`, payload) console.debug(`[${scope}]: Received event: [${event}]`, payload)
// emit current event directly // emit current event directly
this.emitter2.emit(event, payload) this.emitter2.emit(event, payload)
this.#handlers.forEach((handler) => handler(event, payload))
this.#handlers.forEach((handler) => handler(event, payload, scope))
}) })
} }

View File

@@ -12,6 +12,7 @@ import { getRedisKey } from '~/utils'
import { version } from '../../../package.json' import { version } from '../../../package.json'
import { CacheService } from '../redis/cache.service' import { CacheService } from '../redis/cache.service'
const DEFAULT_UA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 MX-Space/${version}`
declare module 'axios' { declare module 'axios' {
interface AxiosRequestConfig { interface AxiosRequestConfig {
__requestStartedAt?: number __requestStartedAt?: number
@@ -33,7 +34,7 @@ export class HttpService {
axios.create({ axios.create({
...AXIOS_CONFIG, ...AXIOS_CONFIG,
headers: { headers: {
'user-agent': `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 MX-Space/${version}`, 'user-agent': DEFAULT_UA,
}, },
}), }),
) )
@@ -49,7 +50,7 @@ export class HttpService {
private axiosDefaultConfig: AxiosRequestConfig<any> = { private axiosDefaultConfig: AxiosRequestConfig<any> = {
...AXIOS_CONFIG, ...AXIOS_CONFIG,
headers: { headers: {
'user-agent': `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 MX-Space/${version}`, 'user-agent': DEFAULT_UA,
}, },
'axios-retry': { 'axios-retry': {
retries: 3, retries: 3,

View File

@@ -36,7 +36,7 @@ export class WriteBaseModel extends BaseCommentIndexModel {
@IsDate() @IsDate()
@Transform(({ value }) => (value ? new Date(value) : void 0)) @Transform(({ value }) => (value ? new Date(value) : void 0))
@prop() @prop()
created?: Date declare created?: Date
@prop( @prop(
{ {

6
packages/webhook/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare global {
interface JSON {
safeParse: typeof JSON.parse
}
}
export {}

15
packages/webhook/index.js Normal file
View File

@@ -0,0 +1,15 @@
const express = require('express')
const { createHandler, BusinessEvents } = require('./dist/index')
const app = express()
app.use(express.json())
const handler = createHandler({ secret: 'test' })
app.post('/webhook', (req, res) => {
handler(req, res)
})
handler.emitter.on(BusinessEvents.POST_UPDATE, (event) => {
console.log(event)
})
app.listen('13333')

View File

@@ -0,0 +1,37 @@
{
"name": "@mx-space/webhook",
"version": "0.0.1",
"devDependencies": {
"express": "4.18.2"
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup && node scripts/post-build.js"
},
"bump": {
"before": [
"git pull --rebase",
"pnpm i",
"npm run package"
],
"after": [
"npm publish --access=public"
],
"tag": false,
"commit_message": "chore(release): bump @mx-space/webhook to v${NEW_VERSION}"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./dist/*": {
"import": "./dist/*.js",
"require": "./dist/*.cjs"
},
"./package.json": "./package.json"
}
}

View File

@@ -0,0 +1,33 @@
const fs = require('fs')
const path = require('path')
// Function to replace content in a file
function replaceContent(filePath, searchValue, replaceValue) {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error(`Error reading file from disk: ${err}`)
} else {
// Replace the text
const result = data.replace(searchValue, replaceValue)
// Write the file back
fs.writeFile(filePath, result, 'utf8', (writeErr) => {
if (writeErr) console.error(`Error writing file: ${writeErr}`)
else console.log(`Updated file: ${filePath}`)
})
}
})
}
// File paths
const files = ['dist/index.d.ts', 'dist/index.d.mts']
// The string to be replaced and its replacement
const searchValue = "import { Ref } from '@typegoose/typegoose';"
const replaceValue = 'type Ref <T> = unknown;'
// Apply the replacement for each file
files.forEach((file) => {
const filePath = path.join(__dirname, '../', file)
replaceContent(filePath, searchValue, replaceValue)
})

View File

@@ -0,0 +1,4 @@
export {
BusinessEvents,
EventScope,
} from '@core/constants/business-event.constant'

View File

@@ -0,0 +1,103 @@
import assert from 'assert'
import { createHmac, timingSafeEqual } from 'crypto'
import { EventEmitter } from 'events'
import type { IncomingMessage, ServerResponse } from 'http'
import type { BusinessEvents } from './event.enum'
import type { ExtendedEventEmitter } from './types'
interface CreateHandlerOptions {
secret: string
events?: 'all' | BusinessEvents[]
}
type Handler = {
(
req: IncomingMessage,
res: ServerResponse,
callback: (err: Error) => void,
): void
} & {
emitter: ExtendedEventEmitter
}
export const createHandler = (options: CreateHandlerOptions): Handler => {
const { secret } = options
const handler: Handler = async function (req, res) {
const signature = req.headers['x-webhook-signature']
assert(typeof signature === 'string', 'X-Webhook-Signature must be string')
const event = req.headers['x-webhook-event']
const signature256 = req.headers['x-webhook-signature256']
assert(
typeof signature256 === 'string',
'X-Webhook-Signature256 must be string',
)
const obj = (req as any).body || (await parseJSONFromRequest(req))
const stringifyPayload = JSON.stringify(obj)
const isValid =
verifyWebhook(secret, stringifyPayload, signature256 as string) &&
verifyWebhookSha1(secret, stringifyPayload, signature as string)
if (isValid) {
handler.emitter.emit(event as BusinessEvents, obj)
res.statusCode = 200
res.end()
} else {
console.error('revice a invalidate webhook payload', req.headers)
handler.emitter.emit('error', new Error('invalidate signature'))
res.statusCode = 400
res.end('invalidate signature')
}
}
handler.emitter = new EventEmitter()
return handler
}
function parseJSONFromRequest(req: IncomingMessage) {
return new Promise((resolve, reject) => {
let body = ''
req.on('data', (chunk) => {
body += chunk
})
req.on('end', () => {
try {
resolve(JSON.parse(body))
} catch (err) {
reject(err)
}
})
})
}
export function verifyWebhook(
secret: string,
payload: string,
receivedSignature: string,
): boolean {
const hmac = createHmac('sha256', secret)
hmac.update(payload)
// 安全地比较两个签名,以防止时间攻击
return timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(hmac.digest('hex')),
)
}
export function verifyWebhookSha1(
secret: string,
payload: string,
receivedSignature: string,
): boolean {
const hmac = createHmac('sha1', secret)
hmac.update(payload)
// 安全地比较两个签名,以防止时间攻击
return timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(hmac.digest('hex')),
)
}

View File

@@ -0,0 +1,3 @@
export * from './event.enum'
export * from './handler'
export * from './types'

View File

@@ -0,0 +1,55 @@
import type { CommentModel } from '@core/modules/comment/comment.model'
import type { LinkModel } from '@core/modules/link/link.model'
import type { NoteModel } from '@core/modules/note/note.model'
import type { NormalizedNote } from '@core/modules/note/note.type'
import type { PageModel } from '@core/modules/page/page.model'
import type { PostModel } from '@core/modules/post/post.model'
import type { NormalizedPost } from '@core/modules/post/post.type'
import type { RecentlyModel } from '@core/modules/recently/recently.model'
import type { SayModel } from '@core/modules/say/say.model'
import type { EventEmitter } from 'events'
import type { BusinessEvents } from './event.enum'
export interface ExtendedEventEmitter extends EventEmitter {
on<T extends BusinessEvents>(
event: T,
listener: (
data: EventPayloadMapping[Extract<T, keyof EventPayloadMapping>],
) => void,
): this
}
export type Id = string
export type PayloadOnlyId = { data: Id }
export interface EventPayloadMapping {
[BusinessEvents.POST_CREATE]: NormalizedPost
[BusinessEvents.POST_UPDATE]: NormalizedPost
[BusinessEvents.POST_DELETE]: PayloadOnlyId
[BusinessEvents.NOTE_CREATE]: NormalizedNote
[BusinessEvents.NOTE_UPDATE]: NormalizedNote
[BusinessEvents.NOTE_DELETE]: PayloadOnlyId
[BusinessEvents.PAGE_CREATE]: PageModel
[BusinessEvents.PAGE_UPDATE]: PageModel
[BusinessEvents.PAGE_DELETE]: PayloadOnlyId
[BusinessEvents.SAY_CREATE]: SayModel
[BusinessEvents.RECENTLY_CREATE]: RecentlyModel
[BusinessEvents.ACTIVITY_LIKE]: IActivityLike
[BusinessEvents.LINK_APPLY]: LinkModel
[BusinessEvents.COMMENT_CREATE]: Omit<CommentModel, 'ref'> & {
ref: Id | PostModel | PageModel | NoteModel | RecentlyModel
}
}
export interface IActivityLike {
id: string
type: 'Note' | 'Post'
created: string
ref: {
id: string
title: string
}
}

View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"declaration": true,
"baseUrl": ".",
"target": "ES2020",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "node",
"strict": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"paths": {
"~/*": [
"../../apps/core/src/*"
],
"@core/*": [
"../../apps/core/src/*"
],
}
},
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup'
export default defineConfig({
clean: true,
target: 'es2020',
entry: ['src/index.ts'],
dts: true,
format: ['cjs', 'esm'],
})

6
pnpm-lock.yaml generated
View File

@@ -466,6 +466,12 @@ importers:
specifier: 7.2.3 specifier: 7.2.3
version: 7.2.3 version: 7.2.3
packages/webhook:
devDependencies:
express:
specifier: 4.18.2
version: 4.18.2
packages: packages:
/@aashutoshrathi/word-wrap@1.2.6: /@aashutoshrathi/word-wrap@1.2.6: