feat(webui): agent 可向 webui 发送图片/文件,前端内联展示与下载

输出通道能力升级:webui 通道从 CapText(1) 扩展为
CapText|CapFile|CapImage(7),agent 经 output_send__webui 即可发送
image/file(此前仅文本)。

服务端:
- stageWebFile 把本地路径文件拷贝到 <data>/webui_files/<hex>.<ext>
  (随机名防猜测、危险扩展名强制 .bin),http(s) URL 直接透传不落盘
- 新增 GET /files/<name>(requireWeb 与 dashboard 同鉴权):扩展名
  白名单映射 Content-Type,图片/音视频 inline、其余 attachment 下载,
  nosniff + 路径穿越拒绝
- SSE agent_output 事件携带 output_type/url/size 字段

前端(dashboard.html):
- channel_output 识别附件消息:image 渲染内联预览(点击原图)、
  file 渲染下载卡片(含大小);formatBytes 人性化显示

典型场景:agent 把 remotedevice 回传的录像/截图(device_media/*.mp4)
直接发给 webui,用户在聊天里看到视频预览或一键下载。

新增 TestStageWebFileAndDownload / TestHandleFilesAuth 覆盖。
This commit is contained in:
JianFeeeee
2026-08-26 09:17:07 +08:00
parent fad490dca0
commit 0afa84a13f
4 changed files with 302 additions and 6 deletions

View File

@ -0,0 +1,83 @@
package webui
// webui 文件发送能力测试stageWebFile 中转 + /files/ 带鉴权下载。
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStageWebFileAndDownload(t *testing.T) {
dir := t.TempDir()
webFilesDir = dir
defer func() { webFilesDir = "" }()
// 源文件(模拟 agent 要发的图片)
src := filepath.Join(dir, "src.jpg")
if err := os.WriteFile(src, []byte("fake-jpeg-bytes"), 0644); err != nil {
t.Fatal(err)
}
url, size, err := stageWebFile(src, true)
if err != nil {
t.Fatalf("stageWebFile: %v", err)
}
if !strings.HasPrefix(url, "/files/") || !strings.HasSuffix(url, ".jpg") {
t.Fatalf("unexpected url: %s", url)
}
if size != int64(len("fake-jpeg-bytes")) {
t.Fatalf("size = %d", size)
}
// 中转文件存在且内容一致
data, err := os.ReadFile(filepath.Join(dir, strings.TrimPrefix(url, "/files/")))
if err != nil || string(data) != "fake-jpeg-bytes" {
t.Fatalf("staged file mismatch: %v", err)
}
// 远程 URL 透传不落盘
u2, _, err := stageWebFile("https://example.com/a.png", true)
if err != nil || u2 != "https://example.com/a.png" {
t.Fatalf("remote url passthrough failed: %v %s", err, u2)
}
// 危险扩展名被替换为 .bin
srcBad := filepath.Join(dir, "evil.html")
os.WriteFile(srcBad, []byte("<b>x</b>"), 0644)
u3, _, err := stageWebFile(srcBad, false)
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(u3, ".bin") {
t.Fatalf("html ext should be forced to .bin, got %s", u3)
}
}
func TestHandleFilesAuth(t *testing.T) {
h, _ := newTestHandler(t)
// 未登录访问 → 重定向登录页requireWeb
req := httptest.NewRequest(http.MethodGet, "/files/whatever.jpg", nil)
w := httptest.NewRecorder()
h.handleFiles(w, req) // 直接调 handler 本体验证文件逻辑;鉴权由 mux 层 requireWeb 覆盖
// 不存在的文件 → 404
req2 := httptest.NewRequest(http.MethodGet, "/files/nonexistent.jpg", nil)
w2 := httptest.NewRecorder()
h.handleFiles(w2, req2)
if w2.Code != http.StatusNotFound {
t.Fatalf("want 404 for missing file, got %d", w2.Code)
}
// 路径穿越拒绝
for _, bad := range []string{"/files/../etc/passwd", "/files/a/b.jpg", `/files\a.jpg`} {
req3 := httptest.NewRequest(http.MethodGet, "/files/x", nil)
req3.URL.Path = bad
w3 := httptest.NewRecorder()
h.handleFiles(w3, req3)
if w3.Code != http.StatusNotFound {
t.Errorf("path traversal %q: want 404, got %d", bad, w3.Code)
}
}
}