Skip to content

Commit

Permalink
feat(quqi): add download link with cdn (#5938)
Browse files Browse the repository at this point in the history
* feat(quqi): add download link by cdn

* fix(quqi): cookie error when login with phone number
  • Loading branch information
EchoResponse authored Jan 24, 2024
1 parent d88b54d commit 9222510
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 42 deletions.
58 changes: 18 additions & 40 deletions drivers/quqi/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import (
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/alist-org/alist/v3/pkg/utils/random"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
"github.com/tencentyun/cos-go-sdk-v5"
)

type Quqi struct {
model.Storage
Addition
Cookie string // Cookie
GroupID string // 私人云群组ID
ClientID string // 随机生成客户端ID 经过测试,部分接口调用若不携带client id会出现错误
}
Expand Down Expand Up @@ -125,51 +127,27 @@ func (d *Quqi) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]
}

func (d *Quqi) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
var getDocResp = &GetDocRes{}

// 优先从getDoc接口获取文件预览链接,速度比实际下载链接更快
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": file.GetID(),
"client_id": d.ClientID,
})
}, getDocResp); err != nil {
return nil, err
if d.CDN {
link, err := d.linkFromCDN(file.GetID())
if err != nil {
log.Warn(err)
} else {
return link, nil
}
}
if getDocResp.Data.OriginPath != "" {
return &model.Link{
URL: getDocResp.Data.OriginPath,
Header: http.Header{
"Origin": []string{"https://quqi.com"},
"Cookie": []string{d.Cookie},
},
}, nil

link, err := d.linkFromPreview(file.GetID())
if err != nil {
log.Warn(err)
} else {
return link, nil
}

// 对于非会员用户,无法从getDoc接口获取文件预览链接,只能获取下载链接
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": file.GetID(),
"url_type": "undefined",
"entry_type": "undefined",
"client_id": d.ClientID,
"no_redirect": "1",
})
}, &getDownloadResp); err != nil {
link, err = d.linkFromDownload(file.GetID())
if err != nil {
return nil, err
}
return &model.Link{
URL: getDownloadResp.Data.Url,
Header: http.Header{
"Origin": []string{"https://quqi.com"},
"Cookie": []string{d.Cookie},
},
}, nil
return link, nil
}

func (d *Quqi) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
Expand Down
1 change: 1 addition & 0 deletions drivers/quqi/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Addition struct {
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{
Expand Down
20 changes: 20 additions & 0 deletions drivers/quqi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,23 @@ type UploadFinishResp struct {
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"`
}
205 changes: 203 additions & 2 deletions drivers/quqi/util.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
package quqi

import (
"bufio"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
stdpath "path"
"strings"
"time"

"github.com/alist-org/alist/v3/drivers/base"
"github.com/alist-org/alist/v3/internal/errs"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/internal/stream"
"github.com/alist-org/alist/v3/pkg/http_range"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/go-resty/resty/v2"
"github.com/minio/sio"
)

// do others that not defined in Driver interface
Expand Down Expand Up @@ -64,10 +73,12 @@ func (d *Quqi) request(host string, path string, method string, callback base.Re
}

func (d *Quqi) login() error {
if d.Cookie != "" && d.checkLogin() {
if d.Addition.Cookie != "" {
d.Cookie = d.Addition.Cookie
}
if d.checkLogin() {
return nil
}

if d.Cookie != "" {
return errors.New("cookie is invalid")
}
Expand Down Expand Up @@ -113,3 +124,193 @@ func rawExt(name string) string {

return ext
}

// 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 utils.NewReadCloser(bufferReader, func() error {
return nil
}), nil
}

return &model.Link{
Header: http.Header{
"Origin": []string{"https://quqi.com"},
"Cookie": []string{d.Cookie},
},
RangeReadCloser: &model.RangeReadCloser{RangeReader: resultRangeReader, Closers: remoteClosers},
Expiration: &expiration,
}, nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ require (
github.com/jlaffaye/ftp v0.2.0
github.com/json-iterator/go v1.1.12
github.com/maruel/natural v1.1.1
github.com/minio/sio v0.3.0
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/orzogc/fake115uploader v0.3.3-0.20230715111618-58f9eb76f831
github.com/pkg/errors v0.9.1
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zk
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/minio/sio v0.3.0 h1:syEFBewzOMOYVzSTFpp1MqpSZk8rUNbz8VIIc+PNzus=
github.com/minio/sio v0.3.0/go.mod h1:8b0yPp2avGThviy/+OCJBI6OMpvxoUuiLvE6F1lebhw=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
Expand Down Expand Up @@ -486,6 +488,7 @@ golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
Expand Down

0 comments on commit 9222510

Please sign in to comment.