feat(degoo): token improvement (#1149)

* Update driver.go

Signed-off-by: Caspian <app@caspian.im>

* Update meta.go

Signed-off-by: Caspian <app@caspian.im>

* Update util.go

Signed-off-by: Caspian <app@caspian.im>

* Update util.go

Signed-off-by: Caspian <app@caspian.im>

* Update util.go

Signed-off-by: Caspian <app@caspian.im>

* Update util.go

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

* make account optional

* ensure username and password

Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>

---------

Signed-off-by: Caspian <app@caspian.im>
Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
Co-authored-by: MadDogOwner <xiaoran@xrgzs.top>
This commit is contained in:
Caspian
2025-08-25 10:22:59 -07:00
committed by GitHub
parent b0dbbebfb0
commit 4f2de9395e
4 changed files with 267 additions and 51 deletions

View File

@ -32,11 +32,9 @@ func (d *Degoo) Init(ctx context.Context) error {
d.client = base.HttpClient d.client = base.HttpClient
if d.Token == "" { // Ensure we have a valid token (will login if needed or refresh if expired)
err := d.login(ctx) if err := d.ensureValidToken(ctx); err != nil {
if err != nil { return fmt.Errorf("failed to initialize token: %w", err)
return err
}
} }
return d.getDevices(ctx) return d.getDevices(ctx)
@ -87,7 +85,7 @@ func (d *Degoo) MakeDir(ctx context.Context, parentDir model.Obj, dirName string
const query = `mutation SetUploadFile3($Token: String!, $FileInfos: [FileInfoUpload3]!) { setUploadFile3(Token: $Token, FileInfos: $FileInfos) }` const query = `mutation SetUploadFile3($Token: String!, $FileInfos: [FileInfoUpload3]!) { setUploadFile3(Token: $Token, FileInfos: $FileInfos) }`
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"FileInfos": []map[string]interface{}{ "FileInfos": []map[string]interface{}{
{ {
"Checksum": folderChecksum, "Checksum": folderChecksum,
@ -111,7 +109,7 @@ func (d *Degoo) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj,
const query = `mutation SetMoveFile($Token: String!, $Copy: Boolean, $NewParentID: String!, $FileIDs: [String]!) { setMoveFile(Token: $Token, Copy: $Copy, NewParentID: $NewParentID, FileIDs: $FileIDs) }` 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{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"Copy": false, "Copy": false,
"NewParentID": dstDir.GetID(), "NewParentID": dstDir.GetID(),
"FileIDs": []string{srcObj.GetID()}, "FileIDs": []string{srcObj.GetID()},
@ -129,7 +127,7 @@ func (d *Degoo) Rename(ctx context.Context, srcObj model.Obj, newName string) er
const query = `mutation SetRenameFile($Token: String!, $FileRenames: [FileRenameInfo]!) { setRenameFile(Token: $Token, FileRenames: $FileRenames) }` const query = `mutation SetRenameFile($Token: String!, $FileRenames: [FileRenameInfo]!) { setRenameFile(Token: $Token, FileRenames: $FileRenames) }`
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"FileRenames": []DegooFileRenameInfo{ "FileRenames": []DegooFileRenameInfo{
{ {
ID: srcObj.GetID(), ID: srcObj.GetID(),
@ -155,7 +153,7 @@ func (d *Degoo) Remove(ctx context.Context, obj model.Obj) error {
const query = `mutation SetDeleteFile5($Token: String!, $IsInRecycleBin: Boolean!, $IDs: [IDType]!) { setDeleteFile5(Token: $Token, IsInRecycleBin: $IsInRecycleBin, IDs: $IDs) }` const query = `mutation SetDeleteFile5($Token: String!, $IsInRecycleBin: Boolean!, $IDs: [IDType]!) { setDeleteFile5(Token: $Token, IsInRecycleBin: $IsInRecycleBin, IDs: $IDs) }`
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"IsInRecycleBin": false, "IsInRecycleBin": false,
"IDs": []map[string]string{{"FileID": obj.GetID()}}, "IDs": []map[string]string{{"FileID": obj.GetID()}},
} }

View File

@ -7,9 +7,10 @@ import (
type Addition struct { type Addition struct {
driver.RootID driver.RootID
Username string `json:"username" required:"true" help:"Your Degoo account email"` Username string `json:"username" help:"Your Degoo account email"`
Password string `json:"password" required:"true" help:"Your Degoo account password"` Password string `json:"password" help:"Your Degoo account password"`
Token string `json:"token" help:"Access token for Degoo API, will be obtained automatically if not provided"` RefreshToken string `json:"refresh_token" help:"Refresh token for automatic token renewal, obtained automatically"`
AccessToken string `json:"access_token" help:"Access token for Degoo API, obtained automatically"`
} }
var config = driver.Config{ var config = driver.Config{

View File

@ -49,7 +49,7 @@ func (d *Degoo) getBucketWriteAuth4(ctx context.Context, file model.FileStreamer
}` }`
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"ParentID": parentID, "ParentID": parentID,
"StorageUploadInfos": []map[string]string{{ "StorageUploadInfos": []map[string]string{{
"FileName": file.GetName(), "FileName": file.GetName(),
@ -174,7 +174,7 @@ func (d *Degoo) SetUploadFile3(ctx context.Context, file model.FileStreamer, par
}` }`
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"FileInfos": []map[string]string{{ "FileInfos": []map[string]string{{
"Checksum": checksum, "Checksum": checksum,
"CreationTime": strconv.FormatInt(file.CreateTime().UnixMilli(), 10), "CreationTime": strconv.FormatInt(file.CreateTime().UnixMilli(), 10),

View File

@ -3,10 +3,13 @@ package degoo
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync"
"time" "time"
"github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/drivers/base"
@ -16,23 +19,195 @@ import (
// Thanks to https://github.com/bernd-wechner/Degoo for API research. // Thanks to https://github.com/bernd-wechner/Degoo for API research.
const ( const (
// API endpoints
loginURL = "https://rest-api.degoo.com/login" loginURL = "https://rest-api.degoo.com/login"
accessTokenURL = "https://rest-api.degoo.com/access-token/v2" accessTokenURL = "https://rest-api.degoo.com/access-token/v2"
// Degoo GraphQL API endpoint.
apiURL = "https://production-appsync.degoo.com/graphql" apiURL = "https://production-appsync.degoo.com/graphql"
// Fixed API key.
// API configuration
apiKey = "da2-vs6twz5vnjdavpqndtbzg3prra" apiKey = "da2-vs6twz5vnjdavpqndtbzg3prra"
// Checksum for new folder.
folderChecksum = "CgAQAg" folderChecksum = "CgAQAg"
// Token management
tokenRefreshThreshold = 5 * time.Minute
// Rate limiting
minRequestInterval = 1 * time.Second
// Error messages
errRateLimited = "rate limited (429), please try again later"
errUnauthorized = "unauthorized access"
) )
var (
// Global rate limiting - protects against concurrent API calls
lastRequestTime time.Time
requestMutex sync.Mutex
)
// JWT payload structure for token expiration checking
type JWTPayload struct {
UserID string `json:"userID"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
}
// Rate limiting helper functions
// applyRateLimit ensures minimum interval between API requests
func applyRateLimit() {
requestMutex.Lock()
defer requestMutex.Unlock()
if !lastRequestTime.IsZero() {
if elapsed := time.Since(lastRequestTime); elapsed < minRequestInterval {
time.Sleep(minRequestInterval - elapsed)
}
}
lastRequestTime = time.Now()
}
// HTTP request helper functions
// createJSONRequest creates a new HTTP request with JSON body
func createJSONRequest(ctx context.Context, method, url string, body interface{}) (*http.Request, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", base.UserAgent)
return req, nil
}
// checkHTTPResponse checks for common HTTP error conditions
func checkHTTPResponse(resp *http.Response, operation string) error {
if resp.StatusCode == http.StatusTooManyRequests {
return fmt.Errorf("%s %s", operation, errRateLimited)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s failed: %s", operation, resp.Status)
}
return nil
}
// isTokenExpired checks if the JWT token is expired or will expire soon
func (d *Degoo) isTokenExpired() bool {
if d.AccessToken == "" {
return true
}
payload, err := extractJWTPayload(d.AccessToken)
if err != nil {
return true // Invalid token format
}
// Check if token expires within the threshold
expireTime := time.Unix(payload.Exp, 0)
return time.Now().Add(tokenRefreshThreshold).After(expireTime)
}
// extractJWTPayload extracts and parses JWT payload
func extractJWTPayload(token string) (*JWTPayload, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
// Decode the payload (second part)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}
var jwtPayload JWTPayload
if err := json.Unmarshal(payload, &jwtPayload); err != nil {
return nil, fmt.Errorf("failed to parse JWT payload: %w", err)
}
return &jwtPayload, nil
}
// refreshToken attempts to refresh the access token using the refresh token
func (d *Degoo) refreshToken(ctx context.Context) error {
if d.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
// Create request
tokenReq := DegooAccessTokenRequest{RefreshToken: d.RefreshToken}
req, err := createJSONRequest(ctx, "POST", accessTokenURL, tokenReq)
if err != nil {
return fmt.Errorf("failed to create refresh token request: %w", err)
}
// Execute request
resp, err := d.client.Do(req)
if err != nil {
return fmt.Errorf("refresh token request failed: %w", err)
}
defer resp.Body.Close()
// Check response
if err := checkHTTPResponse(resp, "refresh token"); err != nil {
return err
}
var accessTokenResp DegooAccessTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&accessTokenResp); err != nil {
return fmt.Errorf("failed to parse access token response: %w", err)
}
if accessTokenResp.AccessToken == "" {
return fmt.Errorf("empty access token received")
}
d.AccessToken = accessTokenResp.AccessToken
// Save the updated token to storage
op.MustSaveDriverStorage(d)
return nil
}
// ensureValidToken ensures we have a valid, non-expired token
func (d *Degoo) ensureValidToken(ctx context.Context) error {
// Check if token is expired or will expire soon
if d.isTokenExpired() {
// Try to refresh token first if we have a refresh token
if d.RefreshToken != "" {
if refreshErr := d.refreshToken(ctx); refreshErr == nil {
return nil // Successfully refreshed
} else {
// If refresh failed, fall back to full login
fmt.Printf("Token refresh failed, falling back to full login: %v\n", refreshErr)
}
}
// Perform full login
if d.Username != "" && d.Password != "" {
return d.login(ctx)
}
}
return nil
}
// login performs the login process and retrieves the access token. // login performs the login process and retrieves the access token.
func (d *Degoo) login(ctx context.Context) error { func (d *Degoo) login(ctx context.Context) error {
if d.Username == "" || d.Password == "" {
return fmt.Errorf("username or password not provided")
}
creds := DegooLoginRequest{ creds := DegooLoginRequest{
GenerateToken: true, GenerateToken: true,
Username: d.Addition.Username, Username: d.Username,
Password: d.Addition.Password, Password: d.Password,
} }
jsonCreds, err := json.Marshal(creds) jsonCreds, err := json.Marshal(creds)
@ -54,6 +229,11 @@ func (d *Degoo) login(ctx context.Context) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
// Handle rate limiting (429 Too Many Requests)
if resp.StatusCode == http.StatusTooManyRequests {
return fmt.Errorf("login rate limited (429), please try again later")
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed: %s", resp.Status) return fmt.Errorf("login failed: %s", resp.Status)
} }
@ -87,71 +267,108 @@ func (d *Degoo) login(ctx context.Context) error {
if err := json.NewDecoder(tokenResp.Body).Decode(&accessTokenResp); err != nil { if err := json.NewDecoder(tokenResp.Body).Decode(&accessTokenResp); err != nil {
return fmt.Errorf("failed to parse access token response: %w", err) return fmt.Errorf("failed to parse access token response: %w", err)
} }
d.Token = accessTokenResp.AccessToken d.AccessToken = accessTokenResp.AccessToken
d.RefreshToken = loginResp.RefreshToken // Save refresh token
} else if loginResp.Token != "" { } else if loginResp.Token != "" {
d.Token = loginResp.Token d.AccessToken = loginResp.Token
d.RefreshToken = "" // Direct token, no refresh token available
} else { } else {
return fmt.Errorf("login failed, no valid token returned") return fmt.Errorf("login failed, no valid token returned")
} }
// Save the updated tokens to storage
op.MustSaveDriverStorage(d)
return nil return nil
} }
// apiCall performs a Degoo GraphQL API request. // apiCall performs a Degoo GraphQL API request.
func (d *Degoo) apiCall(ctx context.Context, operationName, query string, variables map[string]interface{}) (json.RawMessage, error) { func (d *Degoo) apiCall(ctx context.Context, operationName, query string, variables map[string]interface{}) (json.RawMessage, error) {
// Apply rate limiting
applyRateLimit()
// Ensure we have a valid token before making the API call
if err := d.ensureValidToken(ctx); err != nil {
return nil, fmt.Errorf("failed to ensure valid token: %w", err)
}
// Update the Token in variables if it exists (after potential refresh)
d.updateTokenInVariables(variables)
return d.executeGraphQLRequest(ctx, operationName, query, variables)
}
// updateTokenInVariables updates the Token field in GraphQL variables
func (d *Degoo) updateTokenInVariables(variables map[string]interface{}) {
if variables != nil {
if _, hasToken := variables["Token"]; hasToken {
variables["Token"] = d.AccessToken
}
}
}
// executeGraphQLRequest executes a GraphQL request with retry logic
func (d *Degoo) executeGraphQLRequest(ctx context.Context, operationName, query string, variables map[string]interface{}) (json.RawMessage, error) {
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"operationName": operationName, "operationName": operationName,
"query": query, "query": query,
"variables": variables, "variables": variables,
} }
jsonBody, err := json.Marshal(reqBody) // Create and configure request
req, err := createJSONRequest(ctx, "POST", apiURL, reqBody)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to serialize request body: %w", err) return nil, err
} }
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(jsonBody)) // Set Degoo-specific headers
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("x-api-key", apiKey)
req.Header.Set("User-Agent", base.UserAgent) if d.AccessToken != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.AccessToken))
if d.Token != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.Token))
} }
// Execute request
resp, err := d.client.Do(req) resp, err := d.client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("API request failed: %w", err) return nil, fmt.Errorf("GraphQL API request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // Check for HTTP errors
return nil, fmt.Errorf("API response error: %s", resp.Status) if err := checkHTTPResponse(resp, "GraphQL API"); err != nil {
return nil, err
} }
// Parse GraphQL response
var degooResp DegooGraphqlResponse var degooResp DegooGraphqlResponse
if err := json.NewDecoder(resp.Body).Decode(&degooResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&degooResp); err != nil {
return nil, fmt.Errorf("failed to decode API response: %w", err) return nil, fmt.Errorf("failed to decode GraphQL response: %w", err)
} }
// Handle GraphQL errors
if len(degooResp.Errors) > 0 { if len(degooResp.Errors) > 0 {
if degooResp.Errors[0].ErrorType == "Unauthorized" { return d.handleGraphQLError(ctx, degooResp.Errors[0], operationName, query, variables)
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 return degooResp.Data, nil
} }
// handleGraphQLError handles GraphQL-level errors with retry logic
func (d *Degoo) handleGraphQLError(ctx context.Context, gqlError DegooErrors, operationName, query string, variables map[string]interface{}) (json.RawMessage, error) {
if gqlError.ErrorType == "Unauthorized" {
// Re-login and retry
if err := d.login(ctx); err != nil {
return nil, fmt.Errorf("%s, login failed: %w", errUnauthorized, err)
}
// Update token in variables and retry
d.updateTokenInVariables(variables)
return d.apiCall(ctx, operationName, query, variables)
}
return nil, fmt.Errorf("GraphQL API error: %s", gqlError.Message)
}
// humanReadableTimes converts Degoo timestamps to Go time.Time. // humanReadableTimes converts Degoo timestamps to Go time.Time.
func humanReadableTimes(creation, modification, upload string) (cTime, mTime, uTime time.Time) { func humanReadableTimes(creation, modification, upload string) (cTime, mTime, uTime time.Time) {
cTime, _ = time.Parse(time.RFC3339, creation) cTime, _ = time.Parse(time.RFC3339, creation)
@ -170,7 +387,7 @@ func humanReadableTimes(creation, modification, upload string) (cTime, mTime, uT
func (d *Degoo) getDevices(ctx context.Context) error { 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 } }` 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{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"ParentID": "0", "ParentID": "0",
"Limit": 10, "Limit": 10,
"Order": 3, "Order": 3,
@ -199,7 +416,7 @@ func (d *Degoo) getAllFileChildren5(ctx context.Context, parentID string) ([]Deg
nextToken := "" nextToken := ""
for { for {
variables := map[string]interface{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"ParentID": parentID, "ParentID": parentID,
"Limit": 1000, "Limit": 1000,
"Order": 3, "Order": 3,
@ -228,7 +445,7 @@ func (d *Degoo) getAllFileChildren5(ctx context.Context, parentID string) ([]Deg
func (d *Degoo) getOverlay4(ctx context.Context, id string) (DegooFileItem, error) { 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 } }` 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{}{ variables := map[string]interface{}{
"Token": d.Token, "Token": d.AccessToken,
"ID": map[string]string{ "ID": map[string]string{
"FileID": id, "FileID": id,
}, },