Skip to content

Commit 420dbed

Browse files
wiserainyuval-cloudinary
authored andcommitted
pikpak: fix login issue where token retrieval fails
This addresses the login issue caused by pikpak's recent cancellation of existing login methods and requirement for additional verifications. To resolve this, we've made the following changes: 1. Similar to lib/oauthutil, we've integrated a mechanism to handle captcha tokens. 2. A new pikpakClient has been introduced to wrap the existing rest.Client and incorporate the necessary headers including x-captcha-token for each request. 3. Several options have been added/removed to support persistent user/client identification. * client_id: No longer configurable. * client_secret: Deprecated as it's no longer used. * user_agent: A new option that defaults to PC/Firefox's user agent but can be overridden using the --pikpak-user-agent flag. * device_id: A new option that is randomly generated if invalid. It is recommended not to delete or change it frequently. * captcha_token: A new option that is automatically managed by rclone, similar to the OAuth token. Fixes rclone#7950 rclone#8005
1 parent 94df26d commit 420dbed

File tree

3 files changed

+392
-45
lines changed

3 files changed

+392
-45
lines changed

backend/pikpak/api/types.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,72 @@ type RequestDecompress struct {
513513
DefaultParent bool `json:"default_parent,omitempty"`
514514
}
515515

516+
// ------------------------------------------------------------ authorization
517+
518+
// CaptchaToken is a response to requestCaptchaToken api call
519+
type CaptchaToken struct {
520+
CaptchaToken string `json:"captcha_token"`
521+
ExpiresIn int64 `json:"expires_in"` // currently 300s
522+
// API doesn't provide Expiry field and thus it should be populated from ExpiresIn on retrieval
523+
Expiry time.Time `json:"expiry,omitempty"`
524+
URL string `json:"url,omitempty"` // a link for users to solve captcha
525+
}
526+
527+
// expired reports whether the token is expired.
528+
// t must be non-nil.
529+
func (t *CaptchaToken) expired() bool {
530+
if t.Expiry.IsZero() {
531+
return false
532+
}
533+
534+
expiryDelta := time.Duration(10) * time.Second // same as oauth2's defaultExpiryDelta
535+
return t.Expiry.Round(0).Add(-expiryDelta).Before(time.Now())
536+
}
537+
538+
// Valid reports whether t is non-nil, has an AccessToken, and is not expired.
539+
func (t *CaptchaToken) Valid() bool {
540+
return t != nil && t.CaptchaToken != "" && !t.expired()
541+
}
542+
543+
// CaptchaTokenRequest is to request for captcha token
544+
type CaptchaTokenRequest struct {
545+
Action string `json:"action,omitempty"`
546+
CaptchaToken string `json:"captcha_token,omitempty"`
547+
ClientID string `json:"client_id,omitempty"`
548+
DeviceID string `json:"device_id,omitempty"`
549+
Meta *CaptchaTokenMeta `json:"meta,omitempty"`
550+
}
551+
552+
// CaptchaTokenMeta contains meta info for CaptchaTokenRequest
553+
type CaptchaTokenMeta struct {
554+
CaptchaSign string `json:"captcha_sign,omitempty"`
555+
ClientVersion string `json:"client_version,omitempty"`
556+
PackageName string `json:"package_name,omitempty"`
557+
Timestamp string `json:"timestamp,omitempty"`
558+
UserID string `json:"user_id,omitempty"` // webdrive uses this instead of UserName
559+
UserName string `json:"username,omitempty"`
560+
Email string `json:"email,omitempty"`
561+
PhoneNumber string `json:"phone_number,omitempty"`
562+
}
563+
564+
// Token represents oauth2 token used for pikpak which needs to be converted to be compatible with oauth2.Token
565+
type Token struct {
566+
TokenType string `json:"token_type"`
567+
AccessToken string `json:"access_token"`
568+
RefreshToken string `json:"refresh_token"`
569+
ExpiresIn int `json:"expires_in"`
570+
Sub string `json:"sub"`
571+
}
572+
573+
// Expiry returns expiry from expires in, so it should be called on retrieval
574+
// e must be non-nil.
575+
func (e *Token) Expiry() (t time.Time) {
576+
if v := e.ExpiresIn; v != 0 {
577+
return time.Now().Add(time.Duration(v) * time.Second)
578+
}
579+
return
580+
}
581+
516582
// ------------------------------------------------------------
517583

518584
// NOT implemented YET

backend/pikpak/helper.go

Lines changed: 219 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package pikpak
33
import (
44
"bytes"
55
"context"
6+
"crypto/md5"
67
"crypto/sha1"
78
"encoding/hex"
9+
"encoding/json"
810
"errors"
911
"fmt"
1012
"io"
@@ -14,10 +16,13 @@ import (
1416
"os"
1517
"strconv"
1618
"strings"
19+
"sync"
1720
"time"
1821

1922
"github.com/rclone/rclone/backend/pikpak/api"
2023
"github.com/rclone/rclone/fs"
24+
"github.com/rclone/rclone/fs/config/configmap"
25+
"github.com/rclone/rclone/fs/fserrors"
2126
"github.com/rclone/rclone/lib/rest"
2227
)
2328

@@ -262,15 +267,20 @@ func (f *Fs) getGcid(ctx context.Context, src fs.ObjectInfo) (gcid string, err e
262267
if err != nil {
263268
return
264269
}
270+
if src.Size() == 0 {
271+
// If src is zero-length, the API will return
272+
// Error "cid and file_size is required" (400)
273+
// In this case, we can simply return cid == gcid
274+
return cid, nil
275+
}
265276

266277
params := url.Values{}
267278
params.Set("cid", cid)
268279
params.Set("file_size", strconv.FormatInt(src.Size(), 10))
269280
opts := rest.Opts{
270-
Method: "GET",
271-
Path: "/drive/v1/resource/cid",
272-
Parameters: params,
273-
ExtraHeaders: map[string]string{"x-device-id": f.deviceID},
281+
Method: "GET",
282+
Path: "/drive/v1/resource/cid",
283+
Parameters: params,
274284
}
275285

276286
info := struct {
@@ -408,6 +418,8 @@ func calcCid(ctx context.Context, src fs.ObjectInfo) (cid string, err error) {
408418
return
409419
}
410420

421+
// ------------------------------------------------------------ authorization
422+
411423
// randomly generates device id used for request header 'x-device-id'
412424
//
413425
// original javascript implementation
@@ -428,3 +440,206 @@ func genDeviceID() string {
428440
}
429441
return string(base)
430442
}
443+
444+
var md5Salt = []string{
445+
"C9qPpZLN8ucRTaTiUMWYS9cQvWOE",
446+
"+r6CQVxjzJV6LCV",
447+
"F",
448+
"pFJRC",
449+
"9WXYIDGrwTCz2OiVlgZa90qpECPD6olt",
450+
"/750aCr4lm/Sly/c",
451+
"RB+DT/gZCrbV",
452+
"",
453+
"CyLsf7hdkIRxRm215hl",
454+
"7xHvLi2tOYP0Y92b",
455+
"ZGTXXxu8E/MIWaEDB+Sm/",
456+
"1UI3",
457+
"E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO",
458+
"ihtqpG6FMt65+Xk+tWUH2",
459+
"NhXXU9rg4XXdzo7u5o",
460+
}
461+
462+
func md5Sum(text string) string {
463+
hash := md5.Sum([]byte(text))
464+
return hex.EncodeToString(hash[:])
465+
}
466+
467+
func calcCaptchaSign(deviceID string) (timestamp, sign string) {
468+
timestamp = fmt.Sprint(time.Now().UnixMilli())
469+
str := fmt.Sprint(clientID, clientVersion, packageName, deviceID, timestamp)
470+
for _, salt := range md5Salt {
471+
str = md5Sum(str + salt)
472+
}
473+
sign = "1." + str
474+
return
475+
}
476+
477+
func newCaptchaTokenRequest(action, oldToken string, opt *Options) (req *api.CaptchaTokenRequest) {
478+
req = &api.CaptchaTokenRequest{
479+
Action: action,
480+
CaptchaToken: oldToken, // can be empty initially
481+
ClientID: clientID,
482+
DeviceID: opt.DeviceID,
483+
Meta: new(api.CaptchaTokenMeta),
484+
}
485+
switch action {
486+
case "POST:/v1/auth/signin":
487+
req.Meta.UserName = opt.Username
488+
default:
489+
timestamp, captchaSign := calcCaptchaSign(opt.DeviceID)
490+
req.Meta.CaptchaSign = captchaSign
491+
req.Meta.Timestamp = timestamp
492+
req.Meta.ClientVersion = clientVersion
493+
req.Meta.PackageName = packageName
494+
req.Meta.UserID = opt.UserID
495+
}
496+
return
497+
}
498+
499+
// CaptchaTokenSource stores updated captcha tokens in the config file
500+
type CaptchaTokenSource struct {
501+
mu sync.Mutex
502+
m configmap.Mapper
503+
opt *Options
504+
token *api.CaptchaToken
505+
ctx context.Context
506+
rst *pikpakClient
507+
}
508+
509+
// initialize CaptchaTokenSource from rclone.conf if possible
510+
func newCaptchaTokenSource(ctx context.Context, opt *Options, m configmap.Mapper) *CaptchaTokenSource {
511+
token := new(api.CaptchaToken)
512+
tokenString, ok := m.Get("captcha_token")
513+
if !ok || tokenString == "" {
514+
fs.Debugf(nil, "failed to read captcha token out of config file")
515+
} else {
516+
if err := json.Unmarshal([]byte(tokenString), token); err != nil {
517+
fs.Debugf(nil, "failed to parse captcha token out of config file: %v", err)
518+
}
519+
}
520+
return &CaptchaTokenSource{
521+
m: m,
522+
opt: opt,
523+
token: token,
524+
ctx: ctx,
525+
rst: newPikpakClient(getClient(ctx, opt), opt),
526+
}
527+
}
528+
529+
// requestToken retrieves captcha token from API
530+
func (cts *CaptchaTokenSource) requestToken(ctx context.Context, req *api.CaptchaTokenRequest) (err error) {
531+
opts := rest.Opts{
532+
Method: "POST",
533+
RootURL: "https://user.mypikpak.com/v1/shield/captcha/init",
534+
}
535+
var info *api.CaptchaToken
536+
_, err = cts.rst.CallJSON(ctx, &opts, &req, &info)
537+
if err == nil && info.ExpiresIn != 0 {
538+
// populate to Expiry
539+
info.Expiry = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
540+
cts.token = info // update with a new one
541+
}
542+
return
543+
}
544+
545+
func (cts *CaptchaTokenSource) refreshToken(opts *rest.Opts) (string, error) {
546+
oldToken := ""
547+
if cts.token != nil {
548+
oldToken = cts.token.CaptchaToken
549+
}
550+
action := "GET:/drive/v1/about"
551+
if opts.RootURL == "" && opts.Path != "" {
552+
action = fmt.Sprintf("%s:%s", opts.Method, opts.Path)
553+
} else if u, err := url.Parse(opts.RootURL); err == nil {
554+
action = fmt.Sprintf("%s:%s", opts.Method, u.Path)
555+
}
556+
req := newCaptchaTokenRequest(action, oldToken, cts.opt)
557+
if err := cts.requestToken(cts.ctx, req); err != nil {
558+
return "", fmt.Errorf("failed to retrieve captcha token from api: %w", err)
559+
}
560+
561+
// put it into rclone.conf
562+
tokenBytes, err := json.Marshal(cts.token)
563+
if err != nil {
564+
return "", fmt.Errorf("failed to marshal captcha token: %w", err)
565+
}
566+
cts.m.Set("captcha_token", string(tokenBytes))
567+
return cts.token.CaptchaToken, nil
568+
}
569+
570+
// Invalidate resets existing captcha token for a forced refresh
571+
func (cts *CaptchaTokenSource) Invalidate() {
572+
cts.mu.Lock()
573+
cts.token.CaptchaToken = ""
574+
cts.mu.Unlock()
575+
}
576+
577+
// Token returns a valid captcha token
578+
func (cts *CaptchaTokenSource) Token(opts *rest.Opts) (string, error) {
579+
cts.mu.Lock()
580+
defer cts.mu.Unlock()
581+
if cts.token.Valid() {
582+
return cts.token.CaptchaToken, nil
583+
}
584+
return cts.refreshToken(opts)
585+
}
586+
587+
// pikpakClient wraps rest.Client with a handle of captcha token
588+
type pikpakClient struct {
589+
opt *Options
590+
client *rest.Client
591+
captcha *CaptchaTokenSource
592+
}
593+
594+
// newPikpakClient takes an (oauth) http.Client and makes a new api instance for pikpak with
595+
// * error handler
596+
// * root url
597+
// * default headers
598+
func newPikpakClient(c *http.Client, opt *Options) *pikpakClient {
599+
client := rest.NewClient(c).SetErrorHandler(errorHandler).SetRoot(rootURL)
600+
for key, val := range map[string]string{
601+
"Referer": "https://mypikpak.com/",
602+
"x-client-id": clientID,
603+
"x-client-version": clientVersion,
604+
"x-device-id": opt.DeviceID,
605+
// "x-device-model": "firefox%2F129.0",
606+
// "x-device-name": "PC-Firefox",
607+
// "x-device-sign": fmt.Sprintf("wdi10.%sxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", opt.DeviceID),
608+
// "x-net-work-type": "NONE",
609+
// "x-os-version": "Win32",
610+
// "x-platform-version": "1",
611+
// "x-protocol-version": "301",
612+
// "x-provider-name": "NONE",
613+
// "x-sdk-version": "8.0.3",
614+
} {
615+
client.SetHeader(key, val)
616+
}
617+
return &pikpakClient{
618+
client: client,
619+
opt: opt,
620+
}
621+
}
622+
623+
// This should be called right after pikpakClient initialized
624+
func (c *pikpakClient) SetCaptchaTokener(ctx context.Context, m configmap.Mapper) *pikpakClient {
625+
c.captcha = newCaptchaTokenSource(ctx, c.opt, m)
626+
return c
627+
}
628+
629+
func (c *pikpakClient) CallJSON(ctx context.Context, opts *rest.Opts, request interface{}, response interface{}) (resp *http.Response, err error) {
630+
if c.captcha != nil {
631+
token, err := c.captcha.Token(opts)
632+
if err != nil || token == "" {
633+
return nil, fserrors.FatalError(fmt.Errorf("couldn't get captcha token: %v", err))
634+
}
635+
if opts.ExtraHeaders == nil {
636+
opts.ExtraHeaders = make(map[string]string)
637+
}
638+
opts.ExtraHeaders["x-captcha-token"] = token
639+
}
640+
return c.client.CallJSON(ctx, opts, request, response)
641+
}
642+
643+
func (c *pikpakClient) Call(ctx context.Context, opts *rest.Opts) (resp *http.Response, err error) {
644+
return c.client.Call(ctx, opts)
645+
}

0 commit comments

Comments
 (0)