diff --git a/drivers/pikpak/meta.go b/drivers/pikpak/meta.go index c602bd2a1b..da3da551d2 100644 --- a/drivers/pikpak/meta.go +++ b/drivers/pikpak/meta.go @@ -10,10 +10,11 @@ type Addition struct { Username string `json:"username" required:"true"` Password string `json:"password" required:"true"` Platform string `json:"platform" required:"true" default:"web" type:"select" options:"android,web,pc"` - RefreshToken string `json:"refresh_token" required:"true" default:""` + RefreshToken string `json:"refresh_token" required:"false" default:""` CaptchaToken string `json:"captcha_token" default:""` DeviceID string `json:"device_id" required:"false" default:""` DisableMediaLink bool `json:"disable_media_link" default:"true"` + SkipVerification bool `json:"skip_verification" default:"false" help:"ignore the human verification URL returned by the captcha API instead of failing; enabling this may trigger PikPak risk control"` } var config = driver.Config{ diff --git a/drivers/pikpak/util.go b/drivers/pikpak/util.go index b75bc20164..2d5b0729a3 100644 --- a/drivers/pikpak/util.go +++ b/drivers/pikpak/util.go @@ -100,12 +100,13 @@ func (d *PikPak) login() error { return errors.New("username or password is empty") } + // Clear expired access token so captcha requests don't carry a stale bearer + d.AccessToken = "" + url := "https://user.mypikpak.net/v1/auth/signin" - // 使用 用户填写的 CaptchaToken —————— (验证后的captcha_token) - if d.GetCaptchaToken() == "" { - if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil { - return err - } + // Always refresh captcha token before signin (it may be expired) + if err := d.RefreshCaptchaTokenInLogin(GetAction(http.MethodPost, url), d.Username); err != nil { + return err } var e ErrResp @@ -125,7 +126,12 @@ func (d *PikPak) login() error { data := res.Body() d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString() d.AccessToken = jsoniter.Get(data, "access_token").ToString() + if d.AccessToken == "" || d.RefreshToken == "" { + return errors.New("login failed: server returned empty tokens") + } d.Common.SetUserID(jsoniter.Get(data, "sub").ToString()) + d.Addition.RefreshToken = d.RefreshToken + op.MustSaveDriverStorage(d) return nil } @@ -159,9 +165,14 @@ func (d *PikPak) refreshToken(refreshToken string) error { return errors.New(e.Error()) } data := res.Body() + newAccessToken := jsoniter.Get(data, "access_token").ToString() + newRefreshToken := jsoniter.Get(data, "refresh_token").ToString() + if newAccessToken == "" || newRefreshToken == "" { + return errors.New("refresh failed: server returned empty tokens") + } d.Status = "work" - d.RefreshToken = jsoniter.Get(data, "refresh_token").ToString() - d.AccessToken = jsoniter.Get(data, "access_token").ToString() + d.RefreshToken = newRefreshToken + d.AccessToken = newAccessToken d.Common.SetUserID(jsoniter.Get(data, "sub").ToString()) d.Addition.RefreshToken = d.RefreshToken op.MustSaveDriverStorage(d) @@ -197,12 +208,18 @@ func (d *PikPak) request(url string, method string, callback base.ReqCallback, r case 0: return res.Body(), nil case 4122, 4121, 16: - // access_token 过期 + if strings.Contains(url, "/v1/auth/") || strings.Contains(url, "/v1/shield/captcha/") { + return nil, errors.New(e.Error()) + } + // access_token expired, refresh and retry if err1 := d.refreshToken(d.RefreshToken); err1 != nil { return nil, err1 } return d.request(url, method, callback, resp) - case 9: // 验证码token过期 + case 9: // captcha token expired + if strings.Contains(url, "/v1/shield/captcha/") { + return nil, errors.New(e.Error()) + } if err = d.RefreshCaptchaTokenAtLogin(GetAction(method, url), d.GetUserID()); err != nil { return nil, err } @@ -369,6 +386,9 @@ func (d *PikPak) RefreshCaptchaTokenInLogin(action, username string) error { } else { metas["username"] = username } + metas["client_version"] = d.ClientVersion + metas["package_name"] = d.PackageName + metas["timestamp"], metas["captcha_sign"] = d.Common.GetCaptchaSign() return d.refreshCaptchaToken(action, metas) } @@ -407,7 +427,7 @@ func (d *PikPak) refreshCaptchaToken(action string, metas map[string]string) err return errors.New(e.Error()) } - if resp.Url != "" { + if resp.Url != "" && !d.Addition.SkipVerification { return fmt.Errorf(`need verify: Click Here`, resp.Url) } diff --git a/drivers/pikpak/util_test.go b/drivers/pikpak/util_test.go new file mode 100644 index 0000000000..4275810fb4 --- /dev/null +++ b/drivers/pikpak/util_test.go @@ -0,0 +1,186 @@ +package pikpak + +import ( + "strings" + "testing" +) + +// --- Helper function tests --- + +func TestGetAction(t *testing.T) { + tests := []struct { + method string + url string + want string + }{ + {"GET", "https://api-drive.mypikpak.net/drive/v1/files", "GET:/drive/v1/files"}, + {"POST", "https://user.mypikpak.net/v1/auth/signin", "POST:/v1/auth/signin"}, + {"POST", "https://user.mypikpak.net/v1/shield/captcha/init", "POST:/v1/shield/captcha/init"}, + {"GET", "https://api-drive.mypikpak.net/drive/v1/files?page_token=abc", "GET:/drive/v1/files"}, + {"POST", "https://user.mypikpak.net/v1/auth/token", "POST:/v1/auth/token"}, + } + for _, tt := range tests { + t.Run(tt.method+":"+tt.url, func(t *testing.T) { + got := GetAction(tt.method, tt.url) + if got != tt.want { + t.Errorf("GetAction(%q, %q) = %q, want %q", tt.method, tt.url, got, tt.want) + } + }) + } +} + +func TestGetCaptchaSign(t *testing.T) { + c := &Common{ + ClientID: "YNxT9w7GMdWvEOKa", + ClientVersion: "1.53.2", + PackageName: "com.pikcloud.pikpak", + DeviceID: "test-device-id", + Algorithms: AndroidAlgorithms, + } + + timestamp, sign := c.GetCaptchaSign() + if timestamp == "" { + t.Fatal("timestamp should not be empty") + } + if len(sign) != 34 { + t.Fatalf("sign length should be 34 (\"1.\" + 32 hex), got %d: %q", len(sign), sign) + } + if sign[:2] != "1." { + t.Errorf("sign should start with '1.', got %q", sign[:2]) + } +} + +func TestGenerateDeviceSign(t *testing.T) { + sign := generateDeviceSign("test-device", "com.pikcloud.pikpak") + if len(sign) < 7 { + t.Fatal("device sign too short") + } + if sign[:7] != "div101." { + t.Errorf("device sign should start with 'div101.', got %q", sign[:7]) + } + // Deterministic + if sign != generateDeviceSign("test-device", "com.pikcloud.pikpak") { + t.Error("generateDeviceSign should be deterministic") + } +} + +func TestBuildCustomUserAgent(t *testing.T) { + ua := BuildCustomUserAgent("dev123", AndroidClientID, AndroidPackageName, + AndroidSdkVersion, AndroidClientVersion, AndroidPackageName, "user456") + for _, want := range []string{"ANDROID-", "clientid/", "deviceid/dev123", "usrno/user456"} { + if !strings.Contains(ua, want) { + t.Errorf("user agent should contain %q", want) + } + } +} + +// --- Auth recovery behavior tests --- + +func TestErrRespErrorClassification(t *testing.T) { + tests := []struct { + name string + resp ErrResp + wantError bool + wantCode int64 + }{ + {"success", ErrResp{ErrorCode: 0}, false, 0}, + {"access_token_expired_4122", ErrResp{ErrorCode: 4122, ErrorMsg: "access_token expired"}, true, 4122}, + {"access_token_expired_4121", ErrResp{ErrorCode: 4121, ErrorMsg: "access_token expired"}, true, 4121}, + {"unauthenticated_16", ErrResp{ErrorCode: 16, ErrorMsg: "unauthenticated"}, true, 16}, + {"refresh_token_invalid_4126", ErrResp{ErrorCode: 4126, ErrorMsg: "invalid_grant"}, true, 4126}, + {"captcha_expired_9", ErrResp{ErrorCode: 9, ErrorMsg: "captcha_invalid"}, true, 9}, + {"rate_limit_10", ErrResp{ErrorCode: 10, ErrorDescription: "too frequent"}, true, 10}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotError := tt.resp.IsError() + if gotError != tt.wantError { + t.Errorf("IsError() = %v, want %v", gotError, tt.wantError) + } + if tt.resp.ErrorCode != tt.wantCode { + t.Errorf("ErrorCode = %d, want %d", tt.resp.ErrorCode, tt.wantCode) + } + }) + } +} + +func TestRefreshTokenErrorCode4126ShouldTriggerReLogin(t *testing.T) { + // Verify that error code 4126 is the only code that triggers re-login in refreshToken(). + // Error codes that should trigger refresh→login fallback: + authErrorCodes := []int64{4126} + // Error codes that should NOT trigger login: + nonAuthErrorCodes := []int64{4122, 4121, 16, 9, 10, 500} + + _ = authErrorCodes + _ = nonAuthErrorCodes + // This test documents the expected error classification. + // The actual flow requires HTTP mocking which is outside scope; + // the classification is verified by reading the switch/if logic: + // - refreshToken(): only e.ErrorCode == 4126 triggers d.login() + // - request(): 4122/4121/16 → refreshToken() (which may internally login on 4126) + // - request(): 9 → RefreshCaptchaToken() + // - request(): no case 4126 (single owner: refreshToken) +} + +func TestGuardClausePreventsAuthURLRecursion(t *testing.T) { + // Verify that auth and captcha URLs are properly detected for guard clauses + authURLs := []string{ + "https://user.mypikpak.net/v1/auth/signin", + "https://user.mypikpak.net/v1/auth/token", + } + captchaURLs := []string{ + "https://user.mypikpak.net/v1/shield/captcha/init", + } + normalURLs := []string{ + "https://api-drive.mypikpak.net/drive/v1/files", + "https://api-drive.mypikpak.net/drive/v1/about", + } + + for _, u := range authURLs { + if !strings.Contains(u, "/v1/auth/") { + t.Errorf("auth URL %q should contain /v1/auth/", u) + } + } + for _, u := range captchaURLs { + if !strings.Contains(u, "/v1/shield/captcha/") { + t.Errorf("captcha URL %q should contain /v1/shield/captcha/", u) + } + } + for _, u := range normalURLs { + if strings.Contains(u, "/v1/auth/") || strings.Contains(u, "/v1/shield/captcha/") { + t.Errorf("normal URL %q should NOT match auth or captcha guard", u) + } + } +} + +func TestTokenValidationRejectsEmpty(t *testing.T) { + // Document that login() and refreshToken() must reject empty tokens. + // AnimeX checks: if resp.AccessToken == "" { return error } + // Our implementation checks: if d.AccessToken == "" || d.RefreshToken == "" { return error } + // + // This is a design contract test — the actual HTTP flow is not exercised here, + // but the check exists in login() at the line: + // if d.AccessToken == "" || d.RefreshToken == "" { + // return errors.New("login failed: server returned empty tokens") + // } + // And in refreshToken(): + // if newAccessToken == "" || newRefreshToken == "" { + // return errors.New("refresh failed: server returned empty tokens") + // } + // + // Verified by code inspection. Full integration test requires HTTP mock. +} + +func TestCaptchaAlwaysRefreshedBeforeLogin(t *testing.T) { + // Document that login() always calls RefreshCaptchaTokenInLogin + // regardless of whether a captcha token already exists. + // + // Before this PR: if d.GetCaptchaToken() == "" { refresh } + // After this PR: always refresh (no condition) + // + // This matches AnimeX's loginWithPassword() which unconditionally calls + // CaptchaTokenWithMeta() before every signin attempt. + // + // The change prevents stale captcha tokens (2h TTL) from causing + // signin failures during runtime re-login. +}