From dbfdacc396ee8e116ae4ece3e03eea76c61764ca Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 8 Jun 2026 10:53:20 +0300 Subject: [PATCH] feat(download): support UTF-8 filenames in Content-Disposition Improve the Content-Disposition header formatting for file downloads by implementing RFC 5987 compliant filename encoding. This ensures that downloaded files retain their original names, including spaces and non-ASCII characters, across different browsers. - Add `contentDisposition` helper to generate both standard ASCII fallback and UTF-8 encoded filename parameters. - Sanitize filenames to prevent path traversal and replace unsafe characters with underscores in the ASCII fallback. - Update single file and ZIP downloads to use the new formatting. - Add unit tests to verify correct header generation for various filename scenarios. --- backend/libs/handlers/download.go | 39 ++++++++++++- backend/libs/handlers/upload_stage3_test.go | 61 ++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 3ed5b01..857268f 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -572,9 +572,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi defer object.Body.Close() w.Header().Set("Content-Type", file.ContentType) + disposition := "inline" if attachment { - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name)) + disposition = "attachment" } + w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name)) if seeker, ok := object.Body.(io.ReadSeeker); ok { http.ServeContent(w, r, file.Name, object.ModTime, seeker) } else { @@ -590,6 +592,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi } } +func contentDisposition(disposition, name string) string { + filename := cleanDownloadFilename(name) + return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename)) +} + +func cleanDownloadFilename(name string) string { + clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/")) + clean = filepath.Base(clean) + if clean == "" || clean == "." || clean == "/" { + return "download" + } + return clean +} + +func asciiFilenameFallback(name string) string { + var fallback strings.Builder + for _, char := range name { + switch { + case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';': + fallback.WriteByte('_') + case char <= 0x7e: + fallback.WriteRune(char) + default: + fallback.WriteByte('_') + } + } + clean := strings.TrimSpace(fallback.String()) + if clean == "" { + return "download" + } + return clean +} + func readSeekCloser(source io.ReadCloser) io.ReadSeeker { data, err := io.ReadAll(source) if err != nil { @@ -618,7 +653,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip")) + w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip")) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) if err := a.uploadService.WriteZip(w, box); err != nil { diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index 47aa317..7b9316a 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -218,6 +218,61 @@ func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) { } } +func TestFileDownloadUsesOriginalFilename(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello") + + request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil) + request.SetPathValue("boxID", payload.BoxID) + request.SetPathValue("fileID", payload.Files[0].ID) + response := httptest.NewRecorder() + app.DownloadFileContent(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + disposition := response.Header().Get("Content-Disposition") + for _, want := range []string{ + `attachment;`, + `filename="report final.txt"`, + `filename*=UTF-8''report%20final.txt`, + } { + if !strings.Contains(disposition, want) { + t.Fatalf("Content-Disposition missing %q: %q", want, disposition) + } + } + if response.Body.String() != "hello" { + t.Fatalf("body = %q", response.Body.String()) + } +} + +func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello") + + request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil) + request.SetPathValue("boxID", payload.BoxID) + request.SetPathValue("fileID", payload.Files[0].ID) + response := httptest.NewRecorder() + app.DownloadFileContent(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + disposition := response.Header().Get("Content-Disposition") + for _, want := range []string{ + `inline;`, + `filename="r_sum_ 2026.txt"`, + `filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`, + } { + if !strings.Contains(disposition, want) { + t.Fatalf("Content-Disposition missing %q: %q", want, disposition) + } + } +} + func TestResumableUploadFlowCreatesNormalBox(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() @@ -731,8 +786,12 @@ func newTestApp(t *testing.T) (*App, func()) { } func uploadThroughApp(t *testing.T, app *App) services.UploadResult { + return uploadNamedFileThroughApp(t, app, "note.txt", "hello") +} + +func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult { t.Helper() - request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello") + request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body) request.Header.Set("Accept", "application/json") response := httptest.NewRecorder() app.Upload(response, request)