mirror of
				https://github.com/cloudreve/cloudreve.git
				synced 2025-11-04 04:47:24 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			426 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			426 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package cos
 | 
						||
 | 
						||
import (
 | 
						||
	"context"
 | 
						||
	"crypto/hmac"
 | 
						||
	"crypto/sha1"
 | 
						||
	"encoding/base64"
 | 
						||
	"encoding/json"
 | 
						||
	"errors"
 | 
						||
	"fmt"
 | 
						||
	"io"
 | 
						||
	"net/http"
 | 
						||
	"net/url"
 | 
						||
	"path"
 | 
						||
	"path/filepath"
 | 
						||
	"strings"
 | 
						||
	"time"
 | 
						||
 | 
						||
	model "github.com/cloudreve/Cloudreve/v3/models"
 | 
						||
	"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
 | 
						||
	"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/response"
 | 
						||
	"github.com/cloudreve/Cloudreve/v3/pkg/request"
 | 
						||
	"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
 | 
						||
	"github.com/google/go-querystring/query"
 | 
						||
	cossdk "github.com/tencentyun/cos-go-sdk-v5"
 | 
						||
)
 | 
						||
 | 
						||
// UploadPolicy 腾讯云COS上传策略
 | 
						||
type UploadPolicy struct {
 | 
						||
	Expiration string        `json:"expiration"`
 | 
						||
	Conditions []interface{} `json:"conditions"`
 | 
						||
}
 | 
						||
 | 
						||
// MetaData 文件元信息
 | 
						||
type MetaData struct {
 | 
						||
	Size        uint64
 | 
						||
	CallbackKey string
 | 
						||
	CallbackURL string
 | 
						||
}
 | 
						||
 | 
						||
type urlOption struct {
 | 
						||
	Speed              int    `url:"x-cos-traffic-limit,omitempty"`
 | 
						||
	ContentDescription string `url:"response-content-disposition,omitempty"`
 | 
						||
}
 | 
						||
 | 
						||
// Driver 腾讯云COS适配器模板
 | 
						||
type Driver struct {
 | 
						||
	Policy     *model.Policy
 | 
						||
	Client     *cossdk.Client
 | 
						||
	HTTPClient request.Client
 | 
						||
}
 | 
						||
 | 
						||
// List 列出COS文件
 | 
						||
func (handler Driver) List(ctx context.Context, base string, recursive bool) ([]response.Object, error) {
 | 
						||
	// 初始化列目录参数
 | 
						||
	opt := &cossdk.BucketGetOptions{
 | 
						||
		Prefix:       strings.TrimPrefix(base, "/"),
 | 
						||
		EncodingType: "",
 | 
						||
		MaxKeys:      1000,
 | 
						||
	}
 | 
						||
	// 是否为递归列出
 | 
						||
	if !recursive {
 | 
						||
		opt.Delimiter = "/"
 | 
						||
	}
 | 
						||
	// 手动补齐结尾的slash
 | 
						||
	if opt.Prefix != "" {
 | 
						||
		opt.Prefix += "/"
 | 
						||
	}
 | 
						||
 | 
						||
	var (
 | 
						||
		marker  string
 | 
						||
		objects []cossdk.Object
 | 
						||
		commons []string
 | 
						||
	)
 | 
						||
 | 
						||
	for {
 | 
						||
		res, _, err := handler.Client.Bucket.Get(ctx, opt)
 | 
						||
		if err != nil {
 | 
						||
			return nil, err
 | 
						||
		}
 | 
						||
		objects = append(objects, res.Contents...)
 | 
						||
		commons = append(commons, res.CommonPrefixes...)
 | 
						||
		// 如果本次未列取完,则继续使用marker获取结果
 | 
						||
		marker = res.NextMarker
 | 
						||
		// marker 为空时结果列取完毕,跳出
 | 
						||
		if marker == "" {
 | 
						||
			break
 | 
						||
		}
 | 
						||
	}
 | 
						||
 | 
						||
	// 处理列取结果
 | 
						||
	res := make([]response.Object, 0, len(objects)+len(commons))
 | 
						||
	// 处理目录
 | 
						||
	for _, object := range commons {
 | 
						||
		rel, err := filepath.Rel(opt.Prefix, object)
 | 
						||
		if err != nil {
 | 
						||
			continue
 | 
						||
		}
 | 
						||
		res = append(res, response.Object{
 | 
						||
			Name:         path.Base(object),
 | 
						||
			RelativePath: filepath.ToSlash(rel),
 | 
						||
			Size:         0,
 | 
						||
			IsDir:        true,
 | 
						||
			LastModify:   time.Now(),
 | 
						||
		})
 | 
						||
	}
 | 
						||
	// 处理文件
 | 
						||
	for _, object := range objects {
 | 
						||
		rel, err := filepath.Rel(opt.Prefix, object.Key)
 | 
						||
		if err != nil {
 | 
						||
			continue
 | 
						||
		}
 | 
						||
		res = append(res, response.Object{
 | 
						||
			Name:         path.Base(object.Key),
 | 
						||
			Source:       object.Key,
 | 
						||
			RelativePath: filepath.ToSlash(rel),
 | 
						||
			Size:         uint64(object.Size),
 | 
						||
			IsDir:        false,
 | 
						||
			LastModify:   time.Now(),
 | 
						||
		})
 | 
						||
	}
 | 
						||
 | 
						||
	return res, nil
 | 
						||
 | 
						||
}
 | 
						||
 | 
						||
// CORS 创建跨域策略
 | 
						||
func (handler Driver) CORS() error {
 | 
						||
	_, err := handler.Client.Bucket.PutCORS(context.Background(), &cossdk.BucketPutCORSOptions{
 | 
						||
		Rules: []cossdk.BucketCORSRule{{
 | 
						||
			AllowedMethods: []string{
 | 
						||
				"GET",
 | 
						||
				"POST",
 | 
						||
				"PUT",
 | 
						||
				"DELETE",
 | 
						||
				"HEAD",
 | 
						||
			},
 | 
						||
			AllowedOrigins: []string{"*"},
 | 
						||
			AllowedHeaders: []string{"*"},
 | 
						||
			MaxAgeSeconds:  3600,
 | 
						||
			ExposeHeaders:  []string{},
 | 
						||
		}},
 | 
						||
	})
 | 
						||
 | 
						||
	return err
 | 
						||
}
 | 
						||
 | 
						||
// Get 获取文件
 | 
						||
func (handler Driver) Get(ctx context.Context, path string) (response.RSCloser, error) {
 | 
						||
	// 获取文件源地址
 | 
						||
	downloadURL, err := handler.Source(
 | 
						||
		ctx,
 | 
						||
		path,
 | 
						||
		url.URL{},
 | 
						||
		int64(model.GetIntSetting("preview_timeout", 60)),
 | 
						||
		false,
 | 
						||
		0,
 | 
						||
	)
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
 | 
						||
	// 获取文件数据流
 | 
						||
	resp, err := handler.HTTPClient.Request(
 | 
						||
		"GET",
 | 
						||
		downloadURL,
 | 
						||
		nil,
 | 
						||
		request.WithContext(ctx),
 | 
						||
		request.WithTimeout(time.Duration(0)),
 | 
						||
	).CheckHTTPResponse(200).GetRSCloser()
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
 | 
						||
	resp.SetFirstFakeChunk()
 | 
						||
 | 
						||
	// 尝试自主获取文件大小
 | 
						||
	if file, ok := ctx.Value(fsctx.FileModelCtx).(model.File); ok {
 | 
						||
		resp.SetContentLength(int64(file.Size))
 | 
						||
	}
 | 
						||
 | 
						||
	return resp, nil
 | 
						||
}
 | 
						||
 | 
						||
// Put 将文件流保存到指定目录
 | 
						||
func (handler Driver) Put(ctx context.Context, file fsctx.FileHeader) error {
 | 
						||
	defer file.Close()
 | 
						||
 | 
						||
	opt := &cossdk.ObjectPutOptions{}
 | 
						||
	_, err := handler.Client.Object.Put(ctx, file.Info().SavePath, file, opt)
 | 
						||
	return err
 | 
						||
}
 | 
						||
 | 
						||
// Delete 删除一个或多个文件,
 | 
						||
// 返回未删除的文件,及遇到的最后一个错误
 | 
						||
func (handler Driver) Delete(ctx context.Context, files []string) ([]string, error) {
 | 
						||
	obs := []cossdk.Object{}
 | 
						||
	for _, v := range files {
 | 
						||
		obs = append(obs, cossdk.Object{Key: v})
 | 
						||
	}
 | 
						||
	opt := &cossdk.ObjectDeleteMultiOptions{
 | 
						||
		Objects: obs,
 | 
						||
		Quiet:   true,
 | 
						||
	}
 | 
						||
 | 
						||
	res, _, err := handler.Client.Object.DeleteMulti(context.Background(), opt)
 | 
						||
	if err != nil {
 | 
						||
		return files, err
 | 
						||
	}
 | 
						||
 | 
						||
	// 整理删除结果
 | 
						||
	failed := make([]string, 0, len(files))
 | 
						||
	for _, v := range res.Errors {
 | 
						||
		failed = append(failed, v.Key)
 | 
						||
	}
 | 
						||
 | 
						||
	if len(failed) == 0 {
 | 
						||
		return failed, nil
 | 
						||
	}
 | 
						||
 | 
						||
	return failed, errors.New("删除失败")
 | 
						||
}
 | 
						||
 | 
						||
// Thumb 获取文件缩略图
 | 
						||
func (handler Driver) Thumb(ctx context.Context, path string) (*response.ContentResponse, error) {
 | 
						||
	var (
 | 
						||
		thumbSize = [2]uint{400, 300}
 | 
						||
		ok        = false
 | 
						||
	)
 | 
						||
	if thumbSize, ok = ctx.Value(fsctx.ThumbSizeCtx).([2]uint); !ok {
 | 
						||
		return nil, errors.New("无法获取缩略图尺寸设置")
 | 
						||
	}
 | 
						||
	thumbParam := fmt.Sprintf("imageMogr2/thumbnail/%dx%d", thumbSize[0], thumbSize[1])
 | 
						||
 | 
						||
	source, err := handler.signSourceURL(
 | 
						||
		ctx,
 | 
						||
		path,
 | 
						||
		int64(model.GetIntSetting("preview_timeout", 60)),
 | 
						||
		&urlOption{},
 | 
						||
	)
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
 | 
						||
	thumbURL, _ := url.Parse(source)
 | 
						||
	thumbQuery := thumbURL.Query()
 | 
						||
	thumbQuery.Add(thumbParam, "")
 | 
						||
	thumbURL.RawQuery = thumbQuery.Encode()
 | 
						||
 | 
						||
	return &response.ContentResponse{
 | 
						||
		Redirect: true,
 | 
						||
		URL:      thumbURL.String(),
 | 
						||
	}, nil
 | 
						||
}
 | 
						||
 | 
						||
// Source 获取外链URL
 | 
						||
func (handler Driver) Source(
 | 
						||
	ctx context.Context,
 | 
						||
	path string,
 | 
						||
	baseURL url.URL,
 | 
						||
	ttl int64,
 | 
						||
	isDownload bool,
 | 
						||
	speed int,
 | 
						||
) (string, error) {
 | 
						||
	// 尝试从上下文获取文件名
 | 
						||
	fileName := ""
 | 
						||
	if file, ok := ctx.Value(fsctx.FileModelCtx).(model.File); ok {
 | 
						||
		fileName = file.Name
 | 
						||
	}
 | 
						||
 | 
						||
	// 添加各项设置
 | 
						||
	options := urlOption{}
 | 
						||
	if speed > 0 {
 | 
						||
		if speed < 819200 {
 | 
						||
			speed = 819200
 | 
						||
		}
 | 
						||
		if speed > 838860800 {
 | 
						||
			speed = 838860800
 | 
						||
		}
 | 
						||
		options.Speed = speed
 | 
						||
	}
 | 
						||
	if isDownload {
 | 
						||
		options.ContentDescription = "attachment; filename=\"" + url.PathEscape(fileName) + "\""
 | 
						||
	}
 | 
						||
 | 
						||
	return handler.signSourceURL(ctx, path, ttl, &options)
 | 
						||
}
 | 
						||
 | 
						||
func (handler Driver) signSourceURL(ctx context.Context, path string, ttl int64, options *urlOption) (string, error) {
 | 
						||
	cdnURL, err := url.Parse(handler.Policy.BaseURL)
 | 
						||
	if err != nil {
 | 
						||
		return "", err
 | 
						||
	}
 | 
						||
 | 
						||
	// 公有空间不需要签名
 | 
						||
	if !handler.Policy.IsPrivate {
 | 
						||
		file, err := url.Parse(path)
 | 
						||
		if err != nil {
 | 
						||
			return "", err
 | 
						||
		}
 | 
						||
 | 
						||
		// 非签名URL不支持设置响应header
 | 
						||
		options.ContentDescription = ""
 | 
						||
 | 
						||
		optionQuery, err := query.Values(*options)
 | 
						||
		if err != nil {
 | 
						||
			return "", err
 | 
						||
		}
 | 
						||
		file.RawQuery = optionQuery.Encode()
 | 
						||
		sourceURL := cdnURL.ResolveReference(file)
 | 
						||
 | 
						||
		return sourceURL.String(), nil
 | 
						||
	}
 | 
						||
 | 
						||
	presignedURL, err := handler.Client.Object.GetPresignedURL(ctx, http.MethodGet, path,
 | 
						||
		handler.Policy.AccessKey, handler.Policy.SecretKey, time.Duration(ttl)*time.Second, options)
 | 
						||
	if err != nil {
 | 
						||
		return "", err
 | 
						||
	}
 | 
						||
 | 
						||
	// 将最终生成的签名URL域名换成用户自定义的加速域名(如果有)
 | 
						||
	presignedURL.Host = cdnURL.Host
 | 
						||
	presignedURL.Scheme = cdnURL.Scheme
 | 
						||
 | 
						||
	return presignedURL.String(), nil
 | 
						||
}
 | 
						||
 | 
						||
// Token 获取上传策略和认证Token
 | 
						||
func (handler Driver) Token(ctx context.Context, ttl int64, uploadSession *serializer.UploadSession, file fsctx.FileHeader) (*serializer.UploadCredential, error) {
 | 
						||
	// 生成回调地址
 | 
						||
	siteURL := model.GetSiteURL()
 | 
						||
	apiBaseURI, _ := url.Parse("/api/v3/callback/cos/" + uploadSession.Key)
 | 
						||
	apiURL := siteURL.ResolveReference(apiBaseURI).String()
 | 
						||
 | 
						||
	// 上传策略
 | 
						||
	savePath := file.Info().SavePath
 | 
						||
	startTime := time.Now()
 | 
						||
	endTime := startTime.Add(time.Duration(ttl) * time.Second)
 | 
						||
	keyTime := fmt.Sprintf("%d;%d", startTime.Unix(), endTime.Unix())
 | 
						||
	postPolicy := UploadPolicy{
 | 
						||
		Expiration: endTime.UTC().Format(time.RFC3339),
 | 
						||
		Conditions: []interface{}{
 | 
						||
			map[string]string{"bucket": handler.Policy.BucketName},
 | 
						||
			map[string]string{"$key": savePath},
 | 
						||
			map[string]string{"x-cos-meta-callback": apiURL},
 | 
						||
			map[string]string{"x-cos-meta-key": uploadSession.Key},
 | 
						||
			map[string]string{"q-sign-algorithm": "sha1"},
 | 
						||
			map[string]string{"q-ak": handler.Policy.AccessKey},
 | 
						||
			map[string]string{"q-sign-time": keyTime},
 | 
						||
		},
 | 
						||
	}
 | 
						||
 | 
						||
	if handler.Policy.MaxSize > 0 {
 | 
						||
		postPolicy.Conditions = append(postPolicy.Conditions,
 | 
						||
			[]interface{}{"content-length-range", 0, handler.Policy.MaxSize})
 | 
						||
	}
 | 
						||
 | 
						||
	res, err := handler.getUploadCredential(ctx, postPolicy, keyTime, savePath)
 | 
						||
	if err == nil {
 | 
						||
		res.SessionID = uploadSession.Key
 | 
						||
		res.Callback = apiURL
 | 
						||
		res.UploadURLs = []string{handler.Policy.Server}
 | 
						||
	}
 | 
						||
 | 
						||
	return res, err
 | 
						||
 | 
						||
}
 | 
						||
 | 
						||
// 取消上传凭证
 | 
						||
func (handler Driver) CancelToken(ctx context.Context, uploadSession *serializer.UploadSession) error {
 | 
						||
	return nil
 | 
						||
}
 | 
						||
 | 
						||
// Meta 获取文件信息
 | 
						||
func (handler Driver) Meta(ctx context.Context, path string) (*MetaData, error) {
 | 
						||
	res, err := handler.Client.Object.Head(ctx, path, &cossdk.ObjectHeadOptions{})
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
	return &MetaData{
 | 
						||
		Size:        uint64(res.ContentLength),
 | 
						||
		CallbackKey: res.Header.Get("x-cos-meta-key"),
 | 
						||
		CallbackURL: res.Header.Get("x-cos-meta-callback"),
 | 
						||
	}, nil
 | 
						||
}
 | 
						||
 | 
						||
func (handler Driver) getUploadCredential(ctx context.Context, policy UploadPolicy, keyTime string, savePath string) (*serializer.UploadCredential, error) {
 | 
						||
	// 编码上传策略
 | 
						||
	policyJSON, err := json.Marshal(policy)
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
	policyEncoded := base64.StdEncoding.EncodeToString(policyJSON)
 | 
						||
 | 
						||
	// 签名上传策略
 | 
						||
	hmacSign := hmac.New(sha1.New, []byte(handler.Policy.SecretKey))
 | 
						||
	_, err = io.WriteString(hmacSign, keyTime)
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
	signKey := fmt.Sprintf("%x", hmacSign.Sum(nil))
 | 
						||
 | 
						||
	sha1Sign := sha1.New()
 | 
						||
	_, err = sha1Sign.Write(policyJSON)
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
	stringToSign := fmt.Sprintf("%x", sha1Sign.Sum(nil))
 | 
						||
 | 
						||
	// 最终签名
 | 
						||
	hmacFinalSign := hmac.New(sha1.New, []byte(signKey))
 | 
						||
	_, err = hmacFinalSign.Write([]byte(stringToSign))
 | 
						||
	if err != nil {
 | 
						||
		return nil, err
 | 
						||
	}
 | 
						||
	signature := hmacFinalSign.Sum(nil)
 | 
						||
 | 
						||
	return &serializer.UploadCredential{
 | 
						||
		Policy:     policyEncoded,
 | 
						||
		Path:       savePath,
 | 
						||
		AccessKey:  handler.Policy.AccessKey,
 | 
						||
		Credential: fmt.Sprintf("%x", signature),
 | 
						||
		KeyTime:    keyTime,
 | 
						||
	}, nil
 | 
						||
}
 |