perf(backend): file api for ls and download

This commit is contained in:
pycook
2025-05-28 21:40:40 +08:00
parent 9afce3d030
commit a1d2b461dc
2 changed files with 744 additions and 36 deletions

View File

@@ -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
}
}

View File

@@ -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
}