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 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 } // resty.Request.SetResult cannot parse result correctly sometimes 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 } // rawExt 保留扩展名大小写 func rawExt(name string) string { ext := stdpath.Ext(name) if strings.HasPrefix(ext, ".") { ext = ext[1:] } 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 }