diff --git a/drivers/all.go b/drivers/all.go index 5b274eab..d135aae6 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -23,6 +23,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve" _ "github.com/OpenListTeam/OpenList/v4/drivers/cloudreve_v4" _ "github.com/OpenListTeam/OpenList/v4/drivers/crypt" + _ "github.com/OpenListTeam/OpenList/v4/drivers/degoo" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox" diff --git a/drivers/degoo/driver.go b/drivers/degoo/driver.go new file mode 100644 index 00000000..b42ecf9d --- /dev/null +++ b/drivers/degoo/driver.go @@ -0,0 +1,205 @@ +package degoo + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "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" +) + +type Degoo struct { + model.Storage + Addition + client *http.Client +} + +func (d *Degoo) Config() driver.Config { + return config +} + +func (d *Degoo) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Degoo) Init(ctx context.Context) error { + + d.client = base.HttpClient + + if d.Token == "" { + err := d.login(ctx) + if err != nil { + return err + } + } + + return d.getDevices(ctx) +} + +func (d *Degoo) Drop(ctx context.Context) error { + return nil +} + +func (d *Degoo) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + items, err := d.getAllFileChildren5(ctx, dir.GetID()) + if err != nil { + return nil, err + } + return utils.MustSliceConvert(items, func(s DegooFileItem) model.Obj { + isFolder := s.Category == 2 || s.Category == 1 || s.Category == 10 + + createTime, modTime, _ := humanReadableTimes(s.CreationTime, s.LastModificationTime, s.LastUploadTime) + + size, err := strconv.ParseInt(s.Size, 10, 64) + if err != nil { + size = 0 // Default to 0 if size parsing fails + } + + return &model.Object{ + ID: s.ID, + Path: s.FilePath, + Name: s.Name, + Size: size, + Modified: modTime, + Ctime: createTime, + IsFolder: isFolder, + } + }), nil +} + +func (d *Degoo) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + item, err := d.getOverlay4(ctx, file.GetID()) + if err != nil { + return nil, err + } + + return &model.Link{URL: item.URL}, nil +} + +func (d *Degoo) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + // This is done by calling the setUploadFile3 API with a special checksum and size. + const query = `mutation SetUploadFile3($Token: String!, $FileInfos: [FileInfoUpload3]!) { setUploadFile3(Token: $Token, FileInfos: $FileInfos) }` + + variables := map[string]interface{}{ + "Token": d.Token, + "FileInfos": []map[string]interface{}{ + { + "Checksum": folderChecksum, + "Name": dirName, + "CreationTime": time.Now().UnixMilli(), + "ParentID": parentDir.GetID(), + "Size": 0, + }, + }, + } + + _, err := d.apiCall(ctx, "SetUploadFile3", query, variables) + if err != nil { + return err + } + + return nil +} + +func (d *Degoo) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + const query = `mutation SetMoveFile($Token: String!, $Copy: Boolean, $NewParentID: String!, $FileIDs: [String]!) { setMoveFile(Token: $Token, Copy: $Copy, NewParentID: $NewParentID, FileIDs: $FileIDs) }` + + variables := map[string]interface{}{ + "Token": d.Token, + "Copy": false, + "NewParentID": dstDir.GetID(), + "FileIDs": []string{srcObj.GetID()}, + } + + _, err := d.apiCall(ctx, "SetMoveFile", query, variables) + if err != nil { + return nil, err + } + + return srcObj, nil +} + +func (d *Degoo) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + const query = `mutation SetRenameFile($Token: String!, $FileRenames: [FileRenameInfo]!) { setRenameFile(Token: $Token, FileRenames: $FileRenames) }` + + variables := map[string]interface{}{ + "Token": d.Token, + "FileRenames": []DegooFileRenameInfo{ + { + ID: srcObj.GetID(), + NewName: newName, + }, + }, + } + + _, err := d.apiCall(ctx, "SetRenameFile", query, variables) + if err != nil { + return err + } + return nil +} + +func (d *Degoo) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + // Copy is not implemented, Degoo API does not support direct copy. + return nil, errs.NotImplement +} + +func (d *Degoo) Remove(ctx context.Context, obj model.Obj) error { + // Remove deletes a file or folder (moves to trash). + const query = `mutation SetDeleteFile5($Token: String!, $IsInRecycleBin: Boolean!, $IDs: [IDType]!) { setDeleteFile5(Token: $Token, IsInRecycleBin: $IsInRecycleBin, IDs: $IDs) }` + + variables := map[string]interface{}{ + "Token": d.Token, + "IsInRecycleBin": false, + "IDs": []map[string]string{{"FileID": obj.GetID()}}, + } + + _, err := d.apiCall(ctx, "SetDeleteFile5", query, variables) + return err +} + +func (d *Degoo) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error { + tmpF, err := file.CacheFullAndWriter(&up, nil) + if err != nil { + return err + } + + parentID := dstDir.GetID() + + // Calculate the checksum for the file. + checksum, err := d.checkSum(tmpF) + if err != nil { + return err + } + + // 1. Get upload authorization via getBucketWriteAuth4. + auths, err := d.getBucketWriteAuth4(ctx, file, parentID, checksum) + if err != nil { + return err + } + + // 2. Upload file. + // support rapid upload + if auths.GetBucketWriteAuth4[0].Error != "Already exist!" { + err = d.uploadS3(ctx, auths, tmpF, file, checksum) + if err != nil { + return err + } + } + + // 3. Register metadata with setUploadFile3. + data, err := d.SetUploadFile3(ctx, file, parentID, checksum) + if err != nil { + return err + } + if !data.SetUploadFile3 { + return fmt.Errorf("setUploadFile3 failed: %v", data) + } + return nil +} diff --git a/drivers/degoo/meta.go b/drivers/degoo/meta.go new file mode 100644 index 00000000..62ce306a --- /dev/null +++ b/drivers/degoo/meta.go @@ -0,0 +1,26 @@ +package degoo + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + Username string `json:"username" required:"true" help:"Your Degoo account email"` + Password string `json:"password" required:"true" help:"Your Degoo account password"` + Token string `json:"token" help:"Access token for Degoo API, will be obtained automatically if not provided"` +} + +var config = driver.Config{ + Name: "Degoo", + LocalSort: true, + DefaultRoot: "0", + NoOverwriteUpload: true, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Degoo{} + }) +} diff --git a/drivers/degoo/types.go b/drivers/degoo/types.go new file mode 100644 index 00000000..9d793b76 --- /dev/null +++ b/drivers/degoo/types.go @@ -0,0 +1,110 @@ +package degoo + +import ( + "encoding/json" +) + +// DegooLoginRequest represents the login request body. +type DegooLoginRequest struct { + GenerateToken bool `json:"GenerateToken"` + Username string `json:"Username"` + Password string `json:"Password"` +} + +// DegooLoginResponse represents a successful login response. +type DegooLoginResponse struct { + Token string `json:"Token"` + RefreshToken string `json:"RefreshToken"` +} + +// DegooAccessTokenRequest represents the token refresh request body. +type DegooAccessTokenRequest struct { + RefreshToken string `json:"RefreshToken"` +} + +// DegooAccessTokenResponse represents the token refresh response. +type DegooAccessTokenResponse struct { + AccessToken string `json:"AccessToken"` +} + +// DegooFileItem represents a Degoo file or folder. +type DegooFileItem struct { + ID string `json:"ID"` + ParentID string `json:"ParentID"` + Name string `json:"Name"` + Category int `json:"Category"` + Size string `json:"Size"` + URL string `json:"URL"` + CreationTime string `json:"CreationTime"` + LastModificationTime string `json:"LastModificationTime"` + LastUploadTime string `json:"LastUploadTime"` + MetadataID string `json:"MetadataID"` + DeviceID int64 `json:"DeviceID"` + FilePath string `json:"FilePath"` + IsInRecycleBin bool `json:"IsInRecycleBin"` +} + +type DegooErrors struct { + Path []string `json:"path"` + Data interface{} `json:"data"` + ErrorType string `json:"errorType"` + ErrorInfo interface{} `json:"errorInfo"` + Message string `json:"message"` +} + +// DegooGraphqlResponse is the common structure for GraphQL API responses. +type DegooGraphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []DegooErrors `json:"errors,omitempty"` +} + +// DegooGetChildren5Data is the data field for getFileChildren5. +type DegooGetChildren5Data struct { + GetFileChildren5 struct { + Items []DegooFileItem `json:"Items"` + NextToken string `json:"NextToken"` + } `json:"getFileChildren5"` +} + +// DegooGetOverlay4Data is the data field for getOverlay4. +type DegooGetOverlay4Data struct { + GetOverlay4 DegooFileItem `json:"getOverlay4"` +} + +// DegooFileRenameInfo represents a file rename operation. +type DegooFileRenameInfo struct { + ID string `json:"ID"` + NewName string `json:"NewName"` +} + +// DegooFileIDs represents a list of file IDs for move operations. +type DegooFileIDs struct { + FileIDs []string `json:"FileIDs"` +} + +// DegooGetBucketWriteAuth4Data is the data field for GetBucketWriteAuth4. +type DegooGetBucketWriteAuth4Data struct { + GetBucketWriteAuth4 []struct { + AuthData struct { + PolicyBase64 string `json:"PolicyBase64"` + Signature string `json:"Signature"` + BaseURL string `json:"BaseURL"` + KeyPrefix string `json:"KeyPrefix"` + AccessKey struct { + Key string `json:"Key"` + Value string `json:"Value"` + } `json:"AccessKey"` + ACL string `json:"ACL"` + AdditionalBody []struct { + Key string `json:"Key"` + Value string `json:"Value"` + } `json:"AdditionalBody"` + } `json:"AuthData"` + Error interface{} `json:"Error"` + } `json:"getBucketWriteAuth4"` +} + +// DegooSetUploadFile3Data is the data field for SetUploadFile3. +type DegooSetUploadFile3Data struct { + SetUploadFile3 bool `json:"setUploadFile3"` +} diff --git a/drivers/degoo/upload.go b/drivers/degoo/upload.go new file mode 100644 index 00000000..88b9e740 --- /dev/null +++ b/drivers/degoo/upload.go @@ -0,0 +1,198 @@ +package degoo + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +func (d *Degoo) getBucketWriteAuth4(ctx context.Context, file model.FileStreamer, parentID string, checksum string) (*DegooGetBucketWriteAuth4Data, error) { + const query = `query GetBucketWriteAuth4( + $Token: String! + $ParentID: String! + $StorageUploadInfos: [StorageUploadInfo2] + ) { + getBucketWriteAuth4( + Token: $Token + ParentID: $ParentID + StorageUploadInfos: $StorageUploadInfos + ) { + AuthData { + PolicyBase64 + Signature + BaseURL + KeyPrefix + AccessKey { + Key + Value + } + ACL + AdditionalBody { + Key + Value + } + } + Error + } + }` + + variables := map[string]interface{}{ + "Token": d.Token, + "ParentID": parentID, + "StorageUploadInfos": []map[string]string{{ + "FileName": file.GetName(), + "Checksum": checksum, + "Size": strconv.FormatInt(file.GetSize(), 10), + }}} + + data, err := d.apiCall(ctx, "GetBucketWriteAuth4", query, variables) + if err != nil { + return nil, err + } + + var resp DegooGetBucketWriteAuth4Data + err = json.Unmarshal(data, &resp) + if err != nil { + return nil, err + } + + return &resp, nil +} + +// checkSum calculates the SHA1-based checksum for Degoo upload API. +func (d *Degoo) checkSum(file io.Reader) (string, error) { + seed := []byte{13, 7, 2, 2, 15, 40, 75, 117, 13, 10, 19, 16, 29, 23, 3, 36} + hasher := sha1.New() + hasher.Write(seed) + + if _, err := utils.CopyWithBuffer(hasher, file); err != nil { + return "", err + } + + cs := hasher.Sum(nil) + + csBytes := []byte{10, byte(len(cs))} + csBytes = append(csBytes, cs...) + csBytes = append(csBytes, 16, 0) + + return strings.ReplaceAll(base64.StdEncoding.EncodeToString(csBytes), "/", "_"), nil +} + +func (d *Degoo) uploadS3(ctx context.Context, auths *DegooGetBucketWriteAuth4Data, tmpF model.File, file model.FileStreamer, checksum string) error { + a := auths.GetBucketWriteAuth4[0].AuthData + + _, err := tmpF.Seek(0, io.SeekStart) + if err != nil { + return err + } + + ext := utils.Ext(file.GetName()) + key := fmt.Sprintf("%s%s/%s.%s", a.KeyPrefix, ext, checksum, ext) + + var b bytes.Buffer + w := multipart.NewWriter(&b) + err = w.WriteField("key", key) + if err != nil { + return err + } + err = w.WriteField("acl", a.ACL) + if err != nil { + return err + } + err = w.WriteField("policy", a.PolicyBase64) + if err != nil { + return err + } + err = w.WriteField("signature", a.Signature) + if err != nil { + return err + } + err = w.WriteField(a.AccessKey.Key, a.AccessKey.Value) + if err != nil { + return err + } + for _, additional := range a.AdditionalBody { + err = w.WriteField(additional.Key, additional.Value) + if err != nil { + return err + } + } + err = w.WriteField("Content-Type", "") + if err != nil { + return err + } + + _, err = w.CreateFormFile("file", key) + if err != nil { + return err + } + + headSize := b.Len() + err = w.Close() + if err != nil { + return err + } + head := bytes.NewReader(b.Bytes()[:headSize]) + tail := bytes.NewReader(b.Bytes()[headSize:]) + + rateLimitedRd := driver.NewLimitedUploadStream(ctx, io.MultiReader(head, tmpF, tail)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.BaseURL, rateLimitedRd) + if err != nil { + return err + } + req.Header.Add("ngsw-bypass", "1") + req.Header.Add("Content-Type", w.FormDataContentType()) + + res, err := d.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("upload failed with status code %d", res.StatusCode) + } + return nil +} + +var _ driver.Driver = (*Degoo)(nil) + +func (d *Degoo) SetUploadFile3(ctx context.Context, file model.FileStreamer, parentID string, checksum string) (*DegooSetUploadFile3Data, error) { + const query = `mutation SetUploadFile3($Token: String!, $FileInfos: [FileInfoUpload3]!) { + setUploadFile3(Token: $Token, FileInfos: $FileInfos) + }` + + variables := map[string]interface{}{ + "Token": d.Token, + "FileInfos": []map[string]string{{ + "Checksum": checksum, + "CreationTime": strconv.FormatInt(file.CreateTime().UnixMilli(), 10), + "Name": file.GetName(), + "ParentID": parentID, + "Size": strconv.FormatInt(file.GetSize(), 10), + }}} + + data, err := d.apiCall(ctx, "SetUploadFile3", query, variables) + if err != nil { + return nil, err + } + + var resp DegooSetUploadFile3Data + err = json.Unmarshal(data, &resp) + if err != nil { + return nil, err + } + + return &resp, nil +} diff --git a/drivers/degoo/util.go b/drivers/degoo/util.go new file mode 100644 index 00000000..067f36f9 --- /dev/null +++ b/drivers/degoo/util.go @@ -0,0 +1,245 @@ +package degoo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +// Thanks to https://github.com/bernd-wechner/Degoo for API research. + +const ( + loginURL = "https://rest-api.degoo.com/login" + accessTokenURL = "https://rest-api.degoo.com/access-token/v2" + + // Degoo GraphQL API endpoint. + apiURL = "https://production-appsync.degoo.com/graphql" + // Fixed API key. + apiKey = "da2-vs6twz5vnjdavpqndtbzg3prra" + // Checksum for new folder. + folderChecksum = "CgAQAg" +) + +// login performs the login process and retrieves the access token. +func (d *Degoo) login(ctx context.Context) error { + creds := DegooLoginRequest{ + GenerateToken: true, + Username: d.Addition.Username, + Password: d.Addition.Password, + } + + jsonCreds, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to serialize login credentials: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", loginURL, bytes.NewBuffer(jsonCreds)) + if err != nil { + return fmt.Errorf("failed to create login request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", base.UserAgent) + req.Header.Set("Origin", "https://app.degoo.com") + + resp, err := d.client.Do(req) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("login failed: %s", resp.Status) + } + + var loginResp DegooLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return fmt.Errorf("failed to parse login response: %w", err) + } + + if loginResp.RefreshToken != "" { + tokenReq := DegooAccessTokenRequest{RefreshToken: loginResp.RefreshToken} + jsonTokenReq, err := json.Marshal(tokenReq) + if err != nil { + return fmt.Errorf("failed to serialize access token request: %w", err) + } + + tokenReqHTTP, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, bytes.NewBuffer(jsonTokenReq)) + if err != nil { + return fmt.Errorf("failed to create access token request: %w", err) + } + + tokenReqHTTP.Header.Set("User-Agent", base.UserAgent) + + tokenResp, err := d.client.Do(tokenReqHTTP) + if err != nil { + return fmt.Errorf("failed to get access token: %w", err) + } + defer tokenResp.Body.Close() + + var accessTokenResp DegooAccessTokenResponse + if err := json.NewDecoder(tokenResp.Body).Decode(&accessTokenResp); err != nil { + return fmt.Errorf("failed to parse access token response: %w", err) + } + d.Token = accessTokenResp.AccessToken + } else if loginResp.Token != "" { + d.Token = loginResp.Token + } else { + return fmt.Errorf("login failed, no valid token returned") + } + return nil +} + +// apiCall performs a Degoo GraphQL API request. +func (d *Degoo) apiCall(ctx context.Context, operationName, query string, variables map[string]interface{}) (json.RawMessage, error) { + reqBody := map[string]interface{}{ + "operationName": operationName, + "query": query, + "variables": variables, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to serialize request body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + req.Header.Set("User-Agent", base.UserAgent) + + if d.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.Token)) + } + + resp, err := d.client.Do(req) + if err != nil { + return nil, fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API response error: %s", resp.Status) + } + + var degooResp DegooGraphqlResponse + if err := json.NewDecoder(resp.Body).Decode(°ooResp); err != nil { + return nil, fmt.Errorf("failed to decode API response: %w", err) + } + + if len(degooResp.Errors) > 0 { + if degooResp.Errors[0].ErrorType == "Unauthorized" { + err = d.login(ctx) + if err != nil { + return nil, fmt.Errorf("unauthorized access, login failed: %w", err) + } + // Retry the API call after re-login + return d.apiCall(ctx, operationName, query, variables) + } + return nil, fmt.Errorf("degoo API returned an error: %v", degooResp.Errors[0].Message) + } + + return degooResp.Data, nil +} + +// humanReadableTimes converts Degoo timestamps to Go time.Time. +func humanReadableTimes(creation, modification, upload string) (cTime, mTime, uTime time.Time) { + cTime, _ = time.Parse(time.RFC3339, creation) + if modification != "" { + modMillis, _ := strconv.ParseInt(modification, 10, 64) + mTime = time.Unix(0, modMillis*int64(time.Millisecond)) + } + if upload != "" { + upMillis, _ := strconv.ParseInt(upload, 10, 64) + uTime = time.Unix(0, upMillis*int64(time.Millisecond)) + } + return cTime, mTime, uTime +} + +// getDevices fetches and caches top-level devices and folders. +func (d *Degoo) getDevices(ctx context.Context) error { + const query = `query GetFileChildren5($Token: String! $ParentID: String $AllParentIDs: [String] $Limit: Int! $Order: Int! $NextToken: String ) { getFileChildren5(Token: $Token ParentID: $ParentID AllParentIDs: $AllParentIDs Limit: $Limit Order: $Order NextToken: $NextToken) { Items { ParentID } NextToken } }` + variables := map[string]interface{}{ + "Token": d.Token, + "ParentID": "0", + "Limit": 10, + "Order": 3, + } + data, err := d.apiCall(ctx, "GetFileChildren5", query, variables) + if err != nil { + return err + } + var resp DegooGetChildren5Data + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("failed to parse device list: %w", err) + } + if d.RootFolderID == "0" { + if len(resp.GetFileChildren5.Items) > 0 { + d.RootFolderID = resp.GetFileChildren5.Items[0].ParentID + } + op.MustSaveDriverStorage(d) + } + return nil +} + +// getAllFileChildren5 fetches all children of a directory with pagination. +func (d *Degoo) getAllFileChildren5(ctx context.Context, parentID string) ([]DegooFileItem, error) { + const query = `query GetFileChildren5($Token: String! $ParentID: String $AllParentIDs: [String] $Limit: Int! $Order: Int! $NextToken: String ) { getFileChildren5(Token: $Token ParentID: $ParentID AllParentIDs: $AllParentIDs Limit: $Limit Order: $Order NextToken: $NextToken) { Items { ID ParentID Name Category Size CreationTime LastModificationTime LastUploadTime FilePath IsInRecycleBin DeviceID MetadataID } NextToken } }` + var allItems []DegooFileItem + nextToken := "" + for { + variables := map[string]interface{}{ + "Token": d.Token, + "ParentID": parentID, + "Limit": 1000, + "Order": 3, + } + if nextToken != "" { + variables["NextToken"] = nextToken + } + data, err := d.apiCall(ctx, "GetFileChildren5", query, variables) + if err != nil { + return nil, err + } + var resp DegooGetChildren5Data + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + allItems = append(allItems, resp.GetFileChildren5.Items...) + if resp.GetFileChildren5.NextToken == "" { + break + } + nextToken = resp.GetFileChildren5.NextToken + } + return allItems, nil +} + +// getOverlay4 fetches metadata for a single item by ID. +func (d *Degoo) getOverlay4(ctx context.Context, id string) (DegooFileItem, error) { + const query = `query GetOverlay4($Token: String!, $ID: IDType!) { getOverlay4(Token: $Token, ID: $ID) { ID ParentID Name Category Size CreationTime LastModificationTime LastUploadTime URL FilePath IsInRecycleBin DeviceID MetadataID } }` + variables := map[string]interface{}{ + "Token": d.Token, + "ID": map[string]string{ + "FileID": id, + }, + } + data, err := d.apiCall(ctx, "GetOverlay4", query, variables) + if err != nil { + return DegooFileItem{}, err + } + var resp DegooGetOverlay4Data + if err := json.Unmarshal(data, &resp); err != nil { + return DegooFileItem{}, fmt.Errorf("failed to parse item metadata: %w", err) + } + return resp.GetOverlay4, nil +}