[verified] refactor: establish service and frontend boundaries

This commit is contained in:
chick
2026-07-15 02:43:41 +08:00
parent 0ac6faa6a3
commit e04a16e817
29 changed files with 917 additions and 598 deletions
+14
View File
@@ -0,0 +1,14 @@
import { proxyToInstance } from './service.js'
export function registerProxyRoutes(app, { findClient, proxy = proxyToInstance }) {
app.all('/api/proxy/:id/*', async (request, reply) => {
const client = findClient(request.params.id, reply)
if (!client) return
try { return await proxy({ client, request, reply, rest: request.params['*'] }) }
catch (error) {
if (/invalid proxy path/.test(error.message)) return reply.code(400).send({ error: error.message })
if (error.statusCode === 415) return reply.code(415).send({ error: error.message })
throw error
}
})
}
+58
View File
@@ -0,0 +1,58 @@
const BLOCKED_REQUEST_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'accept-encoding',
])
const BLOCKED_RESPONSE_HEADERS = new Set([...BLOCKED_REQUEST_HEADERS, 'set-cookie', 'content-encoding'])
export function safeProxyPath(rest = '') {
const raw = String(rest || '')
if (!raw || raw.startsWith('/') || raw.includes('\\')) throw new Error('invalid proxy path')
let value = raw
const assertSafe = candidate => {
if (/[\\?#]/.test(candidate) || candidate.startsWith('//') || candidate.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('invalid proxy path')
if (/%(?:2f|5c|3f|23|2e)/i.test(candidate)) throw new Error('invalid proxy path')
}
for (let depth = 0; depth < 16; depth += 1) {
assertSafe(value)
let decoded
try { decoded = decodeURIComponent(value) } catch { throw new Error('invalid proxy path') }
if (decoded === value) return `/${raw}`
value = decoded
}
// Refuse inputs whose semantics still change after a bounded number of decodes.
throw new Error('invalid proxy path')
}
export function proxyHeaders(input = {}) {
const headers = new Headers()
for (const [name, value] of Object.entries(input)) {
if (!BLOCKED_REQUEST_HEADERS.has(name.toLowerCase()) && value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : String(value))
}
return headers
}
export async function proxyToInstance({ client, request, reply, rest }) {
const targetPath = safeProxyPath(rest)
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
const target = new URL(`${targetPath}${query}`, new URL(client.instance.url).origin)
const origin = new URL(client.instance.url).origin
if (target.origin !== origin) throw new Error('invalid proxy path')
const headers = proxyHeaders(request.headers)
let body
if (!['GET', 'HEAD'].includes(request.method)) {
const type = (headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
if (request.body === undefined && !type) body = undefined
else if (type.startsWith('multipart/')) { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
else if (type === 'application/json' || type.endsWith('+json')) body = request.body === undefined ? undefined : JSON.stringify(request.body)
else if (type.startsWith('text/') || type === 'application/x-www-form-urlencoded') body = request.body
else if (Buffer.isBuffer(request.body)) body = request.body
else { const error = new Error('unsupported proxy content type'); error.statusCode = 415; throw error }
}
const upstream = await client.request(target.toString(), { method: request.method, headers, body, redirect: 'manual' })
reply.code(upstream.status)
upstream.headers.forEach((value, name) => {
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
})
return reply.send(Buffer.from(await upstream.arrayBuffer()))
}