|
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" |
|
) |
|
|
|
|
|
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 |
|
} |
|
|
|
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 |
|
} |
|
|
|
|
|
func rawExt(name string) string { |
|
ext := stdpath.Ext(name) |
|
if strings.HasPrefix(ext, ".") { |
|
ext = ext[1:] |
|
} |
|
|
|
return ext |
|
} |
|
|
|
|
|
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 |
|
i := u[l] |
|
|
|
|
|
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 |
|
} |
|
|
|
|
|
|
|
|
|
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 |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
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 |
|
} |
|
|