Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import (
_ "github.com/OpenListTeam/OpenList/v4/drivers/onedrive_sharelink"
_ "github.com/OpenListTeam/OpenList/v4/drivers/openlist"
_ "github.com/OpenListTeam/OpenList/v4/drivers/openlist_share"
_ "github.com/OpenListTeam/OpenList/v4/drivers/pds"
_ "github.com/OpenListTeam/OpenList/v4/drivers/pikpak"
_ "github.com/OpenListTeam/OpenList/v4/drivers/pikpak_share"
_ "github.com/OpenListTeam/OpenList/v4/drivers/proton_drive"
Expand Down
36 changes: 36 additions & 0 deletions drivers/pds/README.md
Comment thread
zymooll marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# PDS Driver

Native OpenList driver for Aliyun PDS.

## Supported Operations

- List files and folders
- Resolve file metadata by path
- Generate direct download links
- Upload files with one-part upload
- Create folders
- Rename files and folders
- Move files and folders
- Copy files and folders
- Move files and folders to recycle bin
- Read drive usage details
- Refresh and persist OAuth tokens when `refresh_token` is configured

Deletion uses the verified `/v2/recyclebin/trash` endpoint, so OpenList delete operations move objects to the PDS recycle bin instead of permanently deleting them.

## Storage Fields

- `root_folder_id`: root folder id, default `root`
- `domain_id`: PDS domain id
- `drive_id`: target drive id
- `client_id`: OAuth client id, default `lMNVp25Sd1MfqZDQ`
- `access_token`: short-lived PDS access token; either `access_token` or `refresh_token` is required
- `refresh_token`: optional token used for automatic refresh; either `access_token` or `refresh_token` is required
- `token_type`: usually `Bearer`
- `expires_at`: Unix timestamp in seconds; set `0` to let the driver refresh on first request when `refresh_token` is present

## Notes

- The driver calls PDS APIs directly from Go and does not execute the Python script at runtime.
- Upload uses PDS `/v2/file/create`, presigned `PUT`, and `/v2/file/complete`.
- Download links are requested through `/v2/file/get` and cached for two hours by OpenList.
214 changes: 214 additions & 0 deletions drivers/pds/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package pds

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

const (
defaultClientID = "lMNVp25Sd1MfqZDQ"
Comment thread
zymooll marked this conversation as resolved.
apiEndpoint = "https://%s.api.aliyunfile.com"
authEndpoint = "https://%s.auth.aliyunfile.com"
)

type client struct {
addition *Addition
http *http.Client
onSave func()
}

func newClient(addition *Addition, onSave func()) *client {
if addition.ClientID == "" {
addition.ClientID = defaultClientID
}
if addition.TokenType == "" {
addition.TokenType = "Bearer"
}
return &client{
addition: addition,
http: &http.Client{Timeout: 5 * time.Minute},
onSave: onSave,
}
}

func (c *client) apiURL(path string) string {
return fmt.Sprintf(apiEndpoint, c.addition.DomainID) + path
}

func (c *client) authURL(path string) string {
return fmt.Sprintf(authEndpoint, c.addition.DomainID) + path
}

func (c *client) refreshToken(ctx context.Context) error {
if c.addition.RefreshToken == "" {
return nil
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", c.addition.RefreshToken)
form.Set("client_id", c.addition.ClientID)

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL("/v2/oauth/token"), bytes.NewBufferString(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("refresh token failed: %s: %s", resp.Status, string(data))
}

var token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(data, &token); err != nil {
return err
}
if token.AccessToken == "" {
return fmt.Errorf("refresh token failed: access_token is empty")
}
c.addition.AccessToken = token.AccessToken
if token.TokenType != "" {
c.addition.TokenType = token.TokenType
}
if token.RefreshToken != "" {
c.addition.RefreshToken = token.RefreshToken
}
c.addition.ExpiresAt = 0
if c.onSave != nil {
c.onSave()
}
return nil
}

func (c *client) ensureToken(ctx context.Context) error {
if c.addition.RefreshToken == "" {
return nil
}
if c.addition.AccessToken == "" {
return c.refreshToken(ctx)
}
if c.addition.ExpiresAt > 0 && time.Now().Unix() >= c.addition.ExpiresAt-300 {
return c.refreshToken(ctx)
}
return nil
}

func (c *client) post(ctx context.Context, path string, body any, out any) error {
if err := c.ensureToken(ctx); err != nil {
return err
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
data, statusCode, status, err := c.postPayload(ctx, path, payload)
if err != nil {
return err
}
if statusCode >= 400 && isAccessTokenExpiredError(statusCode, data) && c.addition.RefreshToken != "" {
if err := c.refreshToken(ctx); err != nil {
return err
}
data, statusCode, status, err = c.postPayload(ctx, path, payload)
if err != nil {
return err
}
}
if statusCode >= 400 {
return fmt.Errorf("pds api %s failed: %s: %s", path, status, string(data))
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}

func (c *client) postPayload(ctx context.Context, path string, payload []byte) ([]byte, int, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL(path), bytes.NewReader(payload))
if err != nil {
return nil, 0, "", err
}
req.Header.Set("Authorization", c.addition.TokenType+" "+c.addition.AccessToken)
req.Header.Set("Content-Type", "application/json")

resp, err := c.http.Do(req)
if err != nil {
return nil, 0, "", err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, "", err
}
return data, resp.StatusCode, resp.Status, nil
}

func isAccessTokenExpiredError(statusCode int, data []byte) bool {
if statusCode < http.StatusBadRequest {
return false
}
var apiErr struct {
Code string `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
}
text := string(data)
if len(data) > 0 && json.Unmarshal(data, &apiErr) == nil {
text = apiErr.Code + " " + apiErr.Message + " " + apiErr.Error
}
text = strings.ToLower(text)
for _, marker := range []string{
"accesstokenexpired",
"access token expired",
"accesstokeninvalid",
"access token invalid",
"invalidaccesstoken",
"invalid access token",
"token expired",
"expiredtoken",
} {
if strings.Contains(text, marker) {
return true
}
}
return false
}

func (c *client) putRaw(ctx context.Context, uploadURL string, r io.Reader) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, r)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pds upload failed: %s: %s", resp.Status, string(data))
}
return nil
}
Loading
Loading