Files
Memoh/internal/handlers/filemanager.go
Acbox c1e6e0cc7a feat(agent): add pagination and smart collapsing to container list tool
Large directories like node_modules/.venv could return thousands of entries,
wasting tokens and causing timeouts. Add offset/limit pagination to ListDir
RPC and collapse heavy subdirectories (>50 items) into summaries in recursive
mode. Collapsing runs at the bridge layer before pagination so the page window
reflects the collapsed view.
2026-04-02 01:51:19 +08:00

575 lines
16 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/workspace/bridge"
)
const mediaContainerRoot = "/data/media"
// ---------- request / response types ----------
type FSFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mode string `json:"mode"`
ModTime string `json:"modTime"`
IsDir bool `json:"isDir"`
}
type FSListResponse struct {
Path string `json:"path"`
Entries []FSFileInfo `json:"entries"`
}
type FSReadResponse struct {
Path string `json:"path"`
Content string `json:"content"`
Size int64 `json:"size"`
}
type FSUploadResponse struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
// FSWriteRequest is the body for creating / overwriting a file.
type FSWriteRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
// FSMkdirRequest is the body for creating a directory.
type FSMkdirRequest struct {
Path string `json:"path"`
}
// FSDeleteRequest is the body for deleting a file or directory.
type FSDeleteRequest struct {
Path string `json:"path"`
Recursive bool `json:"recursive"`
}
// FSRenameRequest is the body for renaming / moving an entry.
type FSRenameRequest struct {
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
type fsOpResponse struct {
OK bool `json:"ok"`
}
// ---------- helpers ----------
// resolveContainerPath cleans and validates a container-relative path.
func resolveContainerPath(rawPath string) (string, error) {
cleaned := filepath.Clean("/" + strings.TrimSpace(rawPath))
if cleaned == "" {
cleaned = "/"
}
if strings.HasPrefix(cleaned, "..") {
return "", errors.New("invalid path")
}
return cleaned, nil
}
func isContainerMediaPath(containerPath string) bool {
cleaned := filepath.Clean("/" + strings.TrimSpace(containerPath))
return cleaned == mediaContainerRoot || strings.HasPrefix(cleaned, mediaContainerRoot+"/")
}
// getGRPCClient returns the gRPC client for the bot's container.
func (h *ContainerdHandler) getGRPCClient(ctx context.Context, botID string) (*bridge.Client, error) {
return h.manager.MCPClient(ctx, botID)
}
// fsFileInfoFromEntry converts a gRPC FileEntry to FSFileInfo.
func fsFileInfoFromEntry(containerPath, name string, isDir bool, size int64, mode, modTime string) FSFileInfo {
return FSFileInfo{
Name: name,
Path: filepath.Join(containerPath, name),
Size: size,
Mode: mode,
ModTime: modTime,
IsDir: isDir,
}
}
// fsHTTPError maps mcpclient domain errors to HTTP status codes.
func fsHTTPError(err error) *echo.HTTPError {
switch {
case errors.Is(err, bridge.ErrNotFound):
return echo.NewHTTPError(http.StatusNotFound, err.Error())
case errors.Is(err, bridge.ErrBadRequest):
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
case errors.Is(err, bridge.ErrForbidden):
return echo.NewHTTPError(http.StatusForbidden, err.Error())
case errors.Is(err, bridge.ErrUnavailable):
return echo.NewHTTPError(http.StatusServiceUnavailable, err.Error())
default:
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
}
// ---------- handlers ----------
// FSStat godoc
// @Summary Get file or directory info
// @Description Returns metadata about a file or directory at the given container path
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Container path"
// @Success 200 {object} FSFileInfo
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs [get].
func (h *ContainerdHandler) FSStat(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
rawPath := c.QueryParam("path")
if strings.TrimSpace(rawPath) == "" {
rawPath = "/"
}
containerPath, err := resolveContainerPath(rawPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
entry, err := client.Stat(ctx, containerPath)
if err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSFileInfo{
Name: filepath.Base(containerPath),
Path: containerPath,
Size: entry.GetSize(),
Mode: entry.GetMode(),
ModTime: entry.GetModTime(),
IsDir: entry.GetIsDir(),
})
}
// FSList godoc
// @Summary List directory contents
// @Description Lists files and directories at the given container path
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Container directory path"
// @Success 200 {object} FSListResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/list [get].
func (h *ContainerdHandler) FSList(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
rawPath := c.QueryParam("path")
if strings.TrimSpace(rawPath) == "" {
rawPath = "/"
}
containerPath, err := resolveContainerPath(rawPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
entries, err := client.ListDirAll(ctx, containerPath, false)
if err != nil {
return fsHTTPError(err)
}
fileInfos := make([]FSFileInfo, 0, len(entries))
for _, e := range entries {
if e.Path == containerPath {
continue
}
fileInfos = append(fileInfos, fsFileInfoFromEntry(
containerPath,
filepath.Base(e.Path),
e.IsDir,
e.Size,
e.Mode,
e.ModTime,
))
}
return c.JSON(http.StatusOK, FSListResponse{
Path: containerPath,
Entries: fileInfos,
})
}
// FSRead godoc
// @Summary Read file content as text
// @Description Reads the content of a file and returns it as a JSON string
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Container file path"
// @Success 200 {object} FSReadResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/read [get].
func (h *ContainerdHandler) FSRead(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
rawPath := c.QueryParam("path")
if strings.TrimSpace(rawPath) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(rawPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
rc, err := client.ReadRaw(ctx, containerPath)
if err != nil {
return fsHTTPError(err)
}
defer func() { _ = rc.Close() }()
data, err := io.ReadAll(rc)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file")
}
return c.JSON(http.StatusOK, FSReadResponse{
Path: containerPath,
Content: string(data),
Size: int64(len(data)),
})
}
// FSDownload godoc
// @Summary Download a file as binary stream
// @Description Downloads a file from the container with appropriate Content-Type
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param path query string true "Container file path"
// @Produce octet-stream
// @Success 200 {file} binary
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/download [get].
func (h *ContainerdHandler) FSDownload(c echo.Context) error {
rawPath := c.QueryParam("path")
if strings.TrimSpace(rawPath) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(rawPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
requireAccess := h.requireBotAccess
if isContainerMediaPath(containerPath) {
requireAccess = h.requireBotAccessWithGuest
}
botID, err := requireAccess(c)
if err != nil {
return err
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
rc, err := client.ReadRaw(ctx, containerPath)
if err != nil {
return fsHTTPError(err)
}
defer func() { _ = rc.Close() }()
data, err := io.ReadAll(rc)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file")
}
fileName := filepath.Base(containerPath)
contentType := mime.TypeByExtension(filepath.Ext(fileName))
if contentType == "" {
contentType = "application/octet-stream"
}
c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
return c.Blob(http.StatusOK, contentType, data)
}
// FSWrite godoc
// @Summary Write text content to a file
// @Description Creates or overwrites a file with the provided text content
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body FSWriteRequest true "Write request"
// @Success 200 {object} fsOpResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/write [post].
func (h *ContainerdHandler) FSWrite(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSWriteRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.Path) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(req.Path)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
if err := client.WriteFile(ctx, containerPath, []byte(req.Content)); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, fsOpResponse{OK: true})
}
// FSUpload godoc
// @Summary Upload a file via multipart form
// @Description Uploads a binary file to the given container path
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param path formData string true "Destination container path"
// @Param file formData file true "File to upload"
// @Accept multipart/form-data
// @Success 200 {object} FSUploadResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/upload [post].
func (h *ContainerdHandler) FSUpload(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
destPath := strings.TrimSpace(c.FormValue("path"))
if destPath == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(destPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
file, err := c.FormFile("file")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "file is required")
}
src, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
defer func() { _ = src.Close() }()
written, err := client.WriteRaw(ctx, containerPath, src)
if err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, FSUploadResponse{
Path: containerPath,
Size: written,
})
}
// FSMkdir godoc
// @Summary Create a directory
// @Description Creates a directory (and parents) at the given container path
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body FSMkdirRequest true "Mkdir request"
// @Success 200 {object} fsOpResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/mkdir [post].
func (h *ContainerdHandler) FSMkdir(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSMkdirRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.Path) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(req.Path)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
if err := client.Mkdir(ctx, containerPath); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, fsOpResponse{OK: true})
}
// FSDelete godoc
// @Summary Delete a file or directory
// @Description Deletes a file or directory at the given container path
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body FSDeleteRequest true "Delete request"
// @Success 200 {object} fsOpResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/delete [post].
func (h *ContainerdHandler) FSDelete(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSDeleteRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.Path) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
}
containerPath, err := resolveContainerPath(req.Path)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if containerPath == "/" {
return echo.NewHTTPError(http.StatusForbidden, "cannot delete root directory")
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
if err := client.DeleteFile(ctx, containerPath, req.Recursive); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, fsOpResponse{OK: true})
}
// FSRename godoc
// @Summary Rename or move a file/directory
// @Description Renames or moves a file/directory from oldPath to newPath
// @Tags containerd
// @Param bot_id path string true "Bot ID"
// @Param payload body FSRenameRequest true "Rename request"
// @Success 200 {object} fsOpResponse
// @Failure 400 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /bots/{bot_id}/container/fs/rename [post].
func (h *ContainerdHandler) FSRename(c echo.Context) error {
botID, err := h.requireBotAccess(c)
if err != nil {
return err
}
var req FSRenameRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.TrimSpace(req.OldPath) == "" || strings.TrimSpace(req.NewPath) == "" {
return echo.NewHTTPError(http.StatusBadRequest, "oldPath and newPath are required")
}
oldPath, err := resolveContainerPath(req.OldPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
newPath, err := resolveContainerPath(req.NewPath)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
ctx := c.Request().Context()
client, err := h.getGRPCClient(ctx, botID)
if err != nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, fmt.Sprintf("container not reachable: %v", err))
}
if err := client.Rename(ctx, oldPath, newPath); err != nil {
return fsHTTPError(err)
}
return c.JSON(http.StatusOK, fsOpResponse{OK: true})
}