feat: add supports for thunder_browser driver (#6529 close #6526)

* feat: add supports for thunderX driver

* fix: Fix the bug where UserID is not passed correctly

* feat: add support for thunder_browser driver
This commit is contained in:
YangXu 2024-05-27 21:34:26 +08:00 committed by GitHub
parent 163af0515f
commit 639b7817bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1395 additions and 0 deletions

View File

@ -46,6 +46,7 @@ import (
_ "github.com/alist-org/alist/v3/drivers/teambition"
_ "github.com/alist-org/alist/v3/drivers/terabox"
_ "github.com/alist-org/alist/v3/drivers/thunder"
_ "github.com/alist-org/alist/v3/drivers/thunder_browser"
_ "github.com/alist-org/alist/v3/drivers/thunderx"
_ "github.com/alist-org/alist/v3/drivers/trainbit"
_ "github.com/alist-org/alist/v3/drivers/url_tree"

View File

@ -0,0 +1,813 @@
package thunder_browser
import (
"context"
"fmt"
"github.com/alist-org/alist/v3/drivers/base"
"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/internal/op"
"github.com/alist-org/alist/v3/pkg/utils"
hash_extend "github.com/alist-org/alist/v3/pkg/utils/hash"
"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/s3manager"
"github.com/go-resty/resty/v2"
"net/http"
"regexp"
"strings"
)
type ThunderBrowser struct {
*XunLeiBrowserCommon
model.Storage
Addition
identity string
}
func (x *ThunderBrowser) Config() driver.Config {
return config
}
func (x *ThunderBrowser) GetAddition() driver.Additional {
return &x.Addition
}
func (x *ThunderBrowser) Init(ctx context.Context) (err error) {
spaceTokenFunc := func() error {
// 如果用户未设置 "超级保险柜" 密码 则直接返回
if x.SafePassword == "" {
return nil
}
// 通过 GetSafeAccessToken 获取
token, err := x.GetSafeAccessToken(x.SafePassword)
x.SetSpaceTokenResp(token)
return err
}
// 初始化所需参数
if x.XunLeiBrowserCommon == nil {
x.XunLeiBrowserCommon = &XunLeiBrowserCommon{
Common: &Common{
client: base.NewRestyClient(),
Algorithms: []string{
"x+I5XiTByg",
"6QU1x5DqGAV3JKg6h",
"VI1vL1WXr7st0es",
"n+/3yhlrnKs4ewhLgZhZ5ITpt554",
"UOip2PE7BLIEov/ZX6VOnsz",
"Q70h9lpViNCOC8sGVkar9o22LhBTjfP",
"IVHFuB1JcMlaZHnW",
"bKE",
"HZRbwxOiQx+diNopi6Nu",
"fwyasXgYL3rP314331b",
"LWxXAiSW4",
"UlWIjv1HGrC6Ngmt4Nohx",
"FOa+Lc0bxTDpTwIh2",
"0+RY",
"xmRVMqokHHpvsiH0",
},
DeviceID: utils.GetMD5EncodeStr(x.Username + x.Password),
ClientID: "ZUBzD9J_XPXfn7f7",
ClientSecret: "yESVmHecEe6F0aou69vl-g",
ClientVersion: "1.0.7.1938",
PackageName: "com.xunlei.browser",
UserAgent: "ANDROID-com.xunlei.browser/1.0.7.1938 netWorkType/5G appid/22062 deviceName/Xiaomi_M2004j7ac deviceModel/M2004J7AC OSVersion/12 protocolVersion/301 platformVersion/10 sdkVersion/233100 Oauth2Client/0.9 (Linux 4_14_186-perf-gddfs8vbb238b) (JAVA 0)",
DownloadUserAgent: "AndroidDownloadManager/12 (Linux; U; Android 12; M2004J7AC Build/SP1A.210812.016)",
UseVideoUrl: x.UseVideoUrl,
refreshCTokenCk: func(token string) {
x.CaptchaToken = token
op.MustSaveDriverStorage(x)
},
},
refreshTokenFunc: func() error {
// 通过RefreshToken刷新
token, err := x.RefreshToken(x.TokenResp.RefreshToken)
if err != nil {
// 重新登录
token, err = x.Login(x.Username, x.Password)
if err != nil {
x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error()))
op.MustSaveDriverStorage(x)
}
}
x.SetTokenResp(token)
return err
},
}
}
// 自定义验证码token
ctoekn := strings.TrimSpace(x.CaptchaToken)
if ctoekn != "" {
x.SetCaptchaToken(ctoekn)
}
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.Addition.RootFolderID = x.RootFolderID
// 防止重复登录
identity := x.GetIdentity()
if x.identity != identity || !x.IsLogin() {
x.identity = identity
// 登录
token, err := x.Login(x.Username, x.Password)
if err != nil {
return err
}
x.SetTokenResp(token)
}
// 获取 spaceToken
err = spaceTokenFunc()
if err != nil {
return err
}
return nil
}
func (x *ThunderBrowser) Drop(ctx context.Context) error {
return nil
}
type ThunderBrowserExpert struct {
*XunLeiBrowserCommon
model.Storage
ExpertAddition
identity string
}
func (x *ThunderBrowserExpert) Config() driver.Config {
return configExpert
}
func (x *ThunderBrowserExpert) GetAddition() driver.Additional {
return &x.ExpertAddition
}
func (x *ThunderBrowserExpert) Init(ctx context.Context) (err error) {
spaceTokenFunc := func() error {
// 如果用户未设置 "超级保险柜" 密码 则直接返回
if x.SafePassword == "" {
return nil
}
// 通过 GetSafeAccessToken 获取
token, err := x.GetSafeAccessToken(x.SafePassword)
x.SetSpaceTokenResp(token)
return err
}
// 防止重复登录
identity := x.GetIdentity()
if identity != x.identity || !x.IsLogin() {
x.identity = identity
x.XunLeiBrowserCommon = &XunLeiBrowserCommon{
Common: &Common{
client: base.NewRestyClient(),
DeviceID: func() string {
if len(x.DeviceID) != 32 {
return utils.GetMD5EncodeStr(x.DeviceID)
}
return x.DeviceID
}(),
ClientID: x.ClientID,
ClientSecret: x.ClientSecret,
ClientVersion: x.ClientVersion,
PackageName: x.PackageName,
UserAgent: x.UserAgent,
DownloadUserAgent: x.DownloadUserAgent,
UseVideoUrl: x.UseVideoUrl,
refreshCTokenCk: func(token string) {
x.CaptchaToken = token
op.MustSaveDriverStorage(x)
},
},
}
if x.CaptchaToken != "" {
x.SetCaptchaToken(x.CaptchaToken)
}
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.ExpertAddition.RootFolderID = x.RootFolderID
// 签名方法
if x.SignType == "captcha_sign" {
x.Common.Timestamp = x.Timestamp
x.Common.CaptchaSign = x.CaptchaSign
} else {
x.Common.Algorithms = strings.Split(x.Algorithms, ",")
}
// 登录方式
if x.LoginType == "refresh_token" {
// 通过RefreshToken登录
token, err := x.XunLeiBrowserCommon.RefreshToken(x.ExpertAddition.RefreshToken)
if err != nil {
return err
}
x.SetTokenResp(token)
// 刷新token方法
x.SetRefreshTokenFunc(func() error {
token, err := x.XunLeiBrowserCommon.RefreshToken(x.TokenResp.RefreshToken)
if err != nil {
x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error()))
}
x.SetTokenResp(token)
op.MustSaveDriverStorage(x)
return err
})
err = spaceTokenFunc()
if err != nil {
return err
}
} else {
// 通过用户密码登录
token, err := x.Login(x.Username, x.Password)
if err != nil {
return err
}
x.SetTokenResp(token)
x.SetRefreshTokenFunc(func() error {
token, err := x.XunLeiBrowserCommon.RefreshToken(x.TokenResp.RefreshToken)
if err != nil {
token, err = x.Login(x.Username, x.Password)
if err != nil {
x.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error()))
}
}
x.SetTokenResp(token)
op.MustSaveDriverStorage(x)
return err
})
err = spaceTokenFunc()
if err != nil {
return err
}
}
} else {
// 仅修改验证码token
if x.CaptchaToken != "" {
x.SetCaptchaToken(x.CaptchaToken)
}
err = spaceTokenFunc()
if err != nil {
return err
}
x.XunLeiBrowserCommon.UserAgent = x.UserAgent
x.XunLeiBrowserCommon.DownloadUserAgent = x.DownloadUserAgent
x.XunLeiBrowserCommon.UseVideoUrl = x.UseVideoUrl
x.ExpertAddition.RootFolderID = x.RootFolderID
}
return nil
}
func (x *ThunderBrowserExpert) Drop(ctx context.Context) error {
return nil
}
func (x *ThunderBrowserExpert) SetTokenResp(token *TokenResp) {
x.XunLeiBrowserCommon.SetTokenResp(token)
if token != nil {
x.ExpertAddition.RefreshToken = token.RefreshToken
}
}
type XunLeiBrowserCommon struct {
*Common
*TokenResp // 登录信息
refreshTokenFunc func() error
}
func (xc *XunLeiBrowserCommon) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
return xc.getFiles(ctx, dir.GetID(), args.ReqPath)
}
func (xc *XunLeiBrowserCommon) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
var lFile Files
params := map[string]string{
"_magic": "2021",
"space": "SPACE_BROWSER",
"thumbnail_size": "SIZE_LARGE",
"with": "url",
}
// 对 "迅雷云盘" 内的文件 特殊处理
if file.GetPath() == ThunderDriveFileID {
params = map[string]string{}
} else if file.GetPath() == ThunderBrowserDriveSafeFileID {
// 对 "超级保险箱" 内的文件 特殊处理
params["space"] = "SPACE_BROWSER_SAFE"
}
_, err := xc.Request(FILE_API_URL+"/{fileID}", http.MethodGet, func(r *resty.Request) {
r.SetContext(ctx)
r.SetPathParam("fileID", file.GetID())
r.SetQueryParams(params)
//r.SetQueryParam("space", "")
}, &lFile)
if err != nil {
return nil, err
}
link := &model.Link{
URL: lFile.WebContentLink,
Header: http.Header{
"User-Agent": {xc.DownloadUserAgent},
},
}
if xc.UseVideoUrl {
for _, media := range lFile.Medias {
if media.Link.URL != "" {
link.URL = media.Link.URL
break
}
}
}
return link, nil
}
func (xc *XunLeiBrowserCommon) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
js := base.Json{
"kind": FOLDER,
"name": dirName,
"parent_id": parentDir.GetID(),
"space": "SPACE_BROWSER",
}
if parentDir.GetPath() == ThunderDriveFileID {
js = base.Json{
"kind": FOLDER,
"name": dirName,
"parent_id": parentDir.GetID(),
}
} else if parentDir.GetPath() == ThunderBrowserDriveSafeFileID {
js = base.Json{
"kind": FOLDER,
"name": dirName,
"parent_id": parentDir.GetID(),
"space": "SPACE_BROWSER_SAFE",
}
}
_, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
}, nil)
return err
}
func (xc *XunLeiBrowserCommon) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
srcSpace := "SPACE_BROWSER"
dstSpace := "SPACE_BROWSER"
// 对 "超级保险箱" 内的文件 特殊处理
if srcObj.GetPath() == ThunderBrowserDriveSafeFileID {
srcSpace = "SPACE_BROWSER_SAFE"
}
if dstDir.GetPath() == ThunderBrowserDriveSafeFileID {
dstSpace = "SPACE_BROWSER_SAFE"
}
params := map[string]string{
"_from": dstSpace,
}
js := base.Json{
"to": base.Json{"parent_id": dstDir.GetID(), "space": dstSpace},
"space": srcSpace,
"ids": []string{srcObj.GetID()},
}
// 对 "迅雷云盘" 内的文件 特殊处理
if srcObj.GetPath() == ThunderDriveFileID {
params = map[string]string{}
js = base.Json{
"to": base.Json{"parent_id": dstDir.GetID()},
"ids": []string{srcObj.GetID()},
}
}
_, err := xc.Request(FILE_API_URL+":batchMove", http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
r.SetQueryParams(params)
}, nil)
return err
}
func (xc *XunLeiBrowserCommon) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
params := map[string]string{
"space": "SPACE_BROWSER",
}
// 对 "迅雷云盘" 内的文件 特殊处理
if srcObj.GetPath() == ThunderDriveFileID {
params = map[string]string{}
} else if srcObj.GetPath() == ThunderBrowserDriveSafeFileID {
// 对 "超级保险箱" 内的文件 特殊处理
params = map[string]string{
"space": "SPACE_BROWSER_SAFE",
}
}
_, err := xc.Request(FILE_API_URL+"/{fileID}", http.MethodPatch, func(r *resty.Request) {
r.SetContext(ctx)
r.SetPathParam("fileID", srcObj.GetID())
r.SetBody(&base.Json{"name": newName})
r.SetQueryParams(params)
}, nil)
return err
}
func (xc *XunLeiBrowserCommon) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
srcSpace := "SPACE_BROWSER"
dstSpace := "SPACE_BROWSER"
// 对 "超级保险箱" 内的文件 特殊处理
if srcObj.GetPath() == ThunderBrowserDriveSafeFileID {
srcSpace = "SPACE_BROWSER_SAFE"
}
if dstDir.GetPath() == ThunderBrowserDriveSafeFileID {
dstSpace = "SPACE_BROWSER_SAFE"
}
params := map[string]string{
"_from": dstSpace,
}
js := base.Json{
"to": base.Json{"parent_id": dstDir.GetID(), "space": dstSpace},
"space": srcSpace,
"ids": []string{srcObj.GetID()},
}
// 对 "迅雷云盘" 内的文件 特殊处理
if srcObj.GetPath() == ThunderDriveFileID {
params = map[string]string{}
js = base.Json{
"to": base.Json{"parent_id": dstDir.GetID()},
"ids": []string{srcObj.GetID()},
}
}
_, err := xc.Request(FILE_API_URL+":batchCopy", http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
r.SetQueryParams(params)
}, nil)
return err
}
func (xc *XunLeiBrowserCommon) Remove(ctx context.Context, obj model.Obj) error {
js := base.Json{
"ids": []string{obj.GetID()},
"space": "SPACE_BROWSER",
}
// 对 "迅雷云盘" 内的文件 特殊处理
if obj.GetPath() == ThunderDriveFileID {
js = base.Json{
"ids": []string{obj.GetID()},
}
} else if obj.GetPath() == ThunderBrowserDriveSafeFileID {
// 对 "超级保险箱" 内的文件 特殊处理
js = base.Json{
"ids": []string{obj.GetID()},
"space": "SPACE_BROWSER_SAFE",
}
}
if xc.RemoveWay == "delete" && obj.GetPath() == ThunderDriveFileID {
_, err := xc.Request(FILE_API_URL+"/{fileID}/trash", http.MethodPatch, func(r *resty.Request) {
r.SetContext(ctx)
r.SetPathParam("fileID", obj.GetID())
r.SetBody("{}")
}, nil)
return err
} else if obj.GetPath() == ThunderBrowserDriveSafeFileID {
_, err := xc.Request(FILE_API_URL+":batchDelete", http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
}, nil)
return err
}
_, err := xc.Request(FILE_API_URL+":batchTrash", http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
}, nil)
return err
}
func (xc *XunLeiBrowserCommon) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
hi := stream.GetHash()
gcid := hi.GetHash(hash_extend.GCID)
if len(gcid) < hash_extend.GCID.Width {
tFile, err := stream.CacheFullInTempFile()
if err != nil {
return err
}
gcid, err = utils.HashFile(hash_extend.GCID, tFile, stream.GetSize())
if err != nil {
return err
}
}
js := base.Json{
"kind": FILE,
"parent_id": dstDir.GetID(),
"name": stream.GetName(),
"size": stream.GetSize(),
"hash": gcid,
"upload_type": UPLOAD_TYPE_RESUMABLE,
"space": "SPACE_BROWSER",
}
// 对 "迅雷云盘" 内的文件 特殊处理
if dstDir.GetPath() == ThunderDriveFileID {
js = base.Json{
"kind": FILE,
"parent_id": dstDir.GetID(),
"name": stream.GetName(),
"size": stream.GetSize(),
"hash": gcid,
"upload_type": UPLOAD_TYPE_RESUMABLE,
}
} else if dstDir.GetPath() == ThunderBrowserDriveSafeFileID {
// 对 "超级保险箱" 内的文件 特殊处理
js = base.Json{
"kind": FILE,
"parent_id": dstDir.GetID(),
"name": stream.GetName(),
"size": stream.GetSize(),
"hash": gcid,
"upload_type": UPLOAD_TYPE_RESUMABLE,
"space": "SPACE_BROWSER_SAFE",
}
}
var resp UploadTaskResponse
_, err := xc.Request(FILE_API_URL, http.MethodPost, func(r *resty.Request) {
r.SetContext(ctx)
r.SetBody(&js)
}, &resp)
if err != nil {
return err
}
param := resp.Resumable.Params
if resp.UploadType == UPLOAD_TYPE_RESUMABLE {
param.Endpoint = strings.TrimLeft(param.Endpoint, param.Bucket+".")
s, err := session.NewSession(&aws.Config{
Credentials: credentials.NewStaticCredentials(param.AccessKeyID, param.AccessKeySecret, param.SecurityToken),
Region: aws.String("xunlei"),
Endpoint: aws.String(param.Endpoint),
})
if err != nil {
return err
}
uploader := s3manager.NewUploader(s)
if stream.GetSize() > s3manager.MaxUploadParts*s3manager.DefaultUploadPartSize {
uploader.PartSize = stream.GetSize() / (s3manager.MaxUploadParts - 1)
}
_, err = uploader.UploadWithContext(ctx, &s3manager.UploadInput{
Bucket: aws.String(param.Bucket),
Key: aws.String(param.Key),
Expires: aws.Time(param.Expiration),
Body: stream,
})
return err
}
return nil
}
func (xc *XunLeiBrowserCommon) getFiles(ctx context.Context, folderId string, path string) ([]model.Obj, error) {
files := make([]model.Obj, 0)
var pageToken string
for {
var fileList FileList
folderSpace := "SPACE_BROWSER"
params := map[string]string{
"parent_id": folderId,
"page_token": pageToken,
"space": folderSpace,
"filters": `{"trashed":{"eq":false}}`,
"with_audit": "true",
"thumbnail_size": "SIZE_LARGE",
}
var fileType int8
// 处理特殊目录 “迅雷云盘” 设置特殊的 params 以便正常访问
pattern1 := fmt.Sprintf(`^/.*/%s(/.*)?$`, ThunderDriveFolderName)
thunderDriveMatch, _ := regexp.MatchString(pattern1, path)
// 处理特殊目录 “超级保险箱” 设置特殊的 params 以便正常访问
pattern2 := fmt.Sprintf(`^/.*/%s(/.*)?$`, ThunderBrowserDriveSafeFolderName)
thunderBrowserDriveSafeMatch, _ := regexp.MatchString(pattern2, path)
// 如果是 "迅雷云盘" 内的
if folderId == ThunderDriveFileID || thunderDriveMatch {
params = map[string]string{
"space": "",
"__type": "drive",
"refresh": "true",
"__sync": "true",
"parent_id": folderId,
"page_token": pageToken,
"with_audit": "true",
"limit": "100",
"filters": `{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}`,
}
// 如果不是 "迅雷云盘"的"根目录"
if folderId == ThunderDriveFileID {
params["parent_id"] = ""
}
fileType = ThunderDriveType
} else if thunderBrowserDriveSafeMatch {
// 如果是 "超级保险箱" 内的
fileType = ThunderBrowserDriveSafeType
params["space"] = "SPACE_BROWSER_SAFE"
}
_, err := xc.Request(FILE_API_URL, http.MethodGet, func(r *resty.Request) {
r.SetContext(ctx)
r.SetQueryParams(params)
}, &fileList)
if err != nil {
return nil, err
}
// 对文件夹也进行处理
fileList.FolderType = fileType
for i := 0; i < len(fileList.Files); i++ {
file := &fileList.Files[i]
// 标记 文件夹内的文件
file.FileType = fileList.FolderType
// 解决 "迅雷云盘" 重复出现问题————迅雷后端发送错误
if file.Name == ThunderDriveFolderName && file.ID == "" && file.FolderType == ThunderDriveFolderType && folderId != "" {
continue
}
// 处理特殊目录 “迅雷云盘” 设置特殊的文件夹ID
if file.Name == ThunderDriveFolderName && file.ID == "" && file.FolderType == ThunderDriveFolderType {
file.ID = ThunderDriveFileID
} else if file.Name == ThunderBrowserDriveSafeFolderName && file.FolderType == ThunderBrowserDriveSafeFolderType {
file.FileType = ThunderBrowserDriveSafeType
}
files = append(files, file)
}
if fileList.NextPageToken == "" {
break
}
pageToken = fileList.NextPageToken
}
return files, nil
}
// SetRefreshTokenFunc 设置刷新Token的方法
func (xc *XunLeiBrowserCommon) SetRefreshTokenFunc(fn func() error) {
xc.refreshTokenFunc = fn
}
// SetTokenResp 设置Token
func (xc *XunLeiBrowserCommon) SetTokenResp(tr *TokenResp) {
xc.TokenResp = tr
}
// SetSpaceTokenResp 设置Token
func (xc *XunLeiBrowserCommon) SetSpaceTokenResp(spaceToken string) {
xc.TokenResp.Token = spaceToken
}
// Request 携带Authorization和CaptchaToken的请求
func (xc *XunLeiBrowserCommon) Request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
data, err := xc.Common.Request(url, method, func(req *resty.Request) {
req.SetHeaders(map[string]string{
"Authorization": xc.GetToken(),
"X-Captcha-Token": xc.GetCaptchaToken(),
"X-Space-Authorization": xc.GetSpaceToken(),
})
if callback != nil {
callback(req)
}
}, resp)
errResp, ok := err.(*ErrResp)
if !ok {
return nil, err
}
switch errResp.ErrorCode {
case 0:
return data, nil
case 4122, 4121, 10, 16:
if xc.refreshTokenFunc != nil {
if err = xc.refreshTokenFunc(); err == nil {
break
}
}
return nil, err
case 9:
// space_token 获取失败
if errResp.ErrorMsg == "space_token_invalid" {
if token, err := xc.GetSafeAccessToken(xc.Token); err != nil {
return nil, err
} else {
xc.SetSpaceTokenResp(token)
}
}
if errResp.ErrorMsg == "captcha_invalid" {
// 验证码token过期
if err = xc.RefreshCaptchaTokenAtLogin(GetAction(method, url), xc.UserID); err != nil {
return nil, err
}
}
return nil, err
default:
return nil, err
}
return xc.Request(url, method, callback, resp)
}
// RefreshToken 刷新Token
func (xc *XunLeiBrowserCommon) RefreshToken(refreshToken string) (*TokenResp, error) {
var resp TokenResp
_, err := xc.Common.Request(XLUSER_API_URL+"/auth/token", http.MethodPost, func(req *resty.Request) {
req.SetBody(&base.Json{
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": xc.ClientID,
"client_secret": xc.ClientSecret,
})
}, &resp)
if err != nil {
return nil, err
}
if resp.RefreshToken == "" {
return nil, errs.EmptyToken
}
return &resp, nil
}
// GetSafeAccessToken 获取 超级保险柜 AccessToken
func (xc *XunLeiBrowserCommon) GetSafeAccessToken(safePassword string) (string, error) {
var resp TokenResp
_, err := xc.Request(XLUSER_API_URL+"/password/check", http.MethodPost, func(req *resty.Request) {
req.SetBody(&base.Json{
"scene": "box",
"password": EncryptPassword(safePassword),
})
}, &resp)
if err != nil {
return "", err
}
if resp.Token == "" {
return "", errs.EmptyToken
}
return resp.Token, nil
}
// Login 登录
func (xc *XunLeiBrowserCommon) Login(username, password string) (*TokenResp, error) {
url := XLUSER_API_URL + "/auth/signin"
err := xc.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), username)
if err != nil {
return nil, err
}
var resp TokenResp
_, err = xc.Common.Request(url, http.MethodPost, func(req *resty.Request) {
req.SetBody(&SignInRequest{
CaptchaToken: xc.GetCaptchaToken(),
ClientID: xc.ClientID,
ClientSecret: xc.ClientSecret,
Username: username,
Password: password,
})
}, &resp)
if err != nil {
return nil, err
}
return &resp, nil
}
func (xc *XunLeiBrowserCommon) IsLogin() bool {
if xc.TokenResp == nil {
return false
}
_, err := xc.Request(XLUSER_API_URL+"/user/me", http.MethodGet, nil, nil)
return err == nil
}

View File

@ -0,0 +1,108 @@
package thunder_browser
import (
"crypto/md5"
"encoding/hex"
"github.com/alist-org/alist/v3/internal/driver"
"github.com/alist-org/alist/v3/internal/op"
"github.com/alist-org/alist/v3/pkg/utils"
)
// ExpertAddition 高级设置
type ExpertAddition struct {
driver.RootID
LoginType string `json:"login_type" type:"select" options:"user,refresh_token" default:"user"`
SignType string `json:"sign_type" type:"select" options:"algorithms,captcha_sign" default:"algorithms"`
// 登录方式1
Username string `json:"username" required:"true" help:"login type is user,this is required"`
Password string `json:"password" required:"true" help:"login type is user,this is required"`
SafePassword string `json:"safe_password" required:"false" help:"login type is user,this is required"` // 超级保险箱密码
// 登录方式2
RefreshToken string `json:"refresh_token" required:"true" help:"login type is refresh_token,this is required"`
// 签名方法1
Algorithms string `json:"algorithms" required:"true" help:"sign type is algorithms,this is required" default:"x+I5XiTByg,6QU1x5DqGAV3JKg6h,VI1vL1WXr7st0es,n+/3yhlrnKs4ewhLgZhZ5ITpt554,UOip2PE7BLIEov/ZX6VOnsz,Q70h9lpViNCOC8sGVkar9o22LhBTjfP,IVHFuB1JcMlaZHnW,bKE,HZRbwxOiQx+diNopi6Nu,fwyasXgYL3rP314331b,LWxXAiSW4,UlWIjv1HGrC6Ngmt4Nohx,FOa+Lc0bxTDpTwIh2,0+RY,xmRVMqokHHpvsiH0"`
// 签名方法2
CaptchaSign string `json:"captcha_sign" required:"true" help:"sign type is captcha_sign,this is required"`
Timestamp string `json:"timestamp" required:"true" help:"sign type is captcha_sign,this is required"`
// 验证码
CaptchaToken string `json:"captcha_token"`
// 必要且影响登录,由签名决定
DeviceID string `json:"device_id" required:"true" default:"9aa5c268e7bcfc197a9ad88e2fb330e5"`
ClientID string `json:"client_id" required:"true" default:"ZUBzD9J_XPXfn7f7"`
ClientSecret string `json:"client_secret" required:"true" default:"yESVmHecEe6F0aou69vl-g"`
ClientVersion string `json:"client_version" required:"true" default:"1.0.7.1938"`
PackageName string `json:"package_name" required:"true" default:"com.xunlei.browser"`
// 不影响登录,影响下载速度
UserAgent string `json:"user_agent" required:"true" default:"ANDROID-com.xunlei.browser/1.0.7.1938 netWorkType/5G appid/22062 deviceName/Xiaomi_M2004j7ac deviceModel/M2004J7AC OSVersion/12 protocolVersion/301 platformVersion/10 sdkVersion/233100 Oauth2Client/0.9 (Linux 4_14_186-perf-gddfs8vbb238b) (JAVA 0)"`
DownloadUserAgent string `json:"download_user_agent" required:"true" default:"AndroidDownloadManager/12 (Linux; U; Android 12; M2004J7AC Build/SP1A.210812.016)"`
// 优先使用视频链接代替下载链接
UseVideoUrl bool `json:"use_video_url"`
// 移除方式
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
}
// GetIdentity 登录特征,用于判断是否重新登录
func (i *ExpertAddition) GetIdentity() string {
hash := md5.New()
if i.LoginType == "refresh_token" {
hash.Write([]byte(i.RefreshToken))
} else {
hash.Write([]byte(i.Username + i.Password))
}
if i.SignType == "captcha_sign" {
hash.Write([]byte(i.CaptchaSign + i.Timestamp))
} else {
hash.Write([]byte(i.Algorithms))
}
hash.Write([]byte(i.DeviceID))
hash.Write([]byte(i.ClientID))
hash.Write([]byte(i.ClientSecret))
hash.Write([]byte(i.ClientVersion))
hash.Write([]byte(i.PackageName))
return hex.EncodeToString(hash.Sum(nil))
}
type Addition struct {
driver.RootID
Username string `json:"username" required:"true"`
Password string `json:"password" required:"true"`
SafePassword string `json:"safe_password" required:"false"` // 超级保险箱密码
CaptchaToken string `json:"captcha_token"`
UseVideoUrl bool `json:"use_video_url" default:"false"`
RemoveWay string `json:"remove_way" required:"true" type:"select" options:"trash,delete"`
}
// GetIdentity 登录特征,用于判断是否重新登录
func (i *Addition) GetIdentity() string {
return utils.GetMD5EncodeStr(i.Username + i.Password)
}
var config = driver.Config{
Name: "ThunderBrowser",
LocalSort: true,
OnlyProxy: true,
}
var configExpert = driver.Config{
Name: "ThunderBrowserExpert",
LocalSort: true,
}
func init() {
op.RegisterDriver(func() driver.Driver {
return &ThunderBrowser{}
})
op.RegisterDriver(func() driver.Driver {
return &ThunderBrowserExpert{}
})
}

View File

@ -0,0 +1,223 @@
package thunder_browser
import (
"fmt"
"strconv"
"time"
"github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/pkg/utils"
hash_extend "github.com/alist-org/alist/v3/pkg/utils/hash"
)
type ErrResp struct {
ErrorCode int64 `json:"error_code"`
ErrorMsg string `json:"error"`
ErrorDescription string `json:"error_description"`
// ErrorDetails interface{} `json:"error_details"`
}
func (e *ErrResp) IsError() bool {
return e.ErrorCode != 0 || e.ErrorMsg != "" || e.ErrorDescription != ""
}
func (e *ErrResp) Error() string {
return fmt.Sprintf("ErrorCode: %d ,Error: %s ,ErrorDescription: %s ", e.ErrorCode, e.ErrorMsg, e.ErrorDescription)
}
/*
* 验证码Token
**/
type CaptchaTokenRequest struct {
Action string `json:"action"`
CaptchaToken string `json:"captcha_token"`
ClientID string `json:"client_id"`
DeviceID string `json:"device_id"`
Meta map[string]string `json:"meta"`
RedirectUri string `json:"redirect_uri"`
}
type CaptchaTokenResponse struct {
CaptchaToken string `json:"captcha_token"`
ExpiresIn int64 `json:"expires_in"`
Url string `json:"url"`
}
/*
* 登录
**/
type TokenResp struct {
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Sub string `json:"sub"`
UserID string `json:"user_id"`
Token string `json:"token"` // "超级保险箱" 访问Token
}
func (t *TokenResp) GetToken() string {
return fmt.Sprint(t.TokenType, " ", t.AccessToken)
}
// GetSpaceToken 获取"超级保险箱" 访问Token
func (t *TokenResp) GetSpaceToken() string {
return t.Token
}
type SignInRequest struct {
CaptchaToken string `json:"captcha_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Username string `json:"username"`
Password string `json:"password"`
}
/*
* 文件
**/
type FileList struct {
Kind string `json:"kind"`
NextPageToken string `json:"next_page_token"`
Files []Files `json:"files"`
Version string `json:"version"`
VersionOutdated bool `json:"version_outdated"`
FolderType int8
}
type Link struct {
URL string `json:"url"`
Token string `json:"token"`
Expire time.Time `json:"expire"`
Type string `json:"type"`
}
var _ model.Obj = (*Files)(nil)
type Files struct {
Kind string `json:"kind"`
ID string `json:"id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
//UserID string `json:"user_id"`
Size string `json:"size"`
//Revision string `json:"revision"`
//FileExtension string `json:"file_extension"`
//MimeType string `json:"mime_type"`
//Starred bool `json:"starred"`
WebContentLink string `json:"web_content_link"`
CreatedTime CustomTime `json:"created_time"`
ModifiedTime CustomTime `json:"modified_time"`
IconLink string `json:"icon_link"`
ThumbnailLink string `json:"thumbnail_link"`
// Md5Checksum string `json:"md5_checksum"`
Hash string `json:"hash"`
// Links map[string]Link `json:"links"`
// Phase string `json:"phase"`
// Audit struct {
// Status string `json:"status"`
// Message string `json:"message"`
// Title string `json:"title"`
// } `json:"audit"`
Medias []struct {
//Category string `json:"category"`
//IconLink string `json:"icon_link"`
//IsDefault bool `json:"is_default"`
//IsOrigin bool `json:"is_origin"`
//IsVisible bool `json:"is_visible"`
Link Link `json:"link"`
//MediaID string `json:"media_id"`
//MediaName string `json:"media_name"`
//NeedMoreQuota bool `json:"need_more_quota"`
//Priority int `json:"priority"`
//RedirectLink string `json:"redirect_link"`
//ResolutionName string `json:"resolution_name"`
// Video struct {
// AudioCodec string `json:"audio_codec"`
// BitRate int `json:"bit_rate"`
// Duration int `json:"duration"`
// FrameRate int `json:"frame_rate"`
// Height int `json:"height"`
// VideoCodec string `json:"video_codec"`
// VideoType string `json:"video_type"`
// Width int `json:"width"`
// } `json:"video"`
// VipTypes []string `json:"vip_types"`
} `json:"medias"`
Trashed bool `json:"trashed"`
DeleteTime string `json:"delete_time"`
OriginalURL string `json:"original_url"`
//Params struct{} `json:"params"`
//OriginalFileIndex int `json:"original_file_index"`
//Space string `json:"space"`
//Apps []interface{} `json:"apps"`
//Writable bool `json:"writable"`
FolderType string `json:"folder_type"`
//Collection interface{} `json:"collection"`
FileType int8
}
func (c *Files) GetHash() utils.HashInfo {
return utils.NewHashInfo(hash_extend.GCID, c.Hash)
}
func (c *Files) GetSize() int64 { size, _ := strconv.ParseInt(c.Size, 10, 64); return size }
func (c *Files) GetName() string { return c.Name }
func (c *Files) CreateTime() time.Time { return c.CreatedTime.Time }
func (c *Files) ModTime() time.Time { return c.ModifiedTime.Time }
func (c *Files) IsDir() bool { return c.Kind == FOLDER }
func (c *Files) GetID() string { return c.ID }
func (c *Files) GetPath() string {
// 对特殊文件进行特殊处理
if c.FileType == ThunderDriveType {
return ThunderDriveFileID
} else if c.FileType == ThunderBrowserDriveSafeType {
return ThunderBrowserDriveSafeFileID
}
return ""
}
func (c *Files) Thumb() string { return c.ThumbnailLink }
/*
* 上传
**/
type UploadTaskResponse struct {
UploadType string `json:"upload_type"`
/*//UPLOAD_TYPE_FORM
Form struct {
//Headers struct{} `json:"headers"`
Kind string `json:"kind"`
Method string `json:"method"`
MultiParts struct {
OSSAccessKeyID string `json:"OSSAccessKeyId"`
Signature string `json:"Signature"`
Callback string `json:"callback"`
Key string `json:"key"`
Policy string `json:"policy"`
XUserData string `json:"x:user_data"`
} `json:"multi_parts"`
URL string `json:"url"`
} `json:"form"`*/
//UPLOAD_TYPE_RESUMABLE
Resumable struct {
Kind string `json:"kind"`
Params struct {
AccessKeyID string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
Bucket string `json:"bucket"`
Endpoint string `json:"endpoint"`
Expiration time.Time `json:"expiration"`
Key string `json:"key"`
SecurityToken string `json:"security_token"`
} `json:"params"`
Provider string `json:"provider"`
} `json:"resumable"`
File Files `json:"file"`
}

View File

@ -0,0 +1,249 @@
package thunder_browser
import (
"crypto/md5"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"net/http"
"regexp"
"time"
"github.com/alist-org/alist/v3/drivers/base"
"github.com/alist-org/alist/v3/pkg/utils"
"github.com/go-resty/resty/v2"
)
const (
API_URL = "https://x-api-pan.xunlei.com/drive/v1"
FILE_API_URL = API_URL + "/files"
XLUSER_API_URL = "https://xluser-ssl.xunlei.com/v1"
)
const (
FOLDER = "drive#folder"
FILE = "drive#file"
RESUMABLE = "drive#resumable"
)
const (
UPLOAD_TYPE_UNKNOWN = "UPLOAD_TYPE_UNKNOWN"
//UPLOAD_TYPE_FORM = "UPLOAD_TYPE_FORM"
UPLOAD_TYPE_RESUMABLE = "UPLOAD_TYPE_RESUMABLE"
UPLOAD_TYPE_URL = "UPLOAD_TYPE_URL"
)
const (
ThunderDriveFileID = "XXXXXXXXXXXXXXXXXXXXXXXXXX"
ThunderBrowserDriveSafeFileID = "YYYYYYYYYYYYYYYYYYYYYYYYYY"
ThunderDriveFolderName = "迅雷云盘"
ThunderBrowserDriveSafeFolderName = "超级保险箱"
ThunderDriveType = 1
ThunderBrowserDriveSafeType = 2
ThunderDriveFolderType = "DEFAULT_ROOT"
ThunderBrowserDriveSafeFolderType = "BROWSER_SAFE"
)
func GetAction(method string, url string) string {
urlpath := regexp.MustCompile(`://[^/]+((/[^/\s?#]+)*)`).FindStringSubmatch(url)[1]
return method + ":" + urlpath
}
type Common struct {
client *resty.Client
captchaToken string
// 签名相关,二选一
Algorithms []string
Timestamp, CaptchaSign string
// 必要值,签名相关
DeviceID string
ClientID string
ClientSecret string
ClientVersion string
PackageName string
UserAgent string
DownloadUserAgent string
UseVideoUrl bool
RemoveWay string
// 验证码token刷新成功回调
refreshCTokenCk func(token string)
}
func (c *Common) SetCaptchaToken(captchaToken string) {
c.captchaToken = captchaToken
}
func (c *Common) GetCaptchaToken() string {
return c.captchaToken
}
// RefreshCaptchaTokenAtLogin 刷新验证码token(登录后)
func (c *Common) RefreshCaptchaTokenAtLogin(action, userID string) error {
metas := map[string]string{
"client_version": c.ClientVersion,
"package_name": c.PackageName,
"user_id": userID,
}
metas["timestamp"], metas["captcha_sign"] = c.GetCaptchaSign()
return c.refreshCaptchaToken(action, metas)
}
// RefreshCaptchaTokenInLogin 刷新验证码token(登录时)
func (c *Common) RefreshCaptchaTokenInLogin(action, username string) error {
metas := make(map[string]string)
if ok, _ := regexp.MatchString(`\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*`, username); ok {
metas["email"] = username
} else if len(username) >= 11 && len(username) <= 18 {
metas["phone_number"] = username
} else {
metas["username"] = username
}
return c.refreshCaptchaToken(action, metas)
}
// GetCaptchaSign 获取验证码签名
func (c *Common) GetCaptchaSign() (timestamp, sign string) {
if len(c.Algorithms) == 0 {
return c.Timestamp, c.CaptchaSign
}
timestamp = fmt.Sprint(time.Now().UnixMilli())
str := fmt.Sprint(c.ClientID, c.ClientVersion, c.PackageName, c.DeviceID, timestamp)
for _, algorithm := range c.Algorithms {
str = utils.GetMD5EncodeStr(str + algorithm)
}
sign = "1." + str
return
}
// 刷新验证码token
func (c *Common) refreshCaptchaToken(action string, metas map[string]string) error {
param := CaptchaTokenRequest{
Action: action,
CaptchaToken: c.captchaToken,
ClientID: c.ClientID,
DeviceID: c.DeviceID,
Meta: metas,
RedirectUri: "xlaccsdk01://xunlei.com/callback?state=harbor",
}
var e ErrResp
var resp CaptchaTokenResponse
_, err := c.Request(XLUSER_API_URL+"/shield/captcha/init", http.MethodPost, func(req *resty.Request) {
req.SetError(&e).SetBody(param)
}, &resp)
if err != nil {
return err
}
if e.IsError() {
return &e
}
if resp.Url != "" {
return fmt.Errorf(`need verify: <a target="_blank" href="%s">Click Here</a>`, resp.Url)
}
if resp.CaptchaToken == "" {
return fmt.Errorf("empty captchaToken")
}
if c.refreshCTokenCk != nil {
c.refreshCTokenCk(resp.CaptchaToken)
}
c.SetCaptchaToken(resp.CaptchaToken)
return nil
}
// Request 只有基础信息的请求
func (c *Common) Request(url, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
req := c.client.R().SetHeaders(map[string]string{
"user-agent": c.UserAgent,
"accept": "application/json;charset=UTF-8",
"x-device-id": c.DeviceID,
"x-client-id": c.ClientID,
"x-client-version": c.ClientVersion,
})
if callback != nil {
callback(req)
}
if resp != nil {
req.SetResult(resp)
}
res, err := req.Execute(method, url)
if err != nil {
return nil, err
}
var erron ErrResp
utils.Json.Unmarshal(res.Body(), &erron)
if erron.IsError() {
return nil, &erron
}
return res.Body(), nil
}
// 计算文件Gcid
func getGcid(r io.Reader, size int64) (string, error) {
calcBlockSize := func(j int64) int64 {
var psize int64 = 0x40000
for float64(j)/float64(psize) > 0x200 && psize < 0x200000 {
psize = psize << 1
}
return psize
}
hash1 := sha1.New()
hash2 := sha1.New()
readSize := calcBlockSize(size)
for {
hash2.Reset()
if n, err := utils.CopyWithBufferN(hash2, r, readSize); err != nil && n == 0 {
if err != io.EOF {
return "", err
}
break
}
hash1.Write(hash2.Sum(nil))
}
return hex.EncodeToString(hash1.Sum(nil)), nil
}
type CustomTime struct {
time.Time
}
const timeFormat = time.RFC3339
func (ct *CustomTime) UnmarshalJSON(b []byte) error {
str := string(b)
if str == `""` {
*ct = CustomTime{Time: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)}
return nil
}
t, err := time.Parse(`"`+timeFormat+`"`, str)
if err != nil {
return err
}
*ct = CustomTime{Time: t}
return nil
}
// EncryptPassword 超级保险箱 加密
func EncryptPassword(password string) string {
if password == "" {
return ""
}
// 将字符串转换为字节数组
byteData := []byte(password)
// 计算MD5哈希值
hash := md5.Sum(byteData)
// 将哈希值转换为十六进制字符串
return hex.EncodeToString(hash[:])
}

View File

@ -515,6 +515,7 @@ func (xc *XunLeiXCommon) Login(username, password string) (*TokenResp, error) {
if err != nil {
return nil, err
}
resp.UserID = resp.Sub
return &resp, nil
}