fix(frontend): 端用户附件走 inline base64 路径, 避开 MediaGuard SSRF 闸门
PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16) PR-WB-MP-INBOUND-MEDIA 配套 症状: 用户在前端上传图片成功后 (OSS URL 已返回), AI Agent 仍回复 "我这边并没有收到或看到任何图片附件". 根因链路: 1. backend Upload.php::buildPublicUrl 用 request()->domain() 拼 URL (LAN 部署返回 http://192.168.x.x:8050/upload/..., 公网部署返回公网域名) 2. 前端把 upload 响应直接透给 AI chat attachments[] 字段 3. backend MediaGuard::urlAllowed 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝 → attachments 整批丢 4. 静默失败: MediaGuard 在 '白名单过滤后空集' 时返回 ok=true + media=[] → Kefu.php 不 return error, 业务继续走 5. WB 收到 0 个 media block → Agent 看不到附件 修复: - media-upload.js 新增 fileToBase64(filePath, opts) helper - H5: fetch(blobUrl) → blob → FileReader.readAsDataURL → strip data: 前缀 - MP-WEIXIN/APP-PLUS: uni.getFileSystemManager().readFileSync(base64) - SIZE_LIMIT_INLINE_BASE64 = 5MB, 与后端 INLINE_MEDIA_LIMIT 对齐 - 超限 / 失败返回 null, 调用方退回 URL 路径 - ai-chat-message.vue sendMessage 改为 await Promise.all 模式: - 对每个 pending item 调 fileToBase64(tempPath) 拿 base64 - 把 data + encoding='base64' 注入 attachment - enrichedPendingAttachments 同时用于 readyAttachments + userMessage.attachments - 复用同一份 base64, 不重复读 file 后端接收 data 字段后整条链路已对齐: - MessageMedia::normalizeInput 直接透传 data 字段 - MediaGuard::check data 非空时无条件放行, 不参与 URL 校验 - WbAgentOsClient::buildMediaBlocks (A 路) 路径 1 优先 data + image/audio - WbAcpClient::buildMediaBlocks (B 路) data 非空也走 inline base64 - inline_media_sanity.php 39 个断言全部覆盖 (test 3.1, 3.2, 4.1, 4.2, 7.6) 测试: - E2E test_inline_base64.py 10/10 通过 - mock 上传返回 LAN URL 模拟 MediaGuard 拦截场景 - 抓 /api/kefu/chat POST body, 验证 attachments[].data 是有效 PNG base64 - magic bytes 89504E47 验证通过 - 既有 test_ai_chat_upload.py 20/20 通过 (无回归) 边界: - >5MB 图片 fileToBase64 返回 null, 退回 URL 路径 (buildMediaBlocks 走 路径 2 fetchInline / 路径 3 resource_link); LAN 部署下仍可能被 MediaGuard 拦截, 但 5MB 以下的图片(主流微信/支付宝截图等)不受影响 - H5/MINIProgram/APP 三端都加了 #ifdef 编译条件
This commit is contained in:
@@ -25,6 +25,10 @@ import store from '@/store/index.js'
|
||||
|
||||
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
|
||||
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
|
||||
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
|
||||
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
|
||||
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
|
||||
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
|
||||
@@ -428,6 +432,89 @@ async function pathToFile(filePath, kind) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
|
||||
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
|
||||
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
|
||||
* 而不是路径 ②/③ (URL fetch / resource_link).
|
||||
*
|
||||
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
|
||||
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
|
||||
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
|
||||
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
|
||||
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
|
||||
*
|
||||
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
|
||||
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
|
||||
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
|
||||
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
|
||||
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
|
||||
*
|
||||
* 返回: { data: '<bare-base64>', size, mime } | null
|
||||
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
|
||||
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
|
||||
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
|
||||
*
|
||||
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
|
||||
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
|
||||
*/
|
||||
export async function fileToBase64(filePath, opts = {}) {
|
||||
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
|
||||
if (!filePath) return null
|
||||
|
||||
// #ifdef H5
|
||||
try {
|
||||
const res = await fetch(filePath)
|
||||
if (!res.ok) throw new Error('fetch failed: ' + res.status)
|
||||
const blob = await res.blob()
|
||||
if (blob.size > maxBytes) {
|
||||
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||
return null
|
||||
}
|
||||
const dataUri = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = () => reject(new Error('FileReader failed'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
|
||||
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
|
||||
return { data: m[2], size: blob.size, mime: m[1] }
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] fileToBase64 H5 失败:', e)
|
||||
return null
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
try {
|
||||
const fsm = uni.getFileSystemManager()
|
||||
// 1. 探测大小, 超限直接返回 null
|
||||
let size = 0
|
||||
try {
|
||||
const stat = fsm.statSync(filePath)
|
||||
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
|
||||
size = (stat && (stat.size || stat.fileSize)) || 0
|
||||
} catch (e) {
|
||||
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
|
||||
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
|
||||
}
|
||||
if (size > maxBytes) {
|
||||
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||
return null
|
||||
}
|
||||
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
|
||||
const data = fsm.readFileSync(filePath, 'base64')
|
||||
return { data, size: size || 0, mime: opts.mimeHint || '' }
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
|
||||
return null
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function guessMime(ext) {
|
||||
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
|
||||
import { uploadImage } from '@/common/js/media-upload.js'
|
||||
import { uploadImage, fileToBase64 } from '@/common/js/media-upload.js'
|
||||
import IMAGE_CONFIG from '@/common/js/image-config.js'
|
||||
import { isClipboardPrivacyError } from '@/common/js/uniapp.utils.js'
|
||||
import AiChatSearch from './components/ai-chat-search.vue'
|
||||
@@ -1089,16 +1089,33 @@ async loadChatHistoryIfExist() {
|
||||
})
|
||||
|
||||
// 输入框待发送图片: 全部 push, 第一张作为 userMessage.imageUrl 显示
|
||||
pendingList.forEach((pending, idx) => {
|
||||
if (!pending.uploaded) return
|
||||
readyAttachments.push({
|
||||
type: pending.uploaded.kind || 'image',
|
||||
url: pending.uploaded.url,
|
||||
mime: pending.uploaded.mime,
|
||||
name: pending.uploaded.name,
|
||||
size: pending.uploaded.size,
|
||||
})
|
||||
})
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 同时给附件补 data: <base64> 字段,
|
||||
// 避开 backend MediaGuard SSRF 闸门 (LAN URL 被识别为内网 IP 直接丢).
|
||||
// 注意: 必须 await 让 base64 生成后再 build userMessage / sendStreamMessage, 否则附件没 data 字段
|
||||
const enrichedPendingAttachments = await Promise.all(
|
||||
pendingList
|
||||
.filter(p => p.uploaded)
|
||||
.map(async p => {
|
||||
const base = {
|
||||
type: p.uploaded.kind || 'image',
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size,
|
||||
}
|
||||
if (p.tempPath) {
|
||||
try {
|
||||
const b64 = await fileToBase64(p.tempPath, { mimeHint: p.uploaded.mime })
|
||||
if (b64 && b64.data) {
|
||||
base.data = b64.data
|
||||
base.encoding = 'base64'
|
||||
}
|
||||
} catch (e) { /* skip data 字段, 退回 URL 路径 */ }
|
||||
}
|
||||
return base
|
||||
})
|
||||
)
|
||||
readyAttachments.push(...enrichedPendingAttachments)
|
||||
|
||||
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
|
||||
this._aiRequestStart = Date.now()
|
||||
@@ -1115,12 +1132,15 @@ async loadChatHistoryIfExist() {
|
||||
type: 'image',
|
||||
imageUrl: firstPending.uploaded.url || firstPending.tempPath,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX: 多图时把其他图片 URL 也带在消息上, 模板侧可循环展示
|
||||
attachments: pendingList.map(p => ({
|
||||
url: p.uploaded.url,
|
||||
mime: p.uploaded.mime,
|
||||
name: p.uploaded.name,
|
||||
size: p.uploaded.size,
|
||||
kind: p.uploaded.kind || 'image',
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 复用上面 enrichedPendingAttachments 的 data 字段
|
||||
// (同一份 base64 计算, 不要再读一次 file → 节省内存 + CPU)
|
||||
attachments: enrichedPendingAttachments.map(a => ({
|
||||
url: a.url,
|
||||
mime: a.mime,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
kind: a.type || 'image',
|
||||
...(a.data ? { data: a.data, encoding: a.encoding } : {}),
|
||||
})),
|
||||
content: hasContent ? this.inputText.trim() : '',
|
||||
timestamp: Date.now()
|
||||
|
||||
Reference in New Issue
Block a user