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()
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 {