diff --git a/api/apps/restful_apis/file_api.py b/api/apps/restful_apis/file_api.py index 5ffd99dede..3657a9be99 100644 --- a/api/apps/restful_apis/file_api.py +++ b/api/apps/restful_apis/file_api.py @@ -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) diff --git a/api/utils/file_response.py b/api/utils/file_response.py index 1984b7c17e..95282f2f19 100644 --- a/api/utils/file_response.py +++ b/api/utils/file_response.py @@ -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 diff --git a/internal/handler/file.go b/internal/handler/file.go index 7d42ad39e0..da61d887e3 100644 --- a/internal/handler/file.go +++ b/internal/handler/file.go @@ -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) diff --git a/internal/utility/file.go b/internal/utility/file.go index a1b2fef5aa..6106092541 100644 --- a/internal/utility/file.go +++ b/internal/utility/file.go @@ -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 diff --git a/test/testcases/restful_api/test_file_routes_unit.py b/test/testcases/restful_api/test_file_routes_unit.py index c3a4b82f31..f8dfb91ba7 100644 --- a/test/testcases/restful_api/test_file_routes_unit.py +++ b/test/testcases/restful_api/test_file_routes_unit.py @@ -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 diff --git a/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py b/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py index 30b8c6a8f4..93862c65af 100644 --- a/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py +++ b/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py @@ -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 diff --git a/test/unit_test/api/utils/test_file_response_headers.py b/test/unit_test/api/utils/test_file_response_headers.py index ab0a821a5c..f59df20b3b 100644 --- a/test/unit_test/api/utils/test_file_response_headers.py +++ b/test/unit_test/api/utils/test_file_response_headers.py @@ -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