package controller import ( "errors" "fmt" "io" "io/fs" "mime" "mime/multipart" "net/http" "os" "path/filepath" "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" fileservice "github.com/veops/oneterm/internal/service/file" gsession "github.com/veops/oneterm/internal/session" myErrors "github.com/veops/oneterm/pkg/errors" "github.com/veops/oneterm/pkg/logger" ) const ( MaxMemoryForParsing = 1 << 20 // 1MB for multipart parsing MaxFileSize = 10240 << 20 // 10GB max file size MaxFileSizeForInMemoryProcessing = 64 << 20 // 64MB for in-memory processing ) // 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) { currentUser, _ := acl.GetSessionFromCtx(ctx) db := fileservice.DefaultFileService.BuildFileHistoryQuery(ctx) // Apply user permissions - non-admin users can only see their own history if !acl.IsAdmin(currentUser) { db = db.Where("uid = ?", currentUser.Uid) } doGet[*model.FileHistory](ctx, false, db, "") } // 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, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Use global file service info, err := fileservice.DefaultFileService.ReadDir(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir")) if err != nil { if fileservice.IsPermissionError(err) { ctx.AbortWithError(http.StatusForbidden, fmt.Errorf("permission denied")) } else { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.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 := fileservice.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 &fileservice.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) { 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, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Use global file service if err := fileservice.DefaultFileService.MkdirAll(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir")); err != nil { ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } // Record file history using unified method if err := fileservice.DefaultFileService.RecordFileHistory(ctx, "mkdir", ctx.Query("dir"), "", sess.Session.AssetId, sess.Session.AccountId); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } 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 dir query string false "target directory path (default: /tmp)" // @Param transfer_id query string false "Custom transfer ID for progress tracking (frontend generated)" // @Accept multipart/form-data // @Param file formData file true "file to upload" // @Success 200 {object} HttpResponse // @Router /file/upload/:asset_id/:account_id [post] func (c *Controller) FileUpload(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, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Get transfer_id from URL query parameters (non-blocking) frontendTransferId := ctx.Query("transfer_id") targetDir := ctx.DefaultQuery("dir", "/tmp") // Use frontend provided transfer_id or generate new one var transferId string if frontendTransferId != "" { transferId = frontendTransferId } else { transferId = fmt.Sprintf("%d-%d-%d", sess.Session.AssetId, sess.Session.AccountId, time.Now().UnixNano()) } fileservice.CreateTransferProgress(transferId, "sftp") // Parse multipart form if err := ctx.Request.ParseMultipartForm(MaxMemoryForParsing); err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Failed to parse multipart form: %v", err), }) return } // Get uploaded file file, fileHeader, err := ctx.Request.FormFile("file") if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Failed to get uploaded file: %v", err), }) return } defer file.Close() filename := fileHeader.Filename fileSize := fileHeader.Size if fileSize > MaxFileSize { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("File size %d bytes exceeds limit of %d bytes", fileSize, MaxFileSize), }) return } // Update transfer progress with file size fileservice.UpdateTransferProgress(transferId, fileSize, 0, "") targetPath := filepath.Join(targetDir, filename) // Phase 1: Save file to server temp directory first tempDir := filepath.Join(os.TempDir(), "oneterm-uploads", fmt.Sprintf("%d-%d", sess.Session.AssetId, sess.Session.AccountId)) if err := os.MkdirAll(tempDir, 0755); err != nil { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp directory: %v", err), }) return } tempFilePath := filepath.Join(tempDir, filename) tempFile, err := os.Create(tempFilePath) if err != nil { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp file: %v", err), }) return } // Copy uploaded file to temp location written, err := io.Copy(tempFile, file) tempFile.Close() if err != nil { os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to save file: %v", err), }) return } if written != fileSize { os.Remove(tempFilePath) ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("File size mismatch: expected %d, got %d", fileSize, written), }) return } // Phase 2: Transfer to target machine using SFTP (synchronous) fileservice.UpdateTransferProgress(transferId, fileSize, 0, "transferring") if err := fileservice.TransferToTarget(transferId, "", tempFilePath, targetPath, sess.Session.AssetId, sess.Session.AccountId); err != nil { fileservice.UpdateTransferProgress(transferId, 0, -1, "failed") os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("File transfer failed: %v", err), }) return } // Mark transfer as completed fileservice.UpdateTransferProgress(transferId, 0, -1, "completed") // Clean up temp file after successful transfer os.Remove(tempFilePath) // Record file history using unified method if err := fileservice.DefaultFileService.RecordFileHistory(ctx, "upload", targetDir, filename, sess.Session.AssetId, sess.Session.AccountId); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // Return success response after transfer completion ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "File uploaded successfully", Data: gin.H{ "filename": filename, "path": targetPath, "size": fileSize, "transfer_id": transferId, "status": "completed", }, }) // Clean up progress record after a short delay go func() { time.Sleep(30 * time.Second) // Keep for 30 seconds for any delayed queries fileservice.CleanupTransferProgress(transferId, 0) }() } // 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) { 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, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } filenameParam := ctx.Query("names") if filenameParam == "" { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.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, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": "no valid filenames provided"}}) return } reader, downloadFilename, fileSize, err := fileservice.DefaultFileService.DownloadMultiple(ctx, sess.Session.AssetId, sess.Session.AccountId, ctx.Query("dir"), filenames) if err != nil { if fileservice.IsPermissionError(err) { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"err": err}}) } else { ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) } return } defer reader.Close() // Record file operation history using unified method if err := fileservice.DefaultFileService.RecordFileHistory(ctx, "download", ctx.Query("dir"), strings.Join(filenames, ","), sess.Session.AssetId, sess.Session.AccountId); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // 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 // 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 /rdp/sessions/{session_id}/files [get] func (c *Controller) RDPFileList(ctx *gin.Context) { sessionId := ctx.Param("session_id") path := ctx.DefaultQuery("path", "/") tunnel, err := c.validateRDPAccess(ctx, sessionId) if err != nil { if strings.Contains(err.Error(), "permission") { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: err.Error(), }) } else { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: err.Error(), }) } return } // Check if RDP drive is enabled if !fileservice.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 := fileservice.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 transfer_id query string false "Custom transfer ID for progress tracking (frontend generated)" // @Param path query string false "Target directory path" // @Param file formData file true "File to upload" // @Success 200 {object} HttpResponse // @Router /rdp/sessions/{session_id}/files/upload [post] func (c *Controller) RDPFileUpload(ctx *gin.Context) { sessionId := ctx.Param("session_id") // Get transfer_id from URL query parameters IMMEDIATELY (non-blocking) frontendTransferId := ctx.Query("transfer_id") targetPath := ctx.DefaultQuery("path", "/") // Use frontend provided transfer_id or generate new one var transferId string if frontendTransferId != "" { transferId = frontendTransferId } else { transferId = fmt.Sprintf("rdp-%s-%d", sessionId, time.Now().UnixNano()) } // Create progress record IMMEDIATELY when request starts fileservice.CreateTransferProgress(transferId, "rdp") tunnel, err := c.validateRDPAccess(ctx, sessionId) if err != nil { if strings.Contains(err.Error(), "permission") { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: err.Error(), }) } else { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: err.Error(), }) } return } if !fileservice.IsRDPDriveEnabled(tunnel) { logger.L().Error("RDP drive is not enabled for session", zap.String("sessionId", sessionId)) ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "RDP drive is not enabled for this session", }) return } if !fileservice.IsRDPUploadAllowed(tunnel) { logger.L().Error("RDP upload is disabled for session", zap.String("sessionId", sessionId)) ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "File upload is disabled for this session", }) return } // Parse multipart form with streaming contentType := ctx.GetHeader("Content-Type") if !strings.HasPrefix(contentType, "multipart/form-data") { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Invalid content type, expected multipart/form-data", }) return } // Get boundary from content type _, params, err := mime.ParseMediaType(contentType) if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Invalid content type: %v", err), }) return } boundary := params["boundary"] if boundary == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Missing boundary in content type", }) return } // Create multipart reader for streaming reader := multipart.NewReader(ctx.Request.Body, boundary) var filename string var fileSize int64 // Find the file part and save to temporary file (avoid memory overhead) var tempFilePath string for { part, err := reader.NextPart() if err == io.EOF { break } if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Error reading multipart: %v", err), }) return } formName := part.FormName() if formName == "file" { filename = part.FileName() if filename == "" { part.Close() continue } // Create temporary file to store upload data (streaming, no memory overhead) tempDir := filepath.Join(os.TempDir(), "oneterm-rdp-uploads") if err := os.MkdirAll(tempDir, 0755); err != nil { part.Close() ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp directory: %v", err), }) return } tempFile, err := os.CreateTemp(tempDir, fmt.Sprintf("rdp_upload_%s_*", sessionId)) if err != nil { part.Close() ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp file: %v", err), }) return } tempFilePath = tempFile.Name() // Stream file data directly to temp file (memory-efficient) written, err := io.Copy(tempFile, part) tempFile.Close() part.Close() if err != nil { os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to save file data: %v", err), }) return } fileSize = written break // Found and saved the file } else { part.Close() } } if filename == "" || tempFilePath == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "No file found in upload", }) return } // Use default path if not provided if targetPath == "" { targetPath = "/" } fullPath := filepath.Join(targetPath, filename) // Update progress record with file size fileservice.UpdateTransferProgress(transferId, fileSize, 0, "transferring") // Open temp file for reading and upload synchronously tempFile, err := os.Open(tempFilePath) if err != nil { os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to open temp file for RDP upload: %v", err), }) return } defer tempFile.Close() // Perform RDP upload synchronously err = fileservice.UploadRDPFileStreamWithID(tunnel, transferId, sessionId, fullPath, tempFile, fileSize) if err != nil { fileservice.UpdateTransferProgress(transferId, 0, -1, "failed") os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to upload file to RDP session: %v", err), }) return } // Clean up temp file after successful upload os.Remove(tempFilePath) // Record file history using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "upload", fullPath); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // Return success response after upload completion responseData := gin.H{ "message": "File uploaded successfully", "path": fullPath, "size": fileSize, "status": "completed", } if transferId != "" { responseData["transfer_id"] = transferId } ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "Upload completed", Data: responseData, }) // Clean up progress record after a short delay go func() { time.Sleep(30 * time.Second) // Keep for 30 seconds for any delayed queries fileservice.CleanupTransferProgress(transferId, 0) }() } // RDPFileDownload downloads files from RDP session drive // @Summary Download files from RDP session // @Description Download files from RDP session drive (supports multiple files via names parameter) // @Tags RDP File // @Accept json // @Produce application/octet-stream // @Param session_id path string true "Session ID" // @Param dir query string true "Directory path" // @Param names query string true "File names (comma-separated for multiple files)" // @Success 200 {file} binary // @Router /rdp/sessions/{session_id}/files/download [get] func (c *Controller) RDPFileDownload(ctx *gin.Context) { sessionId := ctx.Param("session_id") tunnel, validationErr := c.validateRDPAccess(ctx, sessionId) if validationErr != nil { if strings.Contains(validationErr.Error(), "permission") { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: validationErr.Error(), }) } else { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: validationErr.Error(), }) } return } if !fileservice.IsRDPDriveEnabled(tunnel) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "Drive redirection not enabled", }) return } if !fileservice.IsRDPDownloadAllowed(tunnel) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "File download not allowed", }) return } // Parse query parameters dir := ctx.Query("dir") if dir == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Directory parameter is required", }) return } filenameParam := ctx.Query("names") if filenameParam == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Filenames parameter is required", }) return } // Parse and validate filenames 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.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "No valid filenames provided", }) return } var reader io.ReadCloser var downloadFilename string var fileSize int64 var err error if len(filenames) == 1 { // Single file download (memory-efficient streaming) path := filepath.Join(dir, filenames[0]) reader, fileSize, err = fileservice.DownloadRDPFile(tunnel, path) if err != nil { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to download file: %v", err), }) return } downloadFilename = filenames[0] } else { // Multiple files download as ZIP reader, downloadFilename, fileSize, err = fileservice.DownloadRDPMultiple(tunnel, dir, filenames) if err != nil { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to download files: %v", err), }) return } } defer reader.Close() // Record file operation history using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "download", filepath.Join(dir, strings.Join(filenames, ","))); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // 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)) } } // 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 path query string true "Directory path" // @Success 200 {object} HttpResponse // @Router /rdp/sessions/{session_id}/files/mkdir [post] func (c *Controller) RDPFileMkdir(ctx *gin.Context) { sessionId := ctx.Param("session_id") path := ctx.Query("path") if path == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Invalid request parameters", }) return } tunnel, validateErr := c.validateRDPAccess(ctx, sessionId) if validateErr != nil { if strings.Contains(validateErr.Error(), "permission") { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: validateErr.Error(), }) } else { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: validateErr.Error(), }) } return } // Check if upload is allowed (mkdir is considered an upload operation) if !fileservice.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 := fileservice.CreateRDPDirectory(tunnel, 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 using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "mkdir", path); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: gin.H{ "message": "Directory created successfully", "path": path, }, }) } func (c *Controller) validateRDPAccess(ctx *gin.Context, sessionId string) (*guacd.Tunnel, error) { currentUser, err := acl.GetSessionFromCtx(ctx) if err != nil || currentUser == nil { return nil, fmt.Errorf("no permission to access this session") } onlineSession := gsession.GetOnlineSessionById(sessionId) if onlineSession == nil { return nil, fmt.Errorf("session not found or not active") } tunnel := onlineSession.GuacdTunnel if tunnel == nil { return nil, fmt.Errorf("session not found or not active") } return tunnel, nil } // ============================================================================= // Sftp-based File Operations // ============================================================================= // SftpFileLS godoc // // @Tags file // @Param session_id path string true "session_id" // @Param dir query string true "dir" // @Param show_hidden query bool false "show hidden files (default: false)" // @Success 200 {object} HttpResponse // @Router /file/session/:session_id/ls [GET] func (c *Controller) SftpFileLS(ctx *gin.Context) { sessionId := ctx.Param("session_id") dir := ctx.Query("dir") if dir == "" { dir = "/" } // Check if session is active if !fileservice.DefaultFileService.IsSessionActive(sessionId) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or inactive", }) return } // Get session info for authorization check onlineSession := gsession.GetOnlineSessionById(sessionId) if onlineSession == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) return } // Check authorization using the same logic as legacy API if ok, err := hasAuthorization(ctx, onlineSession); err != nil { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Use session-based file service fileInfos, err := fileservice.DefaultFileService.SessionLS(ctx, sessionId, dir) if err != nil { if errors.Is(err, fileservice.ErrSessionNotFound) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) } else if fileservice.IsPermissionError(err) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "Permission denied", }) } else { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to list directory: %v", err), }) } return } // Filter hidden files unless show_hidden is true showHidden := cast.ToBool(ctx.Query("show_hidden")) if !showHidden { var filtered []fileservice.FileInfo for _, f := range fileInfos { if !strings.HasPrefix(f.Name, ".") { filtered = append(filtered, f) } } fileInfos = filtered } res := &ListData{ Count: int64(len(fileInfos)), List: lo.Map(fileInfos, func(f fileservice.FileInfo, _ int) any { return f }), } ctx.JSON(http.StatusOK, NewHttpResponseWithData(res)) } // SftpFileMkdir godoc // // @Tags file // @Param session_id path string true "session_id" // @Param dir query string true "dir" // @Success 200 {object} HttpResponse // @Router /file/session/:session_id/mkdir [post] func (c *Controller) SftpFileMkdir(ctx *gin.Context) { sessionId := ctx.Param("session_id") dir := ctx.Query("dir") if dir == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Directory path is required", }) return } // Check if session is active if !fileservice.DefaultFileService.IsSessionActive(sessionId) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or inactive", }) return } // Get session info for authorization check onlineSession := gsession.GetOnlineSessionById(sessionId) if onlineSession == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) return } // Check authorization using the same logic as legacy API if ok, err := hasAuthorization(ctx, onlineSession); err != nil { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Use session-based file service if err := fileservice.DefaultFileService.SessionMkdir(ctx, sessionId, dir); err != nil { if errors.Is(err, fileservice.ErrSessionNotFound) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) } else { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create directory: %v", err), }) } return } // Record history using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "mkdir", dir); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } ctx.JSON(http.StatusOK, defaultHttpResponse) } // TransferProgressById - Unified transfer progress tracking for SFTP and RDP // @Tags file // @Router /file/transfer/progress/id/:transfer_id [get] func (c *Controller) TransferProgressById(ctx *gin.Context) { transferId := ctx.Param("transfer_id") // First check unified progress tracking progress, exists := fileservice.GetTransferProgressById(transferId) if exists { // Calculate transfer progress var progressPercent int if progress.TotalSize > 0 { progressPercent = int(float64(progress.TransferredSize) / float64(progress.TotalSize) * 100) } ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: gin.H{ "status": progress.Status, "progress": progressPercent, "type": progress.Type, "message": fmt.Sprintf("Transferred %d/%d bytes via %s", progress.TransferredSize, progress.TotalSize, strings.ToUpper(progress.Type)), }, }) return } // Fallback: check RDP guacd transfer manager rdpProgress, err := fileservice.GetRDPTransferProgressById(transferId) if err == nil { ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "ok", Data: rdpProgress, }) return } // Transfer not found ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Transfer not found or already completed", Data: gin.H{ "status": "not_found", "progress": 0, "message": "Transfer not found in progress tracking", }, }) } // Helper methods for RDP transfer progress // RDPFileTransferPrepare creates transfer records before upload starts // @Summary Create transfer record for RDP upload // @Description Create transfer record before RDP upload starts for progress tracking // @Tags RDP File // @Accept json // @Produce json // @Param session_id path string true "Session ID" // @Param transfer_id query string false "Custom transfer ID" // @Param filename query string false "Filename" // @Success 200 {object} HttpResponse // @Router /rdp/sessions/{session_id}/files/prepare [post] func (c *Controller) RDPFileTransferPrepare(ctx *gin.Context) { sessionId := ctx.Param("session_id") transferId := ctx.Query("transfer_id") filename := ctx.Query("filename") if transferId == "" { transferId = fmt.Sprintf("rdp-%s-%d", sessionId, time.Now().UnixNano()) } // Create unified progress tracking entry fileservice.CreateTransferProgress(transferId, "rdp") ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "Transfer prepared", Data: gin.H{ "transfer_id": transferId, "status": "prepared", "filename": filename, }, }) } // SftpFileUpload godoc // // @Tags file // @Param session_id path string true "session_id" // @Param dir query string false "target directory path (default: /tmp)" // @Param transfer_id query string false "Custom transfer ID for progress tracking (frontend generated)" // @Accept multipart/form-data // @Param file formData file true "file to upload" // @Success 200 {object} HttpResponse // @Router /file/session/:session_id/upload [post] func (c *Controller) SftpFileUpload(ctx *gin.Context) { sessionId := ctx.Param("session_id") // Get transfer_id from URL query parameters IMMEDIATELY (non-blocking) frontendTransferId := ctx.Query("transfer_id") targetDir := ctx.DefaultQuery("dir", "/tmp") // Use frontend provided transfer_id or generate new one var transferId string if frontendTransferId != "" { transferId = frontendTransferId } else { transferId = fmt.Sprintf("%s-%d", sessionId, time.Now().UnixNano()) } fileservice.CreateTransferProgress(transferId, "sftp") // Validate session if !fileservice.DefaultFileService.IsSessionActive(sessionId) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or inactive", }) return } onlineSession := gsession.GetOnlineSessionById(sessionId) if onlineSession == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) return } if ok, err := hasAuthorization(ctx, onlineSession); err != nil { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } // Use streaming multipart parsing like RDP to minimize memory usage contentType := ctx.GetHeader("Content-Type") if !strings.HasPrefix(contentType, "multipart/form-data") { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Invalid content type, expected multipart/form-data", }) return } // Get boundary from content type _, params, err := mime.ParseMediaType(contentType) if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Invalid content type: %v", err), }) return } boundary := params["boundary"] if boundary == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "Missing boundary in content type", }) return } // Create multipart reader for streaming (memory-efficient like RDP) reader := multipart.NewReader(ctx.Request.Body, boundary) var filename string var fileSize int64 // Find the file part and save to temporary file (avoid memory overhead) var tempFilePath string for { part, err := reader.NextPart() if err == io.EOF { break } if err != nil { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Error reading multipart: %v", err), }) return } formName := part.FormName() if formName == "file" { filename = part.FileName() if filename == "" { part.Close() continue } // Create temporary file to store upload data (streaming, no memory overhead) tempDir := filepath.Join(os.TempDir(), "oneterm-uploads", sessionId) if err := os.MkdirAll(tempDir, 0755); err != nil { part.Close() ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp directory: %v", err), }) return } tempFile, err := os.CreateTemp(tempDir, fmt.Sprintf("sftp_upload_%s_*", sessionId)) if err != nil { part.Close() ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to create temp file: %v", err), }) return } tempFilePath = tempFile.Name() // Stream file data directly to temp file (memory-efficient) written, err := io.Copy(tempFile, part) tempFile.Close() part.Close() if err != nil { os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to save file data: %v", err), }) return } fileSize = written break // Found and saved the file } else { part.Close() } } if filename == "" || tempFilePath == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "No file found in upload", }) return } if fileSize > MaxFileSize { os.Remove(tempFilePath) ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: fmt.Sprintf("File size %d bytes exceeds limit of %d bytes", fileSize, MaxFileSize), }) return } // Update transfer progress with file size now that we have it fileservice.UpdateTransferProgress(transferId, fileSize, 0, "") targetPath := filepath.Join(targetDir, filename) // Phase 2: Transfer to target machine using SFTP (synchronous) fileservice.UpdateTransferProgress(transferId, fileSize, 0, "transferring") if err := fileservice.TransferToTarget(transferId, sessionId, tempFilePath, targetPath, 0, 0); err != nil { // Mark transfer as failed and clean up fileservice.UpdateTransferProgress(transferId, 0, -1, "failed") os.Remove(tempFilePath) ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("File transfer failed: %v", err), }) return } // Mark transfer as completed (success) fileservice.UpdateTransferProgress(transferId, 0, -1, "completed") // Clean up temp file after successful transfer os.Remove(tempFilePath) // Record file history using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "upload", filepath.Join(targetDir, filename)); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // Return success response after transfer completion ctx.JSON(http.StatusOK, HttpResponse{ Code: 0, Message: "File uploaded successfully", Data: gin.H{ "filename": filename, "path": targetPath, "size": fileSize, "transfer_id": transferId, "status": "completed", }, }) // Clean up progress record after a short delay go func() { time.Sleep(30 * time.Second) // Keep for 30 seconds for any delayed queries fileservice.CleanupTransferProgress(transferId, 0) }() } // SftpFileDownload godoc // // @Tags file // @Param session_id path string true "session_id" // @Param dir query string true "dir" // @Param names query string true "names (comma-separated for multiple files)" // @Success 200 {object} HttpResponse // @Router /file/session/:session_id/download [get] func (c *Controller) SftpFileDownload(ctx *gin.Context) { sessionId := ctx.Param("session_id") // Check if session is active if !fileservice.DefaultFileService.IsSessionActive(sessionId) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found or inactive", }) return } // Get session info for authorization check onlineSession := gsession.GetOnlineSessionById(sessionId) if onlineSession == nil { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) return } // Check authorization using the same logic as legacy API if ok, err := hasAuthorization(ctx, onlineSession); err != nil { ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) return } else if !ok { ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) return } filenameParam := ctx.Query("names") if filenameParam == "" { ctx.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "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.JSON(http.StatusBadRequest, HttpResponse{ Code: http.StatusBadRequest, Message: "No valid filenames provided", }) return } reader, downloadFilename, fileSize, err := fileservice.DefaultFileService.SessionDownloadMultiple(ctx, sessionId, ctx.Query("dir"), filenames) if err != nil { if errors.Is(err, fileservice.ErrSessionNotFound) { ctx.JSON(http.StatusNotFound, HttpResponse{ Code: http.StatusNotFound, Message: "Session not found", }) } else if fileservice.IsPermissionError(err) { ctx.JSON(http.StatusForbidden, HttpResponse{ Code: http.StatusForbidden, Message: "Permission denied", }) } else { ctx.JSON(http.StatusInternalServerError, HttpResponse{ Code: http.StatusInternalServerError, Message: fmt.Sprintf("Failed to download files: %v", err), }) } return } defer reader.Close() // Record file operation history using session-based method if err := fileservice.DefaultFileService.RecordFileHistoryBySession(ctx, sessionId, "download", filepath.Join(ctx.Query("dir"), strings.Join(filenames, ","))); err != nil { logger.L().Error("Failed to record file history", zap.Error(err)) } // 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)) } }