fix: include filename in file download Content-Disposition header (#17105)

### Summary

GET /api/v1/files/{id} now sets attachment filename for both Python and
Go handlers so browsers can save downloads with the correct name.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
天海蒼灆
2026-08-28 14:42:04 +08:00
committed by GitHub
parent 191d62a698
commit 9ca62e8da3
7 changed files with 118 additions and 56 deletions

View File

@@ -36,7 +36,7 @@ from api.utils.validation_utils import (
validate_and_parse_json_request,
validate_and_parse_request_args,
)
from api.utils.web_utils import CONTENT_TYPE_MAP, apply_safe_file_response_headers
from api.utils.web_utils import CONTENT_TYPE_MAP, apply_download_file_response_headers
from common import settings
from common.misc_utils import thread_pool_exec
from api.apps.services import file_api_service
@@ -307,7 +307,7 @@ async def download(tenant_id: str = None, file_id: str = None):
if ext:
fallback_prefix = "image" if file.type == FileType.VISUAL.value else "application"
content_type = CONTENT_TYPE_MAP.get(ext, f"{fallback_prefix}/{ext}")
apply_safe_file_response_headers(response, content_type, ext)
apply_download_file_response_headers(response, content_type, ext, file.name)
return response
except Exception as e:
logging.exception(e)

View File

@@ -3,7 +3,7 @@
#
import re
from urllib.parse import urlencode
from urllib.parse import quote, urlencode
CONTENT_TYPE_MAP = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -84,6 +84,28 @@ def sanitize_content_disposition_filename(filename: str | None) -> str | None:
return base or None
def ascii_content_disposition_filename(filename: str | None) -> str | None:
if not filename:
return None
base = str(filename).split("/")[-1].split("\\")[-1]
if not base:
return None
ascii_only = base.encode("ascii", "ignore").decode("ascii")
safe = re.sub(r"[^\w.\-]", "_", ascii_only).strip("._")
return safe or None
def format_content_disposition(disposition: str, filename: str | None) -> str:
if not filename:
return disposition
base = str(filename).split("/")[-1].split("\\")[-1]
if not base:
return disposition
ascii_fallback = ascii_content_disposition_filename(base) or "file"
encoded = quote(base, safe="")
return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{encoded}"
def resolve_attachment_content_type(ext: str | None = None, mime_type: str | None = None) -> tuple[str | None, str | None]:
if mime_type:
normalized_type = mime_type.lower().split(";")[0].strip()
@@ -107,11 +129,10 @@ def apply_preview_file_response_headers(
response.headers.set("Content-Type", content_type)
if should_force_attachment(ext, content_type):
response.headers.set("X-Content-Type-Options", "nosniff")
response.headers.set("Content-Disposition", "attachment")
response.headers.set("Content-Disposition", format_content_disposition("attachment", filename) if filename else "attachment")
return response
safe_filename = sanitize_content_disposition_filename(filename)
if safe_filename:
response.headers.set("Content-Disposition", f'inline; filename="{safe_filename}"')
if filename:
response.headers.set("Content-Disposition", format_content_disposition("inline", filename))
else:
response.headers.set("Content-Disposition", "inline")
return response
@@ -127,11 +148,10 @@ def apply_download_file_response_headers(
response.headers.set("Content-Type", content_type)
if should_force_attachment(ext, content_type):
response.headers.set("X-Content-Type-Options", "nosniff")
response.headers.set("Content-Disposition", "attachment")
response.headers.set("Content-Disposition", format_content_disposition("attachment", filename) if filename else "attachment")
return response
safe_filename = sanitize_content_disposition_filename(filename)
if safe_filename:
response.headers.set("Content-Disposition", f'attachment; filename="{safe_filename}"')
if filename:
response.headers.set("Content-Disposition", format_content_disposition("attachment", filename))
else:
response.headers.set("Content-Disposition", "attachment")
return response

View File

@@ -20,7 +20,6 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"ragflow/internal/common"
"ragflow/internal/storage"
"ragflow/internal/utility"
@@ -522,15 +521,7 @@ func (h *FileHandler) Download(c *gin.Context) {
// Determine content type based on extension and file type
contentType := utility.GetContentType(ext, file.Type)
// Set response headers
if contentType != "" {
c.Header("Content-Type", contentType)
}
if utility.ShouldForceAttachment(ext, contentType) {
c.Header("X-Content-Type-Options", "nosniff")
encodedName := url.QueryEscape(file.Name)
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+encodedName)
}
utility.SetDownloadFileResponseHeaders(c.Writer.Header(), contentType, ext, file.Name)
// Send file data
c.Data(http.StatusOK, contentType, blob)

View File

@@ -331,41 +331,61 @@ func GetContentType(ext string, fileType string) string {
return fallbackPrefix + "/" + normalizedExt
}
// SanitizeContentDispositionFilename sanitizes a filename for use in
// Content-Disposition headers. Strips non-ASCII, path separators,
// control characters, and quotes/percent signs. Falls back to "file"
// when the result is empty. Mirrors Python file_response.py:
// sanitize_content_disposition_filename().
// contentDispositionBasename extracts the final path segment, treating both
// "/" and "\" as separators. Mirrors Python file_response.py basename logic.
func contentDispositionBasename(filename string) string {
base := filename
if idx := strings.LastIndex(base, "/"); idx >= 0 {
base = base[idx+1:]
}
if idx := strings.LastIndex(base, "\\"); idx >= 0 {
base = base[idx+1:]
}
return base
}
// rfc8187Encode percent-encodes a UTF-8 string for RFC 8187 filename*.
// Mirrors Python urllib.parse.quote(s, safe="").
func rfc8187Encode(s string) string {
var buf strings.Builder
for _, b := range []byte(s) {
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') ||
b == '-' || b == '.' || b == '_' || b == '~' {
buf.WriteByte(b)
} else {
buf.WriteString(fmt.Sprintf("%%%02X", b))
}
}
return buf.String()
}
// SanitizeContentDispositionFilename builds the ASCII filename fallback for
// Content-Disposition headers. Mirrors Python file_response.py:
// ascii_content_disposition_filename().
func SanitizeContentDispositionFilename(filename string) string {
if filename == "" {
return "file"
}
// Strip non-ASCII.
var asciiOnly strings.Builder
for _, r := range filename {
if r < 0x80 {
asciiOnly.WriteRune(r)
}
}
sanitized := asciiOnly.String()
// Replace path separators, special chars.
sanitized = strings.ReplaceAll(sanitized, "/", "_")
sanitized = strings.ReplaceAll(sanitized, "\\", "_")
sanitized = strings.ReplaceAll(sanitized, ":", "_")
sanitized = strings.ReplaceAll(sanitized, "\"", "")
sanitized = strings.ReplaceAll(sanitized, "'", "")
sanitized = strings.ReplaceAll(sanitized, "%", "")
// Strip remaining control characters.
ctrlRe := regexp.MustCompile(`[\x00-\x1f\x7f]`)
sanitized = ctrlRe.ReplaceAllString(sanitized, "")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
var sanitized strings.Builder
for _, r := range asciiOnly.String() {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') ||
r == '_' || r == '.' || r == '-' {
sanitized.WriteRune(r)
} else {
sanitized.WriteRune('_')
}
}
result := strings.Trim(sanitized.String(), "._")
if result == "" {
return "file"
}
return sanitized
return result
}
// ResolveAttachmentContentType resolves a content type and extension from
@@ -405,6 +425,18 @@ func ResolveAttachmentContentType(ext string, mimeType string) (string, string)
return contentType, ext
}
// FormatContentDisposition builds a Content-Disposition value with an ASCII
// filename fallback and an RFC 5987 UTF-8 filename* parameter.
func FormatContentDisposition(disposition, filename string) string {
base := contentDispositionBasename(filename)
if base == "" {
return disposition
}
safe := SanitizeContentDispositionFilename(base)
encoded := rfc8187Encode(base)
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, safe, encoded)
}
// SetPreviewFileResponseHeaders sets response headers for inline file
// preview. For force-attachment types (HTML, SVG, XML) it falls back to
// attachment disposition with nosniff. Mirrors Python file_response.py:
@@ -414,11 +446,14 @@ func SetPreviewFileResponseHeaders(h http.Header, contentType, ext, filename str
h.Set("Content-Type", contentType)
}
if ShouldForceAttachment(ext, contentType) {
h.Set("Content-Disposition", "attachment")
h.Set("X-Content-Type-Options", "nosniff")
} else {
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, safe))
if filename != "" {
h.Set("Content-Disposition", FormatContentDisposition("attachment", filename))
} else {
h.Set("Content-Disposition", "attachment")
}
} else if filename != "" {
h.Set("Content-Disposition", FormatContentDisposition("inline", filename))
}
}
@@ -431,11 +466,18 @@ func SetDownloadFileResponseHeaders(h http.Header, contentType, ext, filename st
}
if ShouldForceAttachment(ext, contentType) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Disposition", "attachment")
if filename != "" {
h.Set("Content-Disposition", FormatContentDisposition("attachment", filename))
} else {
h.Set("Content-Disposition", "attachment")
}
return
}
safe := SanitizeContentDispositionFilename(filename)
h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe))
if filename != "" {
h.Set("Content-Disposition", FormatContentDisposition("attachment", filename))
} else {
h.Set("Content-Disposition", "attachment")
}
}
// AgentAttachmentPreviewPath builds the preview URL path for an agent

View File

@@ -179,7 +179,7 @@ def _load_file_api_module(monkeypatch):
web_utils_mod = ModuleType("api.utils.web_utils")
web_utils_mod.CONTENT_TYPE_MAP = {"txt": "text/plain"}
web_utils_mod.apply_safe_file_response_headers = lambda response, content_type, ext: response.headers.update({"content_type": content_type, "ext": ext})
web_utils_mod.apply_download_file_response_headers = lambda response, content_type, ext, filename=None: response.headers.update({"content_type": content_type, "ext": ext, "filename": filename})
monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod)
common_pkg = ModuleType("common")
@@ -328,6 +328,7 @@ def test_download_falls_back_to_document_storage(monkeypatch):
assert res.data == b"fallback-blob"
assert res.headers["content_type"] == "text/plain"
assert res.headers["ext"] == "txt"
assert res.headers["filename"] == "doc.txt"
@pytest.mark.p2

View File

@@ -176,7 +176,7 @@ def _load_file_api_module(monkeypatch):
web_utils_mod = ModuleType("api.utils.web_utils")
web_utils_mod.CONTENT_TYPE_MAP = {"txt": "text/plain"}
web_utils_mod.apply_safe_file_response_headers = lambda response, content_type, ext: response.headers.update({"content_type": content_type, "ext": ext})
web_utils_mod.apply_download_file_response_headers = lambda response, content_type, ext, filename=None: response.headers.update({"content_type": content_type, "ext": ext, "filename": filename})
monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod)
common_pkg = ModuleType("common")
@@ -325,6 +325,7 @@ def test_download_falls_back_to_document_storage(monkeypatch):
assert res.data == b"fallback-blob"
assert res.headers["content_type"] == "text/plain"
assert res.headers["ext"] == "txt"
assert res.headers["filename"] == "doc.txt"
@pytest.mark.p2

View File

@@ -24,14 +24,14 @@ def test_apply_preview_sets_inline_for_pdf():
response = _DummyResponse()
module.apply_preview_file_response_headers(response, "application/pdf", "pdf", "report.pdf")
assert response.headers["Content-Type"] == "application/pdf"
assert response.headers["Content-Disposition"] == 'inline; filename="report.pdf"'
assert response.headers["Content-Disposition"] == "inline; filename=\"report.pdf\"; filename*=UTF-8''report.pdf"
@pytest.mark.p2
def test_apply_preview_forces_attachment_for_html():
response = _DummyResponse()
module.apply_preview_file_response_headers(response, "text/html", "html", "page.html")
assert response.headers["Content-Disposition"] == "attachment"
assert response.headers["Content-Disposition"] == "attachment; filename=\"page.html\"; filename*=UTF-8''page.html"
assert response.headers["X-Content-Type-Options"] == "nosniff"
@@ -39,7 +39,14 @@ def test_apply_preview_forces_attachment_for_html():
def test_apply_download_sets_attachment_for_pdf():
response = _DummyResponse()
module.apply_download_file_response_headers(response, "application/pdf", "pdf", "report.pdf")
assert response.headers["Content-Disposition"] == 'attachment; filename="report.pdf"'
assert response.headers["Content-Disposition"] == "attachment; filename=\"report.pdf\"; filename*=UTF-8''report.pdf"
@pytest.mark.p2
def test_apply_download_sets_utf8_filename_for_chinese():
response = _DummyResponse()
module.apply_download_file_response_headers(response, "application/pdf", "pdf", "报告.pdf")
assert response.headers["Content-Disposition"] == "attachment; filename=\"pdf\"; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf"
@pytest.mark.p2