mirror of https://github.com/Xhofe/alist
				
				
				
			
		
			
				
	
	
		
			438 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
			
		
		
	
	
			438 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
| package quqi
 | ||
| 
 | ||
| import (
 | ||
| 	"bytes"
 | ||
| 	"context"
 | ||
| 	"io"
 | ||
| 	"strconv"
 | ||
| 	"strings"
 | ||
| 	"time"
 | ||
| 
 | ||
| 	"github.com/alist-org/alist/v3/internal/driver"
 | ||
| 	"github.com/alist-org/alist/v3/internal/errs"
 | ||
| 	"github.com/alist-org/alist/v3/internal/model"
 | ||
| 	"github.com/alist-org/alist/v3/pkg/utils"
 | ||
| 	"github.com/alist-org/alist/v3/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, rawExt(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)
 | ||
| 	for partNumber := int64(1); ; partNumber++ {
 | ||
| 		n, err := io.ReadFull(f, buf)
 | ||
| 		if err != nil && err != io.ErrUnexpectedEOF {
 | ||
| 			if err == io.EOF {
 | ||
| 				break
 | ||
| 			}
 | ||
| 			return nil, err
 | ||
| 		}
 | ||
| 		_, err = uploader.S3.UploadPartWithContext(ctx, &s3.UploadPartInput{
 | ||
| 			UploadId:   &uploadInitResp.Data.UploadID,
 | ||
| 			Key:        &uploadInitResp.Data.Key,
 | ||
| 			Bucket:     &uploadInitResp.Data.Bucket,
 | ||
| 			PartNumber: aws.Int64(partNumber),
 | ||
| 			Body:       bytes.NewReader(buf[:n]),
 | ||
| 		})
 | ||
| 		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, rawExt(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)
 |