964 lines
25 KiB
Go
964 lines
25 KiB
Go
package ossdrv
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"u-desk/internal/filesystem"
|
||
"u-desk/internal/oss"
|
||
"u-desk/internal/oss/aliyun"
|
||
"u-desk/internal/oss/qiniu"
|
||
"u-desk/internal/storage"
|
||
)
|
||
|
||
// accountCredentials 账户级凭据
|
||
type accountCredentials struct {
|
||
Provider string
|
||
AccessKey string
|
||
SecretKey string
|
||
Endpoint string
|
||
}
|
||
|
||
// Manager OSS 连接管理器(两级:账户 + 桶级客户端缓存)
|
||
type Manager struct {
|
||
accounts sync.Map // map[string]*accountCredentials key=provider
|
||
clients sync.Map // map[string]oss.OSSProvider key="provider:bucket"
|
||
bucketRegions sync.Map // map[string]string key="provider:bucket" → region
|
||
}
|
||
|
||
var globalManager = &Manager{}
|
||
|
||
func GetManager() *Manager { return globalManager }
|
||
|
||
// Connect 建立账户级连接(验证凭据通过 ListBuckets,同时缓存桶区域)
|
||
func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error {
|
||
var entries []oss.BucketEntry
|
||
var err error
|
||
|
||
switch provider {
|
||
case "qiniu":
|
||
entries, err = qiniu.ListBuckets(accessKey, secretKey)
|
||
if err != nil {
|
||
return fmt.Errorf("七牛云连接失败: %w", err)
|
||
}
|
||
case "aliyun":
|
||
entries, err = aliyun.ListBuckets(accessKey, secretKey, endpoint)
|
||
if err != nil {
|
||
return fmt.Errorf("阿里云连接失败: %w", err)
|
||
}
|
||
default:
|
||
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,
|
||
SecretKey: secretKey,
|
||
Endpoint: endpoint,
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// getOrCreateBucketClient 懒创建桶级 OSSProvider
|
||
func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.OSSProvider, error) {
|
||
key := provider + ":" + bucket
|
||
if v, ok := m.clients.Load(key); ok {
|
||
return v.(oss.OSSProvider), nil
|
||
}
|
||
|
||
cred, ok := m.accounts.Load(provider)
|
||
if !ok {
|
||
return nil, fmt.Errorf("OSS 账户未连接: %s", provider)
|
||
}
|
||
c := cred.(*accountCredentials)
|
||
|
||
// 如果未传 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
var client oss.OSSProvider
|
||
var err error
|
||
|
||
switch provider {
|
||
case "qiniu":
|
||
client, err = qiniu.NewClient(&qiniu.Config{
|
||
AccessKey: c.AccessKey,
|
||
SecretKey: c.SecretKey,
|
||
Bucket: bucket,
|
||
Region: region,
|
||
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: ep,
|
||
UseHTTPS: true,
|
||
})
|
||
default:
|
||
return nil, fmt.Errorf("不支持的提供商: %s", provider)
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建桶客户端失败: %w", err)
|
||
}
|
||
|
||
m.clients.Store(key, client)
|
||
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 {
|
||
return v.(oss.OSSProvider)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Disconnect 关闭账户及所有桶级客户端
|
||
func (m *Manager) Disconnect(provider string) {
|
||
m.accounts.Delete(provider)
|
||
prefix := provider + ":"
|
||
m.clients.Range(func(key, value any) bool {
|
||
if strings.HasPrefix(key.(string), prefix) {
|
||
value.(oss.OSSProvider).Close()
|
||
m.clients.Delete(key)
|
||
}
|
||
return true
|
||
})
|
||
m.bucketRegions.Range(func(key, value any) bool {
|
||
if strings.HasPrefix(key.(string), prefix) {
|
||
m.bucketRegions.Delete(key)
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
// Shutdown 关闭所有连接
|
||
func (m *Manager) Shutdown() {
|
||
m.clients.Range(func(key, value any) bool {
|
||
value.(oss.OSSProvider).Close()
|
||
m.clients.Delete(key)
|
||
return true
|
||
})
|
||
m.accounts.Range(func(key, value any) bool {
|
||
m.accounts.Delete(key)
|
||
return true
|
||
})
|
||
}
|
||
|
||
// Service OSS 文件操作服务
|
||
type Service struct {
|
||
manager *Manager
|
||
}
|
||
|
||
func NewService() *Service {
|
||
return &Service{manager: GetManager()}
|
||
}
|
||
|
||
func (s *Service) GetManager() *Manager { return s.manager }
|
||
|
||
// parseBucketPath 解析路径中的桶名和对象键
|
||
// "/my-bucket/photos/img.jpg" → bucket="my-bucket", key="photos/img.jpg"
|
||
func parseBucketPath(rawPath string) (bucket, key string) {
|
||
rawPath = strings.TrimPrefix(rawPath, "/")
|
||
if rawPath == "" {
|
||
return "", ""
|
||
}
|
||
parts := strings.SplitN(rawPath, "/", 2)
|
||
bucket = parts[0]
|
||
if len(parts) > 1 {
|
||
key = parts[1]
|
||
}
|
||
return
|
||
}
|
||
|
||
// listBuckets 列出所有桶
|
||
func (s *Service) listBuckets(provider string) ([]map[string]interface{}, error) {
|
||
cred, ok := s.manager.accounts.Load(provider)
|
||
if !ok {
|
||
return nil, fmt.Errorf("OSS 账户未连接: %s", provider)
|
||
}
|
||
c := cred.(*accountCredentials)
|
||
|
||
var entries []oss.BucketEntry
|
||
var err error
|
||
|
||
switch provider {
|
||
case "qiniu":
|
||
entries, err = qiniu.ListBuckets(c.AccessKey, c.SecretKey)
|
||
case "aliyun":
|
||
entries, err = aliyun.ListBuckets(c.AccessKey, c.SecretKey, c.Endpoint)
|
||
default:
|
||
return nil, fmt.Errorf("不支持的提供商: %s", provider)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列举存储桶失败: %w", err)
|
||
}
|
||
|
||
// 缓存桶区域信息
|
||
for _, e := range entries {
|
||
if e.Region != "" {
|
||
s.manager.bucketRegions.Store(provider+":"+e.Name, e.Region)
|
||
}
|
||
}
|
||
|
||
items := make([]map[string]interface{}, len(entries))
|
||
for i, e := range entries {
|
||
items[i] = map[string]interface{}{
|
||
"name": e.Name,
|
||
"path": "/" + e.Name,
|
||
"is_dir": true,
|
||
"is_bucket": true,
|
||
"size": int64(0),
|
||
}
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// ListDir 列出目录内容
|
||
func (s *Service) ListDir(connID string, prefix string) ([]map[string]interface{}, error) {
|
||
prefix = strings.TrimPrefix(prefix, "/")
|
||
|
||
// 根目录 → 列出所有桶
|
||
if prefix == "" {
|
||
return s.listBuckets(connID)
|
||
}
|
||
|
||
// 解析桶名和对象前缀
|
||
bucket, objectPrefix := parseBucketPath(prefix)
|
||
if bucket == "" {
|
||
return s.listBuckets(connID)
|
||
}
|
||
|
||
if objectPrefix != "" && !strings.HasSuffix(objectPrefix, "/") {
|
||
objectPrefix += "/"
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ctx := context.Background()
|
||
result, err := c.ListFiles(ctx, &oss.ListOptions{
|
||
Prefix: objectPrefix,
|
||
Delimiter: "/",
|
||
MaxKeys: 1000,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列举文件失败: %w", err)
|
||
}
|
||
|
||
items := make([]map[string]interface{}, 0, len(result.Files)+len(result.Prefixes))
|
||
bucketPrefix := "/" + bucket + "/"
|
||
|
||
for _, p := range result.Prefixes {
|
||
name := strings.TrimSuffix(strings.TrimPrefix(p, objectPrefix), "/")
|
||
if name == "" {
|
||
continue
|
||
}
|
||
items = append(items, map[string]interface{}{
|
||
"name": name,
|
||
"path": bucketPrefix + p,
|
||
"is_dir": true,
|
||
"size": int64(0),
|
||
})
|
||
}
|
||
|
||
for _, f := range result.Files {
|
||
if strings.HasSuffix(f.Key, "/") && f.Size == 0 {
|
||
continue
|
||
}
|
||
items = append(items, map[string]interface{}{
|
||
"name": path.Base(f.Key),
|
||
"path": bucketPrefix + f.Key,
|
||
"is_dir": false,
|
||
"size": f.Size,
|
||
"mod_time": f.LastModified.Format("2006-01-02 15:04:05"),
|
||
})
|
||
}
|
||
|
||
return items, nil
|
||
}
|
||
|
||
// ReadFile 读取文件内容
|
||
func (s *Service) ReadFile(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
|
||
}
|
||
|
||
const maxSize int64 = 10 << 20
|
||
ctx := context.Background()
|
||
|
||
info, err := c.GetFileInfo(ctx, key)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取文件信息失败: %w", err)
|
||
}
|
||
if info.Size > maxSize {
|
||
return "", fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(info.Size), maxSize)
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := c.Download(ctx, key, &buf); err != nil {
|
||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||
}
|
||
return filesystem.BytesToString(buf.Bytes()), nil
|
||
}
|
||
|
||
// WriteFile 写入文件内容
|
||
func (s *Service) WriteFile(connID string, rawPath string, content string) error {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = c.Upload(context.Background(), key, strings.NewReader(content), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("写入文件失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// WriteBase64File 写入 base64 编码的二进制文件
|
||
func (s *Service) WriteBase64File(connID string, rawPath string, base64Content string) error {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||
if err != nil {
|
||
return fmt.Errorf("base64 解码失败: %w", err)
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = c.Upload(context.Background(), key, bytes.NewReader(data), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("写入文件失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetFileInfo 获取文件信息
|
||
func (s *Service) GetFileInfo(connID string, rawPath string) (map[string]interface{}, error) {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return nil, fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
info, err := c.GetFileInfo(context.Background(), key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取文件信息失败: %w", err)
|
||
}
|
||
|
||
bucketPrefix := "/" + bucket + "/"
|
||
return map[string]interface{}{
|
||
"name": path.Base(info.Key),
|
||
"path": bucketPrefix + info.Key,
|
||
"size": info.Size,
|
||
"size_str": filesystem.FormatBytes(info.Size),
|
||
"is_dir": strings.HasSuffix(info.Key, "/"),
|
||
"mod_time": info.LastModified.Format("2006-01-02 15:04:05"),
|
||
}, nil
|
||
}
|
||
|
||
// CreateDir 创建目录
|
||
func (s *Service) CreateDir(connID string, rawPath string) (*filesystem.FileOperationResult, error) {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return nil, fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if !strings.HasSuffix(key, "/") {
|
||
key += "/"
|
||
}
|
||
|
||
_, err = c.Upload(context.Background(), key, strings.NewReader(""), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||
}
|
||
|
||
name := path.Base(strings.TrimSuffix(key, "/"))
|
||
return &filesystem.FileOperationResult{
|
||
Path: "/" + bucket + "/" + key,
|
||
Name: name,
|
||
IsDir: true,
|
||
SizeStr: filesystem.FormatBytes(0),
|
||
}, nil
|
||
}
|
||
|
||
// CreateFile 创建空文件
|
||
func (s *Service) CreateFile(connID string, rawPath string) (*filesystem.FileOperationResult, error) {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return nil, fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, err = c.Upload(context.Background(), key, strings.NewReader(""), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||
}
|
||
|
||
return &filesystem.FileOperationResult{
|
||
Path: "/" + bucket + "/" + key,
|
||
Name: path.Base(key),
|
||
IsDir: false,
|
||
SizeStr: filesystem.FormatBytes(0),
|
||
}, nil
|
||
}
|
||
|
||
// DeletePath 删除文件或目录
|
||
func (s *Service) DeletePath(connID string, rawPath string) (*filesystem.FileOperationResult, error) {
|
||
bucket, key := parseBucketPath(rawPath)
|
||
if bucket == "" {
|
||
return nil, fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
isDir := strings.HasSuffix(key, "/")
|
||
if !isDir {
|
||
prefix := key + "/"
|
||
listResult, listErr := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1})
|
||
if listErr == nil && len(listResult.Files) > 0 {
|
||
isDir = true
|
||
key = prefix
|
||
}
|
||
}
|
||
|
||
infoMap, _ := s.GetFileInfo(connID, "/"+bucket+"/"+key)
|
||
|
||
if isDir {
|
||
prefix := key
|
||
if !strings.HasSuffix(prefix, "/") {
|
||
prefix += "/"
|
||
}
|
||
for {
|
||
listResult, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1000})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列举目录文件失败: %w", err)
|
||
}
|
||
if len(listResult.Files) == 0 {
|
||
break
|
||
}
|
||
keys := make([]string, len(listResult.Files))
|
||
for i, f := range listResult.Files {
|
||
keys[i] = f.Key
|
||
}
|
||
if _, err := c.DeleteMultiple(ctx, keys); err != nil {
|
||
return nil, fmt.Errorf("批量删除失败: %w", err)
|
||
}
|
||
if !listResult.IsTruncated {
|
||
break
|
||
}
|
||
}
|
||
c.Delete(ctx, key) // marker 非关键,忽略错误
|
||
} else {
|
||
if err := c.Delete(ctx, key); err != nil {
|
||
return nil, fmt.Errorf("删除失败: %w", err)
|
||
}
|
||
}
|
||
|
||
result := toOssOperationResult(infoMap, isDir)
|
||
result.Deleted = true
|
||
return result, nil
|
||
}
|
||
|
||
// RenamePath 重命名(Copy + Delete)
|
||
func (s *Service) RenamePath(connID string, oldPath string, newPath string) (*filesystem.FileOperationResult, error) {
|
||
oldBucket, oldKey := parseBucketPath(oldPath)
|
||
newBucket, newKey := parseBucketPath(newPath)
|
||
if oldBucket == "" || newBucket == "" {
|
||
return nil, fmt.Errorf("路径中缺少桶名")
|
||
}
|
||
|
||
if oldBucket != newBucket {
|
||
return nil, fmt.Errorf("不支持跨桶重命名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, oldBucket, "")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
isDir := strings.HasSuffix(oldKey, "/")
|
||
if !isDir {
|
||
prefix := oldKey + "/"
|
||
listResult, listErr := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1})
|
||
if listErr == nil && len(listResult.Files) > 0 {
|
||
isDir = true
|
||
oldKey = prefix
|
||
}
|
||
}
|
||
|
||
if isDir {
|
||
oldPrefix := oldKey
|
||
newPrefix := newKey
|
||
if !strings.HasSuffix(oldPrefix, "/") {
|
||
oldPrefix += "/"
|
||
}
|
||
if !strings.HasSuffix(newPrefix, "/") {
|
||
newPrefix += "/"
|
||
}
|
||
|
||
for {
|
||
listResult, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: oldPrefix, MaxKeys: 1000})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("列举目录文件失败: %w", err)
|
||
}
|
||
if len(listResult.Files) == 0 {
|
||
break
|
||
}
|
||
for _, f := range listResult.Files {
|
||
relativeKey := strings.TrimPrefix(f.Key, oldPrefix)
|
||
if err := c.Copy(ctx, f.Key, newPrefix+relativeKey); err != nil {
|
||
return nil, fmt.Errorf("复制失败: %w", err)
|
||
}
|
||
c.Delete(ctx, f.Key)
|
||
}
|
||
if !listResult.IsTruncated {
|
||
break
|
||
}
|
||
}
|
||
c.Delete(ctx, oldKey) // marker
|
||
} else {
|
||
if err := c.Copy(ctx, oldKey, newKey); err != nil {
|
||
return nil, fmt.Errorf("复制失败: %w", err)
|
||
}
|
||
if err := c.Delete(ctx, oldKey); err != nil {
|
||
return nil, fmt.Errorf("删除源文件失败: %w", err)
|
||
}
|
||
}
|
||
|
||
infoMap, _ := s.GetFileInfo(connID, newPath)
|
||
result := toOssOperationResult(infoMap, isDir)
|
||
result.OldPath = oldPath
|
||
return result, nil
|
||
}
|
||
|
||
// 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("路径中缺少桶名")
|
||
}
|
||
|
||
c, err := s.manager.getOrCreateBucketClient(connID, bucket, "")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
f, err := os.CreateTemp("", "udesk-oss-*-"+path.Base(key))
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建临时文件失败: %w", err)
|
||
}
|
||
localPath := f.Name()
|
||
defer f.Close()
|
||
|
||
if err := c.Download(context.Background(), key, f); err != nil {
|
||
os.Remove(localPath)
|
||
return "", fmt.Errorf("下载文件失败: %w", err)
|
||
}
|
||
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{
|
||
"root": "/",
|
||
}, nil
|
||
}
|
||
|
||
// GetSignedURL 获取预签名 URL
|
||
func (s *Service) GetSignedURL(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
|
||
}
|
||
|
||
url, err := c.GetSignedURL(context.Background(), key, 1*time.Hour)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取签名 URL 失败: %w", err)
|
||
}
|
||
return url, nil
|
||
}
|
||
|
||
func toOssOperationResult(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)
|
||
|
||
return &filesystem.FileOperationResult{
|
||
Path: p,
|
||
Name: name,
|
||
Size: size,
|
||
SizeStr: filesystem.FormatBytes(size),
|
||
IsDir: isDir,
|
||
ModTime: modTime,
|
||
}
|
||
}
|