package handlers import ( "bytes" "context" "encoding/base64" "fmt" "io" "mime" "net/http" "os" "path/filepath" "strings" "time" "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/labstack/echo/v4" "github.com/memohai/memoh/internal/config" ctr "github.com/memohai/memoh/internal/containerd" "github.com/memohai/memoh/internal/db" "github.com/memohai/memoh/internal/mcp" ) // ---------- request / response types ---------- // FSFileInfo describes a file or directory entry. 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"` } // FSListResponse is the response for a directory listing. type FSListResponse struct { Path string `json:"path"` Entries []FSFileInfo `json:"entries"` } // FSReadResponse is the response when reading text content. type FSReadResponse struct { Path string `json:"path"` Content string `json:"content"` Size int64 `json:"size"` } // FSWriteRequest is the body for creating / overwriting a file. type FSWriteRequest struct { Path string `json:"path"` Content string `json:"content"` } // FSUploadResponse is returned after a successful upload. type FSUploadResponse struct { Path string `json:"path"` Size int64 `json:"size"` } // 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"` } // ---------- path resolution ---------- // fsPathContext holds the resolved host path for a container-relative path, // or indicates that exec-based fallback is required. type fsPathContext struct { // containerPath is the cleaned absolute path inside the container. containerPath string // hostPath is set when the path lives under the data mount and can be // served directly from the host filesystem. hostPath string // insideDataMount is true when containerPath is within the data mount. insideDataMount bool } // resolveContainerPath maps a container-internal path to a host path when // possible (i.e. within the data mount), otherwise returns a context that // tells the caller to use exec-based fallback. func (h *ContainerdHandler) resolveContainerPath(botID, rawPath string) (fsPathContext, error) { containerPath := filepath.Clean("/" + strings.TrimSpace(rawPath)) if containerPath == "" { containerPath = "/" } dataMount := config.DefaultDataMount dataMount = filepath.Clean(dataMount) // Check whether the requested path falls under the data mount. if containerPath == dataMount || strings.HasPrefix(containerPath, dataMount+"/") { hostRoot, err := h.ensureBotDataRoot(botID) if err != nil { return fsPathContext{}, err } relPath := strings.TrimPrefix(containerPath, dataMount) if relPath == "" { relPath = "/" } hostPath := filepath.Join(hostRoot, filepath.FromSlash(relPath)) // Prevent path traversal: resolved path must stay under hostRoot. hostPath = filepath.Clean(hostPath) if !strings.HasPrefix(hostPath, hostRoot) { return fsPathContext{}, fmt.Errorf("path traversal detected") } return fsPathContext{ containerPath: containerPath, hostPath: hostPath, insideDataMount: true, }, nil } // Outside data mount – exec fallback only. return fsPathContext{ containerPath: containerPath, insideDataMount: false, }, nil } // resolveContainerID returns the containerd container ID for a given bot. func (h *ContainerdHandler) resolveContainerIDForFS(botID string) string { if h.queries != nil { pgBotID, err := db.ParseUUID(botID) if err == nil { row, dbErr := h.queries.GetContainerByBotID(h.fsContext(), pgBotID) if dbErr == nil && strings.TrimSpace(row.ContainerID) != "" { return row.ContainerID } } } return mcp.ContainerPrefix + botID } func (h *ContainerdHandler) fsContext() context.Context { ctx := context.Background() if strings.TrimSpace(h.namespace) != "" { ctx = namespaces.WithNamespace(ctx, h.namespace) } return ctx } // execRead runs a command inside the container and returns stdout as bytes. func (h *ContainerdHandler) execRead(botID string, args []string) ([]byte, error) { containerID := h.resolveContainerIDForFS(botID) var stdout bytes.Buffer var stderr bytes.Buffer result, err := h.service.ExecTask(h.fsContext(), containerID, ctr.ExecTaskRequest{ Args: args, Stdout: &stdout, Stderr: &stderr, }) if err != nil { return nil, fmt.Errorf("exec failed: %w", err) } if result.ExitCode != 0 { errMsg := strings.TrimSpace(stderr.String()) if errMsg == "" { errMsg = fmt.Sprintf("exit code %d", result.ExitCode) } return nil, fmt.Errorf("command failed: %s", errMsg) } return stdout.Bytes(), nil } // ---------- 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 = "/" } pc, err := h.resolveContainerPath(botID, rawPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if pc.insideDataMount { info, osErr := os.Stat(pc.hostPath) if osErr != nil { if os.IsNotExist(osErr) { return echo.NewHTTPError(http.StatusNotFound, "not found") } return echo.NewHTTPError(http.StatusInternalServerError, osErr.Error()) } return c.JSON(http.StatusOK, osFileInfoToFS(pc.containerPath, info)) } // Exec fallback. out, err := h.execRead(botID, []string{"stat", "-c", `%n|%s|%a|%Y|%F`, pc.containerPath}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } fi, parseErr := parseStatLine(pc.containerPath, strings.TrimSpace(string(out))) if parseErr != nil { return echo.NewHTTPError(http.StatusInternalServerError, parseErr.Error()) } return c.JSON(http.StatusOK, fi) } // 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 = "/" } pc, err := h.resolveContainerPath(botID, rawPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if pc.insideDataMount { dirEntries, osErr := os.ReadDir(pc.hostPath) if osErr != nil { if os.IsNotExist(osErr) { return echo.NewHTTPError(http.StatusNotFound, "directory not found") } return echo.NewHTTPError(http.StatusInternalServerError, osErr.Error()) } entries := make([]FSFileInfo, 0, len(dirEntries)) for _, de := range dirEntries { info, infoErr := de.Info() if infoErr != nil { continue } childPath := filepath.Join(pc.containerPath, de.Name()) entries = append(entries, osFileInfoToFS(childPath, info)) } return c.JSON(http.StatusOK, FSListResponse{Path: pc.containerPath, Entries: entries}) } // Exec fallback. out, err := h.execRead(botID, []string{"ls", "-1a", pc.containerPath}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") entries := make([]FSFileInfo, 0, len(lines)) for _, name := range lines { name = strings.TrimSpace(name) if name == "" || name == "." || name == ".." { continue } childPath := filepath.Join(pc.containerPath, name) // Try to stat each entry for richer info. statOut, statErr := h.execRead(botID, []string{"stat", "-c", `%n|%s|%a|%Y|%F`, childPath}) if statErr != nil { // Best-effort: return name only. entries = append(entries, FSFileInfo{ Name: name, Path: childPath, }) continue } fi, parseErr := parseStatLine(childPath, strings.TrimSpace(string(statOut))) if parseErr != nil { entries = append(entries, FSFileInfo{Name: name, Path: childPath}) continue } entries = append(entries, fi) } return c.JSON(http.StatusOK, FSListResponse{Path: pc.containerPath, Entries: entries}) } // 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") } pc, err := h.resolveContainerPath(botID, rawPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if pc.insideDataMount { data, osErr := os.ReadFile(pc.hostPath) if osErr != nil { if os.IsNotExist(osErr) { return echo.NewHTTPError(http.StatusNotFound, "file not found") } return echo.NewHTTPError(http.StatusInternalServerError, osErr.Error()) } return c.JSON(http.StatusOK, FSReadResponse{ Path: pc.containerPath, Content: string(data), Size: int64(len(data)), }) } // Exec fallback. out, err := h.execRead(botID, []string{"cat", pc.containerPath}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, FSReadResponse{ Path: pc.containerPath, Content: string(out), Size: int64(len(out)), }) } // 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 { 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") } pc, err := h.resolveContainerPath(botID, rawPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } fileName := filepath.Base(pc.containerPath) contentType := mime.TypeByExtension(filepath.Ext(fileName)) if contentType == "" { contentType = "application/octet-stream" } if pc.insideDataMount { info, osErr := os.Stat(pc.hostPath) if osErr != nil { if os.IsNotExist(osErr) { return echo.NewHTTPError(http.StatusNotFound, "file not found") } return echo.NewHTTPError(http.StatusInternalServerError, osErr.Error()) } if info.IsDir() { return echo.NewHTTPError(http.StatusBadRequest, "cannot download a directory") } c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) return c.File(pc.hostPath) } // Exec fallback: base64 encode inside container, decode on host. out, err := h.execRead(botID, []string{"base64", pc.containerPath}) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } decoded, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out))) if decErr != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to decode file content") } c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) return c.Blob(http.StatusOK, contentType, decoded) } // 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") } pc, err := h.resolveContainerPath(botID, req.Path) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !pc.insideDataMount { return echo.NewHTTPError(http.StatusForbidden, "write operations are only allowed within the data directory") } dir := filepath.Dir(pc.hostPath) if err := os.MkdirAll(dir, 0o755); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if err := os.WriteFile(pc.hostPath, []byte(req.Content), 0o644); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } 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") } file, err := c.FormFile("file") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "file is required") } pc, err := h.resolveContainerPath(botID, destPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !pc.insideDataMount { return echo.NewHTTPError(http.StatusForbidden, "upload operations are only allowed within the data directory") } src, err := file.Open() if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } defer src.Close() dir := filepath.Dir(pc.hostPath) if err := os.MkdirAll(dir, 0o755); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } dst, err := os.Create(pc.hostPath) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } defer dst.Close() written, err := io.Copy(dst, src) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, FSUploadResponse{ Path: pc.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") } pc, err := h.resolveContainerPath(botID, req.Path) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !pc.insideDataMount { return echo.NewHTTPError(http.StatusForbidden, "mkdir operations are only allowed within the data directory") } if err := os.MkdirAll(pc.hostPath, 0o755); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } 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") } pc, err := h.resolveContainerPath(botID, req.Path) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !pc.insideDataMount { return echo.NewHTTPError(http.StatusForbidden, "delete operations are only allowed within the data directory") } // Prevent deleting the data mount root itself. dataMount := config.DefaultDataMount if filepath.Clean(pc.containerPath) == filepath.Clean(dataMount) { return echo.NewHTTPError(http.StatusForbidden, "cannot delete the data root directory") } if _, statErr := os.Stat(pc.hostPath); os.IsNotExist(statErr) { return echo.NewHTTPError(http.StatusNotFound, "not found") } if req.Recursive { if err := os.RemoveAll(pc.hostPath); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } } else { if err := os.Remove(pc.hostPath); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } } 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") } oldPC, err := h.resolveContainerPath(botID, req.OldPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } newPC, err := h.resolveContainerPath(botID, req.NewPath) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if !oldPC.insideDataMount || !newPC.insideDataMount { return echo.NewHTTPError(http.StatusForbidden, "rename operations are only allowed within the data directory") } if _, statErr := os.Stat(oldPC.hostPath); os.IsNotExist(statErr) { return echo.NewHTTPError(http.StatusNotFound, "source not found") } // Ensure the parent of the destination exists. if err := os.MkdirAll(filepath.Dir(newPC.hostPath), 0o755); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if err := os.Rename(oldPC.hostPath, newPC.hostPath); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, fsOpResponse{OK: true}) } // ---------- helpers ---------- func osFileInfoToFS(containerPath string, info os.FileInfo) FSFileInfo { return FSFileInfo{ Name: info.Name(), Path: containerPath, Size: info.Size(), Mode: fmt.Sprintf("%04o", info.Mode().Perm()), ModTime: info.ModTime().UTC().Format(time.RFC3339), IsDir: info.IsDir(), } } // parseStatLine parses output from: stat -c '%n|%s|%a|%Y|%F' /path func parseStatLine(containerPath, line string) (FSFileInfo, error) { parts := strings.SplitN(line, "|", 5) if len(parts) < 5 { return FSFileInfo{}, fmt.Errorf("unexpected stat output: %s", line) } var size int64 fmt.Sscanf(parts[1], "%d", &size) mode := strings.TrimSpace(parts[2]) var epoch int64 fmt.Sscanf(parts[3], "%d", &epoch) modTime := time.Unix(epoch, 0).UTC().Format(time.RFC3339) fileType := strings.TrimSpace(parts[4]) isDir := strings.Contains(fileType, "directory") name := filepath.Base(containerPath) if containerPath == "/" { name = "/" } return FSFileInfo{ Name: name, Path: containerPath, Size: size, Mode: mode, ModTime: modTime, IsDir: isDir, }, nil }