Skip to content

Commit

Permalink
feat(satori): support internal/_api bridge
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Dec 23, 2024
1 parent 9616045 commit 6032a76
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 72 deletions.
20 changes: 16 additions & 4 deletions adapters/lark/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,20 @@ export class LarkBot<C extends Context = Context> extends Bot<C, LarkBot.Config>

ctx.plugin(HttpServer, this)

this.defineInternalRoute('/*path', async ({ params, method, headers, body }) => {
return this.http(params.path, { method, data: body, headers })
this.defineInternalRoute('/*path', async ({ params, method, headers, body, query }) => {
const response = await this.http('/' + params.path, {
method,
headers,
data: method === 'GET' || method === 'HEAD' ? null : body,
params: Object.fromEntries(query.entries()),
responseType: 'arraybuffer',
validateStatus: () => true,
})
return {
status: response.status,
body: response.data,
headers: response.headers,
}
})
}

Expand Down Expand Up @@ -163,14 +175,14 @@ export namespace LarkBot {
Schema.object({
platform: Schema.const('feishu').required(),
}),
HTTP.createConfig('https://open.feishu.cn/open-apis/'),
HTTP.createConfig('https://open.feishu.cn/open-apis'),
HttpServer.createConfig('/feishu'),
]),
Schema.intersect([
Schema.object({
platform: Schema.const('lark').required(),
}),
HTTP.createConfig('https://open.larksuite.com/open-apis/'),
HTTP.createConfig('https://open.larksuite.com/open-apis'),
HttpServer.createConfig('/lark'),
]),
]),
Expand Down
16 changes: 12 additions & 4 deletions adapters/satori/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function createInternal(bot: SatoriBot, prefix = '') {
const blobs: Dict<Blob> = Object.create(null)
const data = serialize(args, '$', blobs)
if (!Object.keys(blobs).length) {
return bot.http.post('/v1/internal/' + key, args)
return bot.http.post('/v1/internal/_api/' + key, args)
}
const form = new FormData()
form.append('$', JSON.stringify(data))
Expand All @@ -42,7 +42,7 @@ function createInternal(bot: SatoriBot, prefix = '') {
form.append(key, value)
}
}
return bot.http.post('/v1/internal/' + key, form)
return bot.http.post('/v1/internal/_api/' + key, form)
},
get(target, key, receiver) {
if (typeof key === 'symbol' || key in target) {
Expand All @@ -61,11 +61,19 @@ export class SatoriBot<C extends Context = Context> extends Bot<C, Universal.Log
super(ctx, config, 'satori')
Object.assign(this, config)

this.defineInternalRoute('/*path', async ({ method, params, query }) => {
return await this.http(`/v1/${this.getInternalUrl(params.path, query, true)}`, {
this.defineInternalRoute('/*path', async ({ method, params, query, headers, body }) => {
const response = await this.http(`/v1/${this.getInternalUrl('/' + params.path, query, true)}`, {
method,
headers,
data: method === 'GET' || method === 'HEAD' ? null : body,
responseType: 'arraybuffer',
validateStatus: () => true,
})
return {
status: response.status,
body: response.data,
headers: response.headers,
}
})
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@
"api"
],
"devDependencies": {
"@cordisjs/plugin-server": "^0.2.4",
"@cordisjs/plugin-server": "^0.2.5",
"@satorijs/core": "^4.3.1",
"@types/node": "^22.7.5",
"cordis": "^3.18.1"
},
"peerDependencies": {
"@satorijs/core": "^4.3.1"
},
"dependencies": {
"parse-multipart-data": "^1.5.0"
}
}
140 changes: 77 additions & 63 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Binary, camelCase, Context, Dict, makeArray, sanitize, Schema, Service, Session, snakeCase, Time, Universal, valueMap } from '@satorijs/core'
import { Context, Schema, Service, Session, Universal } from '@satorijs/core'
import { Binary, camelCase, defineProperty, Dict, makeArray, sanitize, snakeCase, Time, valueMap } from 'cosmokit'
import {} from '@cordisjs/plugin-server'
import WebSocket from 'ws'
import { Readable } from 'node:stream'
import { readFile } from 'node:fs/promises'
import { ParameterizedContext } from 'koa'
import { Middleware, ParameterizedContext } from 'koa'
import { getBoundary, parse } from 'parse-multipart-data'

declare module 'cordis' {
interface Context {
Expand Down Expand Up @@ -43,6 +45,15 @@ function deserialize(data: any, path: string, blobs: Dict<Blob>) {
})
}

const FILTER_HEADERS = [
'host',
'authorization',
'satori-user-id',
'satori-platform',
'x-self-id',
'x-platform',
]

class SatoriServer extends Service<SatoriServer.Config> {
static inject = ['server', 'http']

Expand All @@ -51,6 +62,34 @@ class SatoriServer extends Service<SatoriServer.Config> {
const logger = ctx.logger('server')
const path = sanitize(config.path)

ctx.satori.defineInternalRoute('/_api/:name', async ({ bot, headers, params, method, body }) => {
if (method !== 'POST') return { status: 405 }
const type = headers['content-type']
const boundary = getBoundary(type)
let args: any
if (boundary) {
const blobs: Dict<Blob> = {}
const fields: Dict<string> = {}
for (const { name, type, data, filename } of parse(Buffer.from(body), boundary)) {
if (type) {
blobs[name!] = new File([data], filename!, { type })
} else {
fields[name!] = data.toString()
}
}
args = deserialize(JSON.parse(fields.$), '$', blobs)
} else {
args = JSON.parse(new TextDecoder().decode(body))
}
try {
const result = await bot.internal[camelCase(params.name)](...args)
return { body: result, status: 200 }
} catch (error) {
if (!ctx.http.isError(error) || !error.response) throw error
return error.response
}
})

function checkAuth(koa: ParameterizedContext) {
if (!config.token) return
if (koa.request.headers.authorization !== `Bearer ${config.token}`) {
Expand Down Expand Up @@ -113,45 +152,33 @@ class SatoriServer extends Service<SatoriServer.Config> {
koa.status = 200
})

ctx.server.post(path + '/v1/internal/:name', async (koa) => {
if (checkAuth(koa)) return
const marker: Middleware = defineProperty((_, next) => next(), Symbol.for('noParseBody'), true)

const selfId = koa.request.headers['x-self-id']
const platform = koa.request.headers['x-platform']
const bot = ctx.bots.find(bot => bot.selfId === selfId && bot.platform === platform)
if (!bot) {
koa.body = 'login not found'
koa.status = 403
return
ctx.server.all(path + '/v1/internal/:path(.+)', marker, async (koa) => {
const url = new URL(`internal:${koa.params.path}`)
for (const [key, value] of Object.entries(koa.query)) {
for (const item of makeArray(value)) {
url.searchParams.append(key, item)
}
}

const name = camelCase(koa.params.name)
if (!bot.internal?.[name]) {
koa.body = 'method not found'
koa.status = 404
return
const headers = new Headers()
for (const [key, value] of Object.entries(koa.headers)) {
if (FILTER_HEADERS.includes(key)) continue
headers.set(key, value as string)
}
try {
let args = koa.request.body
if (koa.request.files) {
const blobs = Object.fromEntries(await Promise.all(Object.entries(koa.request.files).map(async ([key, value]) => {
value = makeArray(value)[0]
const buffer = await readFile(value.filepath)
return [key, new File([buffer], value.originalFilename!, { type: value.mimetype! })] as const
})))
args = deserialize(JSON.parse(koa.request.body.$), '$', blobs)
}
const result = await bot.internal[name](...args)
koa.body = result
koa.status = 200
} catch (error) {
if (!ctx.http.isError(error) || !error.response) throw error
koa.status = error.response.status
koa.body = error.response.data
for (const [key, value] of error.response.headers) {
koa.set(key, value)
}

const buffers: any[] = []
for await (const chunk of koa.req) {
buffers.push(chunk)
}
const body = Binary.fromSource(Buffer.concat(buffers))
const response = await ctx.satori.handleInternalRoute(koa.method as any, url, headers, body)
for (const [key, value] of response.headers ?? new Headers()) {
koa.set(key, value)
}
koa.status = response.status
koa.body = response.body ? Buffer.from(response.body) : ''
})

ctx.server.get(path + '/v1/proxy/:url(.+)', async (koa) => {
Expand All @@ -165,34 +192,21 @@ class SatoriServer extends Service<SatoriServer.Config> {
}

koa.header['Access-Control-Allow-Origin'] = ctx.server.config.selfUrl || '*'
if (url.protocol === 'internal:') {
const { status, statusText, body, headers } = await ctx.satori.handleInternalRoute('GET', url)
koa.status = status
for (const [key, value] of headers || new Headers()) {
koa.set(key, value)
}
if (status >= 200 && status < 300) {
koa.body = body ? Buffer.from(body) : null
} else {
koa.body = statusText
}
} else {
const proxyUrls = ctx.bots.flatMap(bot => bot.proxyUrls)
if (!proxyUrls.some(proxyUrl => url.href.startsWith(proxyUrl))) {
koa.body = 'forbidden'
koa.status = 403
return
}
const proxyUrls = ctx.bots.flatMap(bot => bot.proxyUrls)
if (!proxyUrls.some(proxyUrl => url.href.startsWith(proxyUrl))) {
koa.body = 'forbidden'
koa.status = 403
return
}

try {
koa.body = Readable.fromWeb(await ctx.http.get(url.href, { responseType: 'stream' }))
} catch (error) {
if (!ctx.http.isError(error) || !error.response) throw error
koa.status = error.response.status
koa.body = error.response.data
for (const [key, value] of error.response.headers) {
koa.set(key, value)
}
try {
koa.body = Readable.fromWeb(await ctx.http.get(url.href, { responseType: 'stream' }))
} catch (error) {
if (!ctx.http.isError(error) || !error.response) throw error
koa.status = error.response.status
koa.body = error.response.data
for (const [key, value] of error.response.headers) {
koa.set(key, value)
}
}
})
Expand Down

0 comments on commit 6032a76

Please sign in to comment.