Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -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:")
}