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..5e0766a9c3 --- /dev/null +++ b/drivers/pds/api.go @@ -0,0 +1,214 @@ +package pds + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "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) refreshToken(ctx context.Context) error { + if c.addition.RefreshToken == "" { + return nil + } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", c.addition.RefreshToken) + form.Set("client_id", c.addition.ClientID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authURL("/v2/oauth/token"), bytes.NewBufferString(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("refresh token failed: %s: %s", resp.Status, string(data)) + } + + var token struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(data, &token); err != nil { + return err + } + if token.AccessToken == "" { + return fmt.Errorf("refresh token failed: access_token is empty") + } + c.addition.AccessToken = token.AccessToken + if token.TokenType != "" { + c.addition.TokenType = token.TokenType + } + if token.RefreshToken != "" { + c.addition.RefreshToken = token.RefreshToken + } + c.addition.ExpiresAt = 0 + if c.onSave != nil { + c.onSave() + } + return nil +} + +func (c *client) ensureToken(ctx context.Context) error { + if c.addition.RefreshToken == "" { + return nil + } + if c.addition.AccessToken == "" { + return c.refreshToken(ctx) + } + if c.addition.ExpiresAt > 0 && time.Now().Unix() >= c.addition.ExpiresAt-300 { + return c.refreshToken(ctx) + } + return nil +} + +func (c *client) post(ctx context.Context, path string, body any, out any) error { + if err := c.ensureToken(ctx); err != nil { + return err + } + payload, err := json.Marshal(body) + if err != nil { + return err + } + data, statusCode, status, err := c.postPayload(ctx, path, payload) + if err != nil { + return err + } + if statusCode >= 400 && isAccessTokenExpiredError(statusCode, data) && c.addition.RefreshToken != "" { + if err := c.refreshToken(ctx); err != nil { + return err + } + data, statusCode, status, err = c.postPayload(ctx, path, payload) + if err != nil { + return err + } + } + if statusCode >= 400 { + return fmt.Errorf("pds api %s failed: %s: %s", path, status, string(data)) + } + if out == nil || len(data) == 0 { + return nil + } + return json.Unmarshal(data, out) +} + +func (c *client) postPayload(ctx context.Context, path string, payload []byte) ([]byte, int, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL(path), bytes.NewReader(payload)) + if err != nil { + return nil, 0, "", err + } + req.Header.Set("Authorization", c.addition.TokenType+" "+c.addition.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, "", err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, 0, "", err + } + return data, resp.StatusCode, resp.Status, nil +} + +func isAccessTokenExpiredError(statusCode int, data []byte) bool { + if statusCode < http.StatusBadRequest { + return false + } + var apiErr struct { + Code string `json:"code"` + Message string `json:"message"` + Error string `json:"error"` + } + text := string(data) + if len(data) > 0 && json.Unmarshal(data, &apiErr) == nil { + text = apiErr.Code + " " + apiErr.Message + " " + apiErr.Error + } + text = strings.ToLower(text) + for _, marker := range []string{ + "accesstokenexpired", + "access token expired", + "accesstokeninvalid", + "access token invalid", + "invalidaccesstoken", + "invalid access token", + "token expired", + "expiredtoken", + } { + if strings.Contains(text, marker) { + return true + } + } + return false +} + +func (c *client) putRaw(ctx context.Context, uploadURL string, r io.Reader) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, r) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pds upload failed: %s: %s", resp.Status, string(data)) + } + return nil +} diff --git a/drivers/pds/direct_upload.go b/drivers/pds/direct_upload.go new file mode 100644 index 0000000000..a548c6ea65 --- /dev/null +++ b/drivers/pds/direct_upload.go @@ -0,0 +1,218 @@ +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) { + 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 + } + 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) { + 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.go b/drivers/pds/driver.go new file mode 100644 index 0000000000..39564e89d8 --- /dev/null +++ b/drivers/pds/driver.go @@ -0,0 +1,341 @@ +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 withParentPath(parentDir.GetPath(), 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 + } + 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) { + 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 withParentPath(path.Dir(srcObj.GetPath()), 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 + } + 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 { + 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 + } + 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) + } + 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 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) +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..a1e62e644e --- /dev/null +++ b/drivers/pds/driver_test.go @@ -0,0 +1,330 @@ +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/meta.go b/drivers/pds/meta.go new file mode 100644 index 0000000000..85dfd33249 --- /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" default:"0" 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", + } +} 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..397ac462c4 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,78 @@ 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 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) { @@ -27,25 +101,16 @@ 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, fileName, err := resolveDirectUploadFile(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" - 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) { @@ -57,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) @@ -68,3 +133,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, 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/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) {