mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-09-08 02:08:01 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user