package controller import ( "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/samber/lo" "github.com/spf13/cast" "go.uber.org/zap" "github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/guacd" "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/service" gsession "github.com/veops/oneterm/internal/session" dbpkg "github.com/veops/oneterm/pkg/db" "github.com/veops/oneterm/pkg/errors" "github.com/veops/oneterm/pkg/logger" ) func isPermissionError(err error) bool { if err == nil { return false } errStr := strings.ToLower(err.Error()) permissionKeywords := []string{ "permission denied", "access denied", "unauthorized", "forbidden", "not authorized", "insufficient privileges", "operation not permitted", "sftp: permission denied", "ssh: permission denied", } for _, keyword := range permissionKeywords { if strings.Contains(errStr, keyword) { return true } } return false } // GetFileHistory godoc // // @Tags file // @Param page_index query int true "page_index" // @Param page_size query int true "page_size" // @Param search query string false "search" // @Param action query int false "saction" // @Param start query string false "start, RFC3339" // @Param end query string false "end, RFC3339" // @Param uid query int false "uid" // @Param asset_id query int false "asset id" // @Param account_id query int false "account id" // @Param client_ip query string false "client_ip" // @Success 200 {object} HttpResponse{data=ListData{list=[]model.Session}} // @Router /file/history [get] func (c *Controller) GetFileHistory(ctx *gin.Context) { // Create filter conditions filters := make(map[string]any) // Get user permissions currentUser, _ := acl.GetSessionFromCtx(ctx) if !acl.IsAdmin(currentUser) { filters["uid"] = currentUser.Uid } // Add other filter conditions if search := ctx.Query("search"); search != "" { filters["user_name LIKE ?"] = "%" + search + "%" } if status := ctx.Query("status"); status != "" { filters["status"] = cast.ToInt(status) } if uid := ctx.Query("uid"); uid != "" { filters["uid"] = cast.ToInt(uid) } if assetId := ctx.Query("asset_id"); assetId != "" { filters["asset_id"] = cast.ToInt(assetId) } if accountId := ctx.Query("account_id"); accountId != "" { filters["account_id"] = cast.ToInt(accountId) } if clientIp := ctx.Query("client_ip"); clientIp != "" { filters["client_ip"] = clientIp } if action := ctx.Query("action"); action != "" { filters["action"] = cast.ToInt(action) } // Process time range if start := ctx.Query("start"); start != "" { filters["created_at >= ?"] = start } if end := ctx.Query("end"); end != "" { filters["created_at <= ?"] = end } // Use global file service histories, count, err := service.DefaultFileService.GetFileHistory(ctx, filters) if err != nil { ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) return } // Convert to slice of any type historiesAny := make([]any, 0, len(histories)) for _, h := range histories { historiesAny = append(historiesAny, h) } result := &ListData{ Count: count, List: historiesAny, } ctx.JSON(http.StatusOK, HttpResponse{ Data: result, }) } // FileLS godoc // // @Tags file // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param dir query string true "dir" // @Param show_hidden query bool false "show hidden files (default: false)" // @Success 200 {object} HttpResponse // @Router /file/ls/:asset_id/:account_id [GET] func (c *Controller) FileLS(ctx *gin.Context) { sess := &gsession.Session{ Session: &model.Session{ AssetId: cast.ToInt(ctx.Param("asset_id")), AccountId: cast.ToInt(ctx.Param("account_id")), }, } if ok, err := hasAuthorization(ctx, sess); err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &errors.ApiError{Code: errors.ErrNoPerm, Data: map[string]any{}}) return } // Use global file service info, err := service.DefaultFileService.ReadDir(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir")) if err != nil { if isPermissionError(err) { ctx.AbortWithError(http.StatusForbidden, fmt.Errorf("permission denied")) } else { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) } return } // Filter hidden files unless show_hidden is true showHidden := cast.ToBool(ctx.Query("show_hidden")) if !showHidden { info = lo.Filter(info, func(f fs.FileInfo, _ int) bool { return !strings.HasPrefix(f.Name(), ".") }) } res := &ListData{ Count: int64(len(info)), List: lo.Map(info, func(f fs.FileInfo, _ int) any { var target string if f.Mode()&os.ModeSymlink != 0 { cli, err := service.GetFileManager().GetFileClient(sess.Session.AssetId, sess.Session.AccountId) if err == nil { linkPath := filepath.Join(ctx.Query("dir"), f.Name()) if linkTarget, err := cli.ReadLink(linkPath); err == nil { target = linkTarget } } } return &service.FileInfo{ Name: f.Name(), IsDir: f.IsDir(), Size: f.Size(), Mode: f.Mode().String(), IsLink: f.Mode()&os.ModeSymlink != 0, Target: target, ModTime: f.ModTime().Format(time.RFC3339), } }), } ctx.JSON(http.StatusOK, NewHttpResponseWithData(res)) } // FileMkdir godoc // // @Tags file // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param dir query string true "dir " // @Success 200 {object} HttpResponse // @Router /file/mkdir/:asset_id/:account_id [post] func (c *Controller) FileMkdir(ctx *gin.Context) { currentUser, _ := acl.GetSessionFromCtx(ctx) sess := &gsession.Session{ Session: &model.Session{ AssetId: cast.ToInt(ctx.Param("asset_id")), AccountId: cast.ToInt(ctx.Param("account_id")), }, } if ok, err := hasAuthorization(ctx, sess); err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &errors.ApiError{Code: errors.ErrNoPerm, Data: map[string]any{}}) return } // Use global file service if err := service.DefaultFileService.MkdirAll(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir")); err != nil { ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } // Create history record h := &model.FileHistory{ Uid: currentUser.GetUid(), UserName: currentUser.GetUserName(), AssetId: sess.Session.AssetId, AccountId: sess.Session.AccountId, ClientIp: ctx.ClientIP(), Action: model.FILE_ACTION_MKDIR, Dir: ctx.Query("dir"), } if err := service.DefaultFileService.AddFileHistory(ctx, h); err != nil { logger.L().Error("record mkdir failed", zap.Error(err), zap.Any("history", h)) } ctx.JSON(http.StatusOK, defaultHttpResponse) } // FileUpload godoc // // @Tags file // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param path query string true "path" // @Success 200 {object} HttpResponse // @Router /file/upload/:asset_id/:account_id [post] func (c *Controller) FileUpload(ctx *gin.Context) { currentUser, _ := acl.GetSessionFromCtx(ctx) sess := &gsession.Session{ Session: &model.Session{ AssetId: cast.ToInt(ctx.Param("asset_id")), AccountId: cast.ToInt(ctx.Param("account_id")), }, } if ok, err := hasAuthorization(ctx, sess); err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &errors.ApiError{Code: errors.ErrNoPerm, Data: map[string]any{}}) return } f, fh, err := ctx.Request.FormFile("file") if err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } content, err := io.ReadAll(f) if err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } // Use global file service rf, err := service.DefaultFileService.Create(ctx, sess.Session.AssetId, sess.Session.AccountId, filepath.Join(ctx.Query("dir"), fh.Filename)) if err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } if _, err = rf.Write(content); err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) return } // Create history record h := &model.FileHistory{ Uid: currentUser.GetUid(), UserName: currentUser.GetUserName(), AssetId: sess.Session.AssetId, AccountId: sess.Session.AccountId, ClientIp: ctx.ClientIP(), Action: model.FILE_ACTION_UPLOAD, Dir: ctx.Query("dir"), Filename: fh.Filename, } if err = service.DefaultFileService.AddFileHistory(ctx, h); err != nil { logger.L().Error("record upload failed", zap.Error(err), zap.Any("history", h)) } ctx.JSON(http.StatusOK, defaultHttpResponse) } // FileDownload godoc // // @Tags file // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param dir query string true "dir" // @Param names query string true "names (comma-separated for multiple files)" // @Success 200 {object} HttpResponse // @Router /file/download/:asset_id/:account_id [get] func (c *Controller) FileDownload(ctx *gin.Context) { currentUser, _ := acl.GetSessionFromCtx(ctx) sess := &gsession.Session{ Session: &model.Session{ AssetId: cast.ToInt(ctx.Param("asset_id")), AccountId: cast.ToInt(ctx.Param("account_id")), }, } if ok, err := hasAuthorization(ctx, sess); err != nil { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &errors.ApiError{Code: errors.ErrNoPerm, Data: map[string]any{}}) return } filenameParam := ctx.Query("names") if filenameParam == "" { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": "names parameter is required"}}) return } filenames := lo.Filter( lo.Map(strings.Split(filenameParam, ","), func(name string, _ int) string { return strings.TrimSpace(name) }), func(name string, _ int) bool { return name != "" }, ) if len(filenames) == 0 { ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": "no valid filenames provided"}}) return } reader, downloadFilename, fileSize, err := service.DefaultFileService.DownloadMultiple(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir"), filenames) if err != nil { if isPermissionError(err) { ctx.AbortWithError(http.StatusForbidden, &errors.ApiError{Code: errors.ErrNoPerm, Data: map[string]any{"err": err}}) } else { ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) } return } defer reader.Close() // Record file operation history h := &model.FileHistory{ Uid: currentUser.GetUid(), UserName: currentUser.GetUserName(), AssetId: sess.Session.AssetId, AccountId: sess.Session.AccountId, ClientIp: ctx.ClientIP(), Action: model.FILE_ACTION_DOWNLOAD, Dir: ctx.Query("dir"), Filename: strings.Join(filenames, ","), CreatedAt: time.Now(), } service.DefaultFileService.AddFileHistory(ctx, h) // Set response headers for file download ctx.Header("Content-Type", "application/octet-stream") ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFilename)) ctx.Header("Cache-Control", "no-cache, no-store, must-revalidate") ctx.Header("Pragma", "no-cache") ctx.Header("Expires", "0") // Set content length if known if fileSize > 0 { ctx.Header("Content-Length", fmt.Sprintf("%d", fileSize)) } // Stream file content directly to response ctx.Status(http.StatusOK) // Use streaming copy with buffer to handle large files efficiently buffer := make([]byte, 32*1024) // 32KB buffer for optimal performance _, err = io.CopyBuffer(ctx.Writer, reader, buffer) if err != nil { logger.L().Error("File transfer failed", zap.Error(err)) } } // RDP File Transfer Methods // RDPFileInfo represents file information for RDP sessions type RDPFileInfo struct { Name string `json:"name"` Size int64 `json:"size"` IsDir bool `json:"is_dir"` ModTime string `json:"mod_time"` } // RDPMkdirRequest represents directory creation request for RDP type RDPMkdirRequest struct { Path string `json:"path" binding:"required"` } // RDPFileList lists files in RDP session drive // @Summary List RDP session files // @Description Get file list for RDP session drive // @Tags RDP File // @Param session_id path string true "Session ID" // @Param path query string false "Directory path" // @Success 200 {object} HttpResponse // @Router /api/v1/rdp/sessions/{session_id}/files [get] func (c *Controller) RDPFileList(ctx *gin.Context) { sessionId := ctx.Param("session_id") path := ctx.DefaultQuery("path", "/") // Check if session exists and user has permission if !c.hasRDPSessionPermission(ctx, sessionId) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "No permission to access this session", }) return } // Get session tunnel tunnel := c.getRDPSessionTunnel(sessionId) if tunnel == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or not active", }) return } // Check if RDP drive is enabled if !c.isRDPDriveEnabled(tunnel) { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "RDP drive is not enabled for this session", }) return } // Send file list request through Guacamole protocol files, err := c.requestRDPFileList(tunnel, path) if err != nil { logger.L().Error("Failed to get RDP file list", zap.Error(err)) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: "Failed to get file list", }) return } ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: files, }) } // RDPFileUpload uploads file to RDP session drive // @Summary Upload file to RDP session // @Description Upload file to RDP session drive // @Tags RDP File // @Accept multipart/form-data // @Param session_id path string true "Session ID" // @Param file formData file true "File to upload" // @Param path formData string false "Target directory path" // @Success 200 {object} HttpResponse // @Router /api/v1/rdp/sessions/{session_id}/files/upload [post] func (c *Controller) RDPFileUpload(ctx *gin.Context) { sessionId := ctx.Param("session_id") targetPath := ctx.DefaultPostForm("path", "/") // Check permission if !c.hasRDPSessionPermission(ctx, sessionId) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "No permission to access this session", }) return } // Get uploaded file file, header, err := ctx.Request.FormFile("file") if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Failed to get uploaded file", }) return } defer file.Close() // Get session tunnel tunnel := c.getRDPSessionTunnel(sessionId) if tunnel == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or not active", }) return } // Check if upload is allowed if !c.isRDPUploadAllowed(tunnel) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "File upload is disabled for this session", }) return } // Read file content content, err := io.ReadAll(file) if err != nil { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: "Failed to read file content", }) return } // Send upload request through Guacamole protocol fullPath := filepath.Join(targetPath, header.Filename) err = c.uploadRDPFile(tunnel, fullPath, content) if err != nil { logger.L().Error("Failed to upload file to RDP session", zap.Error(err)) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: "Failed to upload file", }) return } // Record file operation history c.recordRDPFileHistory(ctx, sessionId, "upload", fullPath, int64(len(content))) ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: gin.H{ "message": "File uploaded successfully", "path": fullPath, "size": len(content), }, }) } // RDPFileDownload downloads file from RDP session drive // @Summary Download file from RDP session // @Description Download file from RDP session drive // @Tags RDP File // @Accept json // @Produce application/octet-stream // @Param session_id path string true "Session ID" // @Param path query string true "File path" // @Success 200 {file} binary // @Router /api/v1/rdp/sessions/{session_id}/files/download [get] func (c *Controller) RDPFileDownload(ctx *gin.Context) { sessionId := ctx.Param("session_id") filePath := ctx.Query("path") if filePath == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "File path is required", }) return } // Check permission if !c.hasRDPSessionPermission(ctx, sessionId) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "No permission to access this session", }) return } // Get session tunnel tunnel := c.getRDPSessionTunnel(sessionId) if tunnel == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or not active", }) return } // Check if download is allowed if !c.isRDPDownloadAllowed(tunnel) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "File download is disabled for this session", }) return } // Request file download through Guacamole protocol content, err := c.downloadRDPFile(tunnel, filePath) if err != nil { logger.L().Error("Failed to download file from RDP session", zap.Error(err)) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: "Failed to download file", }) return } // Record file operation history c.recordRDPFileHistory(ctx, sessionId, "download", filePath, int64(len(content))) // Set response headers filename := filepath.Base(filePath) ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) ctx.Header("Content-Type", "application/octet-stream") ctx.Header("Content-Length", strconv.Itoa(len(content))) ctx.Data(http.StatusOK, "application/octet-stream", content) } // RDPFileMkdir creates directory in RDP session drive // @Summary Create directory in RDP session // @Description Create directory in RDP session drive // @Tags RDP File // @Accept json // @Produce json // @Param session_id path string true "Session ID" // @Param request body RDPMkdirRequest true "Directory creation request" // @Success 200 {object} HttpResponse // @Router /api/v1/rdp/sessions/{session_id}/files/mkdir [post] func (c *Controller) RDPFileMkdir(ctx *gin.Context) { sessionId := ctx.Param("session_id") var req RDPMkdirRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Invalid request parameters", }) return } // Check permission if !c.hasRDPSessionPermission(ctx, sessionId) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "No permission to access this session", }) return } // Get session tunnel tunnel := c.getRDPSessionTunnel(sessionId) if tunnel == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or not active", }) return } // Check if upload is allowed (mkdir is considered an upload operation) if !c.isRDPUploadAllowed(tunnel) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "Directory creation is disabled for this session", }) return } // Send mkdir request through Guacamole protocol err := c.createRDPDirectory(tunnel, req.Path) if err != nil { logger.L().Error("Failed to create directory in RDP session", zap.Error(err)) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: "Failed to create directory", }) return } // Record file operation history c.recordRDPFileHistory(ctx, sessionId, "mkdir", req.Path, 0) ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: gin.H{ "message": "Directory created successfully", "path": req.Path, }, }) } // RDP Helper methods func (c *Controller) hasRDPSessionPermission(ctx *gin.Context, sessionId string) bool { // TODO: Implement proper session permission check // This should verify that the current user has access to the specified session return true } func (c *Controller) getRDPSessionTunnel(sessionId string) *guacd.Tunnel { // TODO: Implement session tunnel retrieval // This should get the active Guacamole tunnel for the session return nil } func (c *Controller) isRDPDriveEnabled(tunnel *guacd.Tunnel) bool { // Check if RDP drive is enabled in tunnel configuration return tunnel.Config.Parameters["enable-drive"] == "true" } func (c *Controller) isRDPUploadAllowed(tunnel *guacd.Tunnel) bool { return tunnel.Config.Parameters["disable-upload"] != "true" } func (c *Controller) isRDPDownloadAllowed(tunnel *guacd.Tunnel) bool { return tunnel.Config.Parameters["disable-download"] != "true" } func (c *Controller) requestRDPFileList(tunnel *guacd.Tunnel, path string) ([]RDPFileInfo, error) { // TODO: Implement Guacamole protocol communication for file listing // This would involve sending appropriate Guacamole instructions and parsing responses return nil, fmt.Errorf("not implemented: RDP file listing through Guacamole protocol") } func (c *Controller) uploadRDPFile(tunnel *guacd.Tunnel, path string, content []byte) error { // TODO: Implement Guacamole protocol communication for file upload // This would involve sending file-upload instruction with base64 encoded content return fmt.Errorf("not implemented: RDP file upload through Guacamole protocol") } func (c *Controller) downloadRDPFile(tunnel *guacd.Tunnel, path string) ([]byte, error) { // TODO: Implement Guacamole protocol communication for file download // This would involve sending file-download instruction and receiving file data return nil, fmt.Errorf("not implemented: RDP file download through Guacamole protocol") } func (c *Controller) createRDPDirectory(tunnel *guacd.Tunnel, path string) error { // TODO: Implement Guacamole protocol communication for directory creation return fmt.Errorf("not implemented: RDP directory creation through Guacamole protocol") } func (c *Controller) recordRDPFileHistory(ctx *gin.Context, sessionId, operation, path string, size int64) { // Record file operation in history history := &model.FileHistory{ Uid: 0, // TODO: Get current user ID UserName: "", // TODO: Get current user name AssetId: 0, // TODO: Get asset ID from session AccountId: 0, // TODO: Get account ID from session ClientIp: ctx.ClientIP(), Action: c.getRDPActionCode(operation), Dir: filepath.Dir(path), Filename: filepath.Base(path), } fileService := service.NewFileService(repository.NewFileRepository(dbpkg.DB)) if err := fileService.AddFileHistory(ctx, history); err != nil { logger.L().Error("Failed to record RDP file history", zap.Error(err)) } } func (c *Controller) getRDPActionCode(operation string) int { switch operation { case "upload": return model.FILE_ACTION_UPLOAD case "download": return model.FILE_ACTION_DOWNLOAD case "mkdir": return model.FILE_ACTION_MKDIR default: return model.FILE_ACTION_LS } }