From a1d2b461dcece5ec2269113e78848540d8e38a00 Mon Sep 17 00:00:00 2001 From: pycook Date: Wed, 28 May 2025 21:40:40 +0800 Subject: [PATCH] perf(backend): file api for ls and download --- backend/internal/api/controller/file.go | 540 ++++++++++++++++++++++-- backend/internal/service/file.go | 240 ++++++++++- 2 files changed, 744 insertions(+), 36 deletions(-) diff --git a/backend/internal/api/controller/file.go b/backend/internal/api/controller/file.go index eed0c8c..ddc61d3 100644 --- a/backend/internal/api/controller/file.go +++ b/backend/internal/api/controller/file.go @@ -5,7 +5,11 @@ import ( "io" "io/fs" "net/http" + "os" "path/filepath" + "strconv" + "strings" + "time" "github.com/gin-gonic/gin" "github.com/samber/lo" @@ -13,13 +17,43 @@ import ( "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 @@ -31,13 +65,13 @@ import ( // @Param end query string false "end, RFC3339" // @Param uid query int false "uid" // @Param asset_id query int false "asset id" -// @Param accout_id query int false "account 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]interface{}) + filters := make(map[string]any) // Get user permissions currentUser, _ := acl.GetSessionFromCtx(ctx) @@ -112,8 +146,9 @@ func (c *Controller) GetFileHistory(ctx *gin.Context) { // @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 [post] +// @Router /file/ls/:asset_id/:account_id [GET] func (c *Controller) FileLS(ctx *gin.Context) { sess := &gsession.Session{ Session: &model.Session{ @@ -133,27 +168,52 @@ func (c *Controller) FileLS(ctx *gin.Context) { // Use global file service info, err := service.DefaultFileService.ReadDir(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir")) if err != nil { - ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) + 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(), + 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 file +// FileMkdir godoc // -// @Tags account +// @Tags file // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param dir query string true "dir " @@ -276,8 +336,7 @@ func (c *Controller) FileUpload(ctx *gin.Context) { // @Param asset_id path int true "asset_id" // @Param account_id path int true "account_id" // @Param dir query string true "dir" -// @Param filename query string true "filename" -// @Param file formData string true "file field name" +// @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) { @@ -298,22 +357,38 @@ func (c *Controller) FileDownload(ctx *gin.Context) { return } - // Use global file service - rf, err := service.DefaultFileService.Open(ctx, sess.Session.AssetId, sess.Session.AccountId, filepath.Join(ctx.Query("dir"), ctx.Query("filename"))) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": err}}) + 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 } - defer rf.Close() + filenames := lo.Filter( + lo.Map(strings.Split(filenameParam, ","), func(name string, _ int) string { + return strings.TrimSpace(name) + }), + func(name string, _ int) bool { + return name != "" + }, + ) - content, err := io.ReadAll(rf) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) + if len(filenames) == 0 { + ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": "no valid filenames provided"}}) return } - // Create history record + 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(), @@ -322,16 +397,421 @@ func (c *Controller) FileDownload(ctx *gin.Context) { ClientIp: ctx.ClientIP(), Action: model.FILE_ACTION_DOWNLOAD, Dir: ctx.Query("dir"), - Filename: ctx.Query("filename"), + 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)) } - if err = service.DefaultFileService.AddFileHistory(ctx, h); err != nil { - logger.L().Error("record download failed", zap.Error(err), zap.Any("history", h)) - } + // Stream file content directly to response + ctx.Status(http.StatusOK) - rw := ctx.Writer - rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", ctx.Query("filename"))) - rw.Header().Set("Content-Type", "application/octet-stream") - rw.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - rw.Write(content) + // 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 + } } diff --git a/backend/internal/service/file.go b/backend/internal/service/file.go index 5ece999..f85e6bd 100644 --- a/backend/internal/service/file.go +++ b/backend/internal/service/file.go @@ -1,21 +1,25 @@ package service import ( + "archive/zip" "context" "fmt" "io" "io/fs" + "path/filepath" + "strings" "sync" "time" "github.com/google/uuid" "github.com/pkg/sftp" - "golang.org/x/crypto/ssh" - "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/tunneling" dbpkg "github.com/veops/oneterm/pkg/db" + "github.com/veops/oneterm/pkg/logger" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" ) var ( @@ -61,10 +65,13 @@ type FileManager struct { } type FileInfo struct { - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - Mode string `json:"mode"` + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mode string `json:"mode"` + IsLink bool `json:"is_link"` + Target string `json:"target"` + ModTime string `json:"mod_time"` } func GetFileManager() *FileManager { @@ -122,8 +129,16 @@ type IFileService interface { MkdirAll(ctx context.Context, assetId, accountId int, dir string) error Create(ctx context.Context, assetId, accountId int, path string) (io.WriteCloser, error) Open(ctx context.Context, assetId, accountId int, path string) (io.ReadCloser, error) + Stat(ctx context.Context, assetId, accountId int, path string) (fs.FileInfo, error) + DownloadMultiple(ctx context.Context, assetId, accountId int, dir string, filenames []string) (io.ReadCloser, string, int64, error) AddFileHistory(ctx context.Context, history *model.FileHistory) error GetFileHistory(ctx context.Context, filters map[string]interface{}) ([]*model.FileHistory, int64, error) + + // RDP file transfer methods + RDPReadDir(ctx context.Context, sessionId, dir string) ([]fs.FileInfo, error) + RDPMkdirAll(ctx context.Context, sessionId, dir string) error + RDPUploadFile(ctx context.Context, sessionId, filename string, content []byte) error + RDPDownloadFile(ctx context.Context, sessionId, filename string) ([]byte, error) } // File service implementation @@ -161,6 +176,7 @@ func (s *FileService) Create(ctx context.Context, assetId, accountId int, path s if err != nil { return nil, err } + return cli.Create(path) } @@ -170,9 +186,159 @@ func (s *FileService) Open(ctx context.Context, assetId, accountId int, path str if err != nil { return nil, err } + return cli.Open(path) } +// Stat gets file/directory information +func (s *FileService) Stat(ctx context.Context, assetId, accountId int, path string) (fs.FileInfo, error) { + cli, err := GetFileManager().GetFileClient(assetId, accountId) + if err != nil { + return nil, err + } + + return cli.Stat(path) +} + +// DownloadMultiple handles downloading single file or multiple files/directories as ZIP +func (s *FileService) DownloadMultiple(ctx context.Context, assetId, accountId int, dir string, filenames []string) (io.ReadCloser, string, int64, error) { + cli, err := GetFileManager().GetFileClient(assetId, accountId) + if err != nil { + return nil, "", 0, err + } + + // Validate and sanitize all filenames for security + var sanitizedFilenames []string + for _, filename := range filenames { + sanitized, err := sanitizeFilename(filename) + if err != nil { + return nil, "", 0, fmt.Errorf("invalid filename '%s': %v", filename, err) + } + + sanitizedFilenames = append(sanitizedFilenames, sanitized) + } + + // If only one file, check if it's a regular file first + if len(sanitizedFilenames) == 1 { + fullPath := filepath.Join(dir, sanitizedFilenames[0]) + fileInfo, err := cli.Stat(fullPath) + if err != nil { + return nil, "", 0, err + } + + // If it's a regular file, return directly + if !fileInfo.IsDir() { + reader, err := cli.Open(fullPath) + if err != nil { + return nil, "", 0, err + } + return reader, sanitizedFilenames[0], fileInfo.Size(), nil + } + } + + // Multiple files or contains directory, create ZIP + return s.createZipArchive(cli, dir, sanitizedFilenames) +} + +// createZipArchive creates a ZIP archive containing the specified files/directories +func (s *FileService) createZipArchive(cli *sftp.Client, baseDir string, filenames []string) (io.ReadCloser, string, int64, error) { + // Generate ZIP filename + var zipName string + if len(filenames) == 1 { + zipName = filenames[0] + ".zip" + } else { + zipName = "download.zip" + } + + // Use pipe for true streaming without memory buffering + pipeReader, pipeWriter := io.Pipe() + + // Create ZIP in a separate goroutine + go func() { + defer pipeWriter.Close() + + zipWriter := zip.NewWriter(pipeWriter) + defer zipWriter.Close() + + // Add each file/directory to ZIP + for _, filename := range filenames { + fullPath := filepath.Join(baseDir, filename) + + if err := s.addToZip(cli, zipWriter, baseDir, filename, fullPath); err != nil { + logger.L().Error("Failed to add file to ZIP", zap.String("path", fullPath), zap.Error(err)) + pipeWriter.CloseWithError(err) + return + } + } + }() + + // Return pipe reader for streaming, size unknown (-1) + return pipeReader, zipName, -1, nil +} + +// addToZip recursively adds files/directories to ZIP archive +func (s *FileService) addToZip(cli *sftp.Client, zipWriter *zip.Writer, baseDir, relativePath, fullPath string) error { + fileInfo, err := cli.Stat(fullPath) + if err != nil { + return err + } + + if fileInfo.IsDir() { + // Add directory + return s.addDirToZip(cli, zipWriter, fullPath, relativePath) + } else { + // Add file + return s.addFileToZip(cli, zipWriter, fullPath, relativePath) + } +} + +// addFileToZip adds a single file to ZIP archive +func (s *FileService) addFileToZip(cli *sftp.Client, zipWriter *zip.Writer, fullPath, relativePath string) error { + // Open remote file + file, err := cli.Open(fullPath) + if err != nil { + return err + } + defer file.Close() + + // Create file in ZIP + zipFile, err := zipWriter.Create(relativePath) + if err != nil { + return err + } + + // Copy file content + _, err = io.Copy(zipFile, file) + return err +} + +// addDirToZip recursively adds a directory to ZIP archive +func (s *FileService) addDirToZip(cli *sftp.Client, zipWriter *zip.Writer, fullPath, relativePath string) error { + // Read directory contents + entries, err := cli.ReadDir(fullPath) + if err != nil { + return err + } + + // If directory is empty, create directory entry + if len(entries) == 0 { + _, err := zipWriter.Create(relativePath + "/") + return err + } + + // Recursively add each entry in the directory + for _, entry := range entries { + entryFullPath := filepath.Join(fullPath, entry.Name()) + entryRelativePath := filepath.Join(relativePath, entry.Name()) + + if err := s.addToZip(cli, zipWriter, fullPath, entryRelativePath, entryFullPath); err != nil { + return err + } + } + + return nil +} + // AddFileHistory adds a file history record func (s *FileService) AddFileHistory(ctx context.Context, history *model.FileHistory) error { return s.repo.AddFileHistory(ctx, history) @@ -182,3 +348,65 @@ func (s *FileService) AddFileHistory(ctx context.Context, history *model.FileHis func (s *FileService) GetFileHistory(ctx context.Context, filters map[string]interface{}) ([]*model.FileHistory, int64, error) { return s.repo.GetFileHistory(ctx, filters) } + +// RDP file transfer methods implementation + +// RDPReadDir reads directory contents for RDP session +func (s *FileService) RDPReadDir(ctx context.Context, sessionId, dir string) ([]fs.FileInfo, error) { + // Get session tunnel to access file transfer manager + tunnel := tunneling.GetTunnelBySessionId(sessionId) + if tunnel == nil { + return nil, fmt.Errorf("session not found: %s", sessionId) + } + + // For RDP sessions, we need to check if drive is enabled + // This would need to be implemented based on the actual session configuration + // For now, return an error indicating RDP file operations need to be handled differently + return nil, fmt.Errorf("RDP file operations should be handled through Guacamole protocol") +} + +// RDPMkdirAll creates directory for RDP session +func (s *FileService) RDPMkdirAll(ctx context.Context, sessionId, dir string) error { + tunnel := tunneling.GetTunnelBySessionId(sessionId) + if tunnel == nil { + return fmt.Errorf("session not found: %s", sessionId) + } + + return fmt.Errorf("RDP file operations should be handled through Guacamole protocol") +} + +// RDPUploadFile uploads file for RDP session +func (s *FileService) RDPUploadFile(ctx context.Context, sessionId, filename string, content []byte) error { + tunnel := tunneling.GetTunnelBySessionId(sessionId) + if tunnel == nil { + return fmt.Errorf("session not found: %s", sessionId) + } + + return fmt.Errorf("RDP file operations should be handled through Guacamole protocol") +} + +// RDPDownloadFile downloads file for RDP session +func (s *FileService) RDPDownloadFile(ctx context.Context, sessionId, filename string) ([]byte, error) { + tunnel := tunneling.GetTunnelBySessionId(sessionId) + if tunnel == nil { + return nil, fmt.Errorf("session not found: %s", sessionId) + } + + return nil, fmt.Errorf("RDP file operations should be handled through Guacamole protocol") +} + +func sanitizeFilename(filename string) (string, error) { + // Remove any path traversal attempts + if strings.Contains(filename, "..") || + strings.Contains(filename, "/") || + strings.Contains(filename, "\\") { + return "", fmt.Errorf("invalid filename: path traversal detected") + } + + // Remove null bytes and control characters + if strings.ContainsAny(filename, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f") { + return "", fmt.Errorf("invalid filename: control characters detected") + } + + return filename, nil +}