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

@@ -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{