diff --git a/drivers/all.go b/drivers/all.go index b9275931..cf0fb8e5 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -25,6 +25,7 @@ import ( _ "github.com/Xhofe/alist/drivers/webdav" _ "github.com/Xhofe/alist/drivers/xunlei" _ "github.com/Xhofe/alist/drivers/yandex" + _ "github.com/Xhofe/alist/drivers/baiduphoto" log "github.com/sirupsen/logrus" "strings" ) diff --git a/drivers/baiduphoto/baidu.go b/drivers/baiduphoto/baidu.go new file mode 100644 index 00000000..287ec433 --- /dev/null +++ b/drivers/baiduphoto/baidu.go @@ -0,0 +1,259 @@ +package baiduphoto + +import ( + "fmt" + "net/http" + + "github.com/Xhofe/alist/drivers/base" + "github.com/Xhofe/alist/model" + "github.com/Xhofe/alist/utils" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +func (driver Baidu) RefreshToken(account *model.Account) error { + err := driver.refreshToken(account) + if err != nil && err == base.ErrEmptyToken { + err = driver.refreshToken(account) + } + if err != nil { + account.Status = err.Error() + } + _ = model.SaveAccount(account) + return err +} + +func (driver Baidu) refreshToken(account *model.Account) error { + u := "https://openapi.baidu.com/oauth/2.0/token" + var resp base.TokenResp + var e TokenErrResp + _, err := base.RestyClient.R(). + SetResult(&resp). + SetError(&e). + SetQueryParams(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": account.RefreshToken, + "client_id": account.ClientId, + "client_secret": account.ClientSecret, + }).Get(u) + if err != nil { + return err + } + if e.ErrorMsg != "" { + return &e + } + if resp.RefreshToken == "" { + return base.ErrEmptyToken + } + account.Status = "work" + account.AccessToken, account.RefreshToken = resp.AccessToken, resp.RefreshToken + return nil +} + +func (driver Baidu) Request(method string, url string, callback func(*resty.Request), account *model.Account) (*resty.Response, error) { + req := base.RestyClient.R() + req.SetQueryParam("access_token", account.AccessToken) + if callback != nil { + callback(req) + } + + res, err := req.Execute(method, url) + if err != nil { + return nil, err + } + log.Debug(res.String()) + + var erron Erron + if err = utils.Json.Unmarshal(res.Body(), &erron); err != nil { + return nil, err + } + + switch erron.Errno { + case 0: + return res, nil + case -6: + if err = driver.RefreshToken(account); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("errno: %d, refer to https://photo.baidu.com/union/doc", erron.Errno) + } + return driver.Request(method, url, callback, account) +} + +// 获取所有根文件 +func (driver Baidu) GetAllFile(account *model.Account) (files []File, err error) { + var cursor string + + for { + var resp FileListResp + _, err = driver.Request(http.MethodGet, FILE_API_URL+"/list", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "need_thumbnail": "1", + "need_filter_hidden": "0", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + + cursor = resp.Cursor + files = append(files, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 获取所有相册 +func (driver Baidu) GetAllAlbum(account *model.Account) (albums []Album, err error) { + var cursor string + for { + var resp AlbumListResp + _, err = driver.Request(http.MethodGet, ALBUM_API_URL+"/list", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "need_amount": "1", + "limit": "100", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + if albums == nil { + albums = make([]Album, 0, resp.TotalCount) + } + + cursor = resp.Cursor + albums = append(albums, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 获取相册中所有文件 +func (driver Baidu) GetAllAlbumFile(albumID string, account *model.Account) (files []AlbumFile, err error) { + var cursor string + for { + var resp AlbumFileListResp + _, err = driver.Request(http.MethodGet, ALBUM_API_URL+"/listfile", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "album_id": splitID(albumID)[0], + "need_amount": "1", + "limit": "1000", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + if files == nil { + files = make([]AlbumFile, 0, resp.TotalCount) + } + + cursor = resp.Cursor + files = append(files, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 创建相册 +func (driver Baidu) CreateAlbum(name string, account *model.Account) error { + if !checkName(name) { + return ErrNotSupportName + } + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/create", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "title": name, + "tid": getTid(), + "source": "0", + }) + }, account) + return err +} + +// 相册改名 +func (driver Baidu) SetAlbumName(albumID string, name string, account *model.Account) error { + if !checkName(name) { + return ErrNotSupportName + } + + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/settitle", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "title": name, + "album_id": e[0], + "tid": e[1], + }) + }, account) + return err +} + +// 删除相册 +func (driver Baidu) DeleteAlbum(albumID string, account *model.Account) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/delete", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": e[0], + "tid": e[1], + "delete_origin_image": "0", // 是否删除原图 0 不删除 + }) + }, account) + return err +} + +// 删除相册文件 +func (driver Baidu) DeleteAlbumFile(albumID string, account *model.Account, fileIDs ...string) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/delfile", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": e[0], + "tid": e[1], + "list": fsidsFormat(fileIDs...), + "del_origin": "0", // 是否删除原图 0 不删除 1 删除 + }) + }, account) + return err +} + +// 增加相册文件 +func (driver Baidu) AddAlbumFile(albumID string, account *model.Account, fileIDs ...string) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodGet, ALBUM_API_URL+"/addfile", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "album_id": e[0], + "tid": e[1], + "list": fsidsFormatNotUk(fileIDs...), + }) + }, account) + return err +} + +// 保存相册文件为根文件 +func (driver Baidu) CopyAlbumFile(albumID string, account *model.Account, fileID string) (*CopyFile, error) { + var resp CopyFileResp + e := splitID(fileID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/copyfile", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": splitID(albumID)[0], + "tid": e[2], + "uk": e[1], + "list": fsidsFormatNotUk(fileID), + }) + r.SetResult(&resp) + }, account) + if err != nil { + return nil, err + } + return &resp.List[0], err +} diff --git a/drivers/baiduphoto/driver.go b/drivers/baiduphoto/driver.go new file mode 100644 index 00000000..a502eefe --- /dev/null +++ b/drivers/baiduphoto/driver.go @@ -0,0 +1,452 @@ +package baiduphoto + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "math" + "net/http" + "os" + "path/filepath" + + "github.com/Xhofe/alist/conf" + "github.com/Xhofe/alist/drivers/base" + "github.com/Xhofe/alist/model" + "github.com/Xhofe/alist/utils" + "github.com/go-resty/resty/v2" +) + +type Baidu struct{} + +func init() { + base.RegisterDriver(new(Baidu)) +} + +func (driver Baidu) Config() base.DriverConfig { + return base.DriverConfig{ + Name: "Baidu.Photo", + LocalSort: true, + } +} + +func (driver Baidu) Items() []base.Item { + return []base.Item{ + { + Name: "refresh_token", + Label: "refresh token", + Type: base.TypeString, + Required: true, + }, + { + Name: "root_folder", + Label: "album_id", + Type: base.TypeString, + }, + { + Name: "client_id", + Label: "client id", + Default: "iYCeC9g08h5vuP9UqvPHKKSVrKFXGa1v", + Type: base.TypeString, + Required: true, + }, + { + Name: "client_secret", + Label: "client secret", + Default: "jXiFMOPVPCWlO2M5CwWQzffpNPaGTRBG", + Type: base.TypeString, + Required: true, + }, + } +} + +func (driver Baidu) Save(account *model.Account, old *model.Account) error { + if account == nil { + return nil + } + return driver.RefreshToken(account) +} + +func (driver Baidu) File(path string, account *model.Account) (*model.File, error) { + path = utils.ParsePath(path) + if path == "/" { + return &model.File{ + Id: account.RootFolder, + Name: account.Name, + Size: 0, + Type: conf.FOLDER, + Driver: driver.Config().Name, + UpdatedAt: account.UpdatedAt, + }, nil + } + + dir, name := filepath.Split(path) + files, err := driver.Files(dir, account) + if err != nil { + return nil, err + } + for _, file := range files { + if file.Name == name { + return &file, nil + } + } + return nil, base.ErrPathNotFound +} + +func (driver Baidu) Files(path string, account *model.Account) ([]model.File, error) { + path = utils.ParsePath(path) + var files []model.File + cache, err := base.GetCache(path, account) + if err == nil { + files, _ = cache.([]model.File) + return files, nil + } + + file, err := driver.File(path, account) + if err != nil { + return nil, err + } + + if IsAlbum(file) { + albumFiles, err := driver.GetAllAlbumFile(file.Id, account) + if err != nil { + return nil, err + } + files = make([]model.File, 0, len(albumFiles)) + for _, file := range albumFiles { + var thumbnail string + if len(file.Thumburl) > 0 { + thumbnail = file.Thumburl[0] + } + files = append(files, model.File{ + Id: joinID(file.Fsid, file.Uk, file.Tid), + Name: file.Name(), + Size: file.Size, + Type: utils.GetFileType(filepath.Ext(file.Path)), + Driver: driver.Config().Name, + UpdatedAt: getTime(file.Mtime), + Thumbnail: thumbnail, + }) + } + } else if IsRoot(file) { + albums, err := driver.GetAllAlbum(account) + if err != nil { + return nil, err + } + + files = make([]model.File, 0, len(albums)) + for _, album := range albums { + files = append(files, model.File{ + Id: joinID(album.AlbumID, album.Tid), + Name: album.Title, + Size: 0, + Type: conf.FOLDER, + Driver: driver.Config().Name, + UpdatedAt: getTime(album.Mtime), + }) + } + } else { + return nil, base.ErrNotSupport + } + + if len(files) > 0 { + _ = base.SetCache(path, files, account) + } + return files, nil +} + +func (driver Baidu) Link(args base.Args, account *model.Account) (*base.Link, error) { + file, err := driver.File(args.Path, account) + if err != nil { + return nil, err + } + if !IsAlbumFile(file) { + return nil, base.ErrNotSupport + } + + album, err := driver.File(filepath.Dir(utils.ParsePath(args.Path)), account) + if err != nil { + return nil, err + } + + e := splitID(file.Id) + res, err := base.NoRedirectClient.R(). + SetQueryParams(map[string]string{ + "access_token": account.AccessToken, + "album_id": splitID(album.Id)[0], + "tid": e[2], + "fsid": e[0], + "uk": e[1], + }). + Head(ALBUM_API_URL + "/download") + if err != nil { + return nil, err + } + return &base.Link{ + Headers: []base.Header{ + {Name: "User-Agent", Value: base.UserAgent}, + }, + Url: res.Header().Get("location"), + }, nil +} + +func (driver Baidu) Path(path string, account *model.Account) (*model.File, []model.File, error) { + path = utils.ParsePath(path) + file, err := driver.File(path, account) + if err != nil { + return nil, nil, err + } + if !file.IsDir() { + return file, nil, nil + } + files, err := driver.Files(path, account) + if err != nil { + return nil, nil, err + } + return nil, files, nil +} + +func (driver Baidu) Preview(path string, account *model.Account) (interface{}, error) { + return nil, base.ErrNotSupport +} + +func (driver Baidu) Rename(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbum(srcFile) { + return driver.SetAlbumName(srcFile.Id, filepath.Base(dst), account) + } + return base.ErrNotSupport +} + +func (driver Baidu) MakeDir(path string, account *model.Account) error { + dir, name := filepath.Split(path) + parentFile, err := driver.File(dir, account) + if err != nil { + return err + } + + if !IsRoot(parentFile) { + return base.ErrNotSupport + } + return driver.CreateAlbum(name, account) +} + +func (driver Baidu) Move(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbumFile(srcFile) { + // 移动相册文件 + dstAlbum, err := driver.File(filepath.Dir(dst), account) + if err != nil { + return err + } + if !IsAlbum(dstAlbum) { + return base.ErrNotSupport + } + + srcAlbum, err := driver.File(filepath.Dir(src), account) + if err != nil { + return err + } + + newFile, err := driver.CopyAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.DeleteAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.AddAlbumFile(dstAlbum.Id, account, joinID(newFile.Fsid)) + if err != nil { + return err + } + return nil + } + return base.ErrNotSupport +} + +func (driver Baidu) Copy(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbumFile(srcFile) { + // 复制相册文件 + dstAlbum, err := driver.File(filepath.Dir(dst), account) + if err != nil { + return err + } + if !IsAlbum(dstAlbum) { + return base.ErrNotSupport + } + + srcAlbum, err := driver.File(filepath.Dir(src), account) + if err != nil { + return err + } + + newFile, err := driver.CopyAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.AddAlbumFile(dstAlbum.Id, account, joinID(newFile.Fsid)) + if err != nil { + return err + } + return nil + } + return base.ErrNotSupport +} + +func (driver Baidu) Delete(path string, account *model.Account) error { + file, err := driver.File(path, account) + if err != nil { + return err + } + + // 删除相册 + if IsAlbum(file) { + return driver.DeleteAlbum(file.Id, account) + } + + // 生成相册文件 + if IsAlbumFile(file) { + // 删除相册文件 + album, err := driver.File(filepath.Dir(path), account) + if err != nil { + return err + } + return driver.DeleteAlbumFile(album.Id, account, file.Id) + } + return base.ErrNotSupport +} + +func (driver Baidu) Upload(file *model.FileStream, account *model.Account) error { + if file == nil { + return base.ErrEmptyFile + } + + parentFile, err := driver.File(file.ParentPath, account) + if err != nil { + return err + } + + if !IsAlbum(parentFile) { + return base.ErrNotSupport + } + + tempFile, err := ioutil.TempFile(conf.Conf.TempDir, "file-*") + if err != nil { + return err + } + defer func() { + tempFile.Close() + os.Remove(tempFile.Name()) + }() + + // 计算需要的数据 + const DEFAULT = 1 << 22 + const SliceSize = 1 << 18 + count := int(math.Ceil(float64(file.Size) / float64(DEFAULT))) + + sliceMD5List := make([]string, 0, count) + fileMd5 := md5.New() + sliceMd5 := md5.New() + for i := 1; i <= count; i++ { + if n, err := io.CopyN(io.MultiWriter(fileMd5, sliceMd5, tempFile), file, DEFAULT); err != io.EOF && n == 0 { + return err + } + sliceMD5List = append(sliceMD5List, hex.EncodeToString(sliceMd5.Sum(nil))) + sliceMd5.Reset() + } + + if _, err = tempFile.Seek(0, io.SeekStart); err != nil { + return err + } + + content_md5 := hex.EncodeToString(fileMd5.Sum(nil)) + slice_md5 := content_md5 + if file.GetSize() > SliceSize { + sliceData := make([]byte, SliceSize) + if _, err = io.ReadFull(tempFile, sliceData); err != nil { + return err + } + sliceMd5.Write(sliceData) + slice_md5 = hex.EncodeToString(sliceMd5.Sum(nil)) + if _, err = tempFile.Seek(0, io.SeekStart); err != nil { + return err + } + } + + // 开始执行上传 + params := map[string]string{ + "autoinit": "1", + "isdir": "0", + "rtype": "1", + "ctype": "11", + "path": utils.ParsePath(file.Name), + "size": fmt.Sprint(file.Size), + "slice-md5": slice_md5, + "content-md5": content_md5, + "block_list": MustString(utils.Json.MarshalToString(sliceMD5List)), + } + + // 预上传 + var precreateResp PrecreateResp + _, err = driver.Request(http.MethodPost, FILE_API_URL+"/precreate", func(r *resty.Request) { + r.SetFormData(params) + r.SetResult(&precreateResp) + }, account) + if err != nil { + return err + } + + switch precreateResp.ReturnType { + case 1: // 上传文件 + uploadParams := map[string]string{ + "method": "upload", + "path": params["path"], + "uploadid": precreateResp.UploadID, + } + + for i := 0; i < count; i++ { + uploadParams["partseq"] = fmt.Sprint(i) + _, err = driver.Request(http.MethodPost, "https://c3.pcs.baidu.com/rest/2.0/pcs/superfile2", func(r *resty.Request) { + r.SetQueryParams(uploadParams) + r.SetFileReader("file", file.Name, io.LimitReader(tempFile, DEFAULT)) + }, account) + if err != nil { + return err + } + } + fallthrough + case 2: // 创建文件 + params["uploadid"] = precreateResp.UploadID + _, err = driver.Request(http.MethodPost, FILE_API_URL+"/create", func(r *resty.Request) { + r.SetFormData(params) + r.SetResult(&precreateResp) + }, account) + if err != nil { + return err + } + fallthrough + case 3: // 增加到相册 + err = driver.AddAlbumFile(parentFile.Id, account, joinID(precreateResp.Data.FsID)) + if err != nil { + return err + } + } + return nil +} + +var _ base.Driver = (*Baidu)(nil) diff --git a/drivers/baiduphoto/types.go b/drivers/baiduphoto/types.go new file mode 100644 index 00000000..9bc954b0 --- /dev/null +++ b/drivers/baiduphoto/types.go @@ -0,0 +1,125 @@ +package baiduphoto + +import ( + "fmt" + "path/filepath" +) + +type TokenErrResp struct { + ErrorDescription string `json:"error_description"` + ErrorMsg string `json:"error"` +} + +func (e *TokenErrResp) Error() string { + return fmt.Sprint(e.ErrorMsg, " : ", e.ErrorDescription) +} + +type Erron struct { + Errno int `json:"errno"` + RequestID int `json:"request_id"` +} + +type Page struct { + HasMore int `json:"has_more"` + Cursor string `json:"cursor"` +} + +func (p Page) HasNextPage() bool { + return p.HasMore == 1 +} + +type ( + FileListResp struct { + Page + List []File `json:"list"` + } + + File struct { + Fsid int64 `json:"fsid"` // 文件ID + Path string `json:"path"` // 文件路径 + Size int64 `json:"size"` + Ctime int64 `json:"ctime"` // 创建时间 s + Mtime int64 `json:"mtime"` // 修改时间 s + Thumburl []string `json:"thumburl"` + } +) + +func (f File) Name() string { + return filepath.Base(f.Path) +} + +/*相册部分*/ +type ( + AlbumListResp struct { + Page + List []Album `json:"list"` + Reset int64 `json:"reset"` + TotalCount int64 `json:"total_count"` + } + + Album struct { + AlbumID string `json:"album_id"` + Tid int64 `json:"tid"` + Title string `json:"title"` + JoinTime int64 `json:"join_time"` + CreateTime int64 `json:"create_time"` + Mtime int64 `json:"mtime"` + } + + AlbumFileListResp struct { + Page + List []AlbumFile `json:"list"` + Reset int64 `json:"reset"` + TotalCount int64 `json:"total_count"` + } + + AlbumFile struct { + File + Tid int64 `json:"tid"` + Uk int64 `json:"uk"` + } +) + +type ( + CopyFileResp struct { + List []CopyFile `json:"list"` + } + CopyFile struct { + FromFsid int64 `json:"from_fsid"` // 源ID + Fsid int64 `json:"fsid"` // 目标ID + Path string `json:"path"` + ShootTime int `json:"shoot_time"` + } +) + +/*上传部分*/ +type ( + UploadFile struct { + FsID int64 `json:"fs_id"` + Size int `json:"size"` + Md5 string `json:"md5"` + ServerFilename string `json:"server_filename"` + Path string `json:"path"` + Ctime int `json:"ctime"` + Mtime int `json:"mtime"` + Isdir int `json:"isdir"` + Category int `json:"category"` + ServerMd5 string `json:"server_md5"` + ShootTime int `json:"shoot_time"` + } + + CreateFileResp struct { + Data UploadFile `json:"data"` + } + + PrecreateResp struct { + ReturnType int `json:"return_type"` //存在返回2 不存在返回1 已经保存3 + //存在返回 + CreateFileResp + + //不存在返回 + Path string `json:"path"` + UploadID string `json:"uploadid"` + Blocklist []int64 `json:"block_list"` + } +) diff --git a/drivers/baiduphoto/util.go b/drivers/baiduphoto/util.go new file mode 100644 index 00000000..5ab40a52 --- /dev/null +++ b/drivers/baiduphoto/util.go @@ -0,0 +1,83 @@ +package baiduphoto + +import ( + "errors" + "fmt" + "math" + "math/rand" + "regexp" + "strings" + "time" + + "github.com/Xhofe/alist/model" +) + +const ( + API_URL = "https://photo.baidu.com/youai" + ALBUM_API_URL = API_URL + "/album/v1" + FILE_API_URL = API_URL + "/file/v1" +) + +var ( + ErrNotSupportName = errors.New("only chinese and english, numbers and underscores are supported, and the length is no more than 20") +) + +//Tid生成 +func getTid() string { + return fmt.Sprintf("3%d%.0f", time.Now().Unix(), math.Floor(9000000*rand.Float64()+1000000)) +} + +// 检查名称 +func checkName(name string) bool { + return len(name) <= 20 && regexp.MustCompile("[\u4e00-\u9fa5A-Za-z0-9_]").MatchString(name) +} + +func getTime(t int64) *time.Time { + tm := time.Unix(t, 0) + return &tm +} + +func fsidsFormat(ids ...string) string { + var buf []string + for _, id := range ids { + e := strings.Split(id, "|") + buf = append(buf, fmt.Sprintf("{\"fsid\":%s,\"uk\":%s}", e[0], e[1])) + } + return fmt.Sprintf("[%s]", strings.Join(buf, ",")) +} + +func fsidsFormatNotUk(ids ...string) string { + var buf []string + for _, id := range ids { + buf = append(buf, fmt.Sprintf("{\"fsid\":%s}", strings.Split(id, "|")[0])) + } + return fmt.Sprintf("[%s]", strings.Join(buf, ",")) +} + +func splitID(id string) []string { + return strings.SplitN(id, "|", 3)[:3] +} + +func joinID(ids ...interface{}) string { + idsStr := make([]string, 0, len(ids)) + for _, id := range ids { + idsStr = append(idsStr, fmt.Sprint(id)) + } + return strings.Join(idsStr, "|") +} + +func IsAlbum(file *model.File) bool { + return file.Id != "" && file.IsDir() +} + +func IsAlbumFile(file *model.File) bool { + return file.Id != "" && !file.IsDir() +} + +func IsRoot(file *model.File) bool { + return file.Id == "" && file.IsDir() +} + +func MustString(str string, err error) string { + return str +} diff --git a/drivers/xunlei/driver.go b/drivers/xunlei/driver.go index 6d39ad22..de192ea3 100644 --- a/drivers/xunlei/driver.go +++ b/drivers/xunlei/driver.go @@ -7,15 +7,14 @@ import ( "os" "path/filepath" "strconv" - - "github.com/aliyun/aliyun-oss-go-sdk/oss" - "github.com/go-resty/resty/v2" + "time" "github.com/Xhofe/alist/conf" "github.com/Xhofe/alist/drivers/base" "github.com/Xhofe/alist/model" "github.com/Xhofe/alist/utils" - log "github.com/sirupsen/logrus" + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/go-resty/resty/v2" ) type XunLeiCloud struct{} @@ -48,10 +47,9 @@ func (driver XunLeiCloud) Items() []base.Item { Description: "account password", }, { - Name: "root_folder", - Label: "root folder file_id", - Type: base.TypeString, - Required: true, + Name: "root_folder", + Label: "root folder file_id", + Type: base.TypeString, }, } } @@ -60,9 +58,9 @@ func (driver XunLeiCloud) Save(account *model.Account, old *model.Account) error if account == nil { return nil } - state := GetState(account) - if state.isTokensExpires() { - return state.Login(account) + client := GetClient(account) + if client.token == "" { + return client.Login(account) } account.Status = "work" model.SaveAccount(account) @@ -101,7 +99,8 @@ func (driver XunLeiCloud) Files(path string, account *model.Account) ([]model.Fi files, _ := cache.([]model.File) return files, nil } - file, err := driver.File(path, account) + + parentFile, err := driver.File(path, account) if err != nil { return nil, err } @@ -109,9 +108,9 @@ func (driver XunLeiCloud) Files(path string, account *model.Account) ([]model.Fi files := make([]model.File, 0) for { var fileList FileList - _, err = GetState(account).Request("GET", FILE_API_URL, func(r *resty.Request) { + _, err = GetClient(account).Request("GET", FILE_API_URL, func(r *resty.Request) { r.SetQueryParams(map[string]string{ - "parent_id": file.Id, + "parent_id": parentFile.Id, "page_token": fileList.NextPageToken, "with_audit": "true", "filters": `{"phase": {"eq": "PHASE_TYPE_COMPLETE"}, "trashed":{"eq":false}}`, @@ -162,8 +161,7 @@ func (driver XunLeiCloud) Link(args base.Args, account *model.Account) (*base.Li return nil, base.ErrNotFile } var lFile Files - _, err = GetState(account).Request("GET", FILE_API_URL+"/{id}", func(r *resty.Request) { - r.SetPathParam("id", file.Id) + _, err = GetClient(account).Request("GET", FILE_API_URL+"/"+file.Id, func(r *resty.Request) { r.SetQueryParam("with_audit", "true") r.SetResult(&lFile) }, account) @@ -180,7 +178,6 @@ func (driver XunLeiCloud) Link(args base.Args, account *model.Account) (*base.Li func (driver XunLeiCloud) Path(path string, account *model.Account) (*model.File, []model.File, error) { path = utils.ParsePath(path) - log.Debugf("xunlei path: %s", path) file, err := driver.File(path, account) if err != nil { return nil, nil, err @@ -199,6 +196,17 @@ func (driver XunLeiCloud) Preview(path string, account *model.Account) (interfac return nil, base.ErrNotSupport } +func (driver XunLeiCloud) Rename(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + _, err = GetClient(account).Request("PATCH", FILE_API_URL+"/"+srcFile.Id, func(r *resty.Request) { + r.SetBody(&base.Json{"name": filepath.Base(dst)}) + }, account) + return err +} + func (driver XunLeiCloud) MakeDir(path string, account *model.Account) error { dir, name := filepath.Split(path) parentFile, err := driver.File(dir, account) @@ -208,7 +216,7 @@ func (driver XunLeiCloud) MakeDir(path string, account *model.Account) error { if !parentFile.IsDir() { return base.ErrNotFolder } - _, err = GetState(account).Request("POST", FILE_API_URL, func(r *resty.Request) { + _, err = GetClient(account).Request("POST", FILE_API_URL, func(r *resty.Request) { r.SetBody(&base.Json{ "kind": FOLDER, "name": name, @@ -229,7 +237,7 @@ func (driver XunLeiCloud) Move(src string, dst string, account *model.Account) e return err } - _, err = GetState(account).Request("POST", FILE_API_URL+":batchMove", func(r *resty.Request) { + _, err = GetClient(account).Request("POST", FILE_API_URL+":batchMove", func(r *resty.Request) { r.SetBody(&base.Json{ "to": base.Json{"parent_id": dstDirFile.Id}, "ids": []string{srcFile.Id}, @@ -248,7 +256,7 @@ func (driver XunLeiCloud) Copy(src string, dst string, account *model.Account) e if err != nil { return err } - _, err = GetState(account).Request("POST", FILE_API_URL+":batchCopy", func(r *resty.Request) { + _, err = GetClient(account).Request("POST", FILE_API_URL+":batchCopy", func(r *resty.Request) { r.SetBody(&base.Json{ "to": base.Json{"parent_id": dstDirFile.Id}, "ids": []string{srcFile.Id}, @@ -262,8 +270,7 @@ func (driver XunLeiCloud) Delete(path string, account *model.Account) error { if err != nil { return err } - _, err = GetState(account).Request("PATCH", FILE_API_URL+"/{id}/trash", func(r *resty.Request) { - r.SetPathParam("id", srcFile.Id) + _, err = GetClient(account).Request("PATCH", FILE_API_URL+"/"+srcFile.Id+"/trash", func(r *resty.Request) { r.SetBody(&base.Json{}) }, account) return err @@ -294,7 +301,7 @@ func (driver XunLeiCloud) Upload(file *model.FileStream, account *model.Account) tempFile.Close() var resp UploadTaskResponse - _, err = GetState(account).Request("POST", FILE_API_URL, func(r *resty.Request) { + _, err = GetClient(account).Request("POST", FILE_API_URL, func(r *resty.Request) { r.SetBody(&base.Json{ "kind": FILE, "parent_id": parentFile.Id, @@ -319,22 +326,13 @@ func (driver XunLeiCloud) Upload(file *model.FileStream, account *model.Account) if err != nil { return err } - return bucket.UploadFile(param.Key, tempFile.Name(), 1<<22, oss.Routines(3), oss.Checkpoint(true, ""), oss.Expires(param.Expiration)) + err = bucket.UploadFile(param.Key, tempFile.Name(), 1<<22, oss.Routines(3), oss.Checkpoint(true, ""), oss.Expires(param.Expiration)) + if err != nil { + return err + } } + time.Sleep(time.Millisecond * 200) return nil } -func (driver XunLeiCloud) Rename(src string, dst string, account *model.Account) error { - _, dstName := filepath.Split(dst) - srcFile, err := driver.File(src, account) - if err != nil { - return err - } - _, err = GetState(account).Request("PATCH", FILE_API_URL+"/{id}", func(r *resty.Request) { - r.SetPathParam("id", srcFile.Id) - r.SetBody(&base.Json{"name": dstName}) - }, account) - return err -} - var _ base.Driver = (*XunLeiCloud)(nil) diff --git a/drivers/xunlei/types.go b/drivers/xunlei/types.go index c73c292c..cd5008f4 100644 --- a/drivers/xunlei/types.go +++ b/drivers/xunlei/types.go @@ -1,16 +1,24 @@ package xunlei import ( + "fmt" "time" ) type Erron struct { - Error string `json:"error"` ErrorCode int64 `json:"error_code"` + ErrorMsg string `json:"error"` ErrorDescription string `json:"error_description"` // ErrorDetails interface{} `json:"error_details"` } +func (e *Erron) 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"` @@ -26,6 +34,9 @@ type CaptchaTokenResponse struct { Url string `json:"url"` } +/* +* 登录 +**/ type TokenResponse struct { TokenType string `json:"token_type"` AccessToken string `json:"access_token"` @@ -36,6 +47,10 @@ type TokenResponse struct { UserID string `json:"user_id"` } +func (t *TokenResponse) Token() string { + return fmt.Sprint(t.TokenType, " ", t.AccessToken) +} + type SignInRequest struct { CaptchaToken string `json:"captcha_token"` @@ -46,6 +61,9 @@ type SignInRequest struct { Password string `json:"password"` } +/* +* 文件 +**/ type FileList struct { Kind string `json:"kind"` NextPageToken string `json:"next_page_token"` @@ -116,6 +134,9 @@ type Files struct { //Collection interface{} `json:"collection"` } +/* +* 上传 +**/ type UploadTaskResponse struct { UploadType string `json:"upload_type"` @@ -152,3 +173,9 @@ type UploadTaskResponse struct { File Files `json:"file"` } + +type Tasks struct { + Tasks []interface{} + NextPageToken string `json:"next_page_token"` + //ExpiresIn int64 `json:"expires_in"` +} diff --git a/drivers/xunlei/util.go b/drivers/xunlei/util.go index 6c5315c9..a2470c3d 100644 --- a/drivers/xunlei/util.go +++ b/drivers/xunlei/util.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "io" + "net" "net/url" "github.com/Xhofe/alist/utils" @@ -57,12 +58,13 @@ const ( UPLOAD_TYPE_URL = "UPLOAD_TYPE_URL" ) +// 验证码签名 func captchaSign(driverID string, time int64) string { str := fmt.Sprint(CLIENT_ID, CLIENT_VERSION, PACKAGE_NAME, driverID, time) for _, algorithm := range Algorithms { - str = utils.GetMD5Encode(fmt.Sprint(str, algorithm)) + str = utils.GetMD5Encode(str + algorithm) } - return fmt.Sprint(ALG_VERSION, ".", str) + return ALG_VERSION + "." + str } func getAction(method string, u string) string { @@ -70,34 +72,27 @@ func getAction(method string, u string) string { return fmt.Sprint(method, ":", c.Path) } +// 计算文件Gcid func getGcid(r io.Reader, size int64) (string, error) { calcBlockSize := func(j int64) int64 { - if j >= 0 && j <= 134217728 { - return 262144 + if j >= 0 && j <= 0x8000000 { + return 0x40000 } - if j <= 134217728 || j > 268435456 { - if j <= 268435456 || j > 536870912 { - return 2097152 + if j <= 0x8000000 || j > 0x10000000 { + if j <= 0x10000000 || j > 0x20000000 { + return 0x200000 } - return 1048576 + return 0x100000 } - return 524288 + return 0x80000 } - /* - calcBlockSize := func(j int64) int64 { - psize := int64(0x40000) - for j/psize > 0x200 { - psize <<= 1 - } - return psize - } - */ hash1 := sha1.New() hash2 := sha1.New() + readSize := calcBlockSize(size) for { hash2.Reset() - if n, err := io.CopyN(hash2, r, calcBlockSize(size)); err != nil && n == 0 { + if n, err := io.CopyN(hash2, r, readSize); err != nil && n == 0 { if err != io.EOF { return "", err } @@ -107,3 +102,13 @@ func getGcid(r io.Reader, size int64) (string, error) { } return hex.EncodeToString(hash1.Sum(nil)), nil } + +// 获取driverID +func getDriverID(username string) string { + interfaces, _ := net.Interfaces() + str := username + for _, inter := range interfaces { + str += inter.HardwareAddr.String() + } + return utils.GetMD5Encode(str) +} diff --git a/drivers/xunlei/xunlei.go b/drivers/xunlei/xunlei.go index 2e9d4c23..692843ea 100644 --- a/drivers/xunlei/xunlei.go +++ b/drivers/xunlei/xunlei.go @@ -13,281 +13,213 @@ import ( log "github.com/sirupsen/logrus" ) -var xunleiClient = resty.New().SetHeaders(map[string]string{"Accept": "application/json;charset=UTF-8"}).SetTimeout(base.DefaultTimeout) +var xunleiClient = resty.New(). + SetHeaders(map[string]string{ + "Accept": "application/json;charset=UTF-8", + }). + SetTimeout(base.DefaultTimeout) -// 一个账户只允许登陆一次 -var userStateCache = struct { +var userClients sync.Map + +func GetClient(account *model.Account) *Client { + if v, ok := userClients.Load(account.Username); ok { + return v.(*Client) + } + + client := &Client{ + Client: xunleiClient, + driverID: getDriverID(account.Username), + } + userClients.Store(account.Username, client) + return client +} + +type Client struct { + *resty.Client sync.Mutex - States map[string]*State -}{States: make(map[string]*State)} -func GetState(account *model.Account) *State { - userStateCache.Lock() - defer userStateCache.Unlock() - if v, ok := userStateCache.States[account.Username]; ok && v != nil { - return v - } - state := new(State).Init() - userStateCache.States[account.Username] = state - return state + driverID string + captchaToken string + + token string + refreshToken string + userID string } -type State struct { - sync.Mutex - captchaToken string - captchaTokenExpiresTime int64 - - tokenType string - accessToken string - refreshToken string - tokenExpiresTime int64 //Milli - - userID string -} - -func (s *State) init() *State { - s.captchaToken = "" - s.captchaTokenExpiresTime = 0 - s.tokenType = "" - s.accessToken = "" - s.refreshToken = "" - s.tokenExpiresTime = 0 - s.userID = "0" - return s -} - -func (s *State) getToken(account *model.Account) (string, error) { - if s.isTokensExpires() { - if err := s.refreshToken_(account); err != nil { - return "", err - } - } - return fmt.Sprint(s.tokenType, " ", s.accessToken), nil -} - -func (s *State) getCaptchaToken(action string, account *model.Account) (string, error) { - if s.isCaptchaTokenExpires() { - return s.newCaptchaToken(action, nil, account) - } - return s.captchaToken, nil -} - -func (s *State) isCaptchaTokenExpires() bool { - return time.Now().UnixMilli() >= s.captchaTokenExpiresTime || s.captchaToken == "" || s.tokenType == "" -} - -func (s *State) isTokensExpires() bool { - return time.Now().UnixMilli() >= s.tokenExpiresTime || s.accessToken == "" -} - -func (s *State) newCaptchaToken(action string, meta map[string]string, account *model.Account) (string, error) { - ctime := time.Now().UnixMilli() - driverID := utils.GetMD5Encode(account.Username) - creq := CaptchaTokenRequest{ +// 请求验证码token +func (c *Client) requestCaptchaToken(action string, meta map[string]string) error { + req := CaptchaTokenRequest{ Action: action, - CaptchaToken: s.captchaToken, + CaptchaToken: c.captchaToken, ClientID: CLIENT_ID, - DeviceID: driverID, - Meta: map[string]string{ - "captcha_sign": captchaSign(driverID, ctime), - "client_version": CLIENT_VERSION, - "package_name": PACKAGE_NAME, - "timestamp": fmt.Sprint(ctime), - "user_id": s.userID, - }, - } - for k, v := range meta { - creq.Meta[k] = v + DeviceID: c.driverID, + Meta: meta, } var e Erron var resp CaptchaTokenResponse _, err := xunleiClient.R(). - SetBody(&creq). + SetBody(&req). SetError(&e). SetResult(&resp). - SetHeader("X-Device-Id", driverID). - SetQueryParam("client_id", CLIENT_ID). Post(XLUSER_API_URL + "/shield/captcha/init") if err != nil { - return "", err + return err } - if e.ErrorCode != 0 { - return "", fmt.Errorf("%s : %s", e.Error, e.ErrorDescription) + if e.ErrorCode != 0 || e.ErrorMsg != "" { + return &e } + if resp.Url != "" { - return "", fmt.Errorf("需要验证验证码") + return fmt.Errorf("need verify:%s", resp.Url) } - s.captchaTokenExpiresTime = (ctime + resp.ExpiresIn*1000) - 30000 - s.captchaToken = resp.CaptchaToken - return s.captchaToken, nil + if resp.CaptchaToken == "" { + return fmt.Errorf("empty captchaToken") + } + c.captchaToken = resp.CaptchaToken + return nil } -func (s *State) refreshToken_(account *model.Account) error { - var e Erron - var resp TokenResponse - _, err := xunleiClient.R(). - SetResult(&resp).SetError(&e). - SetBody(&base.Json{ - "grant_type": "refresh_token", - "refresh_token": s.refreshToken, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - }). - SetHeader("X-Device-Id", utils.GetMD5Encode(account.Username)).SetQueryParam("client_id", CLIENT_ID). - Post(XLUSER_API_URL + "/auth/token") - if err != nil { - return err - } +// 登录 +func (c *Client) Login(account *model.Account) (err error) { + c.Lock() + defer c.Unlock() - switch e.ErrorCode { - case 4122, 4121: - return s.login(account) - case 0: - s.tokenExpiresTime = (time.Now().UnixMilli() + resp.ExpiresIn*1000) - 30000 - s.tokenType = resp.TokenType - s.accessToken = resp.AccessToken - s.refreshToken = resp.RefreshToken - s.userID = resp.UserID - return nil - default: - return fmt.Errorf("%s : %s", e.Error, e.ErrorDescription) - } -} + defer func() { + if err != nil { + account.Status = err.Error() + } else { + account.Status = "work" + } + model.SaveAccount(account) + }() -func (s *State) login(account *model.Account) error { - s.init() - ctime := time.Now().UnixMilli() url := XLUSER_API_URL + "/auth/signin" - captchaToken, err := s.newCaptchaToken(getAction("POST", url), map[string]string{"username": account.Username}, account) + err = c.requestCaptchaToken(getAction(http.MethodPost, url), map[string]string{"username": account.Username}) if err != nil { return err } - signReq := SignInRequest{ - CaptchaToken: captchaToken, - ClientID: CLIENT_ID, - ClientSecret: CLIENT_SECRET, - Username: account.Username, - Password: account.Password, - } - var e Erron var resp TokenResponse _, err = xunleiClient.R(). SetResult(&resp). SetError(&e). - SetBody(&signReq). - SetHeader("X-Device-Id", utils.GetMD5Encode(account.Username)). - SetQueryParam("client_id", CLIENT_ID). + SetBody(&SignInRequest{ + CaptchaToken: c.captchaToken, + ClientID: CLIENT_ID, + ClientSecret: CLIENT_SECRET, + Username: account.Username, + Password: account.Password, + }). Post(url) if err != nil { return err } - defer model.SaveAccount(account) - if e.ErrorCode != 0 { - account.Status = e.Error - return fmt.Errorf("%s : %s", e.Error, e.ErrorDescription) + if e.ErrorCode != 0 || e.ErrorMsg != "" { + return &e } - account.Status = "work" - s.tokenExpiresTime = (ctime + resp.ExpiresIn*1000) - 30000 - s.tokenType = resp.TokenType - s.accessToken = resp.AccessToken - s.refreshToken = resp.RefreshToken - s.userID = resp.UserID + + if resp.RefreshToken == "" { + return base.ErrEmptyToken + } + + c.token = resp.Token() + c.refreshToken = resp.RefreshToken + c.userID = resp.UserID return nil } -func (s *State) Request(method string, url string, callback func(*resty.Request), account *model.Account) (*resty.Response, error) { - s.Lock() - token, err := s.getToken(account) - if err != nil { - return nil, err - } +// 刷新验证码token +func (c *Client) RefreshCaptchaToken(action string) error { + c.Lock() + defer c.Unlock() - captchaToken, err := s.getCaptchaToken(getAction(method, url), account) - if err != nil { - return nil, err - } + ctime := time.Now().UnixMilli() + return c.requestCaptchaToken(action, map[string]string{ + "captcha_sign": captchaSign(c.driverID, ctime), + "client_version": CLIENT_VERSION, + "package_name": PACKAGE_NAME, + "timestamp": fmt.Sprint(ctime), + "user_id": c.userID, + }) +} +// 刷新token +func (c *Client) RefreshToken() error { + c.Lock() + defer c.Unlock() + + var e Erron + var resp TokenResponse + _, err := xunleiClient.R(). + SetError(&e). + SetResult(&resp). + SetBody(&base.Json{ + "grant_type": "refresh_token", + "refresh_token": c.refreshToken, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }). + Post(XLUSER_API_URL + "/auth/token") + if err != nil { + return err + } + if e.ErrorCode != 0 || e.ErrorMsg != "" { + return &e + } + c.token = resp.TokenType + " " + resp.AccessToken + c.refreshToken = resp.RefreshToken + c.userID = resp.UserID + return nil +} + +func (c *Client) Request(method string, url string, callback func(*resty.Request), account *model.Account) (*resty.Response, error) { + c.Lock() req := xunleiClient.R(). SetHeaders(map[string]string{ - "X-Device-Id": utils.GetMD5Encode(account.Username), - "Authorization": token, - "X-Captcha-Token": captchaToken, + "X-Device-Id": c.driverID, + "Authorization": c.token, + "X-Captcha-Token": c.captchaToken, }). SetQueryParam("client_id", CLIENT_ID) - - callback(req) - s.Unlock() - - var res *resty.Response - switch method { - case "GET": - res, err = req.Get(url) - case "POST": - res, err = req.Post(url) - case "DELETE": - res, err = req.Delete(url) - case "PATCH": - res, err = req.Patch(url) - case "PUT": - res, err = req.Put(url) - default: - return nil, base.ErrNotSupport + if callback != nil { + callback(req) } + c.Unlock() + res, err := req.Execute(method, url) if err != nil { return nil, err } log.Debug(res.String()) var e Erron - err = utils.Json.Unmarshal(res.Body(), &e) - if err != nil { + if err = utils.Json.Unmarshal(res.Body(), &e); err != nil { return nil, err } + + // 处理错误 switch e.ErrorCode { - case 9: - _, err = s.newCaptchaToken(getAction(method, url), nil, account) - if err != nil { - return nil, err + case 0: + return res, nil + case 4122, 4121: // token过期 + if err = c.RefreshToken(); err == nil { + break } fallthrough - case 4122, 4121: // Authorization expired - return s.Request(method, url, callback, account) - case 0: - if res.StatusCode() == http.StatusOK { - return res, nil + case 16: // 登录失效 + if err = c.Login(account); err != nil { + return nil, err + } + case 9: // 验证码token过期 + if err = c.RefreshCaptchaToken(getAction(method, url)); err != nil { + return nil, err } - return nil, fmt.Errorf(res.String()) default: - return nil, fmt.Errorf("%s : %s", e.Error, e.ErrorDescription) + return nil, &e } -} - -func (s *State) Init() *State { - s.Lock() - defer s.Unlock() - return s.init() -} - -func (s *State) GetCaptchaToken(action string, account *model.Account) (string, error) { - s.Lock() - defer s.Unlock() - return s.getCaptchaToken(action, account) -} - -func (s *State) GetToken(account *model.Account) (string, error) { - s.Lock() - defer s.Unlock() - return s.getToken(account) -} - -func (s *State) Login(account *model.Account) error { - s.Lock() - defer s.Unlock() - return s.login(account) + return c.Request(method, url, callback, account) }