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)