新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,6 +17,7 @@ import (
|
||||
"u-desk/internal/oss"
|
||||
"u-desk/internal/oss/aliyun"
|
||||
"u-desk/internal/oss/qiniu"
|
||||
"u-desk/internal/storage"
|
||||
)
|
||||
|
||||
// accountCredentials 账户级凭据
|
||||
@@ -36,17 +39,19 @@ var globalManager = &Manager{}
|
||||
|
||||
func GetManager() *Manager { return globalManager }
|
||||
|
||||
// Connect 建立账户级连接(验证凭据通过 ListBuckets)
|
||||
// Connect 建立账户级连接(验证凭据通过 ListBuckets,同时缓存桶区域)
|
||||
func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error {
|
||||
// 验证凭据
|
||||
var entries []oss.BucketEntry
|
||||
var err error
|
||||
|
||||
switch provider {
|
||||
case "qiniu":
|
||||
_, err := qiniu.ListBuckets(accessKey, secretKey)
|
||||
entries, err = qiniu.ListBuckets(accessKey, secretKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("七牛云连接失败: %w", err)
|
||||
}
|
||||
case "aliyun":
|
||||
_, err := aliyun.ListBuckets(accessKey, secretKey, endpoint)
|
||||
entries, err = aliyun.ListBuckets(accessKey, secretKey, endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("阿里云连接失败: %w", err)
|
||||
}
|
||||
@@ -54,6 +59,13 @@ func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error
|
||||
return fmt.Errorf("不支持的 OSS 提供商: %s", provider)
|
||||
}
|
||||
|
||||
// 连接时立即缓存桶区域,避免后续操作因缺少 region 使用默认区域
|
||||
for _, e := range entries {
|
||||
if e.Region != "" {
|
||||
m.bucketRegions.Store(provider+":"+e.Name, e.Region)
|
||||
}
|
||||
}
|
||||
|
||||
m.accounts.Store(provider, &accountCredentials{
|
||||
Provider: provider,
|
||||
AccessKey: accessKey,
|
||||
@@ -76,10 +88,15 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
||||
}
|
||||
c := cred.(*accountCredentials)
|
||||
|
||||
// 如果未传 region,从缓存取
|
||||
// 如果未传 region,从缓存取;仍为空则主动探测
|
||||
if region == "" {
|
||||
if v, ok := m.bucketRegions.Load(key); ok {
|
||||
region = v.(string)
|
||||
} else {
|
||||
region = m.detectBucketRegion(provider, bucket, c)
|
||||
if region != "" {
|
||||
m.bucketRegions.Store(key, region)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,12 +113,17 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
||||
UseHTTPS: true,
|
||||
})
|
||||
case "aliyun":
|
||||
// 有桶级 region 时不传账户 Endpoint,让 NewClient 从 region 派生正确的 endpoint
|
||||
ep := c.Endpoint
|
||||
if region != "" {
|
||||
ep = ""
|
||||
}
|
||||
client, err = aliyun.NewClient(&aliyun.Config{
|
||||
AccessKeyID: c.AccessKey,
|
||||
AccessKeySecret: c.SecretKey,
|
||||
Bucket: bucket,
|
||||
Region: region,
|
||||
Endpoint: c.Endpoint,
|
||||
Endpoint: ep,
|
||||
UseHTTPS: true,
|
||||
})
|
||||
default:
|
||||
@@ -116,6 +138,41 @@ func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// detectBucketRegion 主动探测桶区域(缓存未命中时调用)
|
||||
func (m *Manager) detectBucketRegion(provider, bucket string, c *accountCredentials) string {
|
||||
switch provider {
|
||||
case "aliyun":
|
||||
entries, err := aliyun.ListBuckets(c.AccessKey, c.SecretKey, c.Endpoint)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
key := provider + ":" + e.Name
|
||||
if e.Region != "" {
|
||||
m.bucketRegions.Store(key, e.Region)
|
||||
}
|
||||
if e.Name == bucket {
|
||||
return e.Region
|
||||
}
|
||||
}
|
||||
case "qiniu":
|
||||
entries, err := qiniu.ListBuckets(c.AccessKey, c.SecretKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
key := provider + ":" + e.Name
|
||||
if e.Region != "" {
|
||||
m.bucketRegions.Store(key, e.Region)
|
||||
}
|
||||
if e.Name == bucket {
|
||||
return e.Region
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetClient 获取已有的桶级客户端
|
||||
func (m *Manager) GetClient(provider, bucket string) oss.OSSProvider {
|
||||
if v, ok := m.clients.Load(provider + ":" + bucket); ok {
|
||||
@@ -583,8 +640,255 @@ func (s *Service) RenamePath(connID string, oldPath string, newPath string) (*fi
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadToTemp 下载文件到本地临时目录
|
||||
// htmlResourceRegex 提取 HTML 资源引用的正则
|
||||
var htmlResourceRegex = regexp.MustCompile(`(?:src|href|data|poster)=["']([^"']+)["']`)
|
||||
var htmlCssUrlRegex = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`)
|
||||
|
||||
// DownloadSiteForPreview 下载 HTML 及其引用的资源到临时目录
|
||||
// 对绝对路径(/开头)从 HTML 目录逐级向上嗅探网站根目录
|
||||
func (s *Service) DownloadSiteForPreview(connID string, rawPath string) (string, error) {
|
||||
bucket, key := parseBucketPath(rawPath)
|
||||
if bucket == "" {
|
||||
return "", fmt.Errorf("路径中缺少桶名")
|
||||
}
|
||||
|
||||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 创建临时目录,保留 OSS 目录结构
|
||||
tmpDir, err := os.MkdirTemp("", "udesk-site-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
|
||||
keyDir := path.Dir(key)
|
||||
var htmlLocalPath string
|
||||
if keyDir != "" && keyDir != "." {
|
||||
htmlLocalPath = filepath.Join(tmpDir, filepath.FromSlash(keyDir), path.Base(key))
|
||||
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(key))
|
||||
}
|
||||
|
||||
// 2. 下载 HTML
|
||||
f, err := os.Create(htmlLocalPath)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
if err := c.Download(ctx, key, f); err != nil {
|
||||
f.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", fmt.Errorf("下载 HTML 失败: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// 3. 解析 HTML 提取资源路径
|
||||
htmlContent, err := os.ReadFile(htmlLocalPath)
|
||||
if err != nil {
|
||||
return htmlLocalPath, nil // HTML 已下载,资源解析失败不影响
|
||||
}
|
||||
resources := extractHtmlResources(string(htmlContent))
|
||||
|
||||
// 4. 下载资源
|
||||
htmlOssDir := keyDir
|
||||
if htmlOssDir == "." {
|
||||
htmlOssDir = ""
|
||||
}
|
||||
htmlLocalDir := filepath.Dir(htmlLocalPath)
|
||||
|
||||
var siteRoot string
|
||||
var discoveredDirs []string
|
||||
seenDir := make(map[string]bool)
|
||||
recordDir := func(ossKey string) {
|
||||
dir := path.Dir(ossKey)
|
||||
if !seenDir[dir] {
|
||||
seenDir[dir] = true
|
||||
discoveredDirs = append(discoveredDirs, dir)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resPath := range resources {
|
||||
if 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 := resolveAndDownload(c, ctx, htmlOssDir, cleanPath, localPath, &siteRoot)
|
||||
if resolvedKey != "" {
|
||||
recordDir(resolvedKey)
|
||||
}
|
||||
} else {
|
||||
var ossKey string
|
||||
if htmlOssDir != "" {
|
||||
ossKey = htmlOssDir + "/" + cleanPath
|
||||
} else {
|
||||
ossKey = cleanPath
|
||||
}
|
||||
if downloadResource(c, ctx, ossKey, localPath) {
|
||||
recordDir(ossKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. 补充下载已发现目录中的剩余文件(覆盖 webpack 动态 chunk 等)
|
||||
for _, dir := range discoveredDirs {
|
||||
supplementDir(c, ctx, dir, tmpDir, siteRoot)
|
||||
}
|
||||
|
||||
return htmlLocalPath, nil
|
||||
}
|
||||
|
||||
// resolveAbsoluteResourcePath 解析绝对路径资源,首次嗅探网站根,后续直接使用
|
||||
// resolveAndDownload 解析绝对路径并下载:首次嗅探网站根,后续直接使用
|
||||
func resolveAndDownload(c oss.OSSProvider, ctx context.Context, htmlOssDir string, cleanPath string, localPath string, siteRoot *string) string {
|
||||
if *siteRoot != "" {
|
||||
downloadResource(c, ctx, *siteRoot+cleanPath, localPath)
|
||||
return *siteRoot + cleanPath
|
||||
}
|
||||
|
||||
// 从 HTML 目录向上逐级探测(同时下载,成功即完成嗅探)
|
||||
dir := htmlOssDir
|
||||
for {
|
||||
var candidate string
|
||||
if dir == "" {
|
||||
candidate = cleanPath
|
||||
} else {
|
||||
candidate = dir + "/" + cleanPath
|
||||
}
|
||||
|
||||
if downloadResource(c, ctx, 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 ""
|
||||
}
|
||||
|
||||
// downloadResource 下载资源到本地(失败静默,返回是否成功)
|
||||
func downloadResource(c oss.OSSProvider, ctx context.Context, ossKey string, localPath string) bool {
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||
return false
|
||||
}
|
||||
f, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := c.Download(ctx, ossKey, f); err != nil {
|
||||
f.Close()
|
||||
os.Remove(localPath)
|
||||
return false
|
||||
}
|
||||
f.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// supplementDir 补充下载远程目录中尚未下载的文件(只处理已知资源所在目录)
|
||||
func supplementDir(c oss.OSSProvider, ctx context.Context, remoteDir string, tmpDir string, siteRoot string) {
|
||||
prefix := remoteDir + "/"
|
||||
result, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 200})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range result.Files {
|
||||
if strings.HasSuffix(f.Key, "/") || f.Size == 0 {
|
||||
continue
|
||||
}
|
||||
relPath := strings.TrimPrefix(f.Key, siteRoot)
|
||||
localPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
continue
|
||||
}
|
||||
downloadResource(c, ctx, f.Key, localPath)
|
||||
}
|
||||
}
|
||||
func extractHtmlResources(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 htmlResourceRegex.FindAllStringSubmatch(html, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
for _, m := range htmlCssUrlRegex.FindAllStringSubmatch(html, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
// shouldSkipResource 判断资源路径是否应跳过
|
||||
func shouldSkipResource(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:")
|
||||
}
|
||||
|
||||
// DownloadToTemp 下载文件到本地临时目录(带 SQLite 缓存)
|
||||
func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) {
|
||||
// 先获取文件元信息用于缓存键,确保远程文件变更时能淘汰旧缓存
|
||||
var fileSize int64
|
||||
var modTime string
|
||||
if info, err := s.GetFileInfo(connID, rawPath); err == nil {
|
||||
if sz, ok := info["size"].(int64); ok {
|
||||
fileSize = sz
|
||||
}
|
||||
if mt, ok := info["mod_time"].(string); ok {
|
||||
modTime = mt
|
||||
}
|
||||
}
|
||||
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
|
||||
return s.downloadToTempDirect(connID, rawPath)
|
||||
})
|
||||
}
|
||||
|
||||
// downloadToTempDirect 实际执行下载(无缓存)
|
||||
func (s *Service) downloadToTempDirect(connID string, rawPath string) (string, error) {
|
||||
bucket, key := parseBucketPath(rawPath)
|
||||
if bucket == "" {
|
||||
return "", fmt.Errorf("路径中缺少桶名")
|
||||
@@ -609,6 +913,13 @@ func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error)
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
// DownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径,支持传入文件元信息)
|
||||
func (s *Service) DownloadToTempCached(connID, rawPath string, fileSize int64, modTime string) (string, error) {
|
||||
return storage.DownloadToTempCached("oss", connID, rawPath, fileSize, modTime, func() (string, error) {
|
||||
return s.downloadToTempDirect(connID, rawPath)
|
||||
})
|
||||
}
|
||||
|
||||
// GetCommonPaths 返回常用路径
|
||||
func (s *Service) GetCommonPaths(connID string) (map[string]string, error) {
|
||||
return map[string]string{
|
||||
|
||||
Reference in New Issue
Block a user