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/driver" "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/cloudreve/Cloudreve/v3/pkg/util" "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, 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("delete failed") } // Thumb 获取文件缩略图 func (handler Driver) Thumb(ctx context.Context, file *model.File) (*response.ContentResponse, error) { // quick check by extension name // https://cloud.tencent.com/document/product/436/44893 supported := []string{"png", "jpg", "jpeg", "gif", "bmp", "webp", "heif", "heic"} if len(handler.Policy.OptionsSerialized.ThumbExts) > 0 { supported = handler.Policy.OptionsSerialized.ThumbExts } if !util.IsInExtensionList(supported, file.Name) || file.Size > (32<<(10*2)) { return nil, driver.ErrorThumbNotSupported } var ( thumbSize = [2]uint{400, 300} ok = false ) if thumbSize, ok = ctx.Value(fsctx.ThumbSizeCtx).([2]uint); !ok { return nil, errors.New("failed to get thumbnail size") } thumbEncodeQuality := model.GetIntSetting("thumb_encode_quality", 85) thumbParam := fmt.Sprintf("imageMogr2/thumbnail/%dx%d/quality/%d", thumbSize[0], thumbSize[1], thumbEncodeQuality) source, err := handler.signSourceURL( ctx, file.SourceName, 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, 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 }