Port agent PRs to GO - 4 (#16652)

### Summary

Port

https://github.com/infiniflow/ragflow/pull/15399
https://github.com/infiniflow/ragflow/pull/16469
This commit is contained in:
qinling0210
2026-07-06 10:58:40 +08:00
committed by GitHub
parent 09eb9dbd21
commit 3d2f60c34f
7 changed files with 301 additions and 99 deletions

View File

@@ -14,23 +14,22 @@
// limitations under the License.
//
// Gap D — `GET /api/v1/agents/attachments/<attachment_id>/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/<ext>" 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/<attachment_id>/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/<attachment_id>/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)
}

View File

@@ -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)
}