mirror of
https://github.com/AlistGo/alist.git
synced 2025-12-19 02:50:06 +08:00
- Implement `Rename` operation with retry logic and API calls. - Implement `Copy` operation, including asynchronous handling, polling for completion, and status checks. - Implement `Remove` operation with retry logic and API calls. - Add new API endpoint URLs for rename, copy, and delete, and a new copy success code. - Introduce `AsyncManagerData`, `AsyncTask`, and `AsyncTaskInfo` types to support async copy status monitoring. - Add utility functions `updateObjectName` and `parentPathOf` for object manipulation. - Integrate login retry mechanism for all file operations.
611 lines
14 KiB
Go
611 lines
14 KiB
Go
package bitqiu
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http/cookiejar"
|
|
"path"
|
|
"strconv"
|
|
"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"
|
|
"github.com/alist-org/alist/v3/internal/op"
|
|
streamPkg "github.com/alist-org/alist/v3/internal/stream"
|
|
"github.com/alist-org/alist/v3/pkg/utils"
|
|
"github.com/go-resty/resty/v2"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
baseURL = "https://pan.bitqiu.com"
|
|
loginURL = baseURL + "/loginServer/login"
|
|
listURL = baseURL + "/apiToken/cfi/fs/resources/pages"
|
|
uploadInitializeURL = baseURL + "/apiToken/cfi/fs/upload/v2/initialize"
|
|
downloadURL = baseURL + "/download/getUrl"
|
|
createDirURL = baseURL + "/resource/create"
|
|
moveResourceURL = baseURL + "/resource/remove"
|
|
renameResourceURL = baseURL + "/resource/rename"
|
|
copyResourceURL = baseURL + "/apiToken/cfi/fs/async/copy"
|
|
copyManagerURL = baseURL + "/apiToken/cfi/fs/async/manager"
|
|
deleteResourceURL = baseURL + "/resource/delete"
|
|
|
|
successCode = "10200"
|
|
uploadSuccessCode = "30010"
|
|
copySubmittedCode = "10300"
|
|
orgChannel = "default|default|default"
|
|
)
|
|
|
|
const (
|
|
copyPollInterval = time.Second
|
|
copyPollMaxAttempts = 60
|
|
)
|
|
|
|
type BitQiu struct {
|
|
model.Storage
|
|
Addition
|
|
|
|
client *resty.Client
|
|
userID string
|
|
}
|
|
|
|
func (d *BitQiu) Config() driver.Config {
|
|
return config
|
|
}
|
|
|
|
func (d *BitQiu) GetAddition() driver.Additional {
|
|
return &d.Addition
|
|
}
|
|
|
|
func (d *BitQiu) Init(ctx context.Context) error {
|
|
if d.Addition.UserPlatform == "" {
|
|
d.Addition.UserPlatform = uuid.NewString()
|
|
op.MustSaveDriverStorage(d)
|
|
}
|
|
|
|
if d.client == nil {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.client = base.NewRestyClient()
|
|
d.client.SetBaseURL(baseURL)
|
|
d.client.SetCookieJar(jar)
|
|
}
|
|
|
|
return d.login(ctx)
|
|
}
|
|
|
|
func (d *BitQiu) Drop(ctx context.Context) error {
|
|
d.client = nil
|
|
d.userID = ""
|
|
return nil
|
|
}
|
|
|
|
func (d *BitQiu) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
parentID := d.resolveParentID(dir)
|
|
dirPath := ""
|
|
if dir != nil {
|
|
dirPath = dir.GetPath()
|
|
}
|
|
pageSize := d.pageSize()
|
|
orderType := d.orderType()
|
|
desc := d.orderDesc()
|
|
|
|
var results []model.Obj
|
|
page := 1
|
|
for {
|
|
form := map[string]string{
|
|
"parentId": parentID,
|
|
"limit": strconv.Itoa(pageSize),
|
|
"orderType": orderType,
|
|
"desc": desc,
|
|
"model": "1",
|
|
"userId": d.userID,
|
|
"currentPage": strconv.Itoa(page),
|
|
"page": strconv.Itoa(page),
|
|
"org_channel": orgChannel,
|
|
}
|
|
var resp Response[ResourcePage]
|
|
if err := d.postForm(ctx, listURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Code != successCode {
|
|
if resp.Code == "10401" || resp.Code == "10404" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("list failed: %s", resp.Message)
|
|
}
|
|
|
|
objs, err := utils.SliceConvert(resp.Data.Data, func(item Resource) (model.Obj, error) {
|
|
return item.toObject(parentID, dirPath)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, objs...)
|
|
|
|
if !resp.Data.HasNext || len(resp.Data.Data) == 0 {
|
|
break
|
|
}
|
|
page++
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (d *BitQiu) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
|
if file.IsDir() {
|
|
return nil, errs.NotFile
|
|
}
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
form := map[string]string{
|
|
"fileIds": file.GetID(),
|
|
"org_channel": orgChannel,
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[DownloadData]
|
|
if err := d.postForm(ctx, downloadURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
if resp.Data.URL == "" {
|
|
return nil, fmt.Errorf("empty download url returned")
|
|
}
|
|
return &model.Link{URL: resp.Data.URL}, nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("get link failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("get link failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
parentID := d.resolveParentID(parentDir)
|
|
parentPath := ""
|
|
if parentDir != nil {
|
|
parentPath = parentDir.GetPath()
|
|
}
|
|
form := map[string]string{
|
|
"parentId": parentID,
|
|
"name": dirName,
|
|
"org_channel": orgChannel,
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[CreateDirData]
|
|
if err := d.postForm(ctx, createDirURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
newParentID := parentID
|
|
if resp.Data.ParentID != "" {
|
|
newParentID = resp.Data.ParentID
|
|
}
|
|
name := resp.Data.Name
|
|
if name == "" {
|
|
name = dirName
|
|
}
|
|
resource := Resource{
|
|
ResourceID: resp.Data.DirID,
|
|
ResourceType: 1,
|
|
Name: name,
|
|
ParentID: newParentID,
|
|
}
|
|
obj, err := resource.toObject(newParentID, parentPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if o, ok := obj.(*Object); ok {
|
|
o.ParentID = newParentID
|
|
}
|
|
return obj, nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("create folder failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("create folder failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
targetParentID := d.resolveParentID(dstDir)
|
|
form := map[string]string{
|
|
"dirIds": "",
|
|
"fileIds": "",
|
|
"parentId": targetParentID,
|
|
"org_channel": orgChannel,
|
|
}
|
|
if srcObj.IsDir() {
|
|
form["dirIds"] = srcObj.GetID()
|
|
} else {
|
|
form["fileIds"] = srcObj.GetID()
|
|
}
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[any]
|
|
if err := d.postForm(ctx, moveResourceURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
dstPath := ""
|
|
if dstDir != nil {
|
|
dstPath = dstDir.GetPath()
|
|
}
|
|
if setter, ok := srcObj.(model.SetPath); ok {
|
|
setter.SetPath(path.Join(dstPath, srcObj.GetName()))
|
|
}
|
|
if o, ok := srcObj.(*Object); ok {
|
|
o.ParentID = targetParentID
|
|
}
|
|
return srcObj, nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("move failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("move failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
form := map[string]string{
|
|
"resourceId": srcObj.GetID(),
|
|
"name": newName,
|
|
"type": "0",
|
|
"org_channel": orgChannel,
|
|
}
|
|
if srcObj.IsDir() {
|
|
form["type"] = "1"
|
|
}
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[any]
|
|
if err := d.postForm(ctx, renameResourceURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
return updateObjectName(srcObj, newName), nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("rename failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("rename failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
targetParentID := d.resolveParentID(dstDir)
|
|
form := map[string]string{
|
|
"dirIds": "",
|
|
"fileIds": "",
|
|
"parentId": targetParentID,
|
|
"org_channel": orgChannel,
|
|
}
|
|
if srcObj.IsDir() {
|
|
form["dirIds"] = srcObj.GetID()
|
|
} else {
|
|
form["fileIds"] = srcObj.GetID()
|
|
}
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[any]
|
|
if err := d.postForm(ctx, copyResourceURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
switch resp.Code {
|
|
case successCode, copySubmittedCode:
|
|
return d.waitForCopiedObject(ctx, srcObj, dstDir)
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("copy failed: %s", resp.Message)
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("copy failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) Remove(ctx context.Context, obj model.Obj) error {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
form := map[string]string{
|
|
"dirIds": "",
|
|
"fileIds": "",
|
|
"org_channel": orgChannel,
|
|
}
|
|
if obj.IsDir() {
|
|
form["dirIds"] = obj.GetID()
|
|
} else {
|
|
form["fileIds"] = obj.GetID()
|
|
}
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[any]
|
|
if err := d.postForm(ctx, deleteResourceURL, form, &resp); err != nil {
|
|
return err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
return nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("remove failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return fmt.Errorf("remove failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) {
|
|
if d.userID == "" {
|
|
if err := d.login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
up(0)
|
|
_, md5sum, err := streamPkg.CacheFullInTempFileAndHash(file, utils.MD5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parentID := d.resolveParentID(dstDir)
|
|
form := map[string]string{
|
|
"parentId": parentID,
|
|
"name": file.GetName(),
|
|
"size": strconv.FormatInt(file.GetSize(), 10),
|
|
"hash": md5sum,
|
|
"sampleMd5": md5sum,
|
|
"org_channel": orgChannel,
|
|
}
|
|
var resp Response[Resource]
|
|
if err = d.postForm(ctx, uploadInitializeURL, form, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Code != uploadSuccessCode {
|
|
if resp.Code == successCode {
|
|
return nil, fmt.Errorf("upload requires additional steps not implemented: %s", resp.Message)
|
|
}
|
|
return nil, fmt.Errorf("upload failed: %s", resp.Message)
|
|
}
|
|
|
|
obj, err := resp.Data.toObject(parentID, dstDir.GetPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
up(100)
|
|
return obj, nil
|
|
}
|
|
|
|
func (d *BitQiu) login(ctx context.Context) error {
|
|
if d.client == nil {
|
|
return fmt.Errorf("client not initialized")
|
|
}
|
|
|
|
form := map[string]string{
|
|
"passport": d.Username,
|
|
"password": utils.GetMD5EncodeStr(d.Password),
|
|
"remember": "0",
|
|
"captcha": "",
|
|
"org_channel": orgChannel,
|
|
}
|
|
var resp Response[LoginData]
|
|
if err := d.postForm(ctx, loginURL, form, &resp); err != nil {
|
|
return err
|
|
}
|
|
if resp.Code != successCode {
|
|
return fmt.Errorf("login failed: %s", resp.Message)
|
|
}
|
|
d.userID = strconv.FormatInt(resp.Data.UserID, 10)
|
|
return nil
|
|
}
|
|
|
|
func (d *BitQiu) postForm(ctx context.Context, url string, form map[string]string, result interface{}) error {
|
|
if d.client == nil {
|
|
return fmt.Errorf("client not initialized")
|
|
}
|
|
req := d.client.R().
|
|
SetContext(ctx).
|
|
SetHeaders(d.commonHeaders()).
|
|
SetFormData(form)
|
|
if result != nil {
|
|
req = req.SetResult(result)
|
|
}
|
|
_, err := req.Post(url)
|
|
return err
|
|
}
|
|
|
|
func (d *BitQiu) waitForCopiedObject(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) {
|
|
expectedName := srcObj.GetName()
|
|
expectedIsDir := srcObj.IsDir()
|
|
var lastListErr error
|
|
|
|
for attempt := 0; attempt < copyPollMaxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
if err := waitWithContext(ctx, copyPollInterval); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := d.checkCopyFailure(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj, err := d.findObjectInDir(ctx, dstDir, expectedName, expectedIsDir)
|
|
if err != nil {
|
|
lastListErr = err
|
|
continue
|
|
}
|
|
if obj != nil {
|
|
return obj, nil
|
|
}
|
|
}
|
|
if lastListErr != nil {
|
|
return nil, lastListErr
|
|
}
|
|
return nil, fmt.Errorf("copy task timed out waiting for completion")
|
|
}
|
|
|
|
func (d *BitQiu) checkCopyFailure(ctx context.Context) error {
|
|
form := map[string]string{
|
|
"org_channel": orgChannel,
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
var resp Response[AsyncManagerData]
|
|
if err := d.postForm(ctx, copyManagerURL, form, &resp); err != nil {
|
|
return err
|
|
}
|
|
switch resp.Code {
|
|
case successCode:
|
|
if len(resp.Data.FailTasks) > 0 {
|
|
return fmt.Errorf("copy failed: %s", resp.Data.FailTasks[0].ErrorMessage())
|
|
}
|
|
return nil
|
|
case "10401", "10404":
|
|
if err := d.login(ctx); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("query copy status failed: %s", resp.Message)
|
|
}
|
|
}
|
|
return fmt.Errorf("query copy status failed: retry limit reached")
|
|
}
|
|
|
|
func (d *BitQiu) findObjectInDir(ctx context.Context, dir model.Obj, name string, isDir bool) (model.Obj, error) {
|
|
objs, err := d.List(ctx, dir, model.ListArgs{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, obj := range objs {
|
|
if obj.GetName() == name && obj.IsDir() == isDir {
|
|
return obj, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func waitWithContext(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
|
|
}
|
|
}
|
|
|
|
func (d *BitQiu) commonHeaders() map[string]string {
|
|
headers := map[string]string{
|
|
"accept": "application/json, text/plain, */*",
|
|
"accept-language": "en-US,en;q=0.9",
|
|
"cache-control": "no-cache",
|
|
"pragma": "no-cache",
|
|
"user-platform": d.Addition.UserPlatform,
|
|
"x-kl-saas-ajax-request": "Ajax_Request",
|
|
"x-requested-with": "XMLHttpRequest",
|
|
"referer": baseURL + "/",
|
|
"origin": baseURL,
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func (d *BitQiu) resolveParentID(dir model.Obj) string {
|
|
if dir != nil && dir.GetID() != "" {
|
|
return dir.GetID()
|
|
}
|
|
if root := d.Addition.GetRootId(); root != "" {
|
|
return root
|
|
}
|
|
return config.DefaultRoot
|
|
}
|
|
|
|
func (d *BitQiu) pageSize() int {
|
|
if size, err := strconv.Atoi(d.Addition.PageSize); err == nil && size > 0 {
|
|
return size
|
|
}
|
|
return 24
|
|
}
|
|
|
|
func (d *BitQiu) orderType() string {
|
|
if d.Addition.OrderType != "" {
|
|
return d.Addition.OrderType
|
|
}
|
|
return "updateTime"
|
|
}
|
|
|
|
func (d *BitQiu) orderDesc() string {
|
|
if d.Addition.OrderDesc {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|
|
|
|
var _ driver.Driver = (*BitQiu)(nil)
|
|
var _ driver.PutResult = (*BitQiu)(nil)
|