Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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.
152 changes: 152 additions & 0 deletions drivers/pds/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package pds

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"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) ensureToken(ctx context.Context) error {
if c.addition.RefreshToken == "" {
return nil
}
if c.addition.ExpiresAt > 0 && time.Now().Unix() < c.addition.ExpiresAt-300 {
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
}
c.addition.AccessToken = token.AccessToken
c.addition.TokenType = token.TokenType
if token.RefreshToken != "" {
c.addition.RefreshToken = token.RefreshToken
}
if token.ExpiresIn > 0 {
c.addition.ExpiresAt = time.Now().Unix() + token.ExpiresIn
}
if c.onSave != nil {
c.onSave()
}
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
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL(path), bytes.NewReader(payload))
if err != nil {
return 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 err
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("pds api %s failed: %s: %s", path, resp.Status, string(data))
}
if out == nil || len(data) == 0 {
return nil
}
return json.Unmarshal(data, out)
}

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