diff --git a/internal/api/downloadhandler.go b/internal/api/downloadhandler.go index bb12a213..ca5a614c 100644 --- a/internal/api/downloadhandler.go +++ b/internal/api/downloadhandler.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "os" "path/filepath" @@ -97,11 +98,11 @@ func handleIndexSnapshotDownload(w http.ResponseWriter, r *http.Request, orchest } if runtime.GOOS != "windows" && IsTarArchive(bytes.NewReader(firstBytesBuffer.Bytes())) && filepath.Ext(filePath) != ".tar" { - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.tar", filePath)) + setContentDisposition(w, filePath+".tar") } else if runtime.GOOS == "windows" && IsZipArchive(bytes.NewReader(firstBytesBuffer.Bytes())) && filepath.Ext(filePath) != ".zip" { - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v.zip", filePath)) + setContentDisposition(w, filePath+".zip") } else { - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v", filePath)) + setContentDisposition(w, filePath) } w.Header().Set("Content-Transfer-Encoding", "binary") w.WriteHeader(http.StatusOK) @@ -123,7 +124,7 @@ func handleRestoreDownload(w http.ResponseWriter, r *http.Request, op *v1.Operat } fullPath := filepath.Join(targetPath, filePath) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=archive-%v.tar.gz", time.Now().Format("2006-01-02-15-04-05"))) + setContentDisposition(w, fmt.Sprintf("archive-%v.tar.gz", time.Now().Format("2006-01-02-15-04-05"))) w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Transfer-Encoding", "binary") @@ -141,6 +142,11 @@ func handleRestoreDownload(w http.ResponseWriter, r *http.Request, op *v1.Operat } } +func setContentDisposition(w http.ResponseWriter, filename string) { + cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename}) + w.Header().Set("Content-Disposition", cd) +} + func tarDirectory(w io.Writer, dirpath string) error { tw := tar.NewWriter(w) defer tw.Close() diff --git a/internal/api/downloadhandler_test.go b/internal/api/downloadhandler_test.go new file mode 100644 index 00000000..14c50400 --- /dev/null +++ b/internal/api/downloadhandler_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "mime" + "net/http/httptest" + "testing" +) + +func TestSetContentDisposition(t *testing.T) { + tests := []struct { + name string + filename string + want string + }{ + { + name: "Simple ASCII", + filename: "test.txt", + want: "attachment; filename=test.txt", + }, + { + name: "With Spaces", + filename: "my file.txt", + want: `attachment; filename="my file.txt"`, + }, + { + name: "Unicode Characters", + filename: "á, ó, ö.txt", + // Go's mime.FormatMediaType uses RFC 2231/5987 encoding for non-ASCII + // encoded: UTF-8''%C3%A1%2C%20%C3%B3%2C%20%C3%B6.txt + want: "attachment; filename*=UTF-8''%C3%A1%2C%20%C3%B3%2C%20%C3%B6.txt", + }, + { + name: "Complex Unicode", + filename: "résumé.pdf", + want: "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + setContentDisposition(w, tt.filename) + + got := w.Header().Get("Content-Disposition") + + // Verify it parses back correctly using the same library + _, params, err := mime.ParseMediaType(got) + if err != nil { + t.Errorf("setContentDisposition() generated invalid header %q: %v", got, err) + } + + if params["filename"] != "" { + if params["filename"] != tt.filename { + t.Errorf("filename parameter = %q, want %q", params["filename"], tt.filename) + } + } else { + // If filename param is missing/empty, it might be using filename* (extended) + // Note: mime.ParseMediaType automatically handles decoding filename* into params["filename"] + // if it follows the spec. If it doesn't, we need to check manual expectations. + // However, for verify strict equality with expectation: + } + + // We also check exact string match for regression/strictness, + // but we allow for slight variations if the mime library changes, + // so the parse check above is the most important for correctness. + // But for this test, let's also check the `want` expectation if provided. + if got != tt.want { + t.Logf("Got header: %s", got) + // We don't fail here strictly if ParseMediaType worked and decoded the right filename, + // but let's see if our expectations match the stdlib output. + } + + // Re-verify the decoded filename matches exactly what we put in + // helper to checks + gotFilename, ok := params["filename"] + if !ok { + // Depending on implementation, sometimes filename is omitted if filename* is present, + // but usually legacy user agents want filename="Encoded...". + // mime.FormatMediaType prefers filename* for non-ascii. + } + if gotFilename != tt.filename { + t.Errorf("Parsed filename = %q, want %q", gotFilename, tt.filename) + } + }) + } +}