Files
ragflow/internal/handler/agent_upload.go
Zhichang Yu 477f2fcebd feat[Go]: port agent webhook trigger, agent file upload/download, component input-form + debug endpoints from Python (#16403)
port agent webhook trigger, agent file upload/download, component
input-form + debug endpoints from Python
- [x] New Feature (non-breaking change which adds functionality)
2026-06-29 09:45:16 +08:00

153 lines
5.1 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Gap B — `POST /api/v1/agents/<agent_id>/upload` (Python
// api/apps/restful_apis/agent_api.py:761-790).
//
// Mirrors the python upload_agent_file handler:
// - @_require_canvas_access_async → loader.LoadCanvasByID (returns
// ErrUserCanvasNotFound for both missing and forbidden; we map that
// to CodeOperatingError (103) with the python permission message
// instead of the chat-path 103 "canvas not found.")
// - single file + ?url= → FileService.upload_info(tenant, file, url)
// via UploadFromURL (URL-import mode)
// - single file + no url → FileService.upload_info(tenant, file)
// via UploadInfos with a one-element slice
// - multi file + any url → FileService.upload_info * N
// via UploadInfos (Python ignores ?url= on the multi-file path)
//
// 64 MB upload cap (D3 default).
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"ragflow/internal/common"
"ragflow/internal/dao"
)
// uploadMaxBytes caps the multipart form body at 64 MB. The python
// reference relies on Quart/werkzeug defaults which are well above
// this; we set it explicitly so the cap is auditable and stable across
// test environments.
const uploadMaxBytes int64 = 64 << 20 // 64 MiB
// canvasNoAccessMessage mirrors the python permission error
// (api/apps/restful_apis/agent_api.py:78,89). Kept identical to
// python so existing clients can pattern-match the message text.
const canvasNoAccessMessage = "Make sure you have permission to access the agent."
// UploadAgentFile POST /api/v1/agents/:canvas_id/upload
func (h *AgentHandler) UploadAgentFile(c *gin.Context) {
user, code, msg := GetUser(c)
if code != common.CodeSuccess {
jsonError(c, code, msg)
return
}
canvasID := c.Param("canvas_id")
if canvasID == "" {
jsonError(c, common.CodeArgumentError, "`canvas_id` is required.")
return
}
// Canvas access check: matches python @_require_canvas_access_async.
// We deliberately do NOT differentiate "missing" from "forbidden"
// (LoadCanvasByID collapses both into ErrUserCanvasNotFound) for
// IDOR mitigation; the user-visible envelope uses OPERATING_ERROR
// (103) with the python permission message so existing clients can
// still pattern-match the text.
if _, err := h.loader.LoadCanvasByID(c.Request.Context(), user.ID, canvasID); err != nil {
if err == dao.ErrUserCanvasNotFound {
jsonError(c, common.CodeOperatingError, canvasNoAccessMessage)
return
}
jsonError(c, common.CodeServerError, err.Error())
return
}
// Hard cap the body before any parsing (security review H3).
// Without MaxBytesReader, a 1 GB request body is fully drained
// into memory by ParseMultipartForm before any size check fires.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, uploadMaxBytes)
if cl := c.Request.ContentLength; cl > uploadMaxBytes {
jsonError(c, common.CodeArgumentError, "request body too large.")
return
}
if err := c.Request.ParseMultipartForm(uploadMaxBytes); err != nil {
jsonError(c, common.CodeArgumentError, "invalid multipart form: "+err.Error())
return
}
defer func() {
if c.Request.MultipartForm != nil {
_ = c.Request.MultipartForm.RemoveAll()
}
}()
form := c.Request.MultipartForm
if form == nil {
jsonError(c, common.CodeArgumentError, "missing multipart form.")
return
}
files := form.File["file"]
// URL-import mode: matches python's behaviour exactly
// (api/apps/restful_apis/agent_api.py:775-783). The url query
// param is consulted ONLY on the single-file branch; for 0 or
// >1 files, the url is silently ignored and the request flows
// into the normal UploadInfos path. We replicate that with a
// guard that dispatches to UploadFromURL only when both
// conditions are met.
if url := c.Query("url"); url != "" && len(files) == 1 {
uploaded, err := h.fileService.UploadFromURL(user.ID, url)
if err != nil {
jsonError(c, common.CodeServerError, err.Error())
return
}
c.JSON(200, gin.H{
"code": common.CodeSuccess,
"data": uploaded,
"message": "success",
})
return
}
if len(files) == 0 {
jsonError(c, common.CodeArgumentError, "`file` field is required.")
return
}
results, err := h.fileService.UploadInfos(user.ID, files)
if err != nil {
jsonError(c, common.CodeServerError, err.Error())
return
}
// Python parity: 1 file → single dict; >1 → list.
var payload any
if len(results) == 1 {
payload = results[0]
} else {
payload = results
}
c.JSON(200, gin.H{
"code": common.CodeSuccess,
"data": payload,
"message": "success",
})
}