Add download semantics and extension MIME fallback to artifact content #19
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user