From 7bcc89717f4e76d8202876f4ed20ace4bd064f7d Mon Sep 17 00:00:00 2001 From: zymooll Date: Sat, 27 Jun 2026 06:27:59 +0800 Subject: [PATCH 1/7] feat(driver): add PDS storage driver - add a native PDS driver with list, link, upload, mkdir, rename, move, copy, recycle-bin delete, and storage details support - persist refreshed OAuth tokens and allow either access_token or refresh_token for authentication - register the driver and cover initialization/query escaping behavior with focused tests --- drivers/all.go | 1 + drivers/pds/README.md | 36 +++++ drivers/pds/api.go | 152 ++++++++++++++++++ drivers/pds/driver.go | 316 +++++++++++++++++++++++++++++++++++++ drivers/pds/driver_test.go | 44 ++++++ drivers/pds/meta.go | 30 ++++ drivers/pds/types.go | 99 ++++++++++++ 7 files changed, 678 insertions(+) create mode 100644 drivers/pds/README.md create mode 100644 drivers/pds/api.go create mode 100644 drivers/pds/driver.go create mode 100644 drivers/pds/driver_test.go create mode 100644 drivers/pds/meta.go create mode 100644 drivers/pds/types.go diff --git a/drivers/all.go b/drivers/all.go index 7687faaf25..41e16132ea 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -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" diff --git a/drivers/pds/README.md b/drivers/pds/README.md new file mode 100644 index 0000000000..70523ea845 --- /dev/null +++ b/drivers/pds/README.md @@ -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. diff --git a/drivers/pds/api.go b/drivers/pds/api.go new file mode 100644 index 0000000000..dd494e3cc5 --- /dev/null +++ b/drivers/pds/api.go @@ -0,0 +1,152 @@ +package pds + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + defaultClientID = "lMNVp25Sd1MfqZDQ" + 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 +} diff --git a/drivers/pds/driver.go b/drivers/pds/driver.go new file mode 100644 index 0000000000..b76001eecd --- /dev/null +++ b/drivers/pds/driver.go @@ -0,0 +1,316 @@ +package pds + +import ( + "context" + "errors" + "path" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type PDS struct { + model.Storage + Addition + client *client +} + +func (d *PDS) Config() driver.Config { + return config +} + +func (d *PDS) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *PDS) Init(ctx context.Context) error { + d.client = newClient(&d.Addition, func() { + op.MustSaveDriverStorage(d) + }) + if d.RootFolderID == "" { + d.RootFolderID = "root" + } + if d.DriveID == "" { + return errors.New("drive_id is required") + } + if d.DomainID == "" { + return errors.New("domain_id is required") + } + if d.AccessToken == "" && d.RefreshToken == "" { + return errors.New("access_token or refresh_token is required") + } + return nil +} + +func (d *PDS) Drop(ctx context.Context) error { + return nil +} + +func (d *PDS) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + parentID := d.fileID(dir) + var all []fileItem + marker := "" + for { + var resp listFilesResp + err := d.client.post(ctx, "/v2/file/list", map[string]any{ + "drive_id": d.DriveID, + "parent_file_id": parentID, + "limit": 100, + "marker": marker, + "order_by": "updated_at", + "order_direction": "DESC", + "fields": "*", + "url_expire_sec": 7200, + "include_handover_drive": true, + }, &resp) + if err != nil { + return nil, err + } + all = append(all, resp.Items...) + if resp.NextMarker == "" { + break + } + marker = resp.NextMarker + } + parentPath := dir.GetPath() + if parentPath == "" { + parentPath = "/" + } + return toObjs(all, parentPath), nil +} + +func (d *PDS) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + item, err := d.getFile(ctx, d.fileID(file)) + if err != nil { + return nil, err + } + if item.DownloadURL == "" { + return nil, errs.NotFile + } + exp := 2 * time.Hour + return &model.Link{URL: item.DownloadURL, Expiration: &exp}, nil +} + +func (d *PDS) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + var out createFileResp + err := d.client.post(ctx, "/v2/file/create", map[string]any{ + "drive_id": d.DriveID, + "parent_file_id": d.fileID(parentDir), + "name": dirName, + "type": "folder", + "check_name_mode": "auto_rename", + }, &out) + if err != nil { + return nil, err + } + return out.toObj(), nil +} + +func (d *PDS) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var out copyMoveResp + err := d.client.post(ctx, "/v2/file/move", map[string]any{ + "drive_id": d.DriveID, + "file_id": d.fileID(srcObj), + "to_drive_id": d.DriveID, + "to_parent_file_id": d.fileID(dstDir), + "check_name_mode": "auto_rename", + }, &out) + if err != nil { + return nil, err + } + return d.getFileObj(ctx, out.FileID) +} + +func (d *PDS) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + var out fileItem + err := d.client.post(ctx, "/v2/file/update", map[string]any{ + "drive_id": d.DriveID, + "file_id": d.fileID(srcObj), + "name": newName, + "check_name_mode": "auto_rename", + }, &out) + if err != nil { + return nil, err + } + return out.toObj(), nil +} + +func (d *PDS) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + var out copyMoveResp + err := d.client.post(ctx, "/v2/file/copy", map[string]any{ + "drive_id": d.DriveID, + "file_id": d.fileID(srcObj), + "to_drive_id": d.DriveID, + "to_parent_file_id": d.fileID(dstDir), + "check_name_mode": "auto_rename", + }, &out) + if err != nil { + return nil, err + } + return d.getFileObj(ctx, out.FileID) +} + +func (d *PDS) Remove(ctx context.Context, obj model.Obj) error { + return d.client.post(ctx, "/v2/recyclebin/trash", map[string]any{ + "drive_id": d.DriveID, + "file_id": d.fileID(obj), + }, nil) +} + +func (d *PDS) GetRoot(ctx context.Context) (model.Obj, error) { + return &model.Object{ + ID: d.RootFolderID, + Path: "/", + Name: "root", + Modified: d.Modified, + IsFolder: true, + Mask: model.Locked, + }, nil +} + +func (d *PDS) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + var created createFileResp + err := d.client.post(ctx, "/v2/file/create", map[string]any{ + "drive_id": d.DriveID, + "parent_file_id": d.fileID(dstDir), + "name": stream.GetName(), + "type": "file", + "check_name_mode": "auto_rename", + "size": stream.GetSize(), + "part_info_list": []map[string]int{{"part_number": 1}}, + }, &created) + if err != nil { + return nil, err + } + if len(created.PartInfoList) == 0 || created.PartInfoList[0].UploadURL == "" { + return nil, errors.New("pds create file did not return upload_url") + } + if err := d.client.putRaw(ctx, created.PartInfoList[0].UploadURL, stream); err != nil { + return nil, err + } + err = d.client.post(ctx, "/v2/file/complete", map[string]any{ + "drive_id": d.DriveID, + "file_id": created.FileID, + "upload_id": created.UploadID, + }, &created) + if err != nil { + return nil, err + } + return d.getFileObj(ctx, created.FileID) +} + +func (d *PDS) Get(ctx context.Context, path string) (model.Obj, error) { + if path == "/" || path == "" { + return d.GetRoot(ctx) + } + return d.getByPath(ctx, path) +} + +func (d *PDS) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + var drive driveResp + err := d.client.post(ctx, "/v2/drive/get", map[string]any{ + "drive_id": d.DriveID, + }, &drive) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: drive.TotalSize, + UsedSpace: drive.UsedSize, + }, + }, nil +} + +func (d *PDS) fileID(obj model.Obj) string { + if obj == nil { + return d.RootFolderID + } + if id := obj.GetID(); id != "" { + return id + } + return d.RootFolderID +} + +func (d *PDS) getFile(ctx context.Context, fileID string) (fileItem, error) { + var item fileItem + err := d.client.post(ctx, "/v2/file/get", map[string]any{ + "drive_id": d.DriveID, + "file_id": fileID, + }, &item) + return item, err +} + +func (d *PDS) getFileObj(ctx context.Context, fileID string) (model.Obj, error) { + item, err := d.getFile(ctx, fileID) + if err != nil { + return nil, err + } + return item.toObj(), nil +} + +func (d *PDS) getByPath(ctx context.Context, rawPath string) (model.Obj, error) { + parts := strings.Split(strings.Trim(rawPath, "/"), "/") + parentID := d.RootFolderID + var current fileItem + currentPath := "/" + for _, part := range parts { + if part == "" { + continue + } + found, err := d.findChild(ctx, parentID, part) + if err != nil { + return nil, err + } + current = found + parentID = found.FileID + currentPath = path.Join(currentPath, found.Name) + } + if current.FileID == "" { + return nil, errs.ObjectNotFound + } + obj := current.toObj() + if setter, ok := obj.(model.SetPath); ok { + setter.SetPath(currentPath) + } + return obj, nil +} + +func (d *PDS) findChild(ctx context.Context, parentID, name string) (fileItem, error) { + var resp listFilesResp + err := d.client.post(ctx, "/v2/file/search", map[string]any{ + "drive_id": d.DriveID, + "query": "parent_file_id = \"" + parentID + "\" and name = \"" + escapeQueryValue(name) + "\"", + "limit": 10, + "fields": "*", + }, &resp) + if err != nil { + return fileItem{}, err + } + for _, item := range resp.Items { + if item.Name == name { + return item, nil + } + } + return fileItem{}, errs.ObjectNotFound +} + +func escapeQueryValue(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + return strings.ReplaceAll(s, "\"", "\\\"") +} + +var _ driver.Driver = (*PDS)(nil) +var _ driver.Getter = (*PDS)(nil) +var _ driver.GetRooter = (*PDS)(nil) +var _ driver.PutResult = (*PDS)(nil) +var _ driver.MkdirResult = (*PDS)(nil) +var _ driver.MoveResult = (*PDS)(nil) +var _ driver.RenameResult = (*PDS)(nil) +var _ driver.CopyResult = (*PDS)(nil) +var _ driver.Remove = (*PDS)(nil) +var _ driver.WithDetails = (*PDS)(nil) diff --git a/drivers/pds/driver_test.go b/drivers/pds/driver_test.go new file mode 100644 index 0000000000..ada82292dc --- /dev/null +++ b/drivers/pds/driver_test.go @@ -0,0 +1,44 @@ +package pds + +import ( + "context" + "testing" +) + +func TestInitRequiresToken(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + DomainID: "domain", + DriveID: "drive", + }, + } + + if err := driver.Init(context.Background()); err == nil { + t.Fatal("expected missing token error") + } +} + +func TestInitAcceptsRefreshTokenOnly(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + DomainID: "domain", + DriveID: "drive", + RefreshToken: "refresh", + }, + } + + if err := driver.Init(context.Background()); err != nil { + t.Fatalf("expected refresh token to be enough, got %v", err) + } + if driver.RootFolderID != "root" { + t.Fatalf("expected default root folder id, got %q", driver.RootFolderID) + } +} + +func TestEscapeQueryValue(t *testing.T) { + got := escapeQueryValue(`a\b"c`) + want := `a\\b\"c` + if got != want { + t.Fatalf("escapeQueryValue() = %q, want %q", got, want) + } +} diff --git a/drivers/pds/meta.go b/drivers/pds/meta.go new file mode 100644 index 0000000000..ca2357b2a5 --- /dev/null +++ b/drivers/pds/meta.go @@ -0,0 +1,30 @@ +package pds + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + DomainID string `json:"domain_id" required:"true" help:"PDS domain id"` + DriveID string `json:"drive_id" required:"true" help:"PDS drive id"` + ClientID string `json:"client_id" default:"lMNVp25Sd1MfqZDQ"` + AccessToken string `json:"access_token" type:"text" help:"Short-lived PDS access token; either access_token or refresh_token is required"` + RefreshToken string `json:"refresh_token" type:"text"` + TokenType string `json:"token_type" default:"Bearer"` + ExpiresAt int64 `json:"expires_at" type:"number" help:"Unix timestamp in seconds; leave 0 if unknown"` +} + +var config = driver.Config{ + Name: "PDS", + DefaultRoot: "root", + LocalSort: false, + CheckStatus: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &PDS{} + }) +} diff --git a/drivers/pds/types.go b/drivers/pds/types.go new file mode 100644 index 0000000000..33f3764eb8 --- /dev/null +++ b/drivers/pds/types.go @@ -0,0 +1,99 @@ +package pds + +import ( + "path" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +type fileItem struct { + DriveID string `json:"drive_id"` + FileID string `json:"file_id"` + ParentFileID string `json:"parent_file_id"` + Name string `json:"name"` + Type string `json:"type"` + FileSize int64 `json:"size"` + UpdatedAt string `json:"updated_at"` + CreatedAt string `json:"created_at"` + DownloadURL string `json:"download_url"` +} + +func (f fileItem) ModTime() time.Time { + for _, raw := range []string{f.UpdatedAt, f.CreatedAt} { + if raw == "" { + continue + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t + } + } + return time.Now() +} + +type listFilesResp struct { + Items []fileItem `json:"items"` + NextMarker string `json:"next_marker"` +} + +type createFileResp struct { + DriveID string `json:"drive_id"` + FileID string `json:"file_id"` + UploadID string `json:"upload_id"` + Name string `json:"name"` + FileName string `json:"file_name"` + PartInfoList []struct { + PartNumber int `json:"part_number"` + UploadURL string `json:"upload_url"` + } `json:"part_info_list"` +} + +func (f createFileResp) toObj() model.Obj { + name := f.FileName + if name == "" { + name = f.Name + } + return &model.Object{ + ID: f.FileID, + Name: name, + Modified: time.Now(), + IsFolder: true, + } +} + +type copyMoveResp struct { + DriveID string `json:"drive_id"` + FileID string `json:"file_id"` +} + +type driveResp struct { + DriveID string `json:"drive_id"` + UsedSize int64 `json:"used_size"` + TotalSize int64 `json:"total_size"` +} + +func toObjs(items []fileItem, parentPath string) []model.Obj { + objs := make([]model.Obj, 0, len(items)) + for _, item := range items { + obj := item.toObj() + if setter, ok := obj.(model.SetPath); ok { + setter.SetPath(path.Join(parentPath, item.Name)) + } + objs = append(objs, obj) + } + return objs +} + +func (f fileItem) toObj() model.Obj { + size := f.FileSize + if f.Type == "folder" { + size = 0 + } + return &model.Object{ + ID: f.FileID, + Name: f.Name, + Size: size, + Modified: f.ModTime(), + IsFolder: f.Type == "folder", + } +} From 8fd55804059cc742c8d0a67b029752df18e6ced0 Mon Sep 17 00:00:00 2001 From: zymooll Date: Sun, 28 Jun 2026 03:31:04 +0800 Subject: [PATCH 2/7] feat(pds): support direct upload completion --- drivers/pds/api.go | 98 ++++++++++--- drivers/pds/direct_upload.go | 214 +++++++++++++++++++++++++++ drivers/pds/driver_test.go | 251 ++++++++++++++++++++++++++++++++ drivers/pds/meta.go | 2 +- internal/driver/driver.go | 5 + internal/fs/fs.go | 8 + internal/fs/put.go | 11 ++ internal/op/fs.go | 23 +++ server/handles/direct_upload.go | 93 ++++++++++-- server/router.go | 1 + 10 files changed, 676 insertions(+), 30 deletions(-) create mode 100644 drivers/pds/direct_upload.go diff --git a/drivers/pds/api.go b/drivers/pds/api.go index dd494e3cc5..5e0766a9c3 100644 --- a/drivers/pds/api.go +++ b/drivers/pds/api.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -45,13 +46,10 @@ func (c *client) authURL(path string) string { return fmt.Sprintf(authEndpoint, c.addition.DomainID) + path } -func (c *client) ensureToken(ctx context.Context) error { +func (c *client) refreshToken(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) @@ -86,20 +84,36 @@ func (c *client) ensureToken(ctx context.Context) error { 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 - c.addition.TokenType = token.TokenType + if token.TokenType != "" { + 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 - } + 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 @@ -108,30 +122,78 @@ func (c *client) post(ctx context.Context, path string, body any, out any) error if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL(path), bytes.NewReader(payload)) + 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 err + return nil, 0, "", 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)) + return nil, 0, "", err } - if out == nil || len(data) == 0 { - return nil - } - return json.Unmarshal(data, out) + 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 { diff --git a/drivers/pds/direct_upload.go b/drivers/pds/direct_upload.go new file mode 100644 index 0000000000..fa0b05bf66 --- /dev/null +++ b/drivers/pds/direct_upload.go @@ -0,0 +1,214 @@ +package pds + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/OpenListTeam/OpenList/v4/server/common" +) + +const ( + directUploadTool = "PdsDirect" + directUploadTokenTTL = 2 * time.Hour +) + +type directUploadToken struct { + DomainID string `json:"domain_id"` + DriveID string `json:"drive_id"` + ParentFileID string `json:"parent_file_id"` + FileID string `json:"file_id"` + UploadID string `json:"upload_id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ExpiresAt int64 `json:"expires_at"` +} + +type directUploadInfo struct { + UploadURL string `json:"upload_url"` + Headers map[string]string `json:"headers,omitempty"` + Method string `json:"method,omitempty"` + Complete *directUploadCompletionInfo `json:"complete,omitempty"` +} + +type directUploadCompletionInfo struct { + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body map[string]any `json:"body,omitempty"` +} + +func (d *PDS) GetDirectUploadTools() []string { + return []string{directUploadTool} +} + +func (d *PDS) GetDirectUploadInfo(ctx context.Context, tool string, dstDir model.Obj, fileName string, fileSize int64) (any, error) { + if tool != directUploadTool { + return nil, errs.NotImplement + } + if fileSize < 0 { + return nil, fmt.Errorf("file_size is required for PDS direct upload") + } + var created createFileResp + err := d.client.post(ctx, "/v2/file/create", map[string]any{ + "drive_id": d.DriveID, + "parent_file_id": d.fileID(dstDir), + "name": fileName, + "type": "file", + "check_name_mode": "auto_rename", + "size": fileSize, + "part_info_list": []map[string]int{{"part_number": 1}}, + }, &created) + if err != nil { + return nil, err + } + if len(created.PartInfoList) == 0 || created.PartInfoList[0].UploadURL == "" { + return nil, fmt.Errorf("pds create file did not return upload_url") + } + + uploadToken, err := d.signDirectUploadToken(directUploadToken{ + DomainID: d.DomainID, + DriveID: d.DriveID, + ParentFileID: d.fileID(dstDir), + FileID: created.FileID, + UploadID: created.UploadID, + FileName: fileName, + FileSize: fileSize, + ExpiresAt: time.Now().Add(directUploadTokenTTL).Unix(), + }) + if err != nil { + return nil, err + } + + apiURL := common.GetApiUrl(ctx) + if apiURL == "" { + apiURL = "/" + } + completeURL := strings.TrimRight(apiURL, "/") + "/api/fs/complete_direct_upload" + return &directUploadInfo{ + UploadURL: created.PartInfoList[0].UploadURL, + Method: http.MethodPut, + Headers: map[string]string{ + "Content-Type": "", + }, + Complete: &directUploadCompletionInfo{ + URL: completeURL, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: map[string]any{ + "path": utils.GetFullPath(d.GetStorage().MountPath, dstDir.GetPath()), + "file_name": fileName, + "tool": directUploadTool, + "upload_token": uploadToken, + }, + }, + }, nil +} + +func (d *PDS) CompleteDirectUpload(ctx context.Context, tool string, dstDir model.Obj, fileName string, uploadToken string) (model.Obj, error) { + if tool != directUploadTool { + return nil, errs.NotImplement + } + token, err := d.verifyDirectUploadToken(uploadToken) + if err != nil { + return nil, err + } + if token.DomainID != d.DomainID || token.DriveID != d.DriveID || + token.ParentFileID != d.fileID(dstDir) || token.FileName != fileName { + return nil, fmt.Errorf("direct upload token does not match request") + } + if token.FileID == "" || token.UploadID == "" { + return nil, fmt.Errorf("direct upload token is incomplete") + } + var completed createFileResp + err = d.client.post(ctx, "/v2/file/complete", map[string]any{ + "drive_id": token.DriveID, + "file_id": token.FileID, + "upload_id": token.UploadID, + }, &completed) + if err != nil { + return nil, err + } + fileID := completed.FileID + if fileID == "" { + fileID = token.FileID + } + return d.getFileObj(ctx, fileID) +} + +func (d *PDS) signDirectUploadToken(token directUploadToken) (string, error) { + payload, err := json.Marshal(token) + if err != nil { + return "", err + } + payloadText := base64.RawURLEncoding.EncodeToString(payload) + signature, err := d.signDirectUploadPayload(payloadText) + if err != nil { + return "", err + } + return payloadText + "." + signature, nil +} + +func (d *PDS) verifyDirectUploadToken(raw string) (*directUploadToken, error) { + payloadText, signature, ok := strings.Cut(raw, ".") + if !ok || payloadText == "" || signature == "" { + return nil, fmt.Errorf("invalid direct upload token") + } + expected, err := d.signDirectUploadPayload(payloadText) + if err != nil { + return nil, err + } + if !hmac.Equal([]byte(signature), []byte(expected)) { + return nil, fmt.Errorf("invalid direct upload token signature") + } + payload, err := base64.RawURLEncoding.DecodeString(payloadText) + if err != nil { + return nil, fmt.Errorf("invalid direct upload token payload: %w", err) + } + var token directUploadToken + if err := json.Unmarshal(payload, &token); err != nil { + return nil, err + } + if token.ExpiresAt > 0 && time.Now().Unix() > token.ExpiresAt { + return nil, fmt.Errorf("direct upload token expired") + } + return &token, nil +} + +func (d *PDS) signDirectUploadPayload(payload string) (string, error) { + secret := d.directUploadSecret() + if len(secret) == 0 { + return "", fmt.Errorf("direct upload token secret is empty") + } + mac := hmac.New(sha256.New, secret) + if _, err := mac.Write([]byte(payload)); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (d *PDS) directUploadSecret() []byte { + if conf.Conf != nil && conf.Conf.JwtSecret != "" { + return []byte(conf.Conf.JwtSecret) + } + if d.RefreshToken != "" { + return []byte(d.RefreshToken) + } + return []byte(d.AccessToken) +} + +var _ driver.DirectUploader = (*PDS)(nil) +var _ driver.DirectUploadCompleter = (*PDS)(nil) diff --git a/drivers/pds/driver_test.go b/drivers/pds/driver_test.go index ada82292dc..5abff54b08 100644 --- a/drivers/pds/driver_test.go +++ b/drivers/pds/driver_test.go @@ -2,7 +2,12 @@ package pds import ( "context" + "fmt" + "io" + "net/http" + "strings" "testing" + "time" ) func TestInitRequiresToken(t *testing.T) { @@ -42,3 +47,249 @@ func TestEscapeQueryValue(t *testing.T) { t.Fatalf("escapeQueryValue() = %q, want %q", got, want) } } + +func TestEnsureTokenSkipsRefreshWhenExpiresAtZeroAndAccessTokenExists(t *testing.T) { + addition := &Addition{ + DomainID: "domain", + ClientID: "client", + AccessToken: "access", + RefreshToken: "refresh", + TokenType: "Bearer", + ExpiresAt: 0, + } + client := &client{ + addition: addition, + http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected refresh request to %s", req.URL.String()) + })}, + } + + if err := client.ensureToken(context.Background()); err != nil { + t.Fatalf("ensureToken() error = %v", err) + } + if addition.AccessToken != "access" { + t.Fatalf("AccessToken = %q, want access", addition.AccessToken) + } + if addition.ExpiresAt != 0 { + t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) + } +} + +func TestEnsureTokenRefreshesMissingAccessTokenAndKeepsExpiresAtZero(t *testing.T) { + addition := &Addition{ + DomainID: "domain", + ClientID: "client", + RefreshToken: "refresh", + TokenType: "Bearer", + } + saveCalls := 0 + refreshCalls := 0 + client := &client{ + addition: addition, + http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + refreshCalls++ + if req.URL.Host != "domain.auth.aliyunfile.com" || req.URL.Path != "/v2/oauth/token" { + return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) + } + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if !strings.Contains(string(body), "grant_type=refresh_token") { + return nil, fmt.Errorf("refresh request body = %q", string(body)) + } + return testJSONResponse(req, http.StatusOK, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh2"}`), nil + })}, + onSave: func() { + saveCalls++ + }, + } + + if err := client.ensureToken(context.Background()); err != nil { + t.Fatalf("ensureToken() error = %v", err) + } + if refreshCalls != 1 { + t.Fatalf("refreshCalls = %d, want 1", refreshCalls) + } + if saveCalls != 1 { + t.Fatalf("saveCalls = %d, want 1", saveCalls) + } + if addition.AccessToken != "fresh" { + t.Fatalf("AccessToken = %q, want fresh", addition.AccessToken) + } + if addition.RefreshToken != "refresh2" { + t.Fatalf("RefreshToken = %q, want refresh2", addition.RefreshToken) + } + if addition.ExpiresAt != 0 { + t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) + } +} + +func TestPostRefreshesAndRetriesOnAccessTokenExpired(t *testing.T) { + addition := &Addition{ + DomainID: "domain", + ClientID: "client", + AccessToken: "expired", + RefreshToken: "refresh", + TokenType: "Bearer", + ExpiresAt: 0, + } + apiCalls := 0 + refreshCalls := 0 + saveCalls := 0 + client := &client{ + addition: addition, + http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Host { + case "domain.api.aliyunfile.com": + apiCalls++ + switch apiCalls { + case 1: + if got := req.Header.Get("Authorization"); got != "Bearer expired" { + return nil, fmt.Errorf("first Authorization = %q, want Bearer expired", got) + } + return testJSONResponse(req, http.StatusUnauthorized, `{"code":"AccessTokenExpired","message":"access token expired"}`), nil + case 2: + if got := req.Header.Get("Authorization"); got != "Bearer fresh" { + return nil, fmt.Errorf("second Authorization = %q, want Bearer fresh", got) + } + return testJSONResponse(req, http.StatusOK, `{}`), nil + default: + return nil, fmt.Errorf("unexpected api call %d", apiCalls) + } + case "domain.auth.aliyunfile.com": + refreshCalls++ + return testJSONResponse(req, http.StatusOK, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600}`), nil + default: + return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) + } + })}, + onSave: func() { + saveCalls++ + }, + } + + if err := client.post(context.Background(), "/v2/file/list", map[string]any{"drive_id": "drive"}, nil); err != nil { + t.Fatalf("post() error = %v", err) + } + if apiCalls != 2 { + t.Fatalf("apiCalls = %d, want 2", apiCalls) + } + if refreshCalls != 1 { + t.Fatalf("refreshCalls = %d, want 1", refreshCalls) + } + if saveCalls != 1 { + t.Fatalf("saveCalls = %d, want 1", saveCalls) + } + if addition.AccessToken != "fresh" { + t.Fatalf("AccessToken = %q, want fresh", addition.AccessToken) + } + if addition.ExpiresAt != 0 { + t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) + } +} + +func TestDirectUploadTools(t *testing.T) { + driver := &PDS{} + tools := driver.GetDirectUploadTools() + if len(tools) != 1 || tools[0] != directUploadTool { + t.Fatalf("GetDirectUploadTools() = %v, want [%s]", tools, directUploadTool) + } +} + +func TestDirectUploadTokenRoundTrip(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + RefreshToken: "refresh", + }, + } + token := directUploadToken{ + DomainID: "domain", + DriveID: "drive", + ParentFileID: "root", + FileID: "file", + UploadID: "upload", + FileName: "test.txt", + FileSize: 4, + ExpiresAt: time.Now().Add(time.Minute).Unix(), + } + + raw, err := driver.signDirectUploadToken(token) + if err != nil { + t.Fatalf("signDirectUploadToken() error = %v", err) + } + got, err := driver.verifyDirectUploadToken(raw) + if err != nil { + t.Fatalf("verifyDirectUploadToken() error = %v", err) + } + if *got != token { + t.Fatalf("verifyDirectUploadToken() = %+v, want %+v", *got, token) + } +} + +func TestDirectUploadTokenRejectsTampering(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + RefreshToken: "refresh", + }, + } + raw, err := driver.signDirectUploadToken(directUploadToken{ + DomainID: "domain", + DriveID: "drive", + FileID: "file", + UploadID: "upload", + FileName: "test.txt", + ExpiresAt: time.Now().Add(time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("signDirectUploadToken() error = %v", err) + } + + last := raw[len(raw)-1] + replacement := byte('A') + if last == replacement { + replacement = 'B' + } + tampered := raw[:len(raw)-1] + string(replacement) + if _, err := driver.verifyDirectUploadToken(tampered); err == nil { + t.Fatal("expected tampered direct upload token to be rejected") + } +} + +func TestDirectUploadTokenRejectsExpired(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + RefreshToken: "refresh", + }, + } + raw, err := driver.signDirectUploadToken(directUploadToken{ + DomainID: "domain", + DriveID: "drive", + FileID: "file", + UploadID: "upload", + FileName: "test.txt", + ExpiresAt: time.Now().Add(-time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("signDirectUploadToken() error = %v", err) + } + if _, err := driver.verifyDirectUploadToken(raw); err == nil { + t.Fatal("expected expired direct upload token to be rejected") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func testJSONResponse(req *http.Request, statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} diff --git a/drivers/pds/meta.go b/drivers/pds/meta.go index ca2357b2a5..85dfd33249 100644 --- a/drivers/pds/meta.go +++ b/drivers/pds/meta.go @@ -13,7 +13,7 @@ type Addition struct { AccessToken string `json:"access_token" type:"text" help:"Short-lived PDS access token; either access_token or refresh_token is required"` RefreshToken string `json:"refresh_token" type:"text"` TokenType string `json:"token_type" default:"Bearer"` - ExpiresAt int64 `json:"expires_at" type:"number" help:"Unix timestamp in seconds; leave 0 if unknown"` + ExpiresAt int64 `json:"expires_at" type:"number" default:"0" help:"Unix timestamp in seconds; leave 0 if unknown"` } var config = driver.Config{ diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 373bb56534..6a1da0ed3e 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -218,3 +218,8 @@ type DirectUploader interface { // return errs.NotImplement if the driver does not support the given direct upload tool GetDirectUploadInfo(ctx context.Context, tool string, dstDir model.Obj, fileName string, fileSize int64) (any, error) } + +type DirectUploadCompleter interface { + // CompleteDirectUpload commits a frontend-direct upload after the client has uploaded bytes to storage. + CompleteDirectUpload(ctx context.Context, tool string, dstDir model.Obj, fileName string, uploadToken string) (model.Obj, error) +} diff --git a/internal/fs/fs.go b/internal/fs/fs.go index 67a1ac065e..5b3fc1a04f 100644 --- a/internal/fs/fs.go +++ b/internal/fs/fs.go @@ -206,3 +206,11 @@ func GetDirectUploadInfo(ctx context.Context, tool, path, dstName string, fileSi } return info, err } + +func CompleteDirectUpload(ctx context.Context, tool, path, dstName, uploadToken string) (model.Obj, error) { + obj, err := completeDirectUpload(ctx, tool, path, dstName, uploadToken) + if err != nil { + log.Errorf("failed complete %s direct upload for %s: %+v", path, dstName, err) + } + return obj, err +} diff --git a/internal/fs/put.go b/internal/fs/put.go index 0b905be08c..0a98273d57 100644 --- a/internal/fs/put.go +++ b/internal/fs/put.go @@ -117,3 +117,14 @@ func getDirectUploadInfo(ctx context.Context, tool, dstDirPath, dstName string, } return op.GetDirectUploadInfo(ctx, tool, storage, dstDirActualPath, dstName, fileSize, overwrite) } + +func completeDirectUpload(ctx context.Context, tool, dstDirPath, dstName, uploadToken string) (model.Obj, error) { + storage, dstDirActualPath, err := op.GetStorageAndActualPath(dstDirPath) + if err != nil { + return nil, errors.WithMessage(err, "failed get storage") + } + if storage.Config().NoUpload { + return nil, errors.WithStack(errs.UploadNotSupported) + } + return op.CompleteDirectUpload(ctx, tool, storage, dstDirActualPath, dstName, uploadToken) +} diff --git a/internal/op/fs.go b/internal/op/fs.go index f82a3ca8f8..58ac7fbe05 100644 --- a/internal/op/fs.go +++ b/internal/op/fs.go @@ -814,6 +814,29 @@ func GetDirectUploadInfo(ctx context.Context, tool string, storage driver.Driver return info, nil } +func CompleteDirectUpload(ctx context.Context, tool string, storage driver.Driver, dstDirPath, dstName, uploadToken string) (model.Obj, error) { + du, ok := storage.(driver.DirectUploadCompleter) + if !ok { + return nil, errors.WithStack(errs.NotImplement) + } + if storage.Config().CheckStatus && storage.GetStorage().Status != WORK { + return nil, errors.WithMessagef(errs.StorageNotInit, "storage status: %s", storage.GetStorage().Status) + } + dstDirPath = utils.FixAndCleanPath(dstDirPath) + dstDir, err := GetUnwrap(ctx, storage, dstDirPath) + if err != nil { + return nil, errors.WithMessagef(err, "failed to get dir [%s]", dstDirPath) + } + obj, err := du.CompleteDirectUpload(ctx, tool, dstDir, dstName, uploadToken) + if err != nil { + return nil, errors.WithStack(err) + } + if ctx.Value(conf.SkipHookKey) == nil && needHandleObjsUpdateHook() { + go objsUpdateHook(context.WithoutCancel(ctx), storage, dstDirPath, false) + } + return obj, nil +} + func objsUpdateHook(ctx context.Context, storage driver.Driver, dirPath string, recursive bool) { files, err := List(ctx, storage, dirPath, model.ListArgs{SkipHook: true}) if err != nil { diff --git a/server/handles/direct_upload.go b/server/handles/direct_upload.go index d77c3044cb..fe79c23971 100644 --- a/server/handles/direct_upload.go +++ b/server/handles/direct_upload.go @@ -8,8 +8,10 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) type FsGetDirectUploadInfoReq struct { @@ -19,6 +21,56 @@ type FsGetDirectUploadInfoReq struct { Tool string `json:"tool" form:"tool"` } +type FsCompleteDirectUploadReq struct { + Path string `json:"path" form:"path"` + FileName string `json:"file_name" form:"file_name"` + Tool string `json:"tool" form:"tool"` + UploadToken string `json:"upload_token" form:"upload_token"` +} + +func resolveDirectUploadDir(c *gin.Context, rawPath, fileName string) (string, error) { + path, err := url.PathUnescape(rawPath) + if err != nil { + return "", err + } + user := c.Request.Context().Value(conf.UserKey).(*model.User) + path, err = user.JoinPath(path) + if err != nil { + return "", err + } + if err := checkRelativePath(fileName); err != nil { + return "", err + } + return path, nil +} + +func checkDirectUploadWritePermission(c *gin.Context, parentPath string) error { + user := c.Request.Context().Value(conf.UserKey).(*model.User) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return errs.PermissionDenied + } + return nil +} + +func respondDirectUploadPermissionError(c *gin.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, errs.PermissionDenied) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return true + } + common.ErrorResp(c, err, 500, true) + return true +} + // FsGetDirectUploadInfo returns the direct upload info if supported by the driver // If the driver does not support direct upload, returns null for upload_info func FsGetDirectUploadInfo(c *gin.Context) { @@ -27,21 +79,12 @@ func FsGetDirectUploadInfo(c *gin.Context) { common.ErrorResp(c, err, 400) return } - // Decode path - path, err := url.PathUnescape(req.Path) - if err != nil { - common.ErrorResp(c, err, 400) - return - } - // Get user and join path - user := c.Request.Context().Value(conf.UserKey).(*model.User) - path, err = user.JoinPath(path) + path, err := resolveDirectUploadDir(c, req.Path, req.FileName) if err != nil { common.ErrorResp(c, err, 403) return } - if err := checkRelativePath(req.FileName); err != nil { - common.ErrorResp(c, err, 403) + if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { return } overwrite := c.GetHeader("Overwrite") != "false" @@ -68,3 +111,31 @@ func FsGetDirectUploadInfo(c *gin.Context) { } common.SuccessResp(c, directUploadInfo) } + +// FsCompleteDirectUpload commits a client-side upload session after the client +// has uploaded the file bytes directly to the storage provider. +func FsCompleteDirectUpload(c *gin.Context) { + var req FsCompleteDirectUploadReq + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + path, err := resolveDirectUploadDir(c, req.Path, req.FileName) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + if req.UploadToken == "" { + common.ErrorStrResp(c, "upload_token is required", 400) + return + } + if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { + return + } + obj, err := fs.CompleteDirectUpload(c.Request.Context(), req.Tool, path, req.FileName, req.UploadToken) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, obj) +} diff --git a/server/router.go b/server/router.go index 7330bd2c33..e642e1b3c7 100644 --- a/server/router.go +++ b/server/router.go @@ -225,6 +225,7 @@ func _fs(g *gin.RouterGroup) { g.POST("/torrent/generate", handles.GenerateTorrentForPath) // Direct upload (client-side upload to storage) g.POST("/get_direct_upload_info", middlewares.FsUp, handles.FsGetDirectUploadInfo) + g.POST("/complete_direct_upload", middlewares.FsUp, handles.FsCompleteDirectUpload) } func _task(g *gin.RouterGroup) { From 3e47477faee73368f21666dd2fb4f39f73be8acc Mon Sep 17 00:00:00 2001 From: zymooll Date: Mon, 29 Jun 2026 05:46:17 +0800 Subject: [PATCH 3/7] fix(pds): stabilize direct upload completion --- drivers/pds/direct_upload.go | 8 ++++++-- drivers/pds/driver.go | 35 ++++++++++++++++++++++++++++----- drivers/pds/driver_test.go | 35 +++++++++++++++++++++++++++++++++ server/handles/direct_upload.go | 32 +++++++++++++++++++++++++----- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/drivers/pds/direct_upload.go b/drivers/pds/direct_upload.go index fa0b05bf66..a548c6ea65 100644 --- a/drivers/pds/direct_upload.go +++ b/drivers/pds/direct_upload.go @@ -127,7 +127,7 @@ func (d *PDS) CompleteDirectUpload(ctx context.Context, tool string, dstDir mode return nil, err } if token.DomainID != d.DomainID || token.DriveID != d.DriveID || - token.ParentFileID != d.fileID(dstDir) || token.FileName != fileName { + token.ParentFileID != d.fileID(dstDir) { return nil, fmt.Errorf("direct upload token does not match request") } if token.FileID == "" || token.UploadID == "" { @@ -146,7 +146,11 @@ func (d *PDS) CompleteDirectUpload(ctx context.Context, tool string, dstDir mode if fileID == "" { fileID = token.FileID } - return d.getFileObj(ctx, fileID) + obj, err := d.getFileObj(ctx, fileID) + if err != nil { + return nil, err + } + return withParentPath(dstDir.GetPath(), obj), nil } func (d *PDS) signDirectUploadToken(token directUploadToken) (string, error) { diff --git a/drivers/pds/driver.go b/drivers/pds/driver.go index b76001eecd..39564e89d8 100644 --- a/drivers/pds/driver.go +++ b/drivers/pds/driver.go @@ -107,7 +107,7 @@ func (d *PDS) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) if err != nil { return nil, err } - return out.toObj(), nil + return withParentPath(parentDir.GetPath(), out.toObj()), nil } func (d *PDS) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { @@ -122,7 +122,11 @@ func (d *PDS) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, er if err != nil { return nil, err } - return d.getFileObj(ctx, out.FileID) + obj, err := d.getFileObj(ctx, out.FileID) + if err != nil { + return nil, err + } + return withParentPath(dstDir.GetPath(), obj), nil } func (d *PDS) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { @@ -136,7 +140,7 @@ func (d *PDS) Rename(ctx context.Context, srcObj model.Obj, newName string) (mod if err != nil { return nil, err } - return out.toObj(), nil + return withParentPath(path.Dir(srcObj.GetPath()), out.toObj()), nil } func (d *PDS) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { @@ -151,7 +155,11 @@ func (d *PDS) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, er if err != nil { return nil, err } - return d.getFileObj(ctx, out.FileID) + obj, err := d.getFileObj(ctx, out.FileID) + if err != nil { + return nil, err + } + return withParentPath(dstDir.GetPath(), obj), nil } func (d *PDS) Remove(ctx context.Context, obj model.Obj) error { @@ -200,7 +208,11 @@ func (d *PDS) Put(ctx context.Context, dstDir model.Obj, stream model.FileStream if err != nil { return nil, err } - return d.getFileObj(ctx, created.FileID) + obj, err := d.getFileObj(ctx, created.FileID) + if err != nil { + return nil, err + } + return withParentPath(dstDir.GetPath(), obj), nil } func (d *PDS) Get(ctx context.Context, path string) (model.Obj, error) { @@ -236,6 +248,19 @@ func (d *PDS) fileID(obj model.Obj) string { return d.RootFolderID } +func withParentPath(parentPath string, obj model.Obj) model.Obj { + if obj == nil { + return nil + } + if parentPath == "" || parentPath == "." { + parentPath = "/" + } + if setter, ok := obj.(model.SetPath); ok { + setter.SetPath(path.Join(parentPath, obj.GetName())) + } + return obj +} + func (d *PDS) getFile(ctx context.Context, fileID string) (fileItem, error) { var item fileItem err := d.client.post(ctx, "/v2/file/get", map[string]any{ diff --git a/drivers/pds/driver_test.go b/drivers/pds/driver_test.go index 5abff54b08..a1e62e644e 100644 --- a/drivers/pds/driver_test.go +++ b/drivers/pds/driver_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" ) func TestInitRequiresToken(t *testing.T) { @@ -197,6 +199,39 @@ func TestDirectUploadTools(t *testing.T) { } } +func TestMakeDirSetsReturnedPath(t *testing.T) { + driver := &PDS{ + Addition: Addition{ + DomainID: "domain", + DriveID: "drive", + AccessToken: "access", + TokenType: "Bearer", + }, + } + driver.client = &client{ + addition: &driver.Addition, + http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host != "domain.api.aliyunfile.com" || req.URL.Path != "/v2/file/create" { + return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) + } + return testJSONResponse(req, http.StatusOK, `{"file_id":"child-id","name":"child"}`), nil + })}, + } + + obj, err := driver.MakeDir(context.Background(), &model.Object{ + ID: "parent-id", + Path: "/parent", + Name: "parent", + IsFolder: true, + }, "child") + if err != nil { + t.Fatalf("MakeDir() error = %v", err) + } + if obj.GetPath() != "/parent/child" { + t.Fatalf("MakeDir() path = %q, want /parent/child", obj.GetPath()) + } +} + func TestDirectUploadTokenRoundTrip(t *testing.T) { driver := &PDS{ Addition: Addition{ diff --git a/server/handles/direct_upload.go b/server/handles/direct_upload.go index fe79c23971..397ac462c4 100644 --- a/server/handles/direct_upload.go +++ b/server/handles/direct_upload.go @@ -44,6 +44,28 @@ func resolveDirectUploadDir(c *gin.Context, rawPath, fileName string) (string, e return path, nil } +func resolveDirectUploadFile(c *gin.Context, rawPath, fileName string) (string, string, error) { + filePath := c.GetHeader("File-Path") + if filePath != "" { + path, err := url.PathUnescape(filePath) + if err != nil { + return "", "", err + } + user := c.Request.Context().Value(conf.UserKey).(*model.User) + path, err = user.JoinPath(path) + if err != nil { + return "", "", err + } + name := stdpath.Base(path) + if err := checkRelativePath(name); err != nil { + return "", "", err + } + return stdpath.Dir(path), name, nil + } + path, err := resolveDirectUploadDir(c, rawPath, fileName) + return path, fileName, err +} + func checkDirectUploadWritePermission(c *gin.Context, parentPath string) error { user := c.Request.Context().Value(conf.UserKey).(*model.User) parentMeta, err := op.GetNearestMeta(parentPath) @@ -79,7 +101,7 @@ func FsGetDirectUploadInfo(c *gin.Context) { common.ErrorResp(c, err, 400) return } - path, err := resolveDirectUploadDir(c, req.Path, req.FileName) + path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) if err != nil { common.ErrorResp(c, err, 403) return @@ -88,7 +110,7 @@ func FsGetDirectUploadInfo(c *gin.Context) { return } overwrite := c.GetHeader("Overwrite") != "false" - dstPath := stdpath.Join(path, req.FileName) + dstPath := stdpath.Join(path, fileName) if !overwrite { res, err := fs.Get(c.Request.Context(), dstPath, &fs.GetArgs{NoLog: true}) if err != nil && !errs.IsObjectNotFound(err) { @@ -100,7 +122,7 @@ func FsGetDirectUploadInfo(c *gin.Context) { return } } - directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, req.FileName, req.FileSize, overwrite) + directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, fileName, req.FileSize, overwrite) if err != nil { if !overwrite && errs.IsObjectAlreadyExists(err) { common.ErrorStrResp(c, "file exists", 403) @@ -120,7 +142,7 @@ func FsCompleteDirectUpload(c *gin.Context) { common.ErrorResp(c, err, 400) return } - path, err := resolveDirectUploadDir(c, req.Path, req.FileName) + path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) if err != nil { common.ErrorResp(c, err, 403) return @@ -132,7 +154,7 @@ func FsCompleteDirectUpload(c *gin.Context) { if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { return } - obj, err := fs.CompleteDirectUpload(c.Request.Context(), req.Tool, path, req.FileName, req.UploadToken) + obj, err := fs.CompleteDirectUpload(c.Request.Context(), req.Tool, path, fileName, req.UploadToken) if err != nil { common.ErrorResp(c, err, 500) return From 7f0125964610ce3ceef6bc56c9ce34892dac95cf Mon Sep 17 00:00:00 2001 From: zymooll Date: Tue, 30 Jun 2026 19:21:59 +0800 Subject: [PATCH 4/7] refactor(pds): remove direct upload functionality and restructure related code --- drivers/pds/driver.go | 35 ----- drivers/pds/{direct_upload.go => upload.go} | 36 +++++ server/handles/direct_upload.go | 163 -------------------- server/handles/fsup.go | 150 ++++++++++++++++++ 4 files changed, 186 insertions(+), 198 deletions(-) rename drivers/pds/{direct_upload.go => upload.go} (84%) delete mode 100644 server/handles/direct_upload.go diff --git a/drivers/pds/driver.go b/drivers/pds/driver.go index 39564e89d8..a710a62f7a 100644 --- a/drivers/pds/driver.go +++ b/drivers/pds/driver.go @@ -180,41 +180,6 @@ func (d *PDS) GetRoot(ctx context.Context) (model.Obj, error) { }, nil } -func (d *PDS) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { - var created createFileResp - err := d.client.post(ctx, "/v2/file/create", map[string]any{ - "drive_id": d.DriveID, - "parent_file_id": d.fileID(dstDir), - "name": stream.GetName(), - "type": "file", - "check_name_mode": "auto_rename", - "size": stream.GetSize(), - "part_info_list": []map[string]int{{"part_number": 1}}, - }, &created) - if err != nil { - return nil, err - } - if len(created.PartInfoList) == 0 || created.PartInfoList[0].UploadURL == "" { - return nil, errors.New("pds create file did not return upload_url") - } - if err := d.client.putRaw(ctx, created.PartInfoList[0].UploadURL, stream); err != nil { - return nil, err - } - err = d.client.post(ctx, "/v2/file/complete", map[string]any{ - "drive_id": d.DriveID, - "file_id": created.FileID, - "upload_id": created.UploadID, - }, &created) - if err != nil { - return nil, err - } - obj, err := d.getFileObj(ctx, created.FileID) - if err != nil { - return nil, err - } - return withParentPath(dstDir.GetPath(), obj), nil -} - func (d *PDS) Get(ctx context.Context, path string) (model.Obj, error) { if path == "/" || path == "" { return d.GetRoot(ctx) diff --git a/drivers/pds/direct_upload.go b/drivers/pds/upload.go similarity index 84% rename from drivers/pds/direct_upload.go rename to drivers/pds/upload.go index a548c6ea65..3e2f83840a 100644 --- a/drivers/pds/direct_upload.go +++ b/drivers/pds/upload.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -49,6 +50,41 @@ type directUploadCompletionInfo struct { Body map[string]any `json:"body,omitempty"` } +func (d *PDS) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + var created createFileResp + err := d.client.post(ctx, "/v2/file/create", map[string]any{ + "drive_id": d.DriveID, + "parent_file_id": d.fileID(dstDir), + "name": stream.GetName(), + "type": "file", + "check_name_mode": "auto_rename", + "size": stream.GetSize(), + "part_info_list": []map[string]int{{"part_number": 1}}, + }, &created) + if err != nil { + return nil, err + } + if len(created.PartInfoList) == 0 || created.PartInfoList[0].UploadURL == "" { + return nil, errors.New("pds create file did not return upload_url") + } + if err := d.client.putRaw(ctx, created.PartInfoList[0].UploadURL, stream); err != nil { + return nil, err + } + err = d.client.post(ctx, "/v2/file/complete", map[string]any{ + "drive_id": d.DriveID, + "file_id": created.FileID, + "upload_id": created.UploadID, + }, &created) + if err != nil { + return nil, err + } + obj, err := d.getFileObj(ctx, created.FileID) + if err != nil { + return nil, err + } + return withParentPath(dstDir.GetPath(), obj), nil +} + func (d *PDS) GetDirectUploadTools() []string { return []string{directUploadTool} } diff --git a/server/handles/direct_upload.go b/server/handles/direct_upload.go deleted file mode 100644 index 397ac462c4..0000000000 --- a/server/handles/direct_upload.go +++ /dev/null @@ -1,163 +0,0 @@ -package handles - -import ( - "net/url" - stdpath "path" - - "github.com/OpenListTeam/OpenList/v4/internal/conf" - "github.com/OpenListTeam/OpenList/v4/internal/errs" - "github.com/OpenListTeam/OpenList/v4/internal/fs" - "github.com/OpenListTeam/OpenList/v4/internal/model" - "github.com/OpenListTeam/OpenList/v4/internal/op" - "github.com/OpenListTeam/OpenList/v4/server/common" - "github.com/gin-gonic/gin" - "github.com/pkg/errors" -) - -type FsGetDirectUploadInfoReq struct { - Path string `json:"path" form:"path"` - FileName string `json:"file_name" form:"file_name"` - FileSize int64 `json:"file_size" form:"file_size"` - Tool string `json:"tool" form:"tool"` -} - -type FsCompleteDirectUploadReq struct { - Path string `json:"path" form:"path"` - FileName string `json:"file_name" form:"file_name"` - Tool string `json:"tool" form:"tool"` - UploadToken string `json:"upload_token" form:"upload_token"` -} - -func resolveDirectUploadDir(c *gin.Context, rawPath, fileName string) (string, error) { - path, err := url.PathUnescape(rawPath) - if err != nil { - return "", err - } - user := c.Request.Context().Value(conf.UserKey).(*model.User) - path, err = user.JoinPath(path) - if err != nil { - return "", err - } - if err := checkRelativePath(fileName); err != nil { - return "", err - } - return path, nil -} - -func resolveDirectUploadFile(c *gin.Context, rawPath, fileName string) (string, string, error) { - filePath := c.GetHeader("File-Path") - if filePath != "" { - path, err := url.PathUnescape(filePath) - if err != nil { - return "", "", err - } - user := c.Request.Context().Value(conf.UserKey).(*model.User) - path, err = user.JoinPath(path) - if err != nil { - return "", "", err - } - name := stdpath.Base(path) - if err := checkRelativePath(name); err != nil { - return "", "", err - } - return stdpath.Dir(path), name, nil - } - path, err := resolveDirectUploadDir(c, rawPath, fileName) - return path, fileName, err -} - -func checkDirectUploadWritePermission(c *gin.Context, parentPath string) error { - user := c.Request.Context().Value(conf.UserKey).(*model.User) - parentMeta, err := op.GetNearestMeta(parentPath) - if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { - return err - } - if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { - return errs.PermissionDenied - } - if !common.CanWrite(user, parentMeta, parentPath) { - return errs.PermissionDenied - } - return nil -} - -func respondDirectUploadPermissionError(c *gin.Context, err error) bool { - if err == nil { - return false - } - if errors.Is(err, errs.PermissionDenied) { - common.ErrorResp(c, errs.PermissionDenied, 403) - return true - } - common.ErrorResp(c, err, 500, true) - return true -} - -// FsGetDirectUploadInfo returns the direct upload info if supported by the driver -// If the driver does not support direct upload, returns null for upload_info -func FsGetDirectUploadInfo(c *gin.Context) { - var req FsGetDirectUploadInfoReq - if err := c.ShouldBind(&req); err != nil { - common.ErrorResp(c, err, 400) - return - } - path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) - if err != nil { - common.ErrorResp(c, err, 403) - return - } - if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { - return - } - overwrite := c.GetHeader("Overwrite") != "false" - dstPath := stdpath.Join(path, fileName) - if !overwrite { - res, err := fs.Get(c.Request.Context(), dstPath, &fs.GetArgs{NoLog: true}) - if err != nil && !errs.IsObjectNotFound(err) { - common.ErrorResp(c, err, 500) - return - } - if res != nil { - common.ErrorStrResp(c, "file exists", 403) - return - } - } - directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, fileName, req.FileSize, overwrite) - if err != nil { - if !overwrite && errs.IsObjectAlreadyExists(err) { - common.ErrorStrResp(c, "file exists", 403) - return - } - common.ErrorResp(c, err, 500) - return - } - common.SuccessResp(c, directUploadInfo) -} - -// FsCompleteDirectUpload commits a client-side upload session after the client -// has uploaded the file bytes directly to the storage provider. -func FsCompleteDirectUpload(c *gin.Context) { - var req FsCompleteDirectUploadReq - if err := c.ShouldBind(&req); err != nil { - common.ErrorResp(c, err, 400) - return - } - path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) - if err != nil { - common.ErrorResp(c, err, 403) - return - } - if req.UploadToken == "" { - common.ErrorStrResp(c, "upload_token is required", 400) - return - } - if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { - return - } - obj, err := fs.CompleteDirectUpload(c.Request.Context(), req.Tool, path, fileName, req.UploadToken) - if err != nil { - common.ErrorResp(c, err, 500) - return - } - common.SuccessResp(c, obj) -} diff --git a/server/handles/fsup.go b/server/handles/fsup.go index 0f46398cdf..f2127fa433 100644 --- a/server/handles/fsup.go +++ b/server/handles/fsup.go @@ -11,12 +11,14 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/errs" "github.com/OpenListTeam/OpenList/v4/internal/fs" "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/op" "github.com/OpenListTeam/OpenList/v4/internal/setting" "github.com/OpenListTeam/OpenList/v4/internal/stream" "github.com/OpenListTeam/OpenList/v4/internal/task" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/OpenListTeam/OpenList/v4/server/common" "github.com/gin-gonic/gin" + "github.com/pkg/errors" ) func getLastModified(c *gin.Context) time.Time { @@ -226,3 +228,151 @@ func FsForm(c *gin.Context) { "task": getTaskInfo(t), }) } + +type FsGetDirectUploadInfoReq struct { + Path string `json:"path" form:"path"` + FileName string `json:"file_name" form:"file_name"` + FileSize int64 `json:"file_size" form:"file_size"` + Tool string `json:"tool" form:"tool"` +} + +type FsCompleteDirectUploadReq struct { + Path string `json:"path" form:"path"` + FileName string `json:"file_name" form:"file_name"` + Tool string `json:"tool" form:"tool"` + UploadToken string `json:"upload_token" form:"upload_token"` +} + +func resolveDirectUploadDir(c *gin.Context, rawPath, fileName string) (string, error) { + path, err := url.PathUnescape(rawPath) + if err != nil { + return "", err + } + user := c.Request.Context().Value(conf.UserKey).(*model.User) + path, err = user.JoinPath(path) + if err != nil { + return "", err + } + if err := checkRelativePath(fileName); err != nil { + return "", err + } + return path, nil +} + +func resolveDirectUploadFile(c *gin.Context, rawPath, fileName string) (string, string, error) { + filePath := c.GetHeader("File-Path") + if filePath != "" { + path, err := url.PathUnescape(filePath) + if err != nil { + return "", "", err + } + user := c.Request.Context().Value(conf.UserKey).(*model.User) + path, err = user.JoinPath(path) + if err != nil { + return "", "", err + } + name := stdpath.Base(path) + if err := checkRelativePath(name); err != nil { + return "", "", err + } + return stdpath.Dir(path), name, nil + } + path, err := resolveDirectUploadDir(c, rawPath, fileName) + return path, fileName, err +} + +func checkDirectUploadWritePermission(c *gin.Context, parentPath string) error { + user := c.Request.Context().Value(conf.UserKey).(*model.User) + parentMeta, err := op.GetNearestMeta(parentPath) + if err != nil && !errors.Is(errors.Cause(err), errs.MetaNotFound) { + return err + } + if !user.CanWriteContent() && !common.CanWriteContentBypassUserPerms(parentMeta, parentPath) { + return errs.PermissionDenied + } + if !common.CanWrite(user, parentMeta, parentPath) { + return errs.PermissionDenied + } + return nil +} + +func respondDirectUploadPermissionError(c *gin.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, errs.PermissionDenied) { + common.ErrorResp(c, errs.PermissionDenied, 403) + return true + } + common.ErrorResp(c, err, 500, true) + return true +} + +// FsGetDirectUploadInfo returns the direct upload info if supported by the driver +// If the driver does not support direct upload, returns null for upload_info +func FsGetDirectUploadInfo(c *gin.Context) { + var req FsGetDirectUploadInfoReq + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { + return + } + overwrite := c.GetHeader("Overwrite") != "false" + dstPath := stdpath.Join(path, fileName) + if !overwrite { + res, err := fs.Get(c.Request.Context(), dstPath, &fs.GetArgs{NoLog: true}) + if err != nil && !errs.IsObjectNotFound(err) { + common.ErrorResp(c, err, 500) + return + } + if res != nil { + common.ErrorStrResp(c, "file exists", 403) + return + } + } + directUploadInfo, err := fs.GetDirectUploadInfo(c, req.Tool, path, fileName, req.FileSize, overwrite) + if err != nil { + if !overwrite && errs.IsObjectAlreadyExists(err) { + common.ErrorStrResp(c, "file exists", 403) + return + } + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, directUploadInfo) +} + +// FsCompleteDirectUpload commits a client-side upload session after the client +// has uploaded the file bytes directly to the storage provider. +func FsCompleteDirectUpload(c *gin.Context) { + var req FsCompleteDirectUploadReq + if err := c.ShouldBind(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + path, fileName, err := resolveDirectUploadFile(c, req.Path, req.FileName) + if err != nil { + common.ErrorResp(c, err, 403) + return + } + if req.UploadToken == "" { + common.ErrorStrResp(c, "upload_token is required", 400) + return + } + if respondDirectUploadPermissionError(c, checkDirectUploadWritePermission(c, path)) { + return + } + obj, err := fs.CompleteDirectUpload(c.Request.Context(), req.Tool, path, fileName, req.UploadToken) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, obj) +} From 7f9388b6525d2d4ee22aed1b6e9f3975cd5f9b9b Mon Sep 17 00:00:00 2001 From: zymooll Date: Wed, 1 Jul 2026 22:04:29 +0800 Subject: [PATCH 5/7] refactor(pds): move helpers into util - Move PDS helper functions out of driver.go into util.go. - Remove the PDS driver test file from the tracked tree while keeping local tests available. --- drivers/pds/driver.go | 92 ----------- drivers/pds/driver_test.go | 330 ------------------------------------- drivers/pds/util.go | 101 ++++++++++++ 3 files changed, 101 insertions(+), 422 deletions(-) delete mode 100644 drivers/pds/driver_test.go create mode 100644 drivers/pds/util.go diff --git a/drivers/pds/driver.go b/drivers/pds/driver.go index a710a62f7a..0867b4cd68 100644 --- a/drivers/pds/driver.go +++ b/drivers/pds/driver.go @@ -4,7 +4,6 @@ import ( "context" "errors" "path" - "strings" "time" "github.com/OpenListTeam/OpenList/v4/internal/driver" @@ -203,97 +202,6 @@ func (d *PDS) GetDetails(ctx context.Context) (*model.StorageDetails, error) { }, nil } -func (d *PDS) fileID(obj model.Obj) string { - if obj == nil { - return d.RootFolderID - } - if id := obj.GetID(); id != "" { - return id - } - return d.RootFolderID -} - -func withParentPath(parentPath string, obj model.Obj) model.Obj { - if obj == nil { - return nil - } - if parentPath == "" || parentPath == "." { - parentPath = "/" - } - if setter, ok := obj.(model.SetPath); ok { - setter.SetPath(path.Join(parentPath, obj.GetName())) - } - return obj -} - -func (d *PDS) getFile(ctx context.Context, fileID string) (fileItem, error) { - var item fileItem - err := d.client.post(ctx, "/v2/file/get", map[string]any{ - "drive_id": d.DriveID, - "file_id": fileID, - }, &item) - return item, err -} - -func (d *PDS) getFileObj(ctx context.Context, fileID string) (model.Obj, error) { - item, err := d.getFile(ctx, fileID) - if err != nil { - return nil, err - } - return item.toObj(), nil -} - -func (d *PDS) getByPath(ctx context.Context, rawPath string) (model.Obj, error) { - parts := strings.Split(strings.Trim(rawPath, "/"), "/") - parentID := d.RootFolderID - var current fileItem - currentPath := "/" - for _, part := range parts { - if part == "" { - continue - } - found, err := d.findChild(ctx, parentID, part) - if err != nil { - return nil, err - } - current = found - parentID = found.FileID - currentPath = path.Join(currentPath, found.Name) - } - if current.FileID == "" { - return nil, errs.ObjectNotFound - } - obj := current.toObj() - if setter, ok := obj.(model.SetPath); ok { - setter.SetPath(currentPath) - } - return obj, nil -} - -func (d *PDS) findChild(ctx context.Context, parentID, name string) (fileItem, error) { - var resp listFilesResp - err := d.client.post(ctx, "/v2/file/search", map[string]any{ - "drive_id": d.DriveID, - "query": "parent_file_id = \"" + parentID + "\" and name = \"" + escapeQueryValue(name) + "\"", - "limit": 10, - "fields": "*", - }, &resp) - if err != nil { - return fileItem{}, err - } - for _, item := range resp.Items { - if item.Name == name { - return item, nil - } - } - return fileItem{}, errs.ObjectNotFound -} - -func escapeQueryValue(s string) string { - s = strings.ReplaceAll(s, "\\", "\\\\") - return strings.ReplaceAll(s, "\"", "\\\"") -} - var _ driver.Driver = (*PDS)(nil) var _ driver.Getter = (*PDS)(nil) var _ driver.GetRooter = (*PDS)(nil) diff --git a/drivers/pds/driver_test.go b/drivers/pds/driver_test.go deleted file mode 100644 index a1e62e644e..0000000000 --- a/drivers/pds/driver_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package pds - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "testing" - "time" - - "github.com/OpenListTeam/OpenList/v4/internal/model" -) - -func TestInitRequiresToken(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - DomainID: "domain", - DriveID: "drive", - }, - } - - if err := driver.Init(context.Background()); err == nil { - t.Fatal("expected missing token error") - } -} - -func TestInitAcceptsRefreshTokenOnly(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - DomainID: "domain", - DriveID: "drive", - RefreshToken: "refresh", - }, - } - - if err := driver.Init(context.Background()); err != nil { - t.Fatalf("expected refresh token to be enough, got %v", err) - } - if driver.RootFolderID != "root" { - t.Fatalf("expected default root folder id, got %q", driver.RootFolderID) - } -} - -func TestEscapeQueryValue(t *testing.T) { - got := escapeQueryValue(`a\b"c`) - want := `a\\b\"c` - if got != want { - t.Fatalf("escapeQueryValue() = %q, want %q", got, want) - } -} - -func TestEnsureTokenSkipsRefreshWhenExpiresAtZeroAndAccessTokenExists(t *testing.T) { - addition := &Addition{ - DomainID: "domain", - ClientID: "client", - AccessToken: "access", - RefreshToken: "refresh", - TokenType: "Bearer", - ExpiresAt: 0, - } - client := &client{ - addition: addition, - http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("unexpected refresh request to %s", req.URL.String()) - })}, - } - - if err := client.ensureToken(context.Background()); err != nil { - t.Fatalf("ensureToken() error = %v", err) - } - if addition.AccessToken != "access" { - t.Fatalf("AccessToken = %q, want access", addition.AccessToken) - } - if addition.ExpiresAt != 0 { - t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) - } -} - -func TestEnsureTokenRefreshesMissingAccessTokenAndKeepsExpiresAtZero(t *testing.T) { - addition := &Addition{ - DomainID: "domain", - ClientID: "client", - RefreshToken: "refresh", - TokenType: "Bearer", - } - saveCalls := 0 - refreshCalls := 0 - client := &client{ - addition: addition, - http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - refreshCalls++ - if req.URL.Host != "domain.auth.aliyunfile.com" || req.URL.Path != "/v2/oauth/token" { - return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) - } - body, err := io.ReadAll(req.Body) - if err != nil { - return nil, err - } - if !strings.Contains(string(body), "grant_type=refresh_token") { - return nil, fmt.Errorf("refresh request body = %q", string(body)) - } - return testJSONResponse(req, http.StatusOK, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh2"}`), nil - })}, - onSave: func() { - saveCalls++ - }, - } - - if err := client.ensureToken(context.Background()); err != nil { - t.Fatalf("ensureToken() error = %v", err) - } - if refreshCalls != 1 { - t.Fatalf("refreshCalls = %d, want 1", refreshCalls) - } - if saveCalls != 1 { - t.Fatalf("saveCalls = %d, want 1", saveCalls) - } - if addition.AccessToken != "fresh" { - t.Fatalf("AccessToken = %q, want fresh", addition.AccessToken) - } - if addition.RefreshToken != "refresh2" { - t.Fatalf("RefreshToken = %q, want refresh2", addition.RefreshToken) - } - if addition.ExpiresAt != 0 { - t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) - } -} - -func TestPostRefreshesAndRetriesOnAccessTokenExpired(t *testing.T) { - addition := &Addition{ - DomainID: "domain", - ClientID: "client", - AccessToken: "expired", - RefreshToken: "refresh", - TokenType: "Bearer", - ExpiresAt: 0, - } - apiCalls := 0 - refreshCalls := 0 - saveCalls := 0 - client := &client{ - addition: addition, - http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - switch req.URL.Host { - case "domain.api.aliyunfile.com": - apiCalls++ - switch apiCalls { - case 1: - if got := req.Header.Get("Authorization"); got != "Bearer expired" { - return nil, fmt.Errorf("first Authorization = %q, want Bearer expired", got) - } - return testJSONResponse(req, http.StatusUnauthorized, `{"code":"AccessTokenExpired","message":"access token expired"}`), nil - case 2: - if got := req.Header.Get("Authorization"); got != "Bearer fresh" { - return nil, fmt.Errorf("second Authorization = %q, want Bearer fresh", got) - } - return testJSONResponse(req, http.StatusOK, `{}`), nil - default: - return nil, fmt.Errorf("unexpected api call %d", apiCalls) - } - case "domain.auth.aliyunfile.com": - refreshCalls++ - return testJSONResponse(req, http.StatusOK, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600}`), nil - default: - return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) - } - })}, - onSave: func() { - saveCalls++ - }, - } - - if err := client.post(context.Background(), "/v2/file/list", map[string]any{"drive_id": "drive"}, nil); err != nil { - t.Fatalf("post() error = %v", err) - } - if apiCalls != 2 { - t.Fatalf("apiCalls = %d, want 2", apiCalls) - } - if refreshCalls != 1 { - t.Fatalf("refreshCalls = %d, want 1", refreshCalls) - } - if saveCalls != 1 { - t.Fatalf("saveCalls = %d, want 1", saveCalls) - } - if addition.AccessToken != "fresh" { - t.Fatalf("AccessToken = %q, want fresh", addition.AccessToken) - } - if addition.ExpiresAt != 0 { - t.Fatalf("ExpiresAt = %d, want 0", addition.ExpiresAt) - } -} - -func TestDirectUploadTools(t *testing.T) { - driver := &PDS{} - tools := driver.GetDirectUploadTools() - if len(tools) != 1 || tools[0] != directUploadTool { - t.Fatalf("GetDirectUploadTools() = %v, want [%s]", tools, directUploadTool) - } -} - -func TestMakeDirSetsReturnedPath(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - DomainID: "domain", - DriveID: "drive", - AccessToken: "access", - TokenType: "Bearer", - }, - } - driver.client = &client{ - addition: &driver.Addition, - http: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.Host != "domain.api.aliyunfile.com" || req.URL.Path != "/v2/file/create" { - return nil, fmt.Errorf("unexpected request to %s", req.URL.String()) - } - return testJSONResponse(req, http.StatusOK, `{"file_id":"child-id","name":"child"}`), nil - })}, - } - - obj, err := driver.MakeDir(context.Background(), &model.Object{ - ID: "parent-id", - Path: "/parent", - Name: "parent", - IsFolder: true, - }, "child") - if err != nil { - t.Fatalf("MakeDir() error = %v", err) - } - if obj.GetPath() != "/parent/child" { - t.Fatalf("MakeDir() path = %q, want /parent/child", obj.GetPath()) - } -} - -func TestDirectUploadTokenRoundTrip(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - RefreshToken: "refresh", - }, - } - token := directUploadToken{ - DomainID: "domain", - DriveID: "drive", - ParentFileID: "root", - FileID: "file", - UploadID: "upload", - FileName: "test.txt", - FileSize: 4, - ExpiresAt: time.Now().Add(time.Minute).Unix(), - } - - raw, err := driver.signDirectUploadToken(token) - if err != nil { - t.Fatalf("signDirectUploadToken() error = %v", err) - } - got, err := driver.verifyDirectUploadToken(raw) - if err != nil { - t.Fatalf("verifyDirectUploadToken() error = %v", err) - } - if *got != token { - t.Fatalf("verifyDirectUploadToken() = %+v, want %+v", *got, token) - } -} - -func TestDirectUploadTokenRejectsTampering(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - RefreshToken: "refresh", - }, - } - raw, err := driver.signDirectUploadToken(directUploadToken{ - DomainID: "domain", - DriveID: "drive", - FileID: "file", - UploadID: "upload", - FileName: "test.txt", - ExpiresAt: time.Now().Add(time.Minute).Unix(), - }) - if err != nil { - t.Fatalf("signDirectUploadToken() error = %v", err) - } - - last := raw[len(raw)-1] - replacement := byte('A') - if last == replacement { - replacement = 'B' - } - tampered := raw[:len(raw)-1] + string(replacement) - if _, err := driver.verifyDirectUploadToken(tampered); err == nil { - t.Fatal("expected tampered direct upload token to be rejected") - } -} - -func TestDirectUploadTokenRejectsExpired(t *testing.T) { - driver := &PDS{ - Addition: Addition{ - RefreshToken: "refresh", - }, - } - raw, err := driver.signDirectUploadToken(directUploadToken{ - DomainID: "domain", - DriveID: "drive", - FileID: "file", - UploadID: "upload", - FileName: "test.txt", - ExpiresAt: time.Now().Add(-time.Minute).Unix(), - }) - if err != nil { - t.Fatalf("signDirectUploadToken() error = %v", err) - } - if _, err := driver.verifyDirectUploadToken(raw); err == nil { - t.Fatal("expected expired direct upload token to be rejected") - } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func testJSONResponse(req *http.Request, statusCode int, body string) *http.Response { - return &http.Response{ - StatusCode: statusCode, - Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)), - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - } -} diff --git a/drivers/pds/util.go b/drivers/pds/util.go new file mode 100644 index 0000000000..7e815dbb58 --- /dev/null +++ b/drivers/pds/util.go @@ -0,0 +1,101 @@ +package pds + +import ( + "context" + "path" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +func (d *PDS) fileID(obj model.Obj) string { + if obj == nil { + return d.RootFolderID + } + if id := obj.GetID(); id != "" { + return id + } + return d.RootFolderID +} + +func withParentPath(parentPath string, obj model.Obj) model.Obj { + if obj == nil { + return nil + } + if parentPath == "" || parentPath == "." { + parentPath = "/" + } + if setter, ok := obj.(model.SetPath); ok { + setter.SetPath(path.Join(parentPath, obj.GetName())) + } + return obj +} + +func (d *PDS) getFile(ctx context.Context, fileID string) (fileItem, error) { + var item fileItem + err := d.client.post(ctx, "/v2/file/get", map[string]any{ + "drive_id": d.DriveID, + "file_id": fileID, + }, &item) + return item, err +} + +func (d *PDS) getFileObj(ctx context.Context, fileID string) (model.Obj, error) { + item, err := d.getFile(ctx, fileID) + if err != nil { + return nil, err + } + return item.toObj(), nil +} + +func (d *PDS) getByPath(ctx context.Context, rawPath string) (model.Obj, error) { + parts := strings.Split(strings.Trim(rawPath, "/"), "/") + parentID := d.RootFolderID + var current fileItem + currentPath := "/" + for _, part := range parts { + if part == "" { + continue + } + found, err := d.findChild(ctx, parentID, part) + if err != nil { + return nil, err + } + current = found + parentID = found.FileID + currentPath = path.Join(currentPath, found.Name) + } + if current.FileID == "" { + return nil, errs.ObjectNotFound + } + obj := current.toObj() + if setter, ok := obj.(model.SetPath); ok { + setter.SetPath(currentPath) + } + return obj, nil +} + +func (d *PDS) findChild(ctx context.Context, parentID, name string) (fileItem, error) { + var resp listFilesResp + err := d.client.post(ctx, "/v2/file/search", map[string]any{ + "drive_id": d.DriveID, + "query": "parent_file_id = \"" + parentID + "\" and name = \"" + escapeQueryValue(name) + "\"", + "limit": 10, + "fields": "*", + }, &resp) + if err != nil { + return fileItem{}, err + } + for _, item := range resp.Items { + if item.Name == name { + return item, nil + } + } + return fileItem{}, errs.ObjectNotFound +} + +func escapeQueryValue(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + return strings.ReplaceAll(s, "\"", "\\\"") +} From 49293e2f871463c0c26f3c81b999cbc489f43dac Mon Sep 17 00:00:00 2001 From: zymooll Date: Wed, 1 Jul 2026 22:19:54 +0800 Subject: [PATCH 6/7] chore(pds): remove local readme from tracked tree --- drivers/pds/README.md | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 drivers/pds/README.md diff --git a/drivers/pds/README.md b/drivers/pds/README.md deleted file mode 100644 index 70523ea845..0000000000 --- a/drivers/pds/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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. From 0f9038b8e4a4cdfe48cb52a62144f71a1cc4a556 Mon Sep 17 00:00:00 2001 From: zymooll Date: Thu, 2 Jul 2026 00:09:15 +0800 Subject: [PATCH 7/7] feat(pds): support thumbnails --- drivers/pds/driver.go | 5 ++--- drivers/pds/types.go | 16 ++++++++++------ drivers/pds/util.go | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/drivers/pds/driver.go b/drivers/pds/driver.go index 0867b4cd68..04becbcf80 100644 --- a/drivers/pds/driver.go +++ b/drivers/pds/driver.go @@ -55,7 +55,7 @@ func (d *PDS) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m marker := "" for { var resp listFilesResp - err := d.client.post(ctx, "/v2/file/list", map[string]any{ + err := d.client.post(ctx, "/v2/file/list", withFilePreviewParams(map[string]any{ "drive_id": d.DriveID, "parent_file_id": parentID, "limit": 100, @@ -63,9 +63,8 @@ func (d *PDS) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]m "order_by": "updated_at", "order_direction": "DESC", "fields": "*", - "url_expire_sec": 7200, "include_handover_drive": true, - }, &resp) + }), &resp) if err != nil { return nil, err } diff --git a/drivers/pds/types.go b/drivers/pds/types.go index 33f3764eb8..f4afd0952b 100644 --- a/drivers/pds/types.go +++ b/drivers/pds/types.go @@ -17,6 +17,7 @@ type fileItem struct { UpdatedAt string `json:"updated_at"` CreatedAt string `json:"created_at"` DownloadURL string `json:"download_url"` + Thumbnail string `json:"thumbnail"` } func (f fileItem) ModTime() time.Time { @@ -89,11 +90,14 @@ func (f fileItem) toObj() model.Obj { if f.Type == "folder" { size = 0 } - return &model.Object{ - ID: f.FileID, - Name: f.Name, - Size: size, - Modified: f.ModTime(), - IsFolder: f.Type == "folder", + return &model.ObjThumb{ + Object: model.Object{ + ID: f.FileID, + Name: f.Name, + Size: size, + Modified: f.ModTime(), + IsFolder: f.Type == "folder", + }, + Thumbnail: model.Thumbnail{Thumbnail: f.Thumbnail}, } } diff --git a/drivers/pds/util.go b/drivers/pds/util.go index 7e815dbb58..d526412bdd 100644 --- a/drivers/pds/util.go +++ b/drivers/pds/util.go @@ -9,6 +9,14 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" ) +func withFilePreviewParams(params map[string]any) map[string]any { + params["url_expire_sec"] = 7200 + params["image_thumbnail_process"] = "image/resize,w_400/format,jpeg" + params["image_url_process"] = "image/resize,w_1920/format,jpeg" + params["video_thumbnail_process"] = "video/snapshot,t_0,f_jpg,ar_auto,w_300" + return params +} + func (d *PDS) fileID(obj model.Obj) string { if obj == nil { return d.RootFolderID @@ -34,10 +42,10 @@ func withParentPath(parentPath string, obj model.Obj) model.Obj { func (d *PDS) getFile(ctx context.Context, fileID string) (fileItem, error) { var item fileItem - err := d.client.post(ctx, "/v2/file/get", map[string]any{ + err := d.client.post(ctx, "/v2/file/get", withFilePreviewParams(map[string]any{ "drive_id": d.DriveID, "file_id": fileID, - }, &item) + }), &item) return item, err } @@ -78,12 +86,12 @@ func (d *PDS) getByPath(ctx context.Context, rawPath string) (model.Obj, error) func (d *PDS) findChild(ctx context.Context, parentID, name string) (fileItem, error) { var resp listFilesResp - err := d.client.post(ctx, "/v2/file/search", map[string]any{ + err := d.client.post(ctx, "/v2/file/search", withFilePreviewParams(map[string]any{ "drive_id": d.DriveID, "query": "parent_file_id = \"" + parentID + "\" and name = \"" + escapeQueryValue(name) + "\"", "limit": 10, "fields": "*", - }, &resp) + }), &resp) if err != nil { return fileItem{}, err }