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.
This commit is contained in:
2026-06-08 10:53:20 +03:00
parent 45507cdcae
commit dbfdacc396
2 changed files with 97 additions and 3 deletions

View File

@@ -572,9 +572,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
defer object.Body.Close() defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Type", file.ContentType)
disposition := "inline"
if attachment { 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 { if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker) http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else { } 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 { func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source) data, err := io.ReadAll(source)
if err != nil { 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-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)) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil { if err := a.uploadService.WriteZip(w, box); err != nil {

View File

@@ -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) { func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -731,8 +786,12 @@ func newTestApp(t *testing.T) (*App, func()) {
} }
func uploadThroughApp(t *testing.T, app *App) services.UploadResult { 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() 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") request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
app.Upload(response, request) app.Upload(response, request)