mirror of
				https://github.com/veops/oneterm.git
				synced 2025-10-31 19:02:39 +08:00 
			
		
		
		
	perf(backend): file api for ls and download
This commit is contained in:
		| @@ -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 | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 pycook
					pycook