diff --git a/drivers/all.go b/drivers/all.go index 140908a8..2ce0c2c6 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -51,6 +51,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/onedrive" _ "github.com/alist-org/alist/v3/drivers/onedrive_app" _ "github.com/alist-org/alist/v3/drivers/onedrive_sharelink" + _ "github.com/alist-org/alist/v3/drivers/pcloud" _ "github.com/alist-org/alist/v3/drivers/pikpak" _ "github.com/alist-org/alist/v3/drivers/pikpak_share" _ "github.com/alist-org/alist/v3/drivers/quark_uc" diff --git a/drivers/pcloud/driver.go b/drivers/pcloud/driver.go new file mode 100644 index 00000000..036dcc40 --- /dev/null +++ b/drivers/pcloud/driver.go @@ -0,0 +1,189 @@ +package pcloud + +import ( + "context" + "fmt" + + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" +) + +type PCloud struct { + model.Storage + Addition + AccessToken string // Actual access token obtained from refresh token +} + +func (d *PCloud) Config() driver.Config { + return config +} + +func (d *PCloud) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *PCloud) Init(ctx context.Context) error { + // Map hostname selection to actual API endpoints + if d.Hostname == "us" { + d.Hostname = "api.pcloud.com" + } else if d.Hostname == "eu" { + d.Hostname = "eapi.pcloud.com" + } + + // Set default root folder ID if not provided + if d.RootFolderID == "" { + d.RootFolderID = "d0" + } + + // Use the access token directly (like rclone) + d.AccessToken = d.RefreshToken // RefreshToken field actually contains the access_token + return nil +} + +func (d *PCloud) Drop(ctx context.Context) error { + return nil +} + +func (d *PCloud) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + folderID := d.RootFolderID + if dir.GetID() != "" { + folderID = dir.GetID() + } + + files, err := d.getFiles(folderID) + if err != nil { + return nil, err + } + + return utils.SliceConvert(files, func(src FileObject) (model.Obj, error) { + return fileToObj(src), nil + }) +} + +func (d *PCloud) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + downloadURL, err := d.getDownloadLink(file.GetID()) + if err != nil { + return nil, err + } + + return &model.Link{ + URL: downloadURL, + }, nil +} + +// Mkdir implements driver.Mkdir +func (d *PCloud) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + parentID := d.RootFolderID + if parentDir.GetID() != "" { + parentID = parentDir.GetID() + } + + return d.createFolder(parentID, dirName) +} + +// Move implements driver.Move +func (d *PCloud) Move(ctx context.Context, srcObj, dstDir model.Obj) error { + // pCloud uses renamefile/renamefolder for both rename and move + endpoint := "/renamefile" + paramName := "fileid" + + if srcObj.IsDir() { + endpoint = "/renamefolder" + paramName = "folderid" + } + + var resp ItemResult + _, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) { + req.SetFormData(map[string]string{ + paramName: extractID(srcObj.GetID()), + "tofolderid": extractID(dstDir.GetID()), + "toname": srcObj.GetName(), + }) + }, &resp) + + if err != nil { + return err + } + + if resp.Result != 0 { + return fmt.Errorf("pCloud error: result code %d", resp.Result) + } + + return nil +} + +// Rename implements driver.Rename +func (d *PCloud) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + endpoint := "/renamefile" + paramName := "fileid" + + if srcObj.IsDir() { + endpoint = "/renamefolder" + paramName = "folderid" + } + + var resp ItemResult + _, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) { + req.SetFormData(map[string]string{ + paramName: extractID(srcObj.GetID()), + "toname": newName, + }) + }, &resp) + + if err != nil { + return err + } + + if resp.Result != 0 { + return fmt.Errorf("pCloud error: result code %d", resp.Result) + } + + return nil +} + +// Copy implements driver.Copy +func (d *PCloud) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + endpoint := "/copyfile" + paramName := "fileid" + + if srcObj.IsDir() { + endpoint = "/copyfolder" + paramName = "folderid" + } + + var resp ItemResult + _, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) { + req.SetFormData(map[string]string{ + paramName: extractID(srcObj.GetID()), + "tofolderid": extractID(dstDir.GetID()), + "toname": srcObj.GetName(), + }) + }, &resp) + + if err != nil { + return err + } + + if resp.Result != 0 { + return fmt.Errorf("pCloud error: result code %d", resp.Result) + } + + return nil +} + +// Remove implements driver.Remove +func (d *PCloud) Remove(ctx context.Context, obj model.Obj) error { + return d.delete(obj.GetID(), obj.IsDir()) +} + +// Put implements driver.Put +func (d *PCloud) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { + parentID := d.RootFolderID + if dstDir.GetID() != "" { + parentID = dstDir.GetID() + } + + return d.uploadFile(ctx, stream, parentID, stream.GetName(), stream.GetSize()) +} \ No newline at end of file diff --git a/drivers/pcloud/meta.go b/drivers/pcloud/meta.go new file mode 100644 index 00000000..84e3dfe4 --- /dev/null +++ b/drivers/pcloud/meta.go @@ -0,0 +1,30 @@ +package pcloud + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + // Using json tag "access_token" for UI display, but internally it's a refresh token + RefreshToken string `json:"access_token" required:"true" help:"OAuth token from pCloud authorization"` + Hostname string `json:"hostname" type:"select" options:"us,eu" default:"us" help:"Select pCloud server region"` + RootFolderID string `json:"root_folder_id" help:"Get folder ID from URL like https://my.pcloud.com/#/filemanager?folder=12345678901 (leave empty for root folder)"` + ClientID string `json:"client_id" help:"Custom OAuth client ID (optional)"` + ClientSecret string `json:"client_secret" help:"Custom OAuth client secret (optional)"` +} + +// Implement IRootId interface +func (a Addition) GetRootId() string { + return a.RootFolderID +} + +var config = driver.Config{ + Name: "pCloud", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &PCloud{} + }) +} \ No newline at end of file diff --git a/drivers/pcloud/types.go b/drivers/pcloud/types.go new file mode 100644 index 00000000..d0a6943c --- /dev/null +++ b/drivers/pcloud/types.go @@ -0,0 +1,91 @@ +package pcloud + +import ( + "strconv" + "time" + + "github.com/alist-org/alist/v3/internal/model" +) + +// ErrorResult represents a pCloud API error response +type ErrorResult struct { + Result int `json:"result"` + Error string `json:"error"` +} + +// TokenResponse represents OAuth token response +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` +} + +// ItemResult represents a common pCloud API response +type ItemResult struct { + Result int `json:"result"` + Metadata *FolderMeta `json:"metadata,omitempty"` +} + +// FolderMeta contains folder metadata including contents +type FolderMeta struct { + Contents []FileObject `json:"contents,omitempty"` +} + +// DownloadLinkResult represents download link response +type DownloadLinkResult struct { + Result int `json:"result"` + Hosts []string `json:"hosts"` + Path string `json:"path"` +} + +// FileObject represents a file or folder object in pCloud +type FileObject struct { + Name string `json:"name"` + Created string `json:"created"` // pCloud returns RFC1123 format string + Modified string `json:"modified"` // pCloud returns RFC1123 format string + IsFolder bool `json:"isfolder"` + FolderID uint64 `json:"folderid,omitempty"` + FileID uint64 `json:"fileid,omitempty"` + Size uint64 `json:"size"` + ParentID uint64 `json:"parentfolderid"` + Icon string `json:"icon,omitempty"` + Hash uint64 `json:"hash,omitempty"` + Category int `json:"category,omitempty"` + ID string `json:"id,omitempty"` +} + +// Convert FileObject to model.Obj +func fileToObj(f FileObject) model.Obj { + // Parse RFC1123 format time from pCloud + modTime, _ := time.Parse(time.RFC1123, f.Modified) + + obj := model.Object{ + Name: f.Name, + Size: int64(f.Size), + Modified: modTime, + IsFolder: f.IsFolder, + } + + if f.IsFolder { + obj.ID = "d" + strconv.FormatUint(f.FolderID, 10) + } else { + obj.ID = "f" + strconv.FormatUint(f.FileID, 10) + } + + return &obj +} + +// Extract numeric ID from string ID (remove 'd' or 'f' prefix) +func extractID(id string) string { + if len(id) > 1 && (id[0] == 'd' || id[0] == 'f') { + return id[1:] + } + return id +} + +// Get folder ID from path, return "0" for root +func getFolderID(path string) string { + if path == "/" || path == "" { + return "0" + } + return extractID(path) +} \ No newline at end of file diff --git a/drivers/pcloud/util.go b/drivers/pcloud/util.go new file mode 100644 index 00000000..f2c1875e --- /dev/null +++ b/drivers/pcloud/util.go @@ -0,0 +1,297 @@ +package pcloud + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" +) + +const ( + defaultClientID = "DnONSzyJXpm" + defaultClientSecret = "VKEnd3ze4jsKFGg8TJiznwFG8" +) + +// Get API base URL +func (d *PCloud) getAPIURL() string { + return "https://" + d.Hostname +} + +// Get OAuth client credentials +func (d *PCloud) getClientCredentials() (string, string) { + clientID := d.ClientID + clientSecret := d.ClientSecret + + if clientID == "" { + clientID = defaultClientID + } + if clientSecret == "" { + clientSecret = defaultClientSecret + } + + return clientID, clientSecret +} + +// Refresh OAuth access token +func (d *PCloud) refreshToken() error { + clientID, clientSecret := d.getClientCredentials() + + var resp TokenResponse + _, err := base.RestyClient.R(). + SetFormData(map[string]string{ + "client_id": clientID, + "client_secret": clientSecret, + "grant_type": "refresh_token", + "refresh_token": d.RefreshToken, + }). + SetResult(&resp). + Post(d.getAPIURL() + "/oauth2_token") + + if err != nil { + return err + } + + d.AccessToken = resp.AccessToken + return nil +} + +// shouldRetry determines if an error should be retried based on pCloud-specific logic +func (d *PCloud) shouldRetry(statusCode int, apiError *ErrorResult) bool { + // HTTP-level retry conditions + if statusCode == 429 || statusCode >= 500 { + return true + } + + // pCloud API-specific retry conditions (like rclone) + if apiError != nil && apiError.Result != 0 { + // 4xxx: rate limiting + if apiError.Result/1000 == 4 { + return true + } + // 5xxx: internal errors + if apiError.Result/1000 == 5 { + return true + } + } + + return false +} + +// requestWithRetry makes authenticated API request with retry logic +func (d *PCloud) requestWithRetry(endpoint string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + maxRetries := 3 + baseDelay := 500 * time.Millisecond + + for attempt := 0; attempt <= maxRetries; attempt++ { + body, err := d.request(endpoint, method, callback, resp) + if err == nil { + return body, nil + } + + // If this is the last attempt, return the error + if attempt == maxRetries { + return nil, err + } + + // Check if we should retry based on error type + if !d.shouldRetryError(err) { + return nil, err + } + + // Exponential backoff + delay := baseDelay * time.Duration(1<