mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2025-09-19 20:26:26 +08:00
删除曲奇云盘驱动 (#294)
* Update all.go 删除quqi Signed-off-by: Ray <eiauo.ray@gmail.com> * Delete drivers/quqi directory删除quqi驱动 Signed-off-by: Ray <eiauo.ray@gmail.com> --------- Signed-off-by: Ray <eiauo.ray@gmail.com>
This commit is contained in:
@ -52,7 +52,6 @@ import (
|
|||||||
_ "github.com/OpenListTeam/OpenList/drivers/pikpak_share"
|
_ "github.com/OpenListTeam/OpenList/drivers/pikpak_share"
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc"
|
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc"
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc_tv"
|
_ "github.com/OpenListTeam/OpenList/drivers/quark_uc_tv"
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/quqi"
|
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/s3"
|
_ "github.com/OpenListTeam/OpenList/drivers/s3"
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/seafile"
|
_ "github.com/OpenListTeam/OpenList/drivers/seafile"
|
||||||
_ "github.com/OpenListTeam/OpenList/drivers/sftp"
|
_ "github.com/OpenListTeam/OpenList/drivers/sftp"
|
||||||
|
@ -1,452 +0,0 @@
|
|||||||
package quqi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/driver"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/errs"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/model"
|
|
||||||
"github.com/OpenListTeam/OpenList/pkg/utils"
|
|
||||||
"github.com/OpenListTeam/OpenList/pkg/utils/random"
|
|
||||||
"github.com/aws/aws-sdk-go/aws"
|
|
||||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
||||||
"github.com/aws/aws-sdk-go/aws/session"
|
|
||||||
"github.com/aws/aws-sdk-go/service/s3"
|
|
||||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
|
||||||
"github.com/go-resty/resty/v2"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Quqi struct {
|
|
||||||
model.Storage
|
|
||||||
Addition
|
|
||||||
Cookie string // Cookie
|
|
||||||
GroupID string // 私人云群组ID
|
|
||||||
ClientID string // 随机生成客户端ID 经过测试,部分接口调用若不携带client id会出现错误
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Config() driver.Config {
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) GetAddition() driver.Additional {
|
|
||||||
return &d.Addition
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Init(ctx context.Context) error {
|
|
||||||
// 登录
|
|
||||||
if err := d.login(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机client id (与网页端生成逻辑一致)
|
|
||||||
d.ClientID = "quqipc_" + random.String(10)
|
|
||||||
|
|
||||||
// 获取私人云ID (暂时仅获取私人云)
|
|
||||||
groupResp := &GroupRes{}
|
|
||||||
if _, err := d.request("group.quqi.com", "/v1/group/list", resty.MethodGet, nil, groupResp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, groupInfo := range groupResp.Data {
|
|
||||||
if groupInfo == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if groupInfo.Type == 2 {
|
|
||||||
d.GroupID = strconv.Itoa(groupInfo.ID)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.GroupID == "" {
|
|
||||||
return errs.StorageNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Drop(ctx context.Context) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
|
||||||
var (
|
|
||||||
listResp = &ListRes{}
|
|
||||||
files []model.Obj
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := d.request("", "/api/dir/ls", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": dir.GetID(),
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, listResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if listResp.Data == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dirs
|
|
||||||
for _, dirInfo := range listResp.Data.Dir {
|
|
||||||
if dirInfo == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
files = append(files, &model.Object{
|
|
||||||
ID: strconv.FormatInt(dirInfo.NodeID, 10),
|
|
||||||
Name: dirInfo.Name,
|
|
||||||
Modified: time.Unix(dirInfo.UpdateTime, 0),
|
|
||||||
Ctime: time.Unix(dirInfo.AddTime, 0),
|
|
||||||
IsFolder: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// files
|
|
||||||
for _, fileInfo := range listResp.Data.File {
|
|
||||||
if fileInfo == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fileInfo.EXT != "" {
|
|
||||||
fileInfo.Name = strings.Join([]string{fileInfo.Name, fileInfo.EXT}, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, &model.Object{
|
|
||||||
ID: strconv.FormatInt(fileInfo.NodeID, 10),
|
|
||||||
Name: fileInfo.Name,
|
|
||||||
Size: fileInfo.Size,
|
|
||||||
Modified: time.Unix(fileInfo.UpdateTime, 0),
|
|
||||||
Ctime: time.Unix(fileInfo.AddTime, 0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
|
||||||
if d.CDN {
|
|
||||||
link, err := d.linkFromCDN(file.GetID())
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
} else {
|
|
||||||
return link, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
link, err := d.linkFromPreview(file.GetID())
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(err)
|
|
||||||
} else {
|
|
||||||
return link, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
link, err = d.linkFromDownload(file.GetID())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return link, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
|
||||||
var (
|
|
||||||
makeDirRes = &MakeDirRes{}
|
|
||||||
timeNow = time.Now()
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := d.request("", "/api/dir/mkDir", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"parent_id": parentDir.GetID(),
|
|
||||||
"name": dirName,
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, makeDirRes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.Object{
|
|
||||||
ID: strconv.FormatInt(makeDirRes.Data.NodeID, 10),
|
|
||||||
Name: dirName,
|
|
||||||
Modified: timeNow,
|
|
||||||
Ctime: timeNow,
|
|
||||||
IsFolder: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
|
||||||
var moveRes = &MoveRes{}
|
|
||||||
|
|
||||||
if _, err := d.request("", "/api/dir/mvDir", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": dstDir.GetID(),
|
|
||||||
"source_quqi_id": d.GroupID,
|
|
||||||
"source_tree_id": "1",
|
|
||||||
"source_node_id": srcObj.GetID(),
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, moveRes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.Object{
|
|
||||||
ID: strconv.FormatInt(moveRes.Data.NodeID, 10),
|
|
||||||
Name: moveRes.Data.NodeName,
|
|
||||||
Size: srcObj.GetSize(),
|
|
||||||
Modified: time.Now(),
|
|
||||||
Ctime: srcObj.CreateTime(),
|
|
||||||
IsFolder: srcObj.IsDir(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
|
|
||||||
var realName = newName
|
|
||||||
|
|
||||||
if !srcObj.IsDir() {
|
|
||||||
srcExt, newExt := utils.Ext(srcObj.GetName()), utils.Ext(newName)
|
|
||||||
|
|
||||||
// 曲奇网盘的文件名称由文件名和扩展名组成,若存在扩展名,则重命名时仅支持更改文件名,扩展名在曲奇服务端保留
|
|
||||||
if srcExt != "" && srcExt == newExt {
|
|
||||||
parts := strings.Split(newName, ".")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
realName = strings.Join(parts[:len(parts)-1], ".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := d.request("", "/api/dir/renameDir", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": srcObj.GetID(),
|
|
||||||
"rename": realName,
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, nil); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.Object{
|
|
||||||
ID: srcObj.GetID(),
|
|
||||||
Name: newName,
|
|
||||||
Size: srcObj.GetSize(),
|
|
||||||
Modified: time.Now(),
|
|
||||||
Ctime: srcObj.CreateTime(),
|
|
||||||
IsFolder: srcObj.IsDir(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
|
||||||
// 无法从曲奇接口响应中直接获取复制后的文件信息
|
|
||||||
if _, err := d.request("", "/api/node/copy", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": dstDir.GetID(),
|
|
||||||
"source_quqi_id": d.GroupID,
|
|
||||||
"source_tree_id": "1",
|
|
||||||
"source_node_id": srcObj.GetID(),
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, nil); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Remove(ctx context.Context, obj model.Obj) error {
|
|
||||||
// 暂时不做直接删除,默认都放到回收站。直接删除方法:先调用删除接口放入回收站,在通过回收站接口删除文件
|
|
||||||
if _, err := d.request("", "/api/node/del", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": obj.GetID(),
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
|
||||||
// base info
|
|
||||||
sizeStr := strconv.FormatInt(stream.GetSize(), 10)
|
|
||||||
f, err := stream.CacheFullInTempFile()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
md5, err := utils.HashFile(utils.MD5, f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sha, err := utils.HashFile(utils.SHA256, f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// init upload
|
|
||||||
var uploadInitResp UploadInitResp
|
|
||||||
_, err = d.request("", "/api/upload/v1/file/init", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"parent_id": dstDir.GetID(),
|
|
||||||
"size": sizeStr,
|
|
||||||
"file_name": stream.GetName(),
|
|
||||||
"md5": md5,
|
|
||||||
"sha": sha,
|
|
||||||
"is_slice": "true",
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, &uploadInitResp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// check exist
|
|
||||||
// if the file already exists in Quqi server, there is no need to actually upload it
|
|
||||||
if uploadInitResp.Data.Exist {
|
|
||||||
// the file name returned by Quqi does not include the extension name
|
|
||||||
nodeName, nodeExt := uploadInitResp.Data.NodeName, utils.Ext(stream.GetName())
|
|
||||||
if nodeExt != "" {
|
|
||||||
nodeName = nodeName + "." + nodeExt
|
|
||||||
}
|
|
||||||
return &model.Object{
|
|
||||||
ID: strconv.FormatInt(uploadInitResp.Data.NodeID, 10),
|
|
||||||
Name: nodeName,
|
|
||||||
Size: stream.GetSize(),
|
|
||||||
Modified: stream.ModTime(),
|
|
||||||
Ctime: stream.CreateTime(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
// listParts
|
|
||||||
_, err = d.request("upload.quqi.com:20807", "/upload/v1/listParts", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"token": uploadInitResp.Data.Token,
|
|
||||||
"task_id": uploadInitResp.Data.TaskID,
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// get temp key
|
|
||||||
var tempKeyResp TempKeyResp
|
|
||||||
_, err = d.request("upload.quqi.com:20807", "/upload/v1/tempKey", resty.MethodGet, func(req *resty.Request) {
|
|
||||||
req.SetQueryParams(map[string]string{
|
|
||||||
"token": uploadInitResp.Data.Token,
|
|
||||||
"task_id": uploadInitResp.Data.TaskID,
|
|
||||||
})
|
|
||||||
}, &tempKeyResp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// upload
|
|
||||||
// u, err := url.Parse(fmt.Sprintf("https://%s.cos.ap-shanghai.myqcloud.com", uploadInitResp.Data.Bucket))
|
|
||||||
// b := &cos.BaseURL{BucketURL: u}
|
|
||||||
// client := cos.NewClient(b, &http.Client{
|
|
||||||
// Transport: &cos.CredentialTransport{
|
|
||||||
// Credential: cos.NewTokenCredential(tempKeyResp.Data.Credentials.TmpSecretID, tempKeyResp.Data.Credentials.TmpSecretKey, tempKeyResp.Data.Credentials.SessionToken),
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// partSize := int64(1024 * 1024 * 2)
|
|
||||||
// partCount := (stream.GetSize() + partSize - 1) / partSize
|
|
||||||
// for i := 1; i <= int(partCount); i++ {
|
|
||||||
// length := partSize
|
|
||||||
// if i == int(partCount) {
|
|
||||||
// length = stream.GetSize() - (int64(i)-1)*partSize
|
|
||||||
// }
|
|
||||||
// _, err := client.Object.UploadPart(
|
|
||||||
// ctx, uploadInitResp.Data.Key, uploadInitResp.Data.UploadID, i, io.LimitReader(f, partSize), &cos.ObjectUploadPartOptions{
|
|
||||||
// ContentLength: length,
|
|
||||||
// },
|
|
||||||
// )
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
cfg := &aws.Config{
|
|
||||||
Credentials: credentials.NewStaticCredentials(tempKeyResp.Data.Credentials.TmpSecretID, tempKeyResp.Data.Credentials.TmpSecretKey, tempKeyResp.Data.Credentials.SessionToken),
|
|
||||||
Region: aws.String("ap-shanghai"),
|
|
||||||
Endpoint: aws.String("cos.ap-shanghai.myqcloud.com"),
|
|
||||||
}
|
|
||||||
s, err := session.NewSession(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
uploader := s3manager.NewUploader(s)
|
|
||||||
buf := make([]byte, 1024*1024*2)
|
|
||||||
fup := &driver.ReaderUpdatingProgress{
|
|
||||||
Reader: &driver.SimpleReaderWithSize{
|
|
||||||
Reader: f,
|
|
||||||
Size: int64(len(buf)),
|
|
||||||
},
|
|
||||||
UpdateProgress: up,
|
|
||||||
}
|
|
||||||
for partNumber := int64(1); ; partNumber++ {
|
|
||||||
n, err := io.ReadFull(fup, buf)
|
|
||||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
reader := bytes.NewReader(buf[:n])
|
|
||||||
_, err = uploader.S3.UploadPartWithContext(ctx, &s3.UploadPartInput{
|
|
||||||
UploadId: &uploadInitResp.Data.UploadID,
|
|
||||||
Key: &uploadInitResp.Data.Key,
|
|
||||||
Bucket: &uploadInitResp.Data.Bucket,
|
|
||||||
PartNumber: aws.Int64(partNumber),
|
|
||||||
Body: struct {
|
|
||||||
*driver.RateLimitReader
|
|
||||||
io.Seeker
|
|
||||||
}{
|
|
||||||
RateLimitReader: driver.NewLimitedUploadStream(ctx, reader),
|
|
||||||
Seeker: reader,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// finish upload
|
|
||||||
var uploadFinishResp UploadFinishResp
|
|
||||||
_, err = d.request("", "/api/upload/v1/file/finish", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"token": uploadInitResp.Data.Token,
|
|
||||||
"task_id": uploadInitResp.Data.TaskID,
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, &uploadFinishResp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// the file name returned by Quqi does not include the extension name
|
|
||||||
nodeName, nodeExt := uploadFinishResp.Data.NodeName, utils.Ext(stream.GetName())
|
|
||||||
if nodeExt != "" {
|
|
||||||
nodeName = nodeName + "." + nodeExt
|
|
||||||
}
|
|
||||||
return &model.Object{
|
|
||||||
ID: strconv.FormatInt(uploadFinishResp.Data.NodeID, 10),
|
|
||||||
Name: nodeName,
|
|
||||||
Size: stream.GetSize(),
|
|
||||||
Modified: stream.ModTime(),
|
|
||||||
Ctime: stream.CreateTime(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//func (d *Template) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) {
|
|
||||||
// return nil, errs.NotSupport
|
|
||||||
//}
|
|
||||||
|
|
||||||
var _ driver.Driver = (*Quqi)(nil)
|
|
@ -1,28 +0,0 @@
|
|||||||
package quqi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/driver"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/op"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Addition struct {
|
|
||||||
driver.RootID
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Cookie string `json:"cookie" help:"Cookie can be used on multiple clients at the same time"`
|
|
||||||
CDN bool `json:"cdn" help:"If you enable this option, the download speed can be increased, but there will be some performance loss"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = driver.Config{
|
|
||||||
Name: "Quqi",
|
|
||||||
OnlyLocal: true,
|
|
||||||
LocalSort: true,
|
|
||||||
//NoUpload: true,
|
|
||||||
DefaultRoot: "0",
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
op.RegisterDriver(func() driver.Driver {
|
|
||||||
return &Quqi{}
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,197 +0,0 @@
|
|||||||
package quqi
|
|
||||||
|
|
||||||
type BaseReqQuery struct {
|
|
||||||
ID string `json:"quqiid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseReq struct {
|
|
||||||
GroupID string `json:"quqi_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseRes struct {
|
|
||||||
//Data interface{} `json:"data"`
|
|
||||||
Code int `json:"err"`
|
|
||||||
Message string `json:"msg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GroupRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data []*Group `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data *List `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetDocRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
OriginPath string `json:"origin_path"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetDownloadResp struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
Url string `json:"url"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MakeDirRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
IsRoot bool `json:"is_root"`
|
|
||||||
NodeID int64 `json:"node_id"`
|
|
||||||
ParentID int64 `json:"parent_id"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MoveRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
NodeChildNum int64 `json:"node_child_num"`
|
|
||||||
NodeID int64 `json:"node_id"`
|
|
||||||
NodeName string `json:"node_name"`
|
|
||||||
ParentID int64 `json:"parent_id"`
|
|
||||||
GroupID int64 `json:"quqi_id"`
|
|
||||||
TreeID int64 `json:"tree_id"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenameRes struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
NodeID int64 `json:"node_id"`
|
|
||||||
GroupID int64 `json:"quqi_id"`
|
|
||||||
Rename string `json:"rename"`
|
|
||||||
TreeID int64 `json:"tree_id"`
|
|
||||||
UpdateTime int64 `json:"updatetime"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CopyRes struct {
|
|
||||||
BaseRes
|
|
||||||
}
|
|
||||||
|
|
||||||
type RemoveRes struct {
|
|
||||||
BaseRes
|
|
||||||
}
|
|
||||||
|
|
||||||
type Group struct {
|
|
||||||
ID int `json:"quqi_id"`
|
|
||||||
Type int `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
IsAdministrator int `json:"is_administrator"`
|
|
||||||
Role int `json:"role"`
|
|
||||||
Avatar string `json:"avatar_url"`
|
|
||||||
IsStick int `json:"is_stick"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Status int `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type List struct {
|
|
||||||
ListDir
|
|
||||||
Dir []*ListDir `json:"dir"`
|
|
||||||
File []*ListFile `json:"file"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListItem struct {
|
|
||||||
AddTime int64 `json:"add_time"`
|
|
||||||
IsDir int `json:"is_dir"`
|
|
||||||
IsExpand int `json:"is_expand"`
|
|
||||||
IsFinalize int `json:"is_finalize"`
|
|
||||||
LastEditorName string `json:"last_editor_name"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
NodeID int64 `json:"nid"`
|
|
||||||
ParentID int64 `json:"parent_id"`
|
|
||||||
Permission int `json:"permission"`
|
|
||||||
TreeID int64 `json:"tid"`
|
|
||||||
UpdateCNT int64 `json:"update_cnt"`
|
|
||||||
UpdateTime int64 `json:"update_time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListDir struct {
|
|
||||||
ListItem
|
|
||||||
ChildDocNum int64 `json:"child_doc_num"`
|
|
||||||
DirDetail string `json:"dir_detail"`
|
|
||||||
DirType int `json:"dir_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListFile struct {
|
|
||||||
ListItem
|
|
||||||
BroadDocType string `json:"broad_doc_type"`
|
|
||||||
CanDisplay bool `json:"can_display"`
|
|
||||||
Detail string `json:"detail"`
|
|
||||||
EXT string `json:"ext"`
|
|
||||||
Filetype string `json:"filetype"`
|
|
||||||
HasMobileThumbnail bool `json:"has_mobile_thumbnail"`
|
|
||||||
HasThumbnail bool `json:"has_thumbnail"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Version int `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadInitResp struct {
|
|
||||||
Data struct {
|
|
||||||
Bucket string `json:"bucket"`
|
|
||||||
Exist bool `json:"exist"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
TaskID string `json:"task_id"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
UploadID string `json:"upload_id"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
NodeID int64 `json:"node_id"`
|
|
||||||
NodeName string `json:"node_name"`
|
|
||||||
ParentID int64 `json:"parent_id"`
|
|
||||||
} `json:"data"`
|
|
||||||
Err int `json:"err"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TempKeyResp struct {
|
|
||||||
Err int `json:"err"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
Data struct {
|
|
||||||
ExpiredTime int `json:"expiredTime"`
|
|
||||||
Expiration string `json:"expiration"`
|
|
||||||
Credentials struct {
|
|
||||||
SessionToken string `json:"sessionToken"`
|
|
||||||
TmpSecretID string `json:"tmpSecretId"`
|
|
||||||
TmpSecretKey string `json:"tmpSecretKey"`
|
|
||||||
} `json:"credentials"`
|
|
||||||
RequestID string `json:"requestId"`
|
|
||||||
StartTime int `json:"startTime"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadFinishResp struct {
|
|
||||||
Data struct {
|
|
||||||
NodeID int64 `json:"node_id"`
|
|
||||||
NodeName string `json:"node_name"`
|
|
||||||
ParentID int64 `json:"parent_id"`
|
|
||||||
QuqiID int64 `json:"quqi_id"`
|
|
||||||
TreeID int64 `json:"tree_id"`
|
|
||||||
} `json:"data"`
|
|
||||||
Err int `json:"err"`
|
|
||||||
Msg string `json:"msg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UrlExchangeResp struct {
|
|
||||||
BaseRes
|
|
||||||
Data struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Mime string `json:"mime"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
DownloadType int `json:"download_type"`
|
|
||||||
ChannelType int `json:"channel_type"`
|
|
||||||
ChannelID int `json:"channel_id"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
ExpiredTime int64 `json:"expired_time"`
|
|
||||||
IsEncrypted bool `json:"is_encrypted"`
|
|
||||||
EncryptedSize int64 `json:"encrypted_size"`
|
|
||||||
EncryptedAlg string `json:"encrypted_alg"`
|
|
||||||
EncryptedKey string `json:"encrypted_key"`
|
|
||||||
PassportID int64 `json:"passport_id"`
|
|
||||||
RequestExpiredTime int64 `json:"request_expired_time"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
@ -1,299 +0,0 @@
|
|||||||
package quqi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/OpenListTeam/OpenList/drivers/base"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/errs"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/model"
|
|
||||||
"github.com/OpenListTeam/OpenList/internal/stream"
|
|
||||||
"github.com/OpenListTeam/OpenList/pkg/http_range"
|
|
||||||
"github.com/OpenListTeam/OpenList/pkg/utils"
|
|
||||||
"github.com/go-resty/resty/v2"
|
|
||||||
"github.com/minio/sio"
|
|
||||||
)
|
|
||||||
|
|
||||||
// do others that not defined in Driver interface
|
|
||||||
func (d *Quqi) request(host string, path string, method string, callback base.ReqCallback, resp interface{}) (*resty.Response, error) {
|
|
||||||
var (
|
|
||||||
reqUrl = url.URL{
|
|
||||||
Scheme: "https",
|
|
||||||
Host: "quqi.com",
|
|
||||||
Path: path,
|
|
||||||
}
|
|
||||||
req = base.RestyClient.R()
|
|
||||||
result BaseRes
|
|
||||||
)
|
|
||||||
|
|
||||||
if host != "" {
|
|
||||||
reqUrl.Host = host
|
|
||||||
}
|
|
||||||
req.SetHeaders(map[string]string{
|
|
||||||
"Origin": "https://quqi.com",
|
|
||||||
"Cookie": d.Cookie,
|
|
||||||
})
|
|
||||||
|
|
||||||
if d.GroupID != "" {
|
|
||||||
req.SetQueryParam("quqiid", d.GroupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if callback != nil {
|
|
||||||
callback(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := req.Execute(method, reqUrl.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// resty.Request.SetResult cannot parse result correctly sometimes
|
|
||||||
err = utils.Json.Unmarshal(res.Body(), &result)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if result.Code != 0 {
|
|
||||||
return nil, errors.New(result.Message)
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
err = utils.Json.Unmarshal(res.Body(), resp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) login() error {
|
|
||||||
if d.Addition.Cookie != "" {
|
|
||||||
d.Cookie = d.Addition.Cookie
|
|
||||||
}
|
|
||||||
if d.checkLogin() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if d.Cookie != "" {
|
|
||||||
return errors.New("cookie is invalid")
|
|
||||||
}
|
|
||||||
if d.Phone == "" {
|
|
||||||
return errors.New("phone number is empty")
|
|
||||||
}
|
|
||||||
if d.Password == "" {
|
|
||||||
return errs.EmptyPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := d.request("", "/auth/person/v2/login/password", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"phone": d.Phone,
|
|
||||||
"password": base64.StdEncoding.EncodeToString([]byte(d.Password)),
|
|
||||||
})
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var cookies []string
|
|
||||||
for _, cookie := range resp.RawResponse.Cookies() {
|
|
||||||
cookies = append(cookies, fmt.Sprintf("%s=%s", cookie.Name, cookie.Value))
|
|
||||||
}
|
|
||||||
d.Cookie = strings.Join(cookies, ";")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) checkLogin() bool {
|
|
||||||
if _, err := d.request("", "/auth/account/baseInfo", resty.MethodGet, nil, nil); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptKey 获取密码
|
|
||||||
func decryptKey(encodeKey string) []byte {
|
|
||||||
// 移除非法字符
|
|
||||||
u := strings.ReplaceAll(encodeKey, "[^A-Za-z0-9+\\/]", "")
|
|
||||||
|
|
||||||
// 计算输出字节数组的长度
|
|
||||||
o := len(u)
|
|
||||||
a := 32
|
|
||||||
|
|
||||||
// 创建输出字节数组
|
|
||||||
c := make([]byte, a)
|
|
||||||
|
|
||||||
// 编码循环
|
|
||||||
s := uint32(0) // 累加器
|
|
||||||
f := 0 // 输出数组索引
|
|
||||||
for l := 0; l < o; l++ {
|
|
||||||
r := l & 3 // 取模4,得到当前字符在四字节块中的位置
|
|
||||||
i := u[l] // 当前字符的ASCII码
|
|
||||||
|
|
||||||
// 编码当前字符
|
|
||||||
switch {
|
|
||||||
case i >= 65 && i < 91: // 大写字母
|
|
||||||
s |= uint32(i-65) << uint32(6*(3-r))
|
|
||||||
case i >= 97 && i < 123: // 小写字母
|
|
||||||
s |= uint32(i-71) << uint32(6*(3-r))
|
|
||||||
case i >= 48 && i < 58: // 数字
|
|
||||||
s |= uint32(i+4) << uint32(6*(3-r))
|
|
||||||
case i == 43: // 加号
|
|
||||||
s |= uint32(62) << uint32(6*(3-r))
|
|
||||||
case i == 47: // 斜杠
|
|
||||||
s |= uint32(63) << uint32(6*(3-r))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果累加器已经包含了四个字符,或者是最后一个字符,则写入输出数组
|
|
||||||
if r == 3 || l == o-1 {
|
|
||||||
for e := 0; e < 3 && f < a; e, f = e+1, f+1 {
|
|
||||||
c[f] = byte(s >> (16 >> e & 24) & 255)
|
|
||||||
}
|
|
||||||
s = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) linkFromPreview(id string) (*model.Link, error) {
|
|
||||||
var getDocResp GetDocRes
|
|
||||||
if _, err := d.request("", "/api/doc/getDoc", resty.MethodPost, func(req *resty.Request) {
|
|
||||||
req.SetFormData(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": id,
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
})
|
|
||||||
}, &getDocResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if getDocResp.Data.OriginPath == "" {
|
|
||||||
return nil, errors.New("cannot get link from preview")
|
|
||||||
}
|
|
||||||
return &model.Link{
|
|
||||||
URL: getDocResp.Data.OriginPath,
|
|
||||||
Header: http.Header{
|
|
||||||
"Origin": []string{"https://quqi.com"},
|
|
||||||
"Cookie": []string{d.Cookie},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) linkFromDownload(id string) (*model.Link, error) {
|
|
||||||
var getDownloadResp GetDownloadResp
|
|
||||||
if _, err := d.request("", "/api/doc/getDownload", resty.MethodGet, func(req *resty.Request) {
|
|
||||||
req.SetQueryParams(map[string]string{
|
|
||||||
"quqi_id": d.GroupID,
|
|
||||||
"tree_id": "1",
|
|
||||||
"node_id": id,
|
|
||||||
"url_type": "undefined",
|
|
||||||
"entry_type": "undefined",
|
|
||||||
"client_id": d.ClientID,
|
|
||||||
"no_redirect": "1",
|
|
||||||
})
|
|
||||||
}, &getDownloadResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if getDownloadResp.Data.Url == "" {
|
|
||||||
return nil, errors.New("cannot get link from download")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.Link{
|
|
||||||
URL: getDownloadResp.Data.Url,
|
|
||||||
Header: http.Header{
|
|
||||||
"Origin": []string{"https://quqi.com"},
|
|
||||||
"Cookie": []string{d.Cookie},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Quqi) linkFromCDN(id string) (*model.Link, error) {
|
|
||||||
downloadLink, err := d.linkFromDownload(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var urlExchangeResp UrlExchangeResp
|
|
||||||
if _, err = d.request("api.quqi.com", "/preview/downloadInfo/url/exchange", resty.MethodGet, func(req *resty.Request) {
|
|
||||||
req.SetQueryParam("url", downloadLink.URL)
|
|
||||||
}, &urlExchangeResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if urlExchangeResp.Data.Url == "" {
|
|
||||||
return nil, errors.New("cannot get link from cdn")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 假设存在未加密的情况
|
|
||||||
if !urlExchangeResp.Data.IsEncrypted {
|
|
||||||
return &model.Link{
|
|
||||||
URL: urlExchangeResp.Data.Url,
|
|
||||||
Header: http.Header{
|
|
||||||
"Origin": []string{"https://quqi.com"},
|
|
||||||
"Cookie": []string{d.Cookie},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据sio(https://github.com/minio/sio/blob/master/DARE.md)描述及实际测试,得出以下结论:
|
|
||||||
// 1. 加密后大小(encrypted_size)-原始文件大小(size) = 加密包的头大小+身份验证标识 = (16+16) * N -> N为加密包的数量
|
|
||||||
// 2. 原始文件大小(size)+64*1024-1 / (64*1024) = N -> 每个包的有效负载为64K
|
|
||||||
remoteClosers := utils.EmptyClosers()
|
|
||||||
payloadSize := int64(1 << 16)
|
|
||||||
expiration := time.Until(time.Unix(urlExchangeResp.Data.ExpiredTime, 0))
|
|
||||||
resultRangeReader := func(ctx context.Context, httpRange http_range.Range) (io.ReadCloser, error) {
|
|
||||||
encryptedOffset := httpRange.Start / payloadSize * (payloadSize + 32)
|
|
||||||
decryptedOffset := httpRange.Start % payloadSize
|
|
||||||
encryptedLength := (httpRange.Length+httpRange.Start+payloadSize-1)/payloadSize*(payloadSize+32) - encryptedOffset
|
|
||||||
if httpRange.Length < 0 {
|
|
||||||
encryptedLength = httpRange.Length
|
|
||||||
} else {
|
|
||||||
if httpRange.Length+httpRange.Start >= urlExchangeResp.Data.Size || encryptedLength+encryptedOffset >= urlExchangeResp.Data.EncryptedSize {
|
|
||||||
encryptedLength = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//log.Debugf("size: %d\tencrypted_size: %d", urlExchangeResp.Data.Size, urlExchangeResp.Data.EncryptedSize)
|
|
||||||
//log.Debugf("http range offset: %d, length: %d", httpRange.Start, httpRange.Length)
|
|
||||||
//log.Debugf("encrypted offset: %d, length: %d, decrypted offset: %d", encryptedOffset, encryptedLength, decryptedOffset)
|
|
||||||
|
|
||||||
rrc, err := stream.GetRangeReadCloserFromLink(urlExchangeResp.Data.EncryptedSize, &model.Link{
|
|
||||||
URL: urlExchangeResp.Data.Url,
|
|
||||||
Header: http.Header{
|
|
||||||
"Origin": []string{"https://quqi.com"},
|
|
||||||
"Cookie": []string{d.Cookie},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := rrc.RangeRead(ctx, http_range.Range{Start: encryptedOffset, Length: encryptedLength})
|
|
||||||
remoteClosers.AddClosers(rrc.GetClosers())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptReader, err := sio.DecryptReader(rc, sio.Config{
|
|
||||||
MinVersion: sio.Version10,
|
|
||||||
MaxVersion: sio.Version20,
|
|
||||||
CipherSuites: []byte{sio.CHACHA20_POLY1305, sio.AES_256_GCM},
|
|
||||||
Key: decryptKey(urlExchangeResp.Data.EncryptedKey),
|
|
||||||
SequenceNumber: uint32(httpRange.Start / payloadSize),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
bufferReader := bufio.NewReader(decryptReader)
|
|
||||||
bufferReader.Discard(int(decryptedOffset))
|
|
||||||
|
|
||||||
return io.NopCloser(bufferReader), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &model.Link{
|
|
||||||
RangeReadCloser: &model.RangeReadCloser{RangeReader: resultRangeReader, Closers: remoteClosers},
|
|
||||||
Expiration: &expiration,
|
|
||||||
}, nil
|
|
||||||
}
|
|
Reference in New Issue
Block a user