Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion drivers/pikpak/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
40 changes: 30 additions & 10 deletions drivers/pikpak/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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: <a target="_blank" href="%s">Click Here</a>`, resp.Url)
}

Expand Down
186 changes: 186 additions & 0 deletions drivers/pikpak/util_test.go
Original file line number Diff line number Diff line change
@@ -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.
}