新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
@@ -8,15 +8,22 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/filesystem"
|
||||
"u-desk/internal/storage"
|
||||
|
||||
sftpclient "github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
var (
|
||||
sftpResRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
|
||||
sftpCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||
)
|
||||
|
||||
// Service SFTP 文件操作服务
|
||||
type Service struct {
|
||||
manager *Manager
|
||||
@@ -257,8 +264,26 @@ func (s *Service) RenamePath(connID string, oldPath, newPath string) (*filesyste
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||
// 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
|
||||
@@ -303,12 +328,201 @@ func (s *Service) DownloadToTemp(connID string, remotePath string) (string, erro
|
||||
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 := sftpExtractResources(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 sftpShouldSkip(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())
|
||||
relPath := strings.TrimPrefix(fullPath, siteRoot)
|
||||
relPath = strings.TrimPrefix(relPath, "/")
|
||||
localPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
|
||||
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)
|
||||
@@ -331,18 +545,9 @@ func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CleanupTempFiles 清理遗留的临时预览文件
|
||||
// CleanupTempFiles 清理遗留的临时预览文件(已由 SQLite 缓存接管)
|
||||
func CleanupTempFiles() {
|
||||
tmpDir := os.TempDir()
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), "udesk-sftp-preview-") {
|
||||
os.Remove(filepath.Join(tmpDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
storage.CleanupExpiredCache()
|
||||
}
|
||||
|
||||
// GetSystemInfo 通过 SSH 命令采集远程系统信息(磁盘/CPU/内存)
|
||||
@@ -501,3 +706,39 @@ func toFileOperationResult(m map[string]interface{}, isDir bool) *filesystem.Fil
|
||||
Mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// sftpExtractResources 从 HTML 内容提取资源路径
|
||||
func sftpExtractResources(html string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var resources []string
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" && !seen[v] {
|
||||
seen[v] = true
|
||||
resources = append(resources, v)
|
||||
}
|
||||
}
|
||||
for _, m := range sftpResRegex.FindAllStringSubmatch(html, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range sftpCssUrlRe.FindAllStringSubmatch(html, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
// sftpShouldSkip 判断资源路径是否应跳过
|
||||
func sftpShouldSkip(p string) bool {
|
||||
return strings.HasPrefix(p, "data:") ||
|
||||
strings.HasPrefix(p, "http://") ||
|
||||
strings.HasPrefix(p, "https://") ||
|
||||
strings.HasPrefix(p, "//") ||
|
||||
strings.HasPrefix(p, "#") ||
|
||||
strings.HasPrefix(p, "javascript:") ||
|
||||
strings.HasPrefix(p, "mailto:") ||
|
||||
strings.HasPrefix(p, "blob:")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user