Files
u-desk/internal/sftp/service.go
T

704 lines
18 KiB
Go

package sftp
import (
"encoding/base64"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"u-desk/internal/filesystem"
"u-desk/internal/storage"
sftpclient "github.com/pkg/sftp"
)
// Service SFTP 文件操作服务
type Service struct {
manager *Manager
}
// NewService 创建 SFTP 服务实例
func NewService() *Service {
return &Service{manager: GetManager()}
}
// GetManager 获取底层连接管理器(供 App 层调用)
func (s *Service) GetManager() *Manager {
return s.manager
}
// ConnID 从配置生成连接标识符
func ConnID(host string, port int) string {
return fmt.Sprintf("%s:%d", host, port)
}
// --- 核心文件操作 ---
func (s *Service) ListDir(connID string, dirPath string) ([]map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
var entries []fs.FileInfo
err = c.WithRetry(func(sc *sftpclient.Client) error {
var e error
entries, e = sc.ReadDir(dirPath)
return e
})
if err != nil {
return nil, fmt.Errorf("读取目录失败: %w", err)
}
result := make([]map[string]interface{}, 0, len(entries))
for _, info := range entries {
fullPath := path.Join(dirPath, info.Name())
result = append(result, map[string]interface{}{
"name": info.Name(),
"path": toUnixPath(fullPath),
"is_dir": info.IsDir(),
"size": info.Size(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
})
}
return result, nil
}
func (s *Service) ReadFile(connID string, filePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 大小限制(与本地模式 ReadFile 的 10MB 上限对齐)
const maxSize int64 = 10 << 20
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(filePath)
if e != nil { return e }
if fi.Size() > maxSize {
return fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(fi.Size()), maxSize)
}
return nil
})
if err != nil {
return "", err
}
var data []byte
err = c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Open(filePath)
if e != nil {
return e
}
defer f.Close()
data, e = io.ReadAll(f)
return e
})
if err != nil {
return "", fmt.Errorf("读取文件失败: %w", err)
}
return filesystem.BytesToString(data), nil
}
func (s *Service) WriteFile(connID string, filePath string, content string) error {
c, err := s.getClient(connID)
if err != nil {
return err
}
return c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return fmt.Errorf("创建文件失败: %w", e)
}
defer f.Close()
_, e = f.Write([]byte(content))
return e
})
}
// WriteBase64File 将 base64 编码的二进制内容写入远程文件(用于粘贴图片等场景)
func (s *Service) WriteBase64File(connID string, filePath string, base64Content string) error {
c, err := s.getClient(connID)
if err != nil {
return err
}
data, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
return fmt.Errorf("base64 解码失败: %w", err)
}
return c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return fmt.Errorf("创建文件失败: %w", e)
}
defer f.Close()
_, e = f.Write(data)
return e
})
}
func (s *Service) GetFileInfo(connID string, filePath string) (map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
var info fs.FileInfo
err = c.WithRetry(func(sc *sftpclient.Client) error {
var e error
info, e = sc.Stat(filePath)
return e
})
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %w", err)
}
return map[string]interface{}{
"name": info.Name(),
"path": toUnixPath(filePath),
"size": info.Size(),
"size_str": filesystem.FormatBytes(info.Size()),
"is_dir": info.IsDir(),
"mod_time": info.ModTime().Format("2006-01-02 15:04:05"),
"mode": info.Mode().String(),
}, nil
}
func (s *Service) CreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
return sc.MkdirAll(dirPath)
})
if err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, dirPath)
return toFileOperationResult(infoMap, true), nil
}
func (s *Service) CreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
f, e := sc.Create(filePath)
if e != nil {
return e
}
return f.Close()
})
if err != nil {
return nil, fmt.Errorf("创建文件失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, filePath)
return toFileOperationResult(infoMap, false), nil
}
func (s *Service) DeletePath(connID string, filePath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
infoMap, _ := s.GetFileInfo(connID, filePath)
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(filePath)
if e != nil {
return e
}
if fi.IsDir() {
// 递归删除目录
return sc.RemoveAll(filePath)
}
return sc.Remove(filePath)
})
if err != nil {
return nil, fmt.Errorf("删除失败: %w", err)
}
result := toFileOperationResult(infoMap, false)
result.Deleted = true
return result, nil
}
func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesystem.FileOperationResult, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
err = c.WithRetry(func(sc *sftpclient.Client) error {
return sc.Rename(oldPath, newPath)
})
if err != nil {
return nil, fmt.Errorf("重命名失败: %w", err)
}
infoMap, _ := s.GetFileInfo(connID, newPath)
result := toFileOperationResult(infoMap, false)
result.OldPath = oldPath
return result, nil
}
// DownloadToTemp 下载远程文件到本地临时目录(带 SQLite 缓存)
func (s *Service) DownloadToTemp(connID string, remotePath string) (string, error) {
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
var fileSize int64
var modTime string
if info, err := s.GetFileInfo(connID, remotePath); err == nil {
if sz, ok := info["size"].(int64); ok {
fileSize = sz
}
if mt, ok := info["mod_time"].(string); ok {
modTime = mt
}
}
return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, remotePath)
})
}
// downloadToTempDirect 实际执行下载(无缓存)
func (s *Service) downloadToTempDirect(connID string, remotePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 预览文件大小上限 50MB(比编辑模式宽松)
const maxPreviewSize int64 = 50 << 20
err = c.WithRetry(func(sc *sftpclient.Client) error {
fi, e := sc.Stat(remotePath)
if e != nil { return e }
if fi.Size() > maxPreviewSize {
return fmt.Errorf("预览文件过大: %s", filesystem.FormatBytes(fi.Size()))
}
return nil
})
if err != nil {
return "", err
}
tmpDir := os.TempDir()
tmpFile, e := os.CreateTemp(tmpDir, "udesk-sftp-*-"+filepath.Base(remotePath))
if e != nil {
return "", fmt.Errorf("创建临时文件失败: %w", e)
}
localPath := tmpFile.Name()
tmpFile.Close()
err = c.WithRetry(func(sc *sftpclient.Client) error {
src, e := sc.Open(remotePath)
if e != nil {
return e
}
defer src.Close()
dst, e := os.Create(localPath)
if e != nil {
return e
}
defer dst.Close()
_, e = io.Copy(dst, src)
return e
})
if err != nil {
os.Remove(localPath)
return "", fmt.Errorf("下载文件失败: %w", err)
}
return localPath, nil
}
// DownloadToTempCached 带缓存的 SFTP 下载(支持传入文件元信息)
func (s *Service) DownloadToTempCached(connID, remotePath string, fileSize int64, modTime string) (string, error) {
return storage.DownloadToTempCached("sftp", connID, remotePath, fileSize, modTime, func() (string, error) {
return s.downloadToTempDirect(connID, remotePath)
})
}
// DownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
func (s *Service) DownloadSiteForPreview(connID string, remotePath string) (string, error) {
c, err := s.getClient(connID)
if err != nil {
return "", err
}
// 1. 创建临时目录
tmpDir, err := os.MkdirTemp("", "udesk-sftp-site-*")
if err != nil {
return "", fmt.Errorf("创建临时目录失败: %w", err)
}
// 2. 确定远程网站根目录(从 HTML 路径推断)
keyDir := path.Dir(remotePath)
if keyDir == "." {
keyDir = ""
}
var htmlLocalPath string
if keyDir != "" {
htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(remotePath))
if err := os.MkdirAll(filepath.Dir(htmlLocalPath), 0755); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("创建目录失败: %w", err)
}
} else {
htmlLocalPath = filepath.Join(tmpDir, path.Base(remotePath))
}
// 3. 下载 HTML
if err := s.sftpDownloadFile(c, remotePath, htmlLocalPath); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("下载 HTML 失败: %w", err)
}
// 4. 解析 HTML 提取资源路径
htmlContent, err := os.ReadFile(htmlLocalPath)
if err != nil {
return htmlLocalPath, nil
}
resources := filesystem.ExtractHtmlResources(string(htmlContent))
// 5. 下载静态引用资源(嗅探网站根)
htmlRemoteDir := keyDir
if htmlRemoteDir == "/" {
htmlRemoteDir = ""
}
htmlLocalDir := filepath.Dir(htmlLocalPath)
var siteRoot string
var discoveredDirs []string
seenDir := make(map[string]bool)
recordDir := func(remoteKey string) {
dir := path.Dir(remoteKey)
if !seenDir[dir] {
seenDir[dir] = true
discoveredDirs = append(discoveredDirs, dir)
}
}
for _, resPath := range resources {
if filesystem.ShouldSkipResource(resPath) {
continue
}
isAbsolute := strings.HasPrefix(resPath, "/")
cleanPath := strings.TrimPrefix(resPath, "/")
cleanPath = strings.TrimPrefix(cleanPath, "./")
if cleanPath == "" {
continue
}
localPath := filepath.Join(htmlLocalDir, filepath.FromSlash(cleanPath))
if isAbsolute {
resolvedKey := sftpResolveAndDownload(s, c, htmlRemoteDir, cleanPath, localPath, &siteRoot)
if resolvedKey != "" {
recordDir(resolvedKey)
}
} else {
remoteKey := path.Join(htmlRemoteDir, cleanPath)
if s.sftpTryDownload(c, remoteKey, localPath) {
recordDir(remoteKey)
}
}
}
// 6. 补充下载已发现目录中的剩余文件(覆盖动态 chunk 等)
for _, dir := range discoveredDirs {
sftpSupplementDir(s, c, dir, tmpDir, siteRoot)
}
return htmlLocalPath, nil
}
// sftpDownloadFile 下载单个远程文件到本地路径
func (s *Service) sftpDownloadFile(c *Client, remotePath, localPath string) error {
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
return err
}
return c.WithRetry(func(sc *sftpclient.Client) error {
src, err := sc.Open(remotePath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(localPath)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
})
}
// sftpTryDownload 尝试下载(失败静默,返回是否成功)
func (s *Service) sftpTryDownload(c *Client, remotePath, localPath string) bool {
err := s.sftpDownloadFile(c, remotePath, localPath)
if err != nil {
os.Remove(localPath)
return false
}
return true
}
// sftpResolveAndDownload 嗅探网站根并下载绝对路径资源
func sftpResolveAndDownload(s *Service, c *Client, htmlDir string, cleanPath string, localPath string, siteRoot *string) string {
if *siteRoot != "" {
s.sftpTryDownload(c, *siteRoot+cleanPath, localPath)
return *siteRoot + cleanPath
}
dir := htmlDir
for {
candidate := path.Join(dir, cleanPath)
if s.sftpTryDownload(c, candidate, localPath) {
if dir == "" {
*siteRoot = ""
} else {
*siteRoot = dir + "/"
}
return candidate
}
if dir == "" {
break
}
parent := path.Dir(dir)
if parent == dir || parent == "." {
dir = ""
} else {
dir = parent
}
}
return ""
}
// sftpSupplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
func sftpSupplementDir(s *Service, c *Client, remoteDir string, tmpDir string, siteRoot string) {
var entries []fs.FileInfo
err := c.WithRetry(func(sc *sftpclient.Client) error {
var e error
entries, e = sc.ReadDir(remoteDir)
return e
})
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || entry.Size() == 0 {
continue
}
fullPath := path.Join(remoteDir, entry.Name())
localPath := filepath.Join(tmpDir, filepath.FromSlash(fullPath))
if _, err := os.Stat(localPath); err == nil {
continue
}
s.sftpTryDownload(c, fullPath, localPath)
}
}
// GetCommonPaths 返回 SFTP 远程主机常用路径
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
c := s.manager.GetClient(connID)
username := "root"
if c != nil {
c.mu.Lock()
username = c.config.Username
c.mu.Unlock()
}
home := "/root"
if username != "root" && username != "" {
home = fmt.Sprintf("/home/%s", username)
}
return map[string]string{
"home": home,
"tmp": "/tmp",
"root": "/",
}, nil
}
// CleanupTempFiles 清理遗留的临时预览文件(已由 SQLite 缓存接管)
func CleanupTempFiles() {
storage.CleanupExpiredCache()
}
// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
func (s *Service) GetSystemInfo(connID string) (map[string]interface{}, error) {
c, err := s.getClient(connID)
if err != nil {
return nil, err
}
type cmdResult struct {
key string // "df" | "mem" | "cpu"
output string
err error
}
// 并发执行三条命令(每条独立超时)
results := make(chan cmdResult, 3)
cmdTimeout := 6 * time.Second
runCmd := func(key, cmd string) {
out, e := c.RunCommand(cmd)
results <- cmdResult{key, out, e}
}
go runCmd("df", "df -B1 / 2>/dev/null | tail -1")
go runCmd("mem", "free -b 2>/dev/null | grep -E '^Mem:' || cat /proc/meminfo 2>/dev/null | head -2")
go runCmd("cpu", "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1")
var dfOut, memOut, cpuOut string
hasErr := false
for i := 0; i < 3; i++ {
select {
case r := <-results:
switch r.key {
case "df":
dfOut = r.output
case "mem":
memOut = r.output
case "cpu":
cpuOut = r.output
}
if r.err != nil {
hasErr = true
}
case <-time.After(cmdTimeout):
hasErr = true
}
}
info := make(map[string]interface{})
// 解析 CPU 使用率
cpuOut = strings.TrimSpace(cpuOut)
if usage, err := strconv.ParseFloat(cpuOut, 64); err == nil && usage >= 0 {
info["cpu_usage"] = fmt.Sprintf("%.0f%%", usage)
}
// 解析磁盘信息: df -B1 / → Filesystem 1M-blocks Used Available Use% Mounted on
dfOut = strings.TrimSpace(dfOut)
if dfFields := strings.Fields(dfOut); len(dfFields) >= 5 {
var diskTotal, diskUsed uint64
if v, err := strconv.ParseUint(dfFields[1], 10, 64); err == nil {
diskTotal = v
info["disk_total"] = v
}
if v, err := strconv.ParseUint(dfFields[2], 10, 64); err == nil {
diskUsed = v
info["disk_used"] = v
}
if diskTotal > 0 {
info["disk_usage"] = fmt.Sprintf("%.0f%%", float64(diskUsed)/float64(diskTotal)*100)
}
}
// 解析内存信息: free -b | grep Mem: 或 /proc/meminfo
memOut = strings.TrimSpace(memOut)
if strings.Contains(memOut, "MemTotal:") {
parseProcMeminfo(memOut, info)
} else if fields := strings.Fields(memOut); len(fields) >= 3 {
var memTotal, memUsed uint64
if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
memTotal = v
info["mem_total"] = v
}
if v, err := strconv.ParseUint(fields[2], 10, 64); err == nil {
memUsed = v
info["mem_used"] = v
}
if memTotal > 0 {
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(memUsed)/float64(memTotal)*100)
}
}
if hasErr && len(info) == 0 {
return nil, fmt.Errorf("采集远程系统信息失败")
}
return info, nil
}
func parseProcMeminfo(output string, info map[string]interface{}) {
lines := strings.Split(output, "\n")
memMap := make(map[string]uint64)
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 2 {
key := strings.TrimSuffix(fields[0], ":")
if val, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
memMap[key] = val
}
}
}
total := memMap["MemTotal"] * 1024 // kB → bytes
// 可用内存 ≈ MemAvailable (较新内核) 或 MemFree + Buffers + Cached
free := memMap["MemFree"]
if avail, ok := memMap["MemAvailable"]; ok {
free = avail
} else {
free += memMap["Buffers"] + memMap["Cached"]
}
used := total - free*1024
info["mem_total"] = total
info["mem_used"] = used
if total > 0 {
info["mem_usage"] = fmt.Sprintf("%.0f%%", float64(used)/float64(total)*100)
}
}
// --- 内部辅助 ---
func (s *Service) getClient(connID string) (*Client, error) {
c := s.manager.GetClient(connID)
if c == nil {
return nil, fmt.Errorf("SFTP 连接不存在: %s", connID)
}
return c, nil
}
func toUnixPath(p string) string {
return strings.ReplaceAll(p, "\\", "/")
}
func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult {
name, _ := m["name"].(string)
p, _ := m["path"].(string)
size, _ := m["size"].(int64)
modTime, _ := m["mod_time"].(string)
mode, _ := m["mode"].(string)
return &filesystem.FileOperationResult{
Path: p,
Name: name,
Size: size,
SizeStr: filesystem.FormatBytes(size),
IsDir: isDir,
ModTime: modTime,
Mode: mode,
}
}