325 lines
8.0 KiB
Go
325 lines
8.0 KiB
Go
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
|
|
}
|