diff --git a/internal/server/artifact_download_test.go b/internal/server/artifact_download_test.go new file mode 100644 index 0000000..ee8bef6 --- /dev/null +++ b/internal/server/artifact_download_test.go @@ -0,0 +1,64 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// TestArtifactContentDisposition:?download=1 切换 attachment;中文名走 RFC 5987 +// filename* 权威编码;默认 inline 便于浏览器内预览。 +func TestArtifactContentDisposition(t *testing.T) { + srv, st := newTestServer(t) + h := srv.Handler() + a := &core.Artifact{ProjectID: "p1", Owner: store.OwnerDefault, Name: "会议纪要.docx", Kind: "document"} + if err := st.CreateArtifact(a, []byte("PK\x03\x04 docx bytes")); err != nil { + t.Fatal(err) + } + + get := func(query string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+a.ID+"/content"+query, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + inline := get("") + if cd := inline.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "inline") || + !strings.Contains(cd, `filename*=UTF-8''%E4%BC%9A%E8%AE%AE%E7%BA%AA%E8%A6%81.docx`) { + t.Errorf("inline disposition mismatch: %s", cd) + } + if ct := inline.Header().Get("Content-Type"); ct != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { + t.Errorf("docx content-type fallback mismatch: %s", ct) + } + + dl := get("?download=1") + if cd := dl.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") || + !strings.Contains(cd, `filename="____.docx"`) { // ASCII 兜底:4 个汉字 → 4 个下划线 + t.Errorf("attachment disposition mismatch: %s", cd) + } +} + +// TestContentTypeByExtension:MIME 为空时按扩展名回退(docx 显式表 + csv 显式表), +// 未知扩展回落 text/plain,image kind 回落二进制流。 +func TestContentTypeByExtension(t *testing.T) { + if ct := contentTypeOf(core.Artifact{Name: "a.DOCX"}); ct != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { + t.Errorf("docx ext (uppercase) mismatch: %s", ct) + } + if ct := contentTypeOf(core.Artifact{Name: "b.csv"}); !strings.HasPrefix(ct, "text/csv") { + t.Errorf("csv ext mismatch: %s", ct) + } + if ct := contentTypeOf(core.Artifact{Name: "c.bin", Kind: "image"}); ct != "application/octet-stream" { + t.Errorf("image fallback mismatch: %s", ct) + } + if ct := contentTypeOf(core.Artifact{Name: "d"}); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("plain fallback mismatch: %s", ct) + } + if ct := contentTypeOf(core.Artifact{Name: "e.md", MIME: "text/x-override"}); ct != "text/x-override" { + t.Errorf("explicit mime must win: %s", ct) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8aad361..7e2f092 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,7 +9,9 @@ import ( "fmt" "io" "io/fs" + "mime" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -852,6 +854,7 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", contentTypeOf(a)) + w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1")) w.Write(content) } @@ -1219,16 +1222,62 @@ func (s *Server) backup(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": dest}) } +// extMIME:常见文档扩展的显式类型表——系统 mime.types 不保证收录 docx/xlsx 等 +// Office 格式,显式兜底让上传件的下载与浏览器预览拿到正确 Content-Type。 +var extMIME = map[string]string{ + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".doc": "application/msword", + ".xls": "application/vnd.ms-excel", + ".ppt": "application/vnd.ms-powerpoint", + ".pdf": "application/pdf", + ".zip": "application/zip", + ".csv": "text/csv; charset=utf-8", + ".md": "text/markdown; charset=utf-8", +} + func contentTypeOf(a core.Artifact) string { if a.MIME != "" { return a.MIME } + ext := strings.ToLower(filepath.Ext(a.Name)) + if t, ok := extMIME[ext]; ok { + return t + } + if t := mime.TypeByExtension(ext); t != "" { + return t + } if a.Kind == "image" { return "application/octet-stream" } return "text/plain; charset=utf-8" } +// contentDisposition:工件文件名带中文(如 会议纪要.docx)时用 RFC 5987 的 +// filename* 权威编码,另给纯 ASCII 兜底;?download=1 切换 attachment(下载), +// 默认 inline(浏览器内预览)。 +func contentDisposition(name string, download bool) string { + disposition := "inline" + if download { + disposition = "attachment" + } + return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFallbackName(name), url.PathEscape(name)) +} + +// asciiFallbackName:非 ASCII 与引号/反斜杠替换为下划线,仅作 filename 兜底。 +func asciiFallbackName(name string) string { + var b strings.Builder + for _, r := range name { + if r >= 0x20 && r < 0x7f && r != '"' && r != '\\' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return b.String() +} + func maskMap(m map[string]any) map[string]any { if m == nil { return map[string]any{}