package controller import ( "fmt" "io" "log" "net/http" "path" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" "gorm.io/gorm" "ezops/model" ) // SFTPHandler 文件浏览器处理器 — 基于 SFTP 的服务器文件管理 type SFTPHandler struct { db *gorm.DB } func NewSFTPHandler(db *gorm.DB) *SFTPHandler { return &SFTPHandler{db: db} } // sftpClient 建立到目标服务器的 SFTP 连接 func (h *SFTPHandler) sftpClient(serverID uint) (*sftp.Client, *ssh.Client, error) { var server model.Server if err := h.db.First(&server, serverID).Error; err != nil { return nil, nil, fmt.Errorf("server not found: %w", err) } sshConfig := &ssh.ClientConfig{ User: server.Username, Timeout: 10 * time.Second, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } switch server.AuthType { case "key": signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey)) if err != nil { sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)} } else { sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} } default: sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)} } addr := fmt.Sprintf("%s:%d", server.IP, server.Port) sshClient, err := ssh.Dial("tcp", addr, sshConfig) if err != nil { return nil, nil, fmt.Errorf("SSH connect failed: %w", err) } sftpClient, err := sftp.NewClient(sshClient) if err != nil { sshClient.Close() return nil, nil, fmt.Errorf("SFTP init failed: %w", err) } return sftpClient, sshClient, nil } // FileInfo JSON 响应结构 type FileItem struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` IsDir bool `json:"is_dir"` ModTime string `json:"mod_time"` Mode string `json:"mode"` } // ListFiles 列出目录内容 // GET /api/v1/servers/:id/files?path=/ func (h *SFTPHandler) ListFiles(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"}) return } dirPath := c.Query("path") if dirPath == "" { dirPath = "/" } sftpClient, sshClient, err := h.sftpClient(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer sshClient.Close() defer sftpClient.Close() entries, err := sftpClient.ReadDir(dirPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("read dir failed: %v", err)}) return } var items []FileItem for _, entry := range entries { items = append(items, FileItem{ Name: entry.Name(), Path: path.Join(dirPath, entry.Name()), Size: entry.Size(), IsDir: entry.IsDir(), ModTime: entry.ModTime().Format("2006-01-02 15:04:05"), Mode: entry.Mode().String(), }) } // 构建面包屑 breadcrumbs := buildBreadcrumbs(dirPath) c.JSON(http.StatusOK, gin.H{ "path": dirPath, "breadcrumbs": breadcrumbs, "items": items, }) } // DownloadFile 下载文件 // GET /api/v1/servers/:id/files/download?path=/etc/hosts func (h *SFTPHandler) DownloadFile(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"}) return } filePath := c.Query("path") if filePath == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) return } sftpClient, sshClient, err := h.sftpClient(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer sshClient.Close() defer sftpClient.Close() stat, err := sftpClient.Stat(filePath) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if stat.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download directory"}) return } file, err := sftpClient.Open(filePath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "open failed"}) return } defer file.Close() fileName := path.Base(filePath) c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName)) c.Header("Content-Type", "application/octet-stream") c.Header("Content-Length", fmt.Sprintf("%d", stat.Size())) io.Copy(c.Writer, file) } // UploadFile 上传文件 // POST /api/v1/servers/:id/files/upload?path=/tmp/ func (h *SFTPHandler) UploadFile(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"}) return } dirPath := c.Query("path") if dirPath == "" { dirPath = "/tmp/" } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return } defer file.Close() sftpClient, sshClient, err := h.sftpClient(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer sshClient.Close() defer sftpClient.Close() destPath := path.Join(dirPath, header.Filename) dest, err := sftpClient.Create(destPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "create file failed"}) return } defer dest.Close() written, err := io.Copy(dest, file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"}) return } log.Printf("[SFTP] Uploaded %s (%d bytes) to server %d", destPath, written, id) c.JSON(http.StatusOK, gin.H{"message": "uploaded", "path": destPath, "size": written}) } // DeleteFile 删除文件或空目录 // DELETE /api/v1/servers/:id/files?path=/tmp/test.txt func (h *SFTPHandler) DeleteFile(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"}) return } filePath := c.Query("path") if filePath == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) return } sftpClient, sshClient, err := h.sftpClient(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer sshClient.Close() defer sftpClient.Close() stat, err := sftpClient.Stat(filePath) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if stat.IsDir() { if err := sftpClient.RemoveDirectory(filePath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "remove dir failed (may not be empty)"}) return } } else { if err := sftpClient.Remove(filePath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "remove failed"}) return } } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } // Mkdir 创建目录 // POST /api/v1/servers/:id/files/mkdir func (h *SFTPHandler) Mkdir(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid server id"}) return } var req struct { Path string `json:"path"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) return } sftpClient, sshClient, err := h.sftpClient(uint(id)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } defer sshClient.Close() defer sftpClient.Close() if err := sftpClient.MkdirAll(req.Path); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "mkdir failed"}) return } c.JSON(http.StatusOK, gin.H{"message": "created", "path": req.Path}) } // buildBreadcrumbs 构建路径面包屑 func buildBreadcrumbs(dirPath string) []map[string]string { if dirPath == "/" || dirPath == "" { return []map[string]string{{"name": "/", "path": "/"}} } parts := strings.Split(strings.Trim(dirPath, "/"), "/") var crumbs []map[string]string crumbs = append(crumbs, map[string]string{"name": "/", "path": "/"}) accum := "" for _, part := range parts { if part == "" { continue } accum += "/" + part crumbs = append(crumbs, map[string]string{ "name": part, "path": accum, }) } return crumbs }