diff --git a/drivers/all.go b/drivers/all.go index a4fce9d0cac..ef872771961 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -83,6 +83,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/wopan" _ "github.com/alist-org/alist/v3/drivers/wukong" _ "github.com/alist-org/alist/v3/drivers/yandex_disk" + _ "github.com/alist-org/alist/v3/drivers/yunpan360" ) // All do nothing,just for import diff --git a/drivers/yunpan360/driver.go b/drivers/yunpan360/driver.go new file mode 100644 index 00000000000..c92e918a0ad --- /dev/null +++ b/drivers/yunpan360/driver.go @@ -0,0 +1,286 @@ +package yunpan360 + +import ( + "context" + "errors" + stdpath "path" + "strings" + "sync" + "time" + + "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/pkg/utils" +) + +type Yunpan360 struct { + model.Storage + Addition + + authMu sync.Mutex + cachedOpenAuth *OpenAuthInfo + openAuthExpire time.Time + + cachedCookieSession *CookieDownloadSession + cookieSessionExpire time.Time +} + +func (d *Yunpan360) Config() driver.Config { + return config +} + +func (d *Yunpan360) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Yunpan360) Init(ctx context.Context) error { + if d.PageSize <= 0 { + d.PageSize = 100 + } + d.RootFolderPath = utils.FixAndCleanPath(d.RootFolderPath) + if d.RootFolderPath == "" { + d.RootFolderPath = "/" + } + d.OrderDirection = strings.ToLower(strings.TrimSpace(d.OrderDirection)) + if d.OrderDirection != "desc" { + d.OrderDirection = "asc" + } + d.AuthType = strings.ToLower(strings.TrimSpace(d.AuthType)) + if d.AuthType == "" { + d.AuthType = authTypeCookie + } + d.SubChannel = strings.TrimSpace(d.SubChannel) + if d.SubChannel == "" { + d.SubChannel = defaultSubChannel + } + d.EcsEnv = strings.ToLower(strings.TrimSpace(d.EcsEnv)) + if d.EcsEnv == "" { + d.EcsEnv = openEnvProd + } + d.Cookie = strings.TrimSpace(d.Cookie) + d.APIKey = strings.TrimSpace(d.APIKey) + d.OwnerQID = strings.TrimSpace(d.OwnerQID) + d.DownloadToken = strings.TrimSpace(d.DownloadToken) + d.cachedOpenAuth = nil + d.openAuthExpire = time.Time{} + d.cachedCookieSession = nil + d.cookieSessionExpire = time.Time{} + + switch d.authMode() { + case authTypeAPIKey: + if d.APIKey == "" { + return errors.New("api_key is empty") + } + _, err := d.openUserInfo(ctx) + return err + case authTypeCookie: + if d.Cookie == "" { + return errors.New("cookie is empty") + } + // Web download URLs require browser-session headers; force local proxying + // so AList can forward Referer/Origin instead of exposing a bare 302 URL. + d.WebProxy = true + _, err := d.listCookiePage(ctx, d.RootFolderPath, 0, 1) + return err + default: + return errors.New("invalid auth_type") + } +} + +func (d *Yunpan360) Drop(ctx context.Context) error { + d.cachedOpenAuth = nil + d.openAuthExpire = time.Time{} + d.cachedCookieSession = nil + d.cookieSessionExpire = time.Time{} + return nil +} + +func (d *Yunpan360) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + dirPath := dir.GetPath() + if dirPath == "" { + dirPath = d.RootFolderPath + } + + objs := make([]model.Obj, 0, d.PageSize) + for page := 0; ; page++ { + resp, err := d.listPage(ctx, dirPath, page, d.PageSize) + if err != nil { + return nil, err + } + pageObjs := resp.Objects(dirPath) + for _, item := range pageObjs { + objs = append(objs, item) + } + if len(pageObjs) == 0 { + break + } + if d.authMode() == authTypeAPIKey { + if len(pageObjs) < d.PageSize { + break + } + continue + } + if !resp.GetHasNextPage() { + break + } + } + return objs, nil +} + +func (d *Yunpan360) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if d.authMode() == authTypeCookie { + resp, err := d.cookieDownloadURL(ctx, file) + if err != nil { + return nil, err + } + downloadURL := strings.TrimSpace(resp.GetURL()) + if downloadURL == "" { + return nil, errors.New("download url is empty") + } + return &model.Link{ + URL: downloadURL, + Header: map[string][]string{ + "Accept": {"text/javascript, text/html, application/xml, text/xml, */*"}, + "Origin": {baseURL}, + "Referer": {baseURL + indexPath}, + }, + }, nil + } + if d.authMode() != authTypeAPIKey { + return nil, errs.NotImplement + } + + resp, err := d.openDownloadURL(ctx, file) + if err != nil { + return nil, err + } + downloadURL := strings.TrimSpace(resp.GetURL()) + if downloadURL == "" { + return nil, errors.New("download url is empty") + } + return &model.Link{URL: downloadURL}, nil +} + +func (d *Yunpan360) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + if d.authMode() == authTypeCookie { + fullPath := ensureDirAPIPath(stdpath.Join(parentDir.GetPath(), dirName)) + resp, err := d.cookieMakeDir(ctx, fullPath) + if err != nil { + return nil, err + } + return &YunpanObject{ + Object: model.Object{ + ID: resp.Data.NID, + Path: normalizeRemotePath(fullPath), + Name: dirName, + Size: 0, + Modified: time.Now(), + IsFolder: true, + }, + }, nil + } + if d.authMode() != authTypeAPIKey { + return nil, errs.NotImplement + } + + fullPath := ensureDirAPIPath(stdpath.Join(parentDir.GetPath(), dirName)) + resp, err := d.openMakeDir(ctx, fullPath) + if err != nil { + return nil, err + } + obj := &model.Object{ + ID: resp.Data.NID, + Path: normalizeRemotePath(fullPath), + Name: dirName, + Size: 0, + Modified: time.Now(), + IsFolder: true, + } + return obj, nil +} + +func (d *Yunpan360) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + if d.authMode() == authTypeCookie { + srcPath := apiPathForObj(srcObj) + dstPath := ensureDirAPIPath(dstDir.GetPath()) + if err := d.cookieMove(ctx, srcPath, dstPath); err != nil { + return nil, err + } + return cloneObj(srcObj, stdpath.Join(dstDir.GetPath(), srcObj.GetName()), srcObj.GetName()), nil + } + if d.authMode() != authTypeAPIKey { + return nil, errs.NotImplement + } + + srcPath := apiPathForObj(srcObj) + dstPath := ensureDirAPIPath(dstDir.GetPath()) + if err := d.openMove(ctx, srcPath, dstPath); err != nil { + return nil, err + } + return cloneObj(srcObj, stdpath.Join(dstDir.GetPath(), srcObj.GetName()), srcObj.GetName()), nil +} + +func (d *Yunpan360) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + if d.authMode() == authTypeCookie { + targetName := strings.TrimSuffix(strings.TrimSpace(newName), "/") + if targetName == "" { + return nil, errors.New("new name is empty") + } + if err := d.cookieRename(ctx, srcObj, targetName); err != nil { + return nil, err + } + parentPath := stdpath.Dir(srcObj.GetPath()) + if parentPath == "." { + parentPath = "/" + } + return cloneObj(srcObj, stdpath.Join(parentPath, targetName), targetName), nil + } + if d.authMode() != authTypeAPIKey { + return nil, errs.NotImplement + } + + srcPath := apiPathForObj(srcObj) + targetName := newName + if srcObj.IsDir() { + targetName = ensureDirSuffix(newName) + } + if err := d.openRename(ctx, srcPath, targetName); err != nil { + return nil, err + } + + parentPath := stdpath.Dir(srcObj.GetPath()) + if parentPath == "." { + parentPath = "/" + } + return cloneObj(srcObj, stdpath.Join(parentPath, strings.TrimSuffix(newName, "/")), strings.TrimSuffix(newName, "/")), nil +} + +func (d *Yunpan360) Remove(ctx context.Context, obj model.Obj) error { + if d.authMode() == authTypeCookie { + return d.cookieRecycle(ctx, obj) + } + if d.authMode() != authTypeAPIKey { + return errs.NotImplement + } + return d.openDelete(ctx, apiPathForObj(obj)) +} + +func (d *Yunpan360) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + if d.authMode() == authTypeCookie { + return nil, errs.NotImplement + } + if d.authMode() != authTypeAPIKey { + return nil, errs.NotImplement + } + return d.putOpenFile(ctx, dstDir, file, up) +} + +func (d *Yunpan360) authMode() string { + if d.AuthType == authTypeAPIKey { + return authTypeAPIKey + } + return authTypeCookie +} + +var _ driver.Driver = (*Yunpan360)(nil) diff --git a/drivers/yunpan360/meta.go b/drivers/yunpan360/meta.go new file mode 100644 index 00000000000..5c4e1c8d245 --- /dev/null +++ b/drivers/yunpan360/meta.go @@ -0,0 +1,34 @@ +package yunpan360 + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + driver.RootPath + AuthType string `json:"auth_type" type:"select" options:"cookie,api_key" default:"cookie"` + Cookie string `json:"cookie" type:"text" help:"Cookie copied from a logged-in yunpan.com session; used when auth_type=cookie"` + OwnerQID string `json:"owner_qid" type:"text" help:"Optional owner_qid for cookie-mode download; leave empty to auto-detect"` + DownloadToken string `json:"download_token" type:"text" help:"Optional web token for cookie-mode download; leave empty to auto-detect"` + APIKey string `json:"api_key" type:"text" help:"360 AI YunPan API key; used when auth_type=api_key"` + EcsEnv string `json:"ecs_env" type:"select" options:"prod,test,hgtest" default:"prod"` + SubChannel string `json:"sub_channel" default:"open"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` + PageSize int `json:"page_size" type:"number" default:"100" help:"List page size"` +} + +var config = driver.Config{ + Name: "360AIYunPan", + LocalSort: false, + CheckStatus: true, + NoUpload: false, + DefaultRoot: "/", + Alert: "info|api_key mode supports list/link/upload/mkdir/rename/move/delete; cookie mode supports list/link/mkdir/rename/move/delete only, and forces web proxy because direct download URLs require web headers.", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Yunpan360{} + }) +} diff --git a/drivers/yunpan360/types.go b/drivers/yunpan360/types.go new file mode 100644 index 00000000000..f5c1b7a2135 --- /dev/null +++ b/drivers/yunpan360/types.go @@ -0,0 +1,462 @@ +package yunpan360 + +import ( + "strconv" + "strings" + "time" + + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" +) + +const ( + authTypeCookie = "cookie" + authTypeAPIKey = "api_key" + openEnvProd = "prod" + defaultSubChannel = "open" + openSignSecret = "e7b24b112a44fdd9ee93bdf998c6ca0e" + openClientID = "e4757e933b6486c08ed206ecb6d5d9e684fcb4e2" + openClientSecret = "885fd3231f1c1e37c9f462261a09b8c38cde0c2b" + openClientSecretQA = "b11b8fff1c75a5d227c8cc93aaeb0bb70c8eee47" +) + +type BaseResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` +} + +type CookieDownloadSession struct { + OwnerQID string + Token string +} + +type ListResp interface { + Objects(parentPath string) []model.Obj + GetHasNextPage() bool +} + +type CookieListResp struct { + BaseResp + Token string `json:"token"` + OwnerQid string `json:"owner_qid"` + Qid string `json:"qid"` + Data []ListItem `json:"data"` + HasNextPage bool `json:"has_next_page"` +} + +func (r *CookieListResp) Objects(parentPath string) []model.Obj { + ownerQID := r.GetOwnerQID() + return utils.MustSliceConvert(r.Data, func(src ListItem) model.Obj { + return src.toObj(parentPath, ownerQID, r.Token) + }) +} + +func (r *CookieListResp) GetHasNextPage() bool { + return r.HasNextPage +} + +func (r *CookieListResp) GetOwnerQID() string { + return firstNonEmpty(r.OwnerQid, r.Qid) +} + +type ListItem struct { + NID string `json:"nid"` + FileName string `json:"file_name"` + FilePath string `json:"file_path"` + FileSize string `json:"file_size"` + IsDir bool `json:"is_dir"` + Fhash string `json:"fhash"` + CreateTime string `json:"create_time"` + ModifyTime string `json:"modify_time"` + Mtime string `json:"mtime"` + ServerTime string `json:"server_time"` + Preview string `json:"preview"` + Thumb string `json:"thumb"` + SrcPic string `json:"srcpic"` + OwnerQid string `json:"owner_qid"` + Qid string `json:"qid"` + Token string `json:"token"` +} + +func (i ListItem) toObj(parentPath, ownerQID, token string) model.Obj { + objPath := normalizeRemotePath(i.FilePath) + if objPath == "" || !pathLooksLikeObject(objPath, i.FileName) { + objPath = joinRemotePath(parentPath, i.FileName) + } + thumb := "" + if !i.IsDir { + thumb = absoluteURL(firstNonEmpty(i.Thumb, i.SrcPic, i.Preview)) + } + + return &YunpanObject{ + Object: model.Object{ + ID: i.NID, + Path: objPath, + Name: i.FileName, + Size: parseSize(i.FileSize), + Modified: parseYunpanTime(i.ModifyTime, i.Mtime), + Ctime: parseYunpanTime(i.CreateTime, i.ServerTime), + IsFolder: i.IsDir, + HashInfo: parseHash(i.Fhash), + }, + Thumbnail: model.Thumbnail{ + Thumbnail: thumb, + }, + OwnerQID: firstNonEmpty(i.OwnerQid, i.Qid, ownerQID), + DownloadToken: firstNonEmpty(i.Token, token), + } +} + +func parseSize(raw string) int64 { + size, _ := strconv.ParseInt(raw, 10, 64) + return size +} + +func parseHash(raw string) utils.HashInfo { + if len(raw) == 40 { + return utils.NewHashInfo(utils.SHA1, raw) + } + return utils.HashInfo{} +} + +func parseYunpanTime(unixStr, text string) time.Time { + if t := parseUnixTime(unixStr); !t.IsZero() { + return t + } + return parseTextTime(text) +} + +func parseUnixTime(raw string) time.Time { + if raw != "" { + sec, err := strconv.ParseInt(raw, 10, 64) + if err == nil && sec > 0 { + return time.Unix(sec, 0) + } + } + return time.Time{} +} + +func parseTextTime(text string) time.Time { + if text == "" { + return time.Time{} + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", text, utils.CNLoc) + if err == nil { + return t + } + return time.Time{} +} + +func normalizeRemotePath(p string) string { + if p == "" { + return "" + } + if p != "/" { + p = strings.TrimSuffix(p, "/") + } + return utils.FixAndCleanPath(p) +} + +type OpenAuthResp struct { + BaseResp + Data struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + AccessTokenExpire int64 `json:"access_token_expire"` + Qid string `json:"qid"` + } `json:"data"` +} + +type OpenAuthInfo struct { + AccessToken string + Qid string + Token string + SubChannel string +} + +type OpenListResp struct { + BaseResp + Data struct { + NodeList []OpenNode `json:"node_list"` + List []OpenNode `json:"list"` + Data []OpenNode `json:"data"` + TotalCount int `json:"total_count"` + Total int `json:"total"` + PageNum int `json:"page_num"` + } `json:"data"` +} + +func (r *OpenListResp) Objects(parentPath string) []model.Obj { + nodes := r.Data.NodeList + if len(nodes) == 0 { + nodes = r.Data.List + } + if len(nodes) == 0 { + nodes = r.Data.Data + } + return utils.MustSliceConvert(nodes, func(src OpenNode) model.Obj { + return src.toObj(parentPath) + }) +} + +func (r *OpenListResp) GetHasNextPage() bool { + total := r.Data.TotalCount + if total <= 0 { + total = r.Data.Total + } + if total <= 0 { + return false + } + loaded := len(r.Data.NodeList) + if loaded == 0 { + loaded = len(r.Data.List) + } + if loaded == 0 { + loaded = len(r.Data.Data) + } + return loaded > 0 && loaded < total +} + +type OpenNode struct { + NID string `json:"nid"` + Name string `json:"name"` + FName string `json:"fname"` + Path string `json:"path"` + FPath string `json:"fpath"` + Type interface{} `json:"type"` + IsDir interface{} `json:"is_dir"` + CountSize interface{} `json:"count_size"` + Size interface{} `json:"size"` + CreateTime interface{} `json:"create_time"` + ModifyTime interface{} `json:"modify_time"` + MTime interface{} `json:"mtime"` + FileHash string `json:"file_hash"` + Fhash string `json:"fhash"` + Thumb string `json:"thumb"` + Preview string `json:"preview"` + SrcPic string `json:"srcpic"` +} + +func (n OpenNode) toObj(parentPath string) model.Obj { + name := firstNonEmpty(strings.TrimSpace(n.Name), strings.TrimSpace(n.FName)) + objPath := normalizeRemotePath(firstNonEmpty(n.FPath, n.Path)) + if objPath == "" || !pathLooksLikeObject(objPath, name) { + objPath = joinRemotePath(parentPath, name) + } + isDir := parseOpenDir(n.IsDir, n.Type) + thumb := "" + if !isDir { + thumb = absoluteURL(firstNonEmpty(n.Thumb, n.SrcPic, n.Preview)) + } + + return &YunpanObject{ + Object: model.Object{ + ID: n.NID, + Path: objPath, + Name: name, + Size: parseAnySize(n.CountSize, n.Size), + Modified: parseAnyTime(n.ModifyTime, n.MTime), + Ctime: parseAnyTime(n.CreateTime), + IsFolder: isDir, + HashInfo: parseHash(firstNonEmpty(n.FileHash, n.Fhash)), + }, + Thumbnail: model.Thumbnail{ + Thumbnail: thumb, + }, + } +} + +type OpenUserInfoResp struct { + BaseResp + Data map[string]interface{} `json:"data"` +} + +type OpenMkdirResp struct { + BaseResp + Data struct { + NID string `json:"nid"` + } `json:"data"` +} + +type CookieMkdirResp struct { + BaseResp + Data struct { + NID string `json:"nid"` + } `json:"data"` +} + +type CookieMoveResp struct { + BaseResp + Data struct { + TaskID string `json:"task_id"` + IsAsync bool `json:"is_async"` + } `json:"data"` +} + +type CookieRecycleResp struct { + BaseResp + Data struct { + TaskID string `json:"task_id"` + IsAsync bool `json:"is_async"` + } `json:"data"` +} + +type CookieAsyncQueryResp struct { + BaseResp + Data map[string]CookieAsyncTask `json:"data"` +} + +type CookieAsyncTask struct { + MessageID string `json:"message_id"` + SendTime string `json:"send_time"` + Status int `json:"status"` + Action string `json:"action"` + Errno int `json:"errno"` + Errstr string `json:"errstr"` + Result string `json:"result"` +} + +type CookieDownloadResp struct { + BaseResp + Data struct { + DownloadURL string `json:"download_url"` + Store string `json:"store"` + Host string `json:"host"` + } `json:"data"` +} + +func (r *CookieDownloadResp) GetURL() string { + return r.Data.DownloadURL +} + +type OpenDownloadResp struct { + BaseResp + Data struct { + DownloadURL string `json:"downloadUrl"` + } `json:"data"` + DownloadURL string `json:"downloadUrl"` +} + +func (r *OpenDownloadResp) GetURL() string { + return firstNonEmpty(r.Data.DownloadURL, r.DownloadURL) +} + +type YunpanObject struct { + model.Object + model.Thumbnail + OwnerQID string + DownloadToken string +} + +func parseAnySize(values ...interface{}) int64 { + for _, value := range values { + switch v := value.(type) { + case string: + if v == "" { + continue + } + size, err := strconv.ParseInt(v, 10, 64) + if err == nil { + return size + } + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + } + } + return 0 +} + +func parseAnyTime(values ...interface{}) time.Time { + for _, value := range values { + switch v := value.(type) { + case string: + if t := parseUnixTime(v); !t.IsZero() { + return t + } + if t := parseTextTime(v); !t.IsZero() { + return t + } + case float64: + if v > 0 { + return time.Unix(int64(v), 0) + } + case int64: + if v > 0 { + return time.Unix(v, 0) + } + case int: + if v > 0 { + return time.Unix(int64(v), 0) + } + } + } + return time.Time{} +} + +func parseOpenDir(values ...interface{}) bool { + for _, value := range values { + switch v := value.(type) { + case bool: + if v { + return true + } + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "dir", "folder": + return true + } + case float64: + if int64(v) == 1 { + return true + } + case int64: + if v == 1 { + return true + } + case int: + if v == 1 { + return true + } + } + } + return false +} + +func pathLooksLikeObject(objPath, name string) bool { + if objPath == "" || name == "" { + return false + } + return strings.TrimSuffix(stdPathBase(objPath), "/") == strings.TrimSuffix(name, "/") +} + +func stdPathBase(p string) string { + if p == "/" { + return "/" + } + idx := strings.LastIndex(strings.TrimSuffix(p, "/"), "/") + if idx < 0 { + return p + } + return p[idx+1:] +} + +func joinRemotePath(parentPath, name string) string { + parentPath = normalizeRemotePath(parentPath) + if parentPath == "" { + parentPath = "/" + } + return normalizeRemotePath(strings.TrimSuffix(parentPath, "/") + "/" + strings.TrimPrefix(name, "/")) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/drivers/yunpan360/upload.go b/drivers/yunpan360/upload.go new file mode 100644 index 00000000000..38c3fded57b --- /dev/null +++ b/drivers/yunpan360/upload.go @@ -0,0 +1,926 @@ +package yunpan360 + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + stdpath "path" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "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" + aliststream "github.com/alist-org/alist/v3/internal/stream" + "github.com/alist-org/alist/v3/pkg/utils" +) + +const ( + yunpanUploadChunkSize = int64(512 * 1024) + yunpanUploadBoundary = "WebKitFormBoundaryQ5OJVvzZwEkg4ttY" + yunpanUploadVersion = "1.0.1" + yunpanUploadDevType = "ecs_openapi" + yunpanUploadDevName = "EYUN_WEB_UPLOAD" +) + +type openUploadPlan struct { + DirPath string + TargetPath string + FileName string + Size int64 + FileHash string + FileSHA1 string + FileSum string + CreatedAt int64 + DeviceID string + Chunks []openUploadChunk +} + +type openUploadChunk struct { + Index int + Offset int64 + Size int64 + Hash string +} + +type openUploadDetectResp struct { + BaseResp + Data struct { + Exists []openUploadDuplicate `json:"exists"` + IsSlice int `json:"is_slice"` + } `json:"data"` +} + +type openUploadDuplicate struct { + FullName string `json:"fullName"` +} + +type openUploadAddressResp struct { + BaseResp + Data struct { + HTTP string `json:"http"` + Addr1 string `json:"addr_1"` + Addr2 string `json:"addr_2"` + Backup string `json:"backup"` + TK string `json:"tk"` + GroupSize string `json:"group_size"` + AutoCommit interface{} `json:"autoCommit"` + IsHTTPS interface{} `json:"is_https"` + } `json:"data"` +} + +type openUploadRequestResp struct { + BaseResp + Data struct { + Tid string `json:"tid"` + BlockInfo []map[string]interface{} `json:"block_info"` + } `json:"data"` +} + +type openUploadFinalizeResp struct { + BaseResp + Data map[string]interface{} `json:"data"` +} + +type uploadEnvelope struct { + Errno *int `json:"errno"` + Errmsg string `json:"errmsg"` + Data json.RawMessage `json:"data"` +} + +func (d *Yunpan360) putOpenFile(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + auth, err := d.getOpenAuth(ctx) + if err != nil { + return nil, err + } + + dirPath := dstDir.GetPath() + if dirPath == "" { + dirPath = d.RootFolderPath + } + dirPath = ensureDirAPIPath(dirPath) + targetPath := joinRemotePath(dirPath, file.GetName()) + + cached, err := d.cacheUploadSource(ctx, file, progressRange(up, 0, 5)) + if err != nil { + return nil, err + } + defer func() { + _, _ = cached.Seek(0, io.SeekStart) + }() + + plan, err := d.buildUploadPlan(ctx, cached, targetPath, file.GetSize(), file.ModTime(), progressRange(up, 5, 10)) + if err != nil { + return nil, err + } + + detectResp, err := d.openDetectUpload(ctx, auth, plan) + if err != nil { + return nil, err + } + if detectResp.Data.IsSlice == 0 { + detectResp.Data.IsSlice = 1 + } + + addrResp, err := d.openGetUploadAddress(ctx, auth, plan) + if err != nil { + return nil, err + } + + finalResp := &openUploadFinalizeResp{BaseResp: BaseResp{Errno: 0}, Data: map[string]interface{}{}} + if strings.TrimSpace(addrResp.Data.HTTP) == "" { + finalResp.Data = map[string]interface{}{"autoCommit": true} + if tk := strings.TrimSpace(addrResp.Data.TK); tk != "" { + finalResp.Data["tk"] = tk + finalResp.Data["autoCommit"] = false + } + } else { + reqResp, err := d.openRequestUpload(ctx, auth, plan, addrResp) + if err != nil { + return nil, err + } + if err := d.openUploadBlocks(ctx, auth, cached, plan, addrResp, reqResp, up); err != nil { + return nil, err + } + finalResp, err = d.openCommitUpload(ctx, auth, plan, addrResp, reqResp) + if err != nil { + return nil, err + } + } + + if err := d.openFinalizeUpload(ctx, auth, finalResp); err != nil { + return nil, err + } + if up != nil { + up(100) + } + + obj, err := d.findUploadedObject(ctx, targetPath) + if err == nil { + return obj, nil + } + if !errors.Is(err, errs.ObjectNotFound) { + return nil, err + } + + return &model.Object{ + Path: normalizeRemotePath(targetPath), + Name: file.GetName(), + Size: file.GetSize(), + Modified: time.Now(), + Ctime: time.Now(), + HashInfo: utils.NewHashInfo(utils.SHA1, firstNonEmpty(plan.FileSHA1, plan.FileHash)), + }, nil +} + +func (d *Yunpan360) cacheUploadSource(ctx context.Context, file model.FileStreamer, up driver.UpdateProgress) (model.File, error) { + if cached := file.GetFile(); cached != nil { + _, _ = cached.Seek(0, io.SeekStart) + if up != nil { + up(100) + } + return cached, nil + } + if up == nil { + return file.CacheFullInTempFile() + } + return aliststream.CacheFullInTempFileAndUpdateProgress(file, up) +} + +func (d *Yunpan360) buildUploadPlan(ctx context.Context, cached model.File, targetPath string, size int64, modTime time.Time, up driver.UpdateProgress) (*openUploadPlan, error) { + if _, err := cached.Seek(0, io.SeekStart); err != nil { + return nil, err + } + + createdAt := time.Now().Unix() + if !modTime.IsZero() { + createdAt = modTime.Unix() + } + plan := &openUploadPlan{ + DirPath: ensureDirAPIPath(stdpath.Dir(targetPath)), + TargetPath: normalizeRemotePath(targetPath), + FileName: stdpath.Base(targetPath), + Size: size, + CreatedAt: createdAt, + DeviceID: sha1HexString("node-sdk-" + runtime.Version()), + } + + if plan.DirPath == "./" || plan.DirPath == "." { + plan.DirPath = "/" + } + + totalChunks := 0 + if size > 0 { + totalChunks = int((size + yunpanUploadChunkSize - 1) / yunpanUploadChunkSize) + } + chunks := make([]openUploadChunk, 0, totalChunks) + var hashConcat strings.Builder + var hashed int64 + + for idx := 0; idx < totalChunks; idx++ { + if err := ctx.Err(); err != nil { + return nil, err + } + offset := int64(idx) * yunpanUploadChunkSize + chunkSize := yunpanUploadChunkSize + if remain := size - offset; remain < chunkSize { + chunkSize = remain + } + + chunkHash, err := sha1HexReader(io.NewSectionReader(cached, offset, chunkSize)) + if err != nil { + return nil, err + } + + chunks = append(chunks, openUploadChunk{ + Index: idx + 1, + Offset: offset, + Size: chunkSize, + Hash: chunkHash, + }) + hashConcat.WriteString(chunkHash) + hashed += chunkSize + reportByteProgress(up, hashed, size) + } + + plan.Chunks = chunks + plan.FileHash = sha1HexString(hashConcat.String()) + if _, err := cached.Seek(0, io.SeekStart); err != nil { + return nil, err + } + sha1Hasher := sha1.New() + md5Hasher := md5.New() + if _, err := io.Copy(io.MultiWriter(sha1Hasher, md5Hasher), cached); err != nil { + return nil, err + } + plan.FileSHA1 = hex.EncodeToString(sha1Hasher.Sum(nil)) + plan.FileSum = hex.EncodeToString(md5Hasher.Sum(nil)) + if size == 0 && up != nil { + up(100) + } + return plan, nil +} + +func (d *Yunpan360) openDetectUpload(ctx context.Context, auth *OpenAuthInfo, plan *openUploadPlan) (*openUploadDetectResp, error) { + payload, err := json.Marshal([]map[string]interface{}{ + {"fname": plan.FileName, "fsize": plan.Size}, + }) + if err != nil { + return nil, err + } + + signParams := map[string]string{ + "data": string(payload), + "path": plan.DirPath, + } + body, contentType, err := createMultipartForm("", map[string]string{ + "qid": auth.Qid, + "data": string(payload), + "path": plan.DirPath, + "sign": openSign(auth.AccessToken, auth.Qid, "Sync.detectFileExists", signParams), + }, nil) + if err != nil { + return nil, err + } + + var resp openUploadDetectResp + err = d.uploadRequest(ctx, http.MethodPost, buildJSQueryURL(openAPIURL(d.EcsEnv), "Sync.detectFileExists", nil), auth.AccessToken, contentType, body, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openGetUploadAddress(ctx context.Context, auth *OpenAuthInfo, plan *openUploadPlan) (*openUploadAddressResp, error) { + query := d.uploadCookieParams(auth, plan, "") + signParams := map[string]string{ + "access_token": auth.AccessToken, + "fhash": plan.FileHash, + "fname": plan.TargetPath, + "fsize": strconv.FormatInt(plan.Size, 10), + } + query["sign"] = openSign(auth.AccessToken, auth.Qid, "Sync.getUploadFileAddr", signParams) + + var resp openUploadAddressResp + err := d.uploadRequest(ctx, http.MethodGet, buildJSQueryURL(openAPIURL(d.EcsEnv), "Sync.getUploadFileAddr", query), auth.AccessToken, "", nil, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openRequestUpload(ctx context.Context, auth *OpenAuthInfo, plan *openUploadPlan, addrResp *openUploadAddressResp) (*openUploadRequestResp, error) { + chunkInfos := make([]map[string]interface{}, 0, len(plan.Chunks)) + for _, chunk := range plan.Chunks { + chunkInfos = append(chunkInfos, map[string]interface{}{ + "bhash": chunk.Hash, + "bidx": chunk.Index, + "boffset": chunk.Offset, + "bsize": chunk.Size, + }) + } + payload, err := json.Marshal(map[string]interface{}{ + "request": map[string]interface{}{ + "block_info": chunkInfos, + }, + }) + if err != nil { + return nil, err + } + + body, contentType, err := createMultipartForm( + yunpanUploadBoundary, + d.uploadCookieParams(auth, plan, strings.TrimSpace(addrResp.Data.TK)), + &multipartFile{ + FieldName: "file", + FileName: "file.dat", + ContentType: "application/octet-stream", + Content: payload, + }, + ) + if err != nil { + return nil, err + } + + url := buildJSQueryURL(d.uploadBaseURL(addrResp), "Upload.request4Web", d.uploadDataParams(auth, plan)) + url = appendHostQuery(url, addrResp.Data.HTTP) + + var resp openUploadRequestResp + err = d.uploadRequest(ctx, http.MethodPost, url, auth.AccessToken, contentType, body, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openUploadBlocks(ctx context.Context, auth *OpenAuthInfo, cached model.File, plan *openUploadPlan, addrResp *openUploadAddressResp, reqResp *openUploadRequestResp, up driver.UpdateProgress) error { + url := buildJSQueryURL(d.uploadBaseURL(addrResp), "Upload.block4Web", d.uploadDataParams(auth, plan)) + url = appendHostQuery(url, addrResp.Data.HTTP) + + var uploaded int64 + for _, chunk := range plan.Chunks { + info := reqResp.blockInfoForChunk(chunk.Index) + if info.found() > 0 { + uploaded += chunk.Size + reportUploadProgress(up, uploaded, plan.Size) + continue + } + + chunkBytes := make([]byte, chunk.Size) + if _, err := cached.ReadAt(chunkBytes, chunk.Offset); err != nil && !errors.Is(err, io.EOF) { + return err + } + + fields := map[string]string{ + "bhash": chunk.Hash, + "bidx": strconv.Itoa(chunk.Index), + "boffset": strconv.FormatInt(chunk.Offset, 10), + "bsize": strconv.FormatInt(chunk.Size, 10), + "filename": plan.TargetPath, + "filesize": strconv.FormatInt(plan.Size, 10), + "q": info.stringValue("q"), + "t": info.stringValue("t"), + "token": auth.Token, + "tid": info.stringValue("tid"), + } + for key, value := range info.extraFields() { + fields[key] = value + } + + body, contentType, err := createMultipartForm( + yunpanUploadBoundary, + fields, + &multipartFile{ + FieldName: "file", + FileName: "file.dat", + ContentType: "application/octet-stream", + Content: chunkBytes, + }, + ) + if err != nil { + return err + } + + chunkStart := uploaded + chunkSize := chunk.Size + err = d.uploadRequestWithProgress(ctx, http.MethodPost, url, auth.AccessToken, contentType, body, func(p float64) { + done := chunkStart + int64(float64(chunkSize)*(p/100.0)) + reportUploadProgress(up, done, plan.Size) + }, nil) + if err != nil { + return err + } + + uploaded += chunk.Size + reportUploadProgress(up, uploaded, plan.Size) + } + return nil +} + +func (d *Yunpan360) openCommitUpload(ctx context.Context, auth *OpenAuthInfo, plan *openUploadPlan, addrResp *openUploadAddressResp, reqResp *openUploadRequestResp) (*openUploadFinalizeResp, error) { + body, contentType, err := createMultipartForm("", map[string]string{ + "q": "", + "t": "", + "token": auth.Token, + "tid": strings.TrimSpace(reqResp.Data.Tid), + }, nil) + if err != nil { + return nil, err + } + + url := buildJSQueryURL(d.uploadBaseURL(addrResp), "Upload.commit4Web", d.uploadDataParams(auth, plan)) + url = appendHostQuery(url, addrResp.Data.HTTP) + + var resp openUploadFinalizeResp + err = d.uploadRequest(ctx, http.MethodPost, url, auth.AccessToken, contentType, body, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openFinalizeUpload(ctx context.Context, auth *OpenAuthInfo, resp *openUploadFinalizeResp) error { + if resp == nil || resp.autoCommit() { + return nil + } + tk := strings.TrimSpace(resp.stringValue("tk")) + if tk == "" { + return errors.New("upload tk is empty") + } + + signParams := map[string]string{ + "tk": tk, + } + body, contentType, err := createMultipartForm("", map[string]string{ + "qid": auth.Qid, + "tk": tk, + "sign": openSign(auth.AccessToken, auth.Qid, "Sync.addFileToApi", signParams), + }, nil) + if err != nil { + return err + } + + return d.uploadRequest(ctx, http.MethodPost, buildJSQueryURL(openAPIURL(d.EcsEnv), "Sync.addFileToApi", nil), auth.AccessToken, contentType, body, nil) +} + +func (d *Yunpan360) findUploadedObject(ctx context.Context, targetPath string) (model.Obj, error) { + targetPath = normalizeRemotePath(targetPath) + parentPath := normalizeRemotePath(stdpath.Dir(targetPath)) + if parentPath == "." || parentPath == "" { + parentPath = "/" + } + targetName := stdpath.Base(targetPath) + + for page := 0; ; page++ { + resp, err := d.listPage(ctx, parentPath, page, d.PageSize) + if err != nil { + return nil, err + } + pageObjs := resp.Objects(parentPath) + for _, obj := range pageObjs { + if normalizeRemotePath(obj.GetPath()) == targetPath || obj.GetName() == targetName { + return obj, nil + } + } + if len(pageObjs) == 0 || len(pageObjs) < d.PageSize { + break + } + } + return nil, errs.ObjectNotFound +} + +func (d *Yunpan360) uploadCookieParams(auth *OpenAuthInfo, plan *openUploadPlan, uploadTK string) map[string]string { + params := map[string]string{ + "owner_qid": auth.Qid, + "fname": plan.TargetPath, + "fsize": strconv.FormatInt(plan.Size, 10), + "fctime": strconv.FormatInt(plan.CreatedAt, 10), + "fmtime": strconv.FormatInt(plan.CreatedAt, 10), + "fhash": plan.FileHash, + "qid": auth.Qid, + "fattr": "0", + "token": auth.Token, + "devtype": yunpanUploadDevType, + } + if uploadTK != "" { + params["tk"] = uploadTK + } + return params +} + +func (d *Yunpan360) uploadDataParams(auth *OpenAuthInfo, plan *openUploadPlan) map[string]string { + return map[string]string{ + "owner_qid": auth.Qid, + "qid": auth.Qid, + "devtype": yunpanUploadDevType, + "devid": plan.DeviceID, + "v": yunpanUploadVersion, + "ofmt": "json", + "devname": yunpanUploadDevName, + "rtick": strconv.FormatInt(time.Now().UnixMilli(), 10), + } +} + +func (d *Yunpan360) uploadBaseURL(addrResp *openUploadAddressResp) string { + host := "" + isHTTPS := false + if addrResp != nil { + host = strings.TrimSpace(addrResp.Data.HTTP) + isHTTPS = parseOpenDir(addrResp.Data.IsHTTPS) + } + scheme := "http" + if isHTTPS { + scheme = "https" + } + if host == "" { + return openAPIURL(d.EcsEnv) + } + return fmt.Sprintf("%s://%s/intf.php", scheme, host) +} + +func (d *Yunpan360) uploadRequest(ctx context.Context, method, reqURL, accessToken, contentType string, body []byte, out interface{}) error { + return d.uploadRequestWithProgress(ctx, method, reqURL, accessToken, contentType, body, nil, out) +} + +func (d *Yunpan360) uploadRequestWithProgress(ctx context.Context, method, reqURL, accessToken, contentType string, body []byte, progress driver.UpdateProgress, out interface{}) error { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + if err := sleepWithContext(ctx, time.Duration(attempt)*500*time.Millisecond); err != nil { + return err + } + } + err := d.doUploadRequest(ctx, method, reqURL, accessToken, contentType, body, progress, out) + if err == nil { + return nil + } + lastErr = err + if ctx.Err() != nil { + return ctx.Err() + } + } + return lastErr +} + +func (d *Yunpan360) doUploadRequest(ctx context.Context, method, reqURL, accessToken, contentType string, body []byte, progress driver.UpdateProgress, out interface{}) error { + var bodyReader io.ReadCloser + if body != nil { + reader := &driver.SimpleReaderWithSize{ + Reader: bytes.NewReader(body), + Size: int64(len(body)), + } + if progress != nil { + bodyReader = driver.NewLimitedUploadStream(ctx, &driver.ReaderUpdatingProgress{ + Reader: reader, + UpdateProgress: progress, + }) + } else { + bodyReader = driver.NewLimitedUploadStream(ctx, reader) + } + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) + if err != nil { + if bodyReader != nil { + _ = bodyReader.Close() + } + return err + } + if body != nil { + req.ContentLength = int64(len(body)) + } + req.Header.Set("Accept", "application/json") + if accessToken != "" { + req.Header.Set("Access-Token", accessToken) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + resp, err := base.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("yunpan upload request failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + return decodeUploadResp(respBody, out) +} + +func decodeUploadResp(body []byte, out interface{}) error { + var env uploadEnvelope + if err := utils.Json.Unmarshal(body, &env); err != nil { + return err + } + if env.Errno != nil && *env.Errno != 0 { + if env.Errmsg == "" { + return fmt.Errorf("yunpan upload request failed: errno=%d", *env.Errno) + } + return errors.New(env.Errmsg) + } + if env.Errno == nil && strings.TrimSpace(env.Errmsg) != "" && len(env.Data) > 0 && string(env.Data) == "[]" { + return errors.New(env.Errmsg) + } + if out == nil { + return nil + } + if err := utils.Json.Unmarshal(body, out); err != nil { + if strings.TrimSpace(env.Errmsg) != "" { + return errors.New(env.Errmsg) + } + return err + } + return nil +} + +type multipartFile struct { + FieldName string + FileName string + ContentType string + Content []byte +} + +func createMultipartForm(boundary string, fields map[string]string, file *multipartFile) ([]byte, string, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if boundary != "" { + if err := writer.SetBoundary(boundary); err != nil { + return nil, "", err + } + } + + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + return nil, "", err + } + } + + if file != nil { + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, file.FieldName, file.FileName)) + partHeader.Set("Content-Type", firstNonEmpty(file.ContentType, "application/octet-stream")) + part, err := writer.CreatePart(partHeader) + if err != nil { + return nil, "", err + } + if _, err := part.Write(file.Content); err != nil { + return nil, "", err + } + } + + if err := writer.Close(); err != nil { + return nil, "", err + } + return body.Bytes(), writer.FormDataContentType(), nil +} + +func buildJSQueryURL(baseURL, method string, params map[string]string) string { + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + + var builder strings.Builder + builder.WriteString(baseURL) + if strings.Contains(baseURL, "?") { + builder.WriteByte('&') + } else { + builder.WriteByte('?') + } + builder.WriteString("method=") + builder.WriteString(jsQueryEscape(method)) + for _, key := range keys { + value := params[key] + if value == "" { + continue + } + builder.WriteByte('&') + builder.WriteString(key) + builder.WriteByte('=') + builder.WriteString(jsQueryEscape(value)) + } + return builder.String() +} + +func buildQueryURL(baseURL string, params map[string]string) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL + } + u.RawQuery = encodeSortedQuery(params) + return u.String() +} + +func encodeSortedQuery(params map[string]string) string { + if len(params) == 0 { + return "" + } + q := make(url.Values, len(params)) + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + q.Set(key, params[key]) + } + return q.Encode() +} + +func appendHostQuery(rawURL, host string) string { + host = strings.TrimSpace(host) + if host == "" { + return rawURL + } + return rawURL + "&host=" + jsQueryEscape(host) +} + +func jsQueryEscape(raw string) string { + replacer := strings.NewReplacer( + "+", "%20", + "%21", "!", + "%27", "'", + "%28", "(", + "%29", ")", + "%2A", "*", + "%7E", "~", + ) + return replacer.Replace(url.QueryEscape(raw)) +} + +func sha1HexReader(r io.Reader) (string, error) { + h := sha1.New() + if _, err := io.Copy(h, r); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func sha1HexString(raw string) string { + sum := sha1.Sum([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func progressRange(up driver.UpdateProgress, start, end float64) driver.UpdateProgress { + if up == nil { + return nil + } + return model.UpdateProgressWithRange(up, start, end) +} + +func reportByteProgress(up driver.UpdateProgress, done, total int64) { + if up == nil { + return + } + if total <= 0 { + up(100) + return + } + up(float64(done) / float64(total) * 100) +} + +func reportUploadProgress(up driver.UpdateProgress, done, total int64) { + if up == nil { + return + } + if total <= 0 { + up(100) + return + } + if done > total { + done = total + } + up(10 + float64(done)/float64(total)*90) +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +type blockInfoMap map[string]interface{} + +func (r *openUploadRequestResp) blockInfoForChunk(index int) blockInfoMap { + if index <= 0 || index > len(r.Data.BlockInfo) { + return blockInfoMap{} + } + return blockInfoMap(r.Data.BlockInfo[index-1]) +} + +func (m blockInfoMap) stringValue(key string) string { + value, ok := m[key] + if !ok || value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case float64: + return strconv.FormatInt(int64(v), 10) + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case bool: + if v { + return "1" + } + return "0" + default: + return fmt.Sprint(v) + } +} + +func (m blockInfoMap) found() int64 { + raw := strings.TrimSpace(m.stringValue("found")) + if raw == "" { + return 0 + } + value, _ := strconv.ParseInt(raw, 10, 64) + return value +} + +func (m blockInfoMap) extraFields() map[string]string { + extras := make(map[string]string) + for key := range m { + switch key { + case "bhash", "bidx", "boffset", "bsize", "filename", "filesize", "q", "t", "token", "tid", "found", "url": + continue + } + value := strings.TrimSpace(m.stringValue(key)) + if value != "" { + extras[key] = value + } + } + return extras +} + +func (r *openUploadFinalizeResp) autoCommit() bool { + raw, ok := r.Data["autoCommit"] + if !ok { + return false + } + switch v := raw.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") || v == "1" + case float64: + return int64(v) == 1 + case int: + return v == 1 + case int64: + return v == 1 + default: + return false + } +} + +func (r *openUploadFinalizeResp) stringValue(key string) string { + if r == nil || r.Data == nil { + return "" + } + value, ok := r.Data[key] + if !ok || value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case float64: + return strconv.FormatInt(int64(v), 10) + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + default: + return fmt.Sprint(v) + } +} diff --git a/drivers/yunpan360/util.go b/drivers/yunpan360/util.go new file mode 100644 index 00000000000..2718f9c99cc --- /dev/null +++ b/drivers/yunpan360/util.go @@ -0,0 +1,909 @@ +package yunpan360 + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "html" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" +) + +const ( + baseURL = "https://www.yunpan.com" + indexPath = "/file/index" + listPath = "/file/list" + downloadPath = "/file/download" + openAPIProdURL = "https://openapi.eyun.360.cn/intf.php" + openAPITestURL = "https://qaopen.eyun.360.cn/intf.php" + openAPIHGTestURL = "https://hg-openapi.eyun.360.cn/intf.php" +) + +func (d *Yunpan360) listPage(ctx context.Context, dirPath string, page, pageSize int) (ListResp, error) { + if d.authMode() == authTypeAPIKey { + return d.listOpenPage(ctx, dirPath, page, pageSize) + } + return d.listCookiePage(ctx, dirPath, page, pageSize) +} + +func (d *Yunpan360) listCookiePage(ctx context.Context, dirPath string, page, pageSize int) (*CookieListResp, error) { + var resp CookieListResp + err := d.cookieRequestForm(ctx, listPath, map[string]string{ + "path": requestPath(dirPath), + "page": strconv.Itoa(page), + "page_size": strconv.Itoa(pageSize), + "order": requestOrder(d.OrderDirection), + "field": "file_name", + "focus_nid": "0", + }, &resp) + if err != nil { + return nil, err + } + d.cacheCookieDownloadSession(resp.GetOwnerQID(), resp.Token) + return &resp, nil +} + +func (d *Yunpan360) cookieRequestForm(ctx context.Context, apiPath string, form map[string]string, out interface{}) error { + req := base.RestyClient.R(). + SetContext(ctx). + SetHeaders(map[string]string{ + "Accept": "text/javascript, text/html, application/xml, text/xml, */*", + "Content-Type": "application/x-www-form-urlencoded", + "Cookie": d.Cookie, + "Origin": baseURL, + "Referer": baseURL + "/file/index", + "X-Requested-With": "XMLHttpRequest", + }). + SetFormData(form) + + res, err := req.Execute(http.MethodPost, baseURL+apiPath) + if err != nil { + return err + } + + var baseResp BaseResp + if err := utils.Json.Unmarshal(res.Body(), &baseResp); err != nil { + return err + } + if baseResp.Errno != 0 { + if baseResp.Errmsg == "" { + return fmt.Errorf("yunpan request failed: errno=%d", baseResp.Errno) + } + return errors.New(baseResp.Errmsg) + } + if out == nil { + return nil + } + return utils.Json.Unmarshal(res.Body(), out) +} + +func requestPath(dirPath string) string { + path := normalizeRemotePath(dirPath) + if path == "" { + return "/" + } + return path +} + +func requestOrder(order string) string { + if strings.EqualFold(order, "desc") { + return "desc" + } + return "asc" +} + +func (d *Yunpan360) cookiePage(ctx context.Context, pagePath string) ([]byte, error) { + req := base.RestyClient.R(). + SetContext(ctx). + SetHeaders(map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Cookie": d.Cookie, + "Referer": baseURL + indexPath, + }) + res, err := req.Get(baseURL + pagePath) + if err != nil { + return nil, err + } + return res.Body(), nil +} + +func openAPIURL(env string) string { + switch env { + case "test": + return openAPITestURL + case "hgtest": + return openAPIHGTestURL + default: + return openAPIProdURL + } +} + +func openClientSecretForEnv(env string) string { + if env == "test" { + return openClientSecretQA + } + return openClientSecret +} + +func phpQueryEscape(raw string) string { + escaped := url.QueryEscape(raw) + return strings.ReplaceAll(escaped, "~", "%7E") +} + +func openSign(accessToken, qid, method string, extra map[string]string) string { + params := map[string]string{ + "access_token": accessToken, + "method": method, + "qid": qid, + } + for key, value := range extra { + if value != "" { + params[key] = value + } + } + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sortStrings(keys) + pairs := make([]string, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, key+"="+phpQueryEscape(params[key])) + } + sum := md5.Sum([]byte(strings.Join(pairs, "&") + openSignSecret)) + return hex.EncodeToString(sum[:]) +} + +func sortStrings(values []string) { + for i := 0; i < len(values); i++ { + for j := i + 1; j < len(values); j++ { + if values[j] < values[i] { + values[i], values[j] = values[j], values[i] + } + } + } +} + +func (d *Yunpan360) getOpenAuth(ctx context.Context) (*OpenAuthInfo, error) { + d.authMu.Lock() + defer d.authMu.Unlock() + + if d.cachedOpenAuth != nil && time.Now().Before(d.openAuthExpire) { + auth := *d.cachedOpenAuth + return &auth, nil + } + + reqURL := openAPIURL(d.EcsEnv) + req := base.RestyClient.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetHeader("api_key", d.APIKey). + SetQueryParams(map[string]string{ + "method": "Oauth.getAccessTokenByApiKey", + "client_id": openClientID, + "client_secret": openClientSecretForEnv(d.EcsEnv), + "grant_type": "authorization_code", + "sub_channel": d.SubChannel, + "api_key": d.APIKey, + }) + + res, err := req.Get(reqURL) + if err != nil { + return nil, err + } + + var resp OpenAuthResp + if err := utils.Json.Unmarshal(res.Body(), &resp); err != nil { + return nil, err + } + if resp.Errno != 0 { + if resp.Errmsg == "" { + return nil, fmt.Errorf("yunpan auth failed: errno=%d", resp.Errno) + } + return nil, errors.New(resp.Errmsg) + } + + auth := &OpenAuthInfo{ + AccessToken: resp.Data.AccessToken, + Qid: resp.Data.Qid, + Token: resp.Data.Token, + SubChannel: d.SubChannel, + } + d.cachedOpenAuth = auth + d.openAuthExpire = time.Now().Add(50 * time.Minute) + + copied := *auth + return &copied, nil +} + +func (d *Yunpan360) openBaseParams(auth *OpenAuthInfo, method string, signParams map[string]string, withSign bool) map[string]string { + params := map[string]string{ + "method": method, + "access_token": auth.AccessToken, + "qid": auth.Qid, + "sub_channel": auth.SubChannel, + } + if withSign { + params["sign"] = openSign(auth.AccessToken, auth.Qid, method, signParams) + } else { + params["sign"] = "" + } + return params +} + +func (d *Yunpan360) openGET(ctx context.Context, method string, signParams map[string]string, query map[string]string, out interface{}, withSign bool) error { + auth, err := d.getOpenAuth(ctx) + if err != nil { + return err + } + params := d.openBaseParams(auth, method, signParams, withSign) + for key, value := range query { + params[key] = value + } + + req := base.RestyClient.R(). + SetContext(ctx). + SetHeader("Access-Token", auth.AccessToken). + SetQueryParams(params) + res, err := req.Get(openAPIURL(d.EcsEnv)) + if err != nil { + return err + } + return decodeBaseResp(res.Body(), out) +} + +func (d *Yunpan360) openPOST(ctx context.Context, method string, signParams map[string]string, query, body map[string]string, out interface{}, withSign bool) error { + auth, err := d.getOpenAuth(ctx) + if err != nil { + return err + } + queryParams := map[string]string{} + for key, value := range query { + queryParams[key] = value + } + bodyParams := map[string]string{} + for key, value := range body { + bodyParams[key] = value + } + + baseParams := d.openBaseParams(auth, method, signParams, withSign) + if len(queryParams) == 0 { + bodyParams = mergeStringMaps(baseParams, bodyParams) + } else { + queryParams = mergeStringMaps(baseParams, queryParams) + } + + req := base.RestyClient.R(). + SetContext(ctx). + SetHeader("Access-Token", auth.AccessToken). + SetHeader("Content-Type", "application/x-www-form-urlencoded") + if len(queryParams) > 0 { + req.SetQueryParams(queryParams) + } + if len(bodyParams) > 0 { + req.SetFormData(bodyParams) + } + res, err := req.Post(openAPIURL(d.EcsEnv)) + if err != nil { + return err + } + return decodeBaseResp(res.Body(), out) +} + +func decodeBaseResp(body []byte, out interface{}) error { + var baseResp BaseResp + if err := utils.Json.Unmarshal(body, &baseResp); err != nil { + return err + } + if baseResp.Errno != 0 { + if baseResp.Errmsg == "" { + return fmt.Errorf("yunpan request failed: errno=%d", baseResp.Errno) + } + return errors.New(baseResp.Errmsg) + } + if out == nil { + return nil + } + return utils.Json.Unmarshal(body, out) +} + +func mergeStringMaps(baseMap, extra map[string]string) map[string]string { + merged := map[string]string{} + for key, value := range baseMap { + merged[key] = value + } + for key, value := range extra { + merged[key] = value + } + return merged +} + +func (d *Yunpan360) cookieDownloadURL(ctx context.Context, file model.Obj) (*CookieDownloadResp, error) { + resp, err := d.cookieDownloadURLOnce(ctx, file, false) + if err == nil { + return resp, nil + } + + d.invalidateCookieDownloadSession() + return d.cookieDownloadURLOnce(ctx, file, true) +} + +func (d *Yunpan360) cookieDownloadURLOnce(ctx context.Context, file model.Obj, refresh bool) (*CookieDownloadResp, error) { + nid := strings.TrimSpace(file.GetID()) + if nid == "" { + return nil, errors.New("missing file id") + } + + fname := normalizeRemotePath(file.GetPath()) + if fname == "" { + return nil, errors.New("missing file path") + } + + ownerQID, token, err := d.resolveCookieDownloadParams(ctx, file, refresh) + if err != nil { + return nil, err + } + + var resp CookieDownloadResp + err = d.cookieRequestForm(ctx, downloadPath, map[string]string{ + "nid": nid, + "fname": fname, + "owner_qid": ownerQID, + "token": token, + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) cookieRename(ctx context.Context, srcObj model.Obj, newName string) error { + path := normalizeRemotePath(srcObj.GetPath()) + if path == "" { + return errors.New("missing object path") + } + nid := strings.TrimSpace(srcObj.GetID()) + if nid == "" { + return errors.New("missing object id") + } + + ownerQID, err := d.resolveCookieOwnerQID(ctx, srcObj, false) + if err != nil { + return err + } + + return d.cookieRequestForm(ctx, "/file/rename", map[string]string{ + "path": path, + "nid": nid, + "newpath": strings.TrimSuffix(strings.TrimSpace(newName), "/"), + "owner_qid": ownerQID, + }, nil) +} + +func (d *Yunpan360) resolveCookieDownloadParams(ctx context.Context, file model.Obj, refresh bool) (string, string, error) { + ownerQID := sanitizeOwnerQID(d.OwnerQID) + token := strings.TrimSpace(d.DownloadToken) + + if obj, ok := file.(*YunpanObject); ok { + ownerQID = firstNonEmpty(sanitizeOwnerQID(obj.OwnerQID), ownerQID) + token = firstNonEmpty(strings.TrimSpace(obj.DownloadToken), token) + } + + ownerQID = firstNonEmpty(ownerQID, + sanitizeOwnerQID(getCookieValue(d.Cookie, "owner_qid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "ownerQid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "qid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "QID")), + ) + token = firstNonEmpty(token, + getCookieValue(d.Cookie, "download_token"), + getCookieValue(d.Cookie, "token"), + getCookieValue(d.Cookie, "Token"), + ) + + if ownerQID == "" && token != "" { + ownerQID = ownerQIDFromToken(token) + } + if ownerQID != "" && token != "" { + return ownerQID, token, nil + } + + if !refresh { + if cached := d.getCachedCookieDownloadSession(); cached != nil { + ownerQID = firstNonEmpty(ownerQID, cached.OwnerQID) + token = firstNonEmpty(token, cached.Token) + } + if ownerQID == "" && token != "" { + ownerQID = ownerQIDFromToken(token) + } + if ownerQID != "" && token != "" { + return ownerQID, token, nil + } + } + + resp, err := d.listCookiePage(ctx, d.RootFolderPath, 0, 1) + if err == nil && resp != nil { + ownerQID = firstNonEmpty(ownerQID, resp.GetOwnerQID()) + token = firstNonEmpty(token, strings.TrimSpace(resp.Token)) + } + if ownerQID == "" && token != "" { + ownerQID = ownerQIDFromToken(token) + } + if ownerQID != "" && token != "" { + d.cacheCookieDownloadSession(ownerQID, token) + return ownerQID, token, nil + } + + pageSession, err := d.getCookieDownloadSessionFromPage(ctx) + if err == nil && pageSession != nil { + ownerQID = firstNonEmpty(ownerQID, pageSession.OwnerQID) + token = firstNonEmpty(token, pageSession.Token) + } + if ownerQID == "" && token != "" { + ownerQID = ownerQIDFromToken(token) + } + if ownerQID == "" || token == "" { + return "", "", errors.New("missing owner_qid or download_token for cookie mode") + } + + d.cacheCookieDownloadSession(ownerQID, token) + return ownerQID, token, nil +} + +func (d *Yunpan360) resolveCookieOwnerQID(ctx context.Context, file model.Obj, refresh bool) (string, error) { + ownerQID := sanitizeOwnerQID(d.OwnerQID) + + if obj, ok := file.(*YunpanObject); ok { + ownerQID = firstNonEmpty(sanitizeOwnerQID(obj.OwnerQID), ownerQID) + } + + ownerQID = firstNonEmpty(ownerQID, + sanitizeOwnerQID(getCookieValue(d.Cookie, "owner_qid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "ownerQid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "qid")), + sanitizeOwnerQID(getCookieValue(d.Cookie, "QID")), + ) + if ownerQID != "" { + return ownerQID, nil + } + + if !refresh { + if cached := d.getCachedCookieDownloadSession(); cached != nil { + ownerQID = firstNonEmpty(ownerQID, cached.OwnerQID) + } + if ownerQID != "" { + return ownerQID, nil + } + } + + resp, err := d.listCookiePage(ctx, d.RootFolderPath, 0, 1) + if err == nil && resp != nil { + ownerQID = firstNonEmpty(ownerQID, resp.GetOwnerQID()) + } + if ownerQID != "" { + return ownerQID, nil + } + + pageSession, err := d.getCookieDownloadSessionFromPage(ctx) + if err == nil && pageSession != nil { + ownerQID = firstNonEmpty(ownerQID, pageSession.OwnerQID) + } + if ownerQID == "" { + return "", errors.New("missing owner_qid for cookie mode") + } + return ownerQID, nil +} + +func (d *Yunpan360) getCookieDownloadSessionFromPage(ctx context.Context) (*CookieDownloadSession, error) { + body, err := d.cookiePage(ctx, indexPath) + if err != nil { + return nil, err + } + session := parseCookieDownloadSessionFromText(string(body)) + if session == nil { + return nil, errors.New("failed to parse cookie download session from page") + } + d.cacheCookieSession(session) + return session, nil +} + +func (d *Yunpan360) getCachedCookieDownloadSession() *CookieDownloadSession { + d.authMu.Lock() + defer d.authMu.Unlock() + + if d.cachedCookieSession == nil || time.Now().After(d.cookieSessionExpire) { + return nil + } + session := *d.cachedCookieSession + return &session +} + +func (d *Yunpan360) cacheCookieDownloadSession(ownerQID, token string) { + d.cacheCookieSession(&CookieDownloadSession{ + OwnerQID: ownerQID, + Token: token, + }) +} + +func (d *Yunpan360) cacheCookieSession(session *CookieDownloadSession) { + if session == nil { + return + } + + cached := &CookieDownloadSession{ + OwnerQID: sanitizeOwnerQID(session.OwnerQID), + Token: strings.TrimSpace(session.Token), + } + if cached.OwnerQID == "" && cached.Token != "" { + cached.OwnerQID = ownerQIDFromToken(cached.Token) + } + if cached.OwnerQID == "" || cached.Token == "" { + return + } + + d.authMu.Lock() + defer d.authMu.Unlock() + + d.cachedCookieSession = cached + d.cookieSessionExpire = time.Now().Add(10 * time.Minute) +} + +func (d *Yunpan360) invalidateCookieDownloadSession() { + d.authMu.Lock() + defer d.authMu.Unlock() + + d.cachedCookieSession = nil + d.cookieSessionExpire = time.Time{} +} + +func getCookieValue(rawCookie, name string) string { + for _, item := range strings.Split(rawCookie, ";") { + part := strings.TrimSpace(item) + if part == "" { + continue + } + key, value, ok := strings.Cut(part, "=") + if !ok || key != name { + continue + } + value = strings.TrimSpace(value) + value = strings.Trim(value, "\"") + unescaped, err := url.QueryUnescape(value) + if err == nil { + return strings.TrimSpace(unescaped) + } + return value + } + return "" +} + +func ownerQIDFromToken(token string) string { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) < 4 { + return "" + } + qid := strings.TrimSpace(parts[3]) + for _, ch := range qid { + if ch < '0' || ch > '9' { + return "" + } + } + return qid +} + +func sanitizeOwnerQID(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "0" { + return "" + } + return raw +} + +func parseCookieDownloadSessionFromText(text string) *CookieDownloadSession { + token := extractFirstMatch(text, + `(?i)["']download_token["']\s*[:=]\s*["']([^"'<>]+)["']`, + `(?i)["']token["']\s*[:=]\s*["']([^"'<>]+)["']`, + `(?i)\btoken\s*[:=]\s*["']([^"'<>]+)["']`, + ) + ownerQID := extractFirstMatch(text, + `(?i)["']owner_qid["']\s*[:=]\s*["']?([0-9]+)["']?`, + `(?i)["']ownerQid["']\s*[:=]\s*["']?([0-9]+)["']?`, + `(?i)["']qid["']\s*[:=]\s*["']?([0-9]+)["']?`, + `(?i)\bowner_qid\s*[:=]\s*["']?([0-9]+)["']?`, + `(?i)\bqid\s*[:=]\s*["']?([0-9]+)["']?`, + ) + if ownerQID == "" && token != "" { + ownerQID = ownerQIDFromToken(token) + } + if ownerQID == "" || token == "" { + return nil + } + return &CookieDownloadSession{ + OwnerQID: ownerQID, + Token: token, + } +} + +func extractFirstMatch(text string, patterns ...string) string { + return extractFirstValidatedMatch(nil, text, patterns...) +} + +func extractFirstValidatedMatch(validate func(string) bool, text string, patterns ...string) string { + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + for _, matches := range re.FindAllStringSubmatch(text, -1) { + if len(matches) < 2 { + continue + } + value := html.UnescapeString(strings.TrimSpace(matches[1])) + value = strings.Trim(value, "\"'") + if value == "" { + continue + } + if validate == nil || validate(value) { + return value + } + } + } + return "" +} + +func (d *Yunpan360) listOpenPage(ctx context.Context, dirPath string, page, pageSize int) (*OpenListResp, error) { + var resp OpenListResp + path := ensureDirAPIPath(dirPath) + params := map[string]string{ + "path": path, + "page": strconv.Itoa(page), + "page_size": strconv.Itoa(pageSize), + } + err := d.openGET(ctx, "File.getList", params, params, &resp, true) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openUserInfo(ctx context.Context) (*OpenUserInfoResp, error) { + var resp OpenUserInfoResp + err := d.openGET(ctx, "User.getUserDetail", nil, nil, &resp, false) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openDownloadURL(ctx context.Context, file model.Obj) (*OpenDownloadResp, error) { + var resp OpenDownloadResp + signParams := map[string]string{} + body := map[string]string{} + + if file.GetPath() != "" { + signParams["fpath"] = normalizeRemotePath(file.GetPath()) + body["fpath"] = signParams["fpath"] + } else if file.GetID() != "" { + signParams["nid"] = file.GetID() + body["nid"] = file.GetID() + } else { + return nil, errors.New("missing file path and id") + } + + err := d.openPOST(ctx, "MCP.getDownLoadUrl", signParams, nil, body, &resp, true) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) cookieMakeDir(ctx context.Context, fullPath string) (*CookieMkdirResp, error) { + var resp CookieMkdirResp + body := map[string]string{ + "path": ensureDirAPIPath(fullPath), + "owner_qid": "0", + } + err := d.cookieRequestForm(ctx, "/file/mkdir", body, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openMakeDir(ctx context.Context, fullPath string) (*OpenMkdirResp, error) { + var resp OpenMkdirResp + body := map[string]string{"fname": ensureDirAPIPath(fullPath)} + err := d.openPOST(ctx, "File.mkdir", body, nil, body, &resp, true) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) openRename(ctx context.Context, srcName, newName string) error { + signParams := map[string]string{ + "src_name": srcName, + "new_name": newName, + } + return d.openPOST(ctx, "File.rename", signParams, nil, signParams, nil, true) +} + +func (d *Yunpan360) cookieMove(ctx context.Context, srcPath, dstPath string) error { + var resp CookieMoveResp + body := map[string]string{ + "path[]": srcPath, + "newpath": ensureDirAPIPath(dstPath), + } + if err := d.cookieRequestForm(ctx, "/file/move", body, &resp); err != nil { + return err + } + if !resp.Data.IsAsync { + return nil + } + return d.waitCookieAsyncTask(ctx, resp.Data.TaskID) +} + +func (d *Yunpan360) cookieRecycle(ctx context.Context, obj model.Obj) error { + path := apiPathForObj(obj) + if path == "" { + return errors.New("missing object path") + } + ownerQID, err := d.resolveCookieOwnerQID(ctx, obj, false) + if err != nil { + return err + } + + var resp CookieRecycleResp + if err := d.cookieRequestForm(ctx, "/file/recycle", map[string]string{ + "path[]": path, + "owner_qid": ownerQID, + }, &resp); err != nil { + return err + } + if !resp.Data.IsAsync { + return nil + } + return d.waitCookieAsyncTask(ctx, resp.Data.TaskID, 3008) +} + +func (d *Yunpan360) cookieAsyncQuery(ctx context.Context, taskID string) (*CookieAsyncQueryResp, error) { + var resp CookieAsyncQueryResp + err := d.cookieRequestForm(ctx, "/async/query", map[string]string{ + "task_id": strings.TrimSpace(taskID), + }, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (d *Yunpan360) waitCookieAsyncTask(ctx context.Context, taskID string, toleratedErrnos ...int) error { + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return nil + } + + tolerated := map[int]struct{}{} + for _, errno := range toleratedErrnos { + tolerated[errno] = struct{}{} + } + + for attempt := 0; attempt < 15; attempt++ { + resp, err := d.cookieAsyncQuery(ctx, taskID) + if err == nil && resp != nil { + if task, ok := resp.Data[taskID]; ok { + done, taskErr := checkCookieAsyncTask(task, tolerated) + if done { + return taskErr + } + } + } + if attempt == 14 { + break + } + if err := sleepWithContext(ctx, 300*time.Millisecond); err != nil { + return err + } + } + + // Keep prior behavior when the async task is still pending after the probe window. + return nil +} + +func checkCookieAsyncTask(task CookieAsyncTask, toleratedErrnos map[int]struct{}) (bool, error) { + if task.Status != 10 { + return false, nil + } + if task.Errno == 0 { + return true, nil + } + if _, ok := toleratedErrnos[task.Errno]; ok { + return true, nil + } + if strings.TrimSpace(task.Errstr) != "" { + return true, errors.New(task.Errstr) + } + if strings.TrimSpace(task.Action) != "" { + return true, fmt.Errorf("yunpan async task %s failed: errno=%d", task.Action, task.Errno) + } + return true, fmt.Errorf("yunpan async task failed: errno=%d", task.Errno) +} + +func (d *Yunpan360) openMove(ctx context.Context, srcName, dstPath string) error { + signParams := map[string]string{ + "src_name": srcName, + "new_name": dstPath, + } + return d.openPOST(ctx, "File.move", signParams, nil, signParams, nil, true) +} + +func (d *Yunpan360) openDelete(ctx context.Context, targetPath string) error { + return d.openPOST(ctx, "File.delete", nil, nil, map[string]string{ + "fname": targetPath, + }, nil, true) +} + +func apiPathForObj(obj model.Obj) string { + if obj.IsDir() { + return ensureDirAPIPath(obj.GetPath()) + } + return normalizeRemotePath(obj.GetPath()) +} + +func ensureDirSuffix(name string) string { + name = strings.TrimSpace(name) + if name == "" || strings.HasSuffix(name, "/") { + return name + } + return name + "/" +} + +func ensureDirAPIPath(p string) string { + p = normalizeRemotePath(p) + if p == "" || p == "/" { + return "/" + } + return p + "/" +} + +func cloneObj(src model.Obj, newPath, newName string) model.Obj { + obj := model.Object{ + ID: src.GetID(), + Path: normalizeRemotePath(newPath), + Name: newName, + Size: src.GetSize(), + Modified: src.ModTime(), + Ctime: src.CreateTime(), + IsFolder: src.IsDir(), + HashInfo: src.GetHash(), + } + if raw, ok := src.(*YunpanObject); ok { + return &YunpanObject{ + Object: obj, + Thumbnail: raw.Thumbnail, + OwnerQID: raw.OwnerQID, + DownloadToken: raw.DownloadToken, + } + } + return &obj +} + +func absoluteURL(raw string) string { + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return raw + } + if strings.HasPrefix(raw, "/") { + return baseURL + raw + } + return baseURL + "/" + raw +}