2024-01-19 02:59:56 +00:00
|
|
|
|
package quqi
|
|
|
|
|
|
|
|
|
|
import (
|
2024-01-24 08:47:49 +00:00
|
|
|
|
"bufio"
|
|
|
|
|
"context"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
"encoding/base64"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2024-01-24 08:47:49 +00:00
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
"net/url"
|
2024-01-20 13:22:50 +00:00
|
|
|
|
stdpath "path"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
"strings"
|
2024-01-24 08:47:49 +00:00
|
|
|
|
"time"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
|
|
|
|
|
"github.com/alist-org/alist/v3/drivers/base"
|
2024-01-19 05:57:31 +00:00
|
|
|
|
"github.com/alist-org/alist/v3/internal/errs"
|
2024-01-24 08:47:49 +00:00
|
|
|
|
"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"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
"github.com/alist-org/alist/v3/pkg/utils"
|
|
|
|
|
"github.com/go-resty/resty/v2"
|
2024-01-24 08:47:49 +00:00
|
|
|
|
"github.com/minio/sio"
|
2024-01-19 02:59:56 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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,
|
2024-01-21 07:28:52 +00:00
|
|
|
|
})
|
2024-01-19 02:59:56 +00:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2024-01-21 07:28:52 +00:00
|
|
|
|
// resty.Request.SetResult cannot parse result correctly sometimes
|
|
|
|
|
err = utils.Json.Unmarshal(res.Body(), &result)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2024-01-19 02:59:56 +00:00
|
|
|
|
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 {
|
2024-01-24 08:47:49 +00:00
|
|
|
|
if d.Addition.Cookie != "" {
|
|
|
|
|
d.Cookie = d.Addition.Cookie
|
|
|
|
|
}
|
|
|
|
|
if d.checkLogin() {
|
2024-01-19 02:59:56 +00:00
|
|
|
|
return nil
|
|
|
|
|
}
|
2024-01-19 05:57:31 +00:00
|
|
|
|
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
|
2024-01-19 02:59:56 +00:00
|
|
|
|
}
|
|
|
|
|
|
2024-01-19 05:57:31 +00:00
|
|
|
|
resp, err := d.request("", "/auth/person/v2/login/password", resty.MethodPost, func(req *resty.Request) {
|
2024-01-19 02:59:56 +00:00
|
|
|
|
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
|
|
|
|
|
}
|
2024-01-20 13:22:50 +00:00
|
|
|
|
|
|
|
|
|
// rawExt 保留扩展名大小写
|
|
|
|
|
func rawExt(name string) string {
|
|
|
|
|
ext := stdpath.Ext(name)
|
|
|
|
|
if strings.HasPrefix(ext, ".") {
|
|
|
|
|
ext = ext[1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ext
|
|
|
|
|
}
|
2024-01-24 08:47:49 +00:00
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|