feat: support webhook (#1298)
This commit is contained in:
6
packages/webhook/globals.d.ts
vendored
Normal file
6
packages/webhook/globals.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare global {
|
||||
interface JSON {
|
||||
safeParse: typeof JSON.parse
|
||||
}
|
||||
}
|
||||
export {}
|
||||
15
packages/webhook/index.js
Normal file
15
packages/webhook/index.js
Normal 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')
|
||||
37
packages/webhook/package.json
Normal file
37
packages/webhook/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
33
packages/webhook/scripts/post-build.js
Normal file
33
packages/webhook/scripts/post-build.js
Normal 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)
|
||||
})
|
||||
4
packages/webhook/src/event.enum.ts
Normal file
4
packages/webhook/src/event.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
BusinessEvents,
|
||||
EventScope,
|
||||
} from '@core/constants/business-event.constant'
|
||||
103
packages/webhook/src/handler.ts
Normal file
103
packages/webhook/src/handler.ts
Normal 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')),
|
||||
)
|
||||
}
|
||||
3
packages/webhook/src/index.ts
Normal file
3
packages/webhook/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './event.enum'
|
||||
export * from './handler'
|
||||
export * from './types'
|
||||
55
packages/webhook/src/types.ts
Normal file
55
packages/webhook/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
packages/webhook/tsconfig.json
Normal file
30
packages/webhook/tsconfig.json
Normal 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/*"
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
9
packages/webhook/tsup.config.ts
Normal file
9
packages/webhook/tsup.config.ts
Normal 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'],
|
||||
})
|
||||
Reference in New Issue
Block a user