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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user