diff --git a/internal/agent/component/docs_generator.go b/internal/agent/component/docs_generator.go index 4dbdfb9c45..0d0d12915a 100644 --- a/internal/agent/component/docs_generator.go +++ b/internal/agent/component/docs_generator.go @@ -40,6 +40,7 @@ import ( "github.com/google/uuid" iow "ragflow/internal/agent/component/io" + "ragflow/internal/utility" ) const componentNameDocsGenerator = "DocsGenerator" @@ -272,15 +273,21 @@ func (d *DocsGenerator) Invoke(ctx context.Context, inputs map[string]any) (map[ docID := uuid.New().String() size := len(payload) downloadStub := fmt.Sprintf("inline://docs/%s/%s", docID, safeName) + // ext from formatExtension includes a leading dot (".pdf"); strip it + // so the preview URL has ?ext=pdf rather than ?ext=.pdf, matching the + // Python branch which passes the raw output_format string. + extWithoutDot := strings.TrimPrefix(ext, ".") + previewURL := utility.AgentAttachmentPreviewPath(docID, extWithoutDot, mime) return map[string]any{ - "doc_id": docID, - "filename": safeName, - "mime_type": mime, - "size": size, - "bytes": payload, - "download": downloadStub, - "created": time.Now().UTC().Format(time.RFC3339), + "doc_id": docID, + "filename": safeName, + "mime_type": mime, + "size": size, + "bytes": payload, + "download": downloadStub, + "preview_url": previewURL, + "created": time.Now().UTC().Format(time.RFC3339), }, nil } @@ -308,13 +315,14 @@ func (d *DocsGenerator) Inputs() map[string]string { // Outputs returns the response surface. func (d *DocsGenerator) Outputs() map[string]string { return map[string]string{ - "doc_id": "Generated document id (UUID).", - "filename": "Sanitized filename (extension matches output_format).", - "mime_type": "MIME type for the payload.", - "size": "Payload size in bytes.", - "bytes": "Raw document bytes (for storage upload).", - "download": "Stub URI the canvas engine can resolve to a signed URL.", - "created": "RFC3339 timestamp of the generation.", + "doc_id": "Generated document id (UUID).", + "filename": "Sanitized filename (extension matches output_format).", + "mime_type": "MIME type for the payload.", + "size": "Payload size in bytes.", + "bytes": "Raw document bytes (for storage upload).", + "download": "Stub URI the canvas engine can resolve to a signed URL.", + "preview_url": "URL path for inline preview of the generated document.", + "created": "RFC3339 timestamp of the generation.", } } diff --git a/internal/agent/component/llm.go b/internal/agent/component/llm.go index 3a9ad8c1de..3370110efc 100644 --- a/internal/agent/component/llm.go +++ b/internal/agent/component/llm.go @@ -1009,14 +1009,13 @@ func mergeLLMParam(base LLMParam, inputs map[string]any) LLMParam { p.MaxTokens = &i } if v, ok := stringFrom(inputs, "thinking"); ok { - // Only allow "enabled" or "disabled"; arbitrary DSL - // strings are dropped. Python PR #15220 removed - // thinking from llm.py's gen_conf() — it is no - // longer forwarded to the model. The field is still - // parsed here to match the Python form parameter - // definition, but einoChatInvoker does not consume - // it, consistent with Python's behavior. - if v == "enabled" || v == "disabled" { + // Forward any non-empty, non-"default" value to match the + // lenient Python gate: hasattr(self,"thinking") and + // self.thinking and self.thinking != "default". + // Downstream (einoChatInvoker) only acts on "enabled" / + // "disabled" and silently ignores unknown values, so + // this is safe. + if v != "" && v != "default" { p.Thinking = v } } diff --git a/internal/agent/component/llm_test.go b/internal/agent/component/llm_test.go index ef794728e4..d041f19868 100644 --- a/internal/agent/component/llm_test.go +++ b/internal/agent/component/llm_test.go @@ -199,14 +199,12 @@ func TestLLM_Registered(t *testing.T) { } // TestLLM_ThinkingFieldRoundTrip guards the agent-component -// portion of PR #15446 (thinking switch). The agent component -// accepts `thinking` from the DSL params (whitelisted to the -// two known sentinels) and threads it through LLMParam and the -// ChatInvokeRequest so the default invoker (or a stub) can -// translate it into the provider-specific request body -// (Qwen `enable_thinking`, Kimi/GLM `thinking.type`). Provider -// policy itself lives in internal/llm and is a separate porting -// stream. +// portion of PR #15446 (thinking switch) and PR #16640 (gen_conf +// forwarding). The agent component accepts `thinking` from the DSL +// params (any non-empty, non-"default" value) and threads it through +// LLMParam and the ChatInvokeRequest. Downstream (einoChatInvoker) +// only acts on "enabled" / "disabled" and silently ignores other +// values, so lenient forwarding is safe. func TestLLM_ThinkingFieldRoundTrip(t *testing.T) { t.Parallel() @@ -231,7 +229,7 @@ func TestLLM_ThinkingFieldRoundTrip(t *testing.T) { t.Errorf("Thinking = %q, want disabled", disabled.Thinking) } - // Case 3: empty / system-default value is preserved (no defaulting). + // Case 3: empty / missing value → empty (system default). defaulted := mergeLLMParam(LLMParam{}, map[string]any{ "model_id": "glm-4.6", "user_prompt": "u", @@ -240,16 +238,27 @@ func TestLLM_ThinkingFieldRoundTrip(t *testing.T) { t.Errorf("Thinking = %q, want empty (system default)", defaulted.Thinking) } - // Case 4: arbitrary string is REJECTED (DSL safety — the LLM - // driver should not see unvalidated values). Mirrors the - // python llm.py:78-79 `if get_attr("thinking") in {"enabled", - // "disabled"}` gate. - arbitrary := mergeLLMParam(LLMParam{}, map[string]any{ - "thinking": "yes please", + // Case 4: "default" is explicitly rejected, matching Python's + // `self.thinking != "default"` gate in gen_conf(). + defaultStr := mergeLLMParam(LLMParam{}, map[string]any{ + "thinking": "default", "model_id": "glm-4.6", "user_prompt": "u", }) - if arbitrary.Thinking != "" { - t.Errorf("arbitrary thinking = %q, want empty (rejected)", arbitrary.Thinking) + if defaultStr.Thinking != "" { + t.Errorf(`Thinking = %q, want empty ("default" rejected)`, defaultStr.Thinking) + } + + // Case 5: arbitrary / unknown values are leniently forwarded + // (matches Python gen_conf() which passes through any truthy + // non-"default" string). Downstream einoChatInvoker ignores + // unknown values, so this is safe. + arbitrary := mergeLLMParam(LLMParam{}, map[string]any{ + "thinking": "auto", + "model_id": "glm-4.6", + "user_prompt": "u", + }) + if arbitrary.Thinking != "auto" { + t.Errorf("arbitrary thinking = %q, want auto (lenient forwarding)", arbitrary.Thinking) } } diff --git a/internal/handler/agent_attachment.go b/internal/handler/agent_attachment.go index ade9dfcb2d..a6a3208067 100644 --- a/internal/handler/agent_attachment.go +++ b/internal/handler/agent_attachment.go @@ -14,23 +14,22 @@ // limitations under the License. // -// Gap D — `GET /api/v1/agents/attachments//download` -// (Python api/apps/restful_apis/agent_api.py:2368). +// Attachment download & preview handlers. // -// Mirrors the python download_agent_attachment handler: -// - auth via @login_required → GetUser -// - reads `attachment_id` from the URL path (NOT a query string) -// - default `ext` query parameter is "markdown" -// - uses utility.CONTENT_TYPE_MAP to pick the content type, falling -// back to "application/" for unknown extensions -// - streams raw bytes back with a sanitized Content-Disposition +// Mirrors the Python handlers in api/apps/restful_apis/agent_api.py: +// - download_attachment (agent_api.py:2368) +// - preview_attachment (agent_api.py:2496) +// - _attachment_request_metadata +// - _stream_agent_attachment +// +// The Python PR #15399 added a dedicated preview endpoint that returns +// Content-Disposition: inline for safe types (PDF, images, Markdown, +// etc.) while still forcing attachment on HTML, SVG, and XML. package handler import ( - "fmt" "net/http" - "net/url" "path/filepath" "strings" @@ -46,7 +45,67 @@ type agentAttachmentFileService interface { DownloadAgentFile(tenantID, location string) ([]byte, error) } +// attachmentRequestMetadata holds the parsed query params used when +// streaming an attachment. Mirrors Python _attachment_request_metadata(). +type attachmentRequestMetadata struct { + ContentType string + Ext string + Filename string +} + +// attachmentRequestMeta parses ext, mime_type, and filename from the +// request query string and resolves the content type. Mirrors Python +// _attachment_request_metadata(). +func attachmentRequestMeta(c *gin.Context) attachmentRequestMetadata { + ext := strings.TrimSpace(c.Query("ext")) + mimeType := strings.TrimSpace(c.Query("mime_type")) + filename := strings.TrimSpace(c.Query("filename")) + + contentType, resolvedExt := utility.ResolveAttachmentContentType(ext, mimeType) + return attachmentRequestMetadata{ + ContentType: contentType, + Ext: resolvedExt, + Filename: filename, + } +} + +// streamAgentAttachment fetches the blob from storage and writes it to +// the response with the appropriate Content-Disposition header. +// When inline=true, uses SetPreviewFileResponseHeaders (inline for +// safe types, attachment for dangerous ones). When inline=false, +// always uses attachment disposition. Mirrors Python +// _stream_agent_attachment(). +func (h *AgentHandler) streamAgentAttachment(c *gin.Context, tenantID, attachmentID string, inline bool) { + if h.fileService == nil { + jsonError(c, common.CodeServerError, "file service not configured") + return + } + + blob, err := h.fileService.DownloadAgentFile(tenantID, attachmentID) + if err != nil { + jsonError(c, common.CodeDataError, "Attachment not found!") + return + } + + meta := attachmentRequestMeta(c) + if inline { + utility.SetPreviewFileResponseHeaders(c.Writer.Header(), meta.ContentType, meta.Ext, meta.Filename) + } else { + utility.SetDownloadFileResponseHeaders(c.Writer.Header(), meta.ContentType, meta.Ext, meta.Filename) + } + + // If content type was not resolved, fall back to octet-stream. + contentType := meta.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + c.Data(http.StatusOK, contentType, blob) +} + // DownloadAttachment GET /api/v1/agents/attachments//download +// +// Supports optional ?disposition=inline for browsers that prefer inline +// rendering. Mirrors Python download_attachment() at agent_api.py:2507. func (h *AgentHandler) DownloadAttachment(c *gin.Context) { user, code, msg := GetUser(c) if code != common.CodeSuccess { @@ -74,44 +133,34 @@ func (h *AgentHandler) DownloadAttachment(c *gin.Context) { return } - // Normalize the ext query once. A blank or dotted input like - // `?ext=` or `?ext=.pdf` would otherwise produce a malformed - // MIME type like `application/` or `application/.pdf`. Trim - // whitespace, lowercase, strip any leading dot, then fall back - // to markdown when the value is empty. - ext := strings.ToLower(strings.TrimSpace(c.DefaultQuery("ext", "markdown"))) - ext = strings.TrimPrefix(ext, ".") - if ext == "" { - ext = "markdown" - } - - // IDOR note: the Go User struct collapses user/tenant into one - // identifier (same model as the python download_agent_file - // endpoint at agent_api.py:523-530). The python attachment - // endpoint relies on the storage bucket's tenant scoping for - // authorisation. The Go port preserves that shape. - if h.fileService == nil { - jsonError(c, common.CodeServerError, "file service not configured") - return - } - blob, err := h.fileService.DownloadAgentFile(user.ID, attachmentID) - if err != nil { - // Mirror agent_download.go error mapping — DAO/transport - // errors collapse to a generic 102 so we don't leak storage - // internals in the response body. - jsonError(c, common.CodeDataError, "Attachment not found!") - return - } - - contentType := utility.CONTENT_TYPE_MAP[ext] - if contentType == "" { - // Fallback for unknown extensions — keep the wire shape - // consistent with the python handler. - contentType = "application/" + ext - } - c.Header("Content-Disposition", fmt.Sprintf( - `attachment; filename="%s"; filename*=UTF-8''%s`, - safe, url.PathEscape(safe), - )) - c.Data(http.StatusOK, contentType, blob) + // Support ?disposition=inline for optional inline viewing. + inline := strings.ToLower(strings.TrimSpace(c.Query("disposition"))) == "inline" + h.streamAgentAttachment(c, user.ID, attachmentID, inline) +} + +// PreviewAttachment GET /api/v1/agents/attachments//preview +// +// Returns the attachment with Content-Disposition: inline for safe types +// (PDF, images, Markdown, etc.) and forces attachment for dangerous types +// (HTML, SVG, XML). This is the endpoint used by MCP clients and the +// preview_url generated by DocGenerator. Mirrors Python preview_attachment() +// at agent_api.py:2496. +func (h *AgentHandler) PreviewAttachment(c *gin.Context) { + user, code, msg := GetUser(c) + if code != common.CodeSuccess { + jsonError(c, code, msg) + return + } + attachmentID := c.Param("attachment_id") + if attachmentID == "" { + jsonError(c, common.CodeArgumentError, "`attachment_id` is required.") + return + } + safe := filepath.Base(attachmentID) + if safe == "" || safe == "." || safe == "/" || strings.ContainsAny(safe, "\r\n\"") { + jsonError(c, common.CodeArgumentError, "invalid attachment id.") + return + } + + h.streamAgentAttachment(c, user.ID, attachmentID, true) } diff --git a/internal/handler/document.go b/internal/handler/document.go index e76c1d9736..e7d9a32cce 100644 --- a/internal/handler/document.go +++ b/internal/handler/document.go @@ -307,14 +307,12 @@ func (h *DocumentHandler) GetDocumentPreview(c *gin.Context) { } ext := utility.GetFileExtension(preview.FileName) - if preview.ContentType != "" { - c.Header("Content-Type", preview.ContentType) - } - - if utility.ShouldForceAttachment(ext, preview.ContentType) { - c.Header("X-Content-Type-Options", "nosniff") - c.Header("Content-Disposition", "attachment") - } + // Use the shared preview-headers helper so that safe types get + // Content-Disposition: inline with filename, while dangerous + // types (HTML, SVG, XML) fall back to forced attachment with + // nosniff. Mirrors Python document_api.py:2063 which calls + // apply_preview_file_response_headers() with the document name. + utility.SetPreviewFileResponseHeaders(c.Writer.Header(), preview.ContentType, ext, preview.FileName) c.Data(http.StatusOK, preview.ContentType, preview.Data) } diff --git a/internal/router/agent_routes.go b/internal/router/agent_routes.go index beeb7e9179..43168e9330 100644 --- a/internal/router/agent_routes.go +++ b/internal/router/agent_routes.go @@ -59,6 +59,7 @@ func RegisterAgentRoutes(g *gin.RouterGroup, h *handler.AgentHandler) { // File operations. g.GET("/download", h.DownloadAgentFile) g.GET("/attachments/:attachment_id/download", h.DownloadAttachment) + g.GET("/attachments/:attachment_id/preview", h.PreviewAttachment) g.POST("/:canvas_id/upload", h.UploadAgentFile) // Component introspection + debug. diff --git a/internal/utility/file.go b/internal/utility/file.go index 15e391c775..2fb1cfaec3 100644 --- a/internal/utility/file.go +++ b/internal/utility/file.go @@ -17,6 +17,9 @@ package utility import ( + "fmt" + "net/http" + "net/url" "path/filepath" "regexp" "strings" @@ -266,13 +269,22 @@ var FORCE_ATTACHMENT_CONTENT_TYPES = map[string]bool{ "multipart/related": true, } +// stripContentTypeParams strips "; charset=..." and similar parameters +// from a content type string. Mirrors Python's .split(";")[0].strip(). +func stripContentTypeParams(ct string) string { + if before, _, found := strings.Cut(ct, ";"); found { + return strings.TrimSpace(before) + } + return strings.TrimSpace(ct) +} + // ShouldForceAttachment determines if the file should be forced as attachment func ShouldForceAttachment(ext string, contentType string) bool { normalizedExt := strings.ToLower(strings.TrimPrefix(ext, ".")) if normalizedExt != "" && FORCE_ATTACHMENT_EXTENSIONS[normalizedExt] { return true } - normalizedType := strings.ToLower(contentType) + normalizedType := strings.ToLower(stripContentTypeParams(contentType)) return FORCE_ATTACHMENT_CONTENT_TYPES[normalizedType] } @@ -292,3 +304,129 @@ 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(). +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 == "" { + return "file" + } + return sanitized +} + +// ResolveAttachmentContentType resolves a content type and extension from +// query-parameter values. When mimeType is non-empty it is preferred and +// reverse-looked up in CONTENT_TYPE_MAP to also resolve the extension; +// otherwise the extension is looked up in CONTENT_TYPE_MAP, falling back +// to "application/". Returns (contentType, ext). Mirrors Python +// file_response.py: resolve_attachment_content_type(). +func ResolveAttachmentContentType(ext string, mimeType string) (string, string) { + ext = strings.ToLower(strings.TrimSpace(ext)) + ext = strings.TrimPrefix(ext, ".") + mimeType = strings.TrimSpace(mimeType) + + contentType := "" + if mimeType != "" { + normalizedType := strings.ToLower(stripContentTypeParams(mimeType)) + contentType = normalizedType + // Reverse-lookup extension from CONTENT_TYPE_MAP only when + // no explicit ext was provided. Never overwrite a caller- + // supplied extension (e.g. ext=svg&mime_type=image/png must + // stay ext=svg for the force-attachment check). + if ext == "" { + for knownExt, knownType := range CONTENT_TYPE_MAP { + if knownType == normalizedType { + ext = knownExt + break + } + } + } + } else if ext != "" { + if ct, ok := CONTENT_TYPE_MAP[ext]; ok { + contentType = ct + } else { + contentType = "application/" + ext + } + } + return contentType, ext +} + +// 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: +// apply_preview_file_response_headers(). +func SetPreviewFileResponseHeaders(h http.Header, contentType, ext, filename string) { + if contentType != "" { + 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)) + } +} + +// SetDownloadFileResponseHeaders sets response headers for file download +// (always attachment disposition). Mirrors Python file_response.py: +// apply_download_file_response_headers(). +func SetDownloadFileResponseHeaders(h http.Header, contentType, ext, filename string) { + if contentType != "" { + h.Set("Content-Type", contentType) + } + if ShouldForceAttachment(ext, contentType) { + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Content-Disposition", "attachment") + return + } + safe := SanitizeContentDispositionFilename(filename) + h.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safe)) +} + +// AgentAttachmentPreviewPath builds the preview URL path for an agent +// attachment. Query parameters ext and mime_type are URL-encoded when +// provided. Mirrors Python file_response.py: +// agent_attachment_preview_path(). +func AgentAttachmentPreviewPath(attachmentID, ext, mimeType string) string { + path := "/api/v1/agents/attachments/" + attachmentID + "/preview" + params := url.Values{} + if ext != "" { + params.Set("ext", ext) + } + if mimeType != "" { + params.Set("mime_type", mimeType) + } + if qs := params.Encode(); qs != "" { + return path + "?" + qs + } + return path +}