2023-04-04 15:33:48 +00:00
|
|
|
package pikpak
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
2024-09-17 16:09:21 +00:00
|
|
|
"crypto/md5"
|
2023-04-04 15:33:48 +00:00
|
|
|
"crypto/sha1"
|
|
|
|
"encoding/hex"
|
2024-09-17 16:09:21 +00:00
|
|
|
"encoding/json"
|
2023-04-04 15:33:48 +00:00
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
2024-07-17 03:20:09 +00:00
|
|
|
"math/rand"
|
2023-04-04 15:33:48 +00:00
|
|
|
"net/http"
|
2024-06-10 17:08:07 +00:00
|
|
|
"net/url"
|
2023-04-04 15:33:48 +00:00
|
|
|
"os"
|
2024-06-10 17:08:07 +00:00
|
|
|
"strconv"
|
2024-07-17 03:20:09 +00:00
|
|
|
"strings"
|
2024-09-17 16:09:21 +00:00
|
|
|
"sync"
|
2024-06-19 15:07:05 +00:00
|
|
|
"time"
|
2023-04-04 15:33:48 +00:00
|
|
|
|
|
|
|
"github.com/rclone/rclone/backend/pikpak/api"
|
2024-07-17 03:20:09 +00:00
|
|
|
"github.com/rclone/rclone/fs"
|
2024-09-17 16:09:21 +00:00
|
|
|
"github.com/rclone/rclone/fs/config/configmap"
|
|
|
|
"github.com/rclone/rclone/fs/fserrors"
|
2023-04-04 15:33:48 +00:00
|
|
|
"github.com/rclone/rclone/lib/rest"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Globals
|
|
|
|
const (
|
2024-06-19 15:57:21 +00:00
|
|
|
cachePrefix = "rclone-pikpak-gcid-"
|
2023-04-04 15:33:48 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// requestDecompress requests decompress of compressed files
|
|
|
|
func (f *Fs) requestDecompress(ctx context.Context, file *api.File, password string) (info *api.DecompressResult, err error) {
|
|
|
|
req := &api.RequestDecompress{
|
|
|
|
Gcid: file.Hash,
|
|
|
|
Password: password,
|
|
|
|
FileID: file.ID,
|
|
|
|
Files: []*api.FileInArchive{},
|
|
|
|
DefaultParent: true,
|
|
|
|
}
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "POST",
|
|
|
|
Path: "/decompress/v1/decompress",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// getUserInfo gets UserInfo from API
|
|
|
|
func (f *Fs) getUserInfo(ctx context.Context) (info *api.User, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "GET",
|
|
|
|
RootURL: "https://user.mypikpak.com/v1/user/me",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// getVIPInfo gets VIPInfo from API
|
|
|
|
func (f *Fs) getVIPInfo(ctx context.Context) (info *api.VIP, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "GET",
|
|
|
|
RootURL: "https://api-drive.mypikpak.com/drive/v1/privilege/vip",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to get vip info: %w", err)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// requestBatchAction requests batch actions to API
|
|
|
|
//
|
|
|
|
// action can be one of batch{Copy,Delete,Trash,Untrash}
|
|
|
|
func (f *Fs) requestBatchAction(ctx context.Context, action string, req *api.RequestBatch) (err error) {
|
|
|
|
opts := rest.Opts{
|
2024-06-19 15:07:05 +00:00
|
|
|
Method: "POST",
|
|
|
|
Path: "/drive/v1/files:" + action,
|
2023-04-04 15:33:48 +00:00
|
|
|
}
|
2024-06-19 15:07:05 +00:00
|
|
|
info := struct {
|
|
|
|
TaskID string `json:"task_id"`
|
|
|
|
}{}
|
2023-04-04 15:33:48 +00:00
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2024-06-19 15:07:05 +00:00
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &info)
|
2023-04-04 15:33:48 +00:00
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return fmt.Errorf("batch action %q failed: %w", action, err)
|
|
|
|
}
|
2024-06-19 15:07:05 +00:00
|
|
|
return f.waitTask(ctx, info.TaskID)
|
2023-04-04 15:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// requestNewTask requests a new api.NewTask and returns api.Task
|
|
|
|
func (f *Fs) requestNewTask(ctx context.Context, req *api.RequestNewTask) (info *api.Task, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "POST",
|
|
|
|
Path: "/drive/v1/files",
|
|
|
|
}
|
|
|
|
var newTask api.NewTask
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &newTask)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return newTask.Task, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// requestNewFile requests a new api.NewFile and returns api.File
|
|
|
|
func (f *Fs) requestNewFile(ctx context.Context, req *api.RequestNewFile) (info *api.NewFile, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "POST",
|
|
|
|
Path: "/drive/v1/files",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// getFile gets api.File from API for the ID passed
|
|
|
|
// and returns rich information containing additional fields below
|
|
|
|
// * web_content_link
|
|
|
|
// * thumbnail_link
|
|
|
|
// * links
|
|
|
|
// * medias
|
|
|
|
func (f *Fs) getFile(ctx context.Context, ID string) (info *api.File, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "GET",
|
|
|
|
Path: "/drive/v1/files/" + ID,
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
2024-06-10 17:08:07 +00:00
|
|
|
if err == nil && !info.Links.ApplicationOctetStream.Valid() {
|
|
|
|
return true, errors.New("no link")
|
2023-04-04 15:33:48 +00:00
|
|
|
}
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
2024-06-19 15:57:21 +00:00
|
|
|
if err == nil {
|
|
|
|
info.Name = f.opt.Enc.ToStandardName(info.Name)
|
|
|
|
}
|
2023-04-04 15:33:48 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// patchFile updates attributes of the file by ID
|
|
|
|
//
|
|
|
|
// currently known patchable fields are
|
|
|
|
// * name
|
|
|
|
func (f *Fs) patchFile(ctx context.Context, ID string, req *api.File) (info *api.File, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "PATCH",
|
|
|
|
Path: "/drive/v1/files/" + ID,
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-10 17:08:07 +00:00
|
|
|
// getTask gets api.Task from API for the ID passed
|
|
|
|
func (f *Fs) getTask(ctx context.Context, ID string, checkPhase bool) (info *api.Task, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "GET",
|
|
|
|
Path: "/drive/v1/tasks/" + ID,
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
|
|
|
if checkPhase {
|
|
|
|
if err == nil && info.Phase != api.PhaseTypeComplete {
|
2024-06-19 15:07:05 +00:00
|
|
|
// could be pending right after the task is created
|
|
|
|
return true, fmt.Errorf("%s (%s) is still in %s", info.Name, info.Type, info.Phase)
|
2024-06-10 17:08:07 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-19 15:07:05 +00:00
|
|
|
// waitTask waits for async tasks to be completed
|
|
|
|
func (f *Fs) waitTask(ctx context.Context, ID string) (err error) {
|
|
|
|
time.Sleep(taskWaitTime)
|
|
|
|
if info, err := f.getTask(ctx, ID, true); err != nil {
|
|
|
|
if info == nil {
|
|
|
|
return fmt.Errorf("can't verify the task is completed: %q", ID)
|
|
|
|
}
|
|
|
|
return fmt.Errorf("can't verify the task is completed: %#v", info)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-10 17:08:07 +00:00
|
|
|
// deleteTask remove a task having the specified ID
|
|
|
|
func (f *Fs) deleteTask(ctx context.Context, ID string, deleteFiles bool) (err error) {
|
|
|
|
params := url.Values{}
|
|
|
|
params.Set("delete_files", strconv.FormatBool(deleteFiles))
|
|
|
|
params.Set("task_ids", ID)
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "DELETE",
|
|
|
|
Path: "/drive/v1/tasks",
|
|
|
|
Parameters: params,
|
|
|
|
NoResponse: true,
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, nil)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-04-04 15:33:48 +00:00
|
|
|
// getAbout gets drive#quota information from server
|
|
|
|
func (f *Fs) getAbout(ctx context.Context) (info *api.About, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "GET",
|
|
|
|
Path: "/drive/v1/about",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-04-06 08:07:01 +00:00
|
|
|
// requestShare returns information about sharable links
|
2023-04-04 15:33:48 +00:00
|
|
|
func (f *Fs) requestShare(ctx context.Context, req *api.RequestShare) (info *api.Share, err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "POST",
|
|
|
|
Path: "/drive/v1/share",
|
|
|
|
}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, &req, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-07-17 03:20:09 +00:00
|
|
|
// getGcid retrieves Gcid cached in API server
|
|
|
|
func (f *Fs) getGcid(ctx context.Context, src fs.ObjectInfo) (gcid string, err error) {
|
|
|
|
cid, err := calcCid(ctx, src)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
2024-09-17 16:09:21 +00:00
|
|
|
if src.Size() == 0 {
|
|
|
|
// If src is zero-length, the API will return
|
|
|
|
// Error "cid and file_size is required" (400)
|
|
|
|
// In this case, we can simply return cid == gcid
|
|
|
|
return cid, nil
|
|
|
|
}
|
2024-07-17 03:20:09 +00:00
|
|
|
|
|
|
|
params := url.Values{}
|
|
|
|
params.Set("cid", cid)
|
|
|
|
params.Set("file_size", strconv.FormatInt(src.Size(), 10))
|
|
|
|
opts := rest.Opts{
|
2024-09-17 16:09:21 +00:00
|
|
|
Method: "GET",
|
|
|
|
Path: "/drive/v1/resource/cid",
|
|
|
|
Parameters: params,
|
2024-07-17 03:20:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
info := struct {
|
|
|
|
Gcid string `json:"gcid,omitempty"`
|
|
|
|
}{}
|
|
|
|
var resp *http.Response
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
resp, err = f.rst.CallJSON(ctx, &opts, nil, &info)
|
|
|
|
return f.shouldRetry(ctx, resp, err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
return info.Gcid, nil
|
|
|
|
}
|
|
|
|
|
2024-06-19 15:57:21 +00:00
|
|
|
// Read the gcid of in returning a reader which will read the same contents
|
2023-04-04 15:33:48 +00:00
|
|
|
//
|
|
|
|
// The cleanup function should be called when out is finished with
|
|
|
|
// regardless of whether this function returned an error or not.
|
2024-06-19 15:57:21 +00:00
|
|
|
func readGcid(in io.Reader, size, threshold int64) (gcid string, out io.Reader, cleanup func(), err error) {
|
2023-04-04 15:33:48 +00:00
|
|
|
// nothing to clean up by default
|
|
|
|
cleanup = func() {}
|
|
|
|
|
|
|
|
// don't cache small files on disk to reduce wear of the disk
|
|
|
|
if size > threshold {
|
|
|
|
var tempFile *os.File
|
|
|
|
|
|
|
|
// create the cache file
|
|
|
|
tempFile, err = os.CreateTemp("", cachePrefix)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
_ = os.Remove(tempFile.Name()) // Delete the file - may not work on Windows
|
|
|
|
|
|
|
|
// clean up the file after we are done downloading
|
|
|
|
cleanup = func() {
|
|
|
|
// the file should normally already be close, but just to make sure
|
|
|
|
_ = tempFile.Close()
|
|
|
|
_ = os.Remove(tempFile.Name()) // delete the cache file after we are done - may be deleted already
|
|
|
|
}
|
|
|
|
|
2024-06-19 15:57:21 +00:00
|
|
|
// use the teeReader to write to the local file AND calculate the gcid while doing so
|
|
|
|
teeReader := io.TeeReader(in, tempFile)
|
|
|
|
|
|
|
|
// copy the ENTIRE file to disk and calculate the gcid in the process
|
|
|
|
if gcid, err = calcGcid(teeReader, size); err != nil {
|
2023-04-04 15:33:48 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
// jump to the start of the local file so we can pass it along
|
|
|
|
if _, err = tempFile.Seek(0, 0); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// replace the already read source with a reader of our cached file
|
|
|
|
out = tempFile
|
|
|
|
} else {
|
2024-06-19 15:57:21 +00:00
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
teeReader := io.TeeReader(in, buf)
|
|
|
|
|
|
|
|
if gcid, err = calcGcid(teeReader, size); err != nil {
|
2023-04-04 15:33:48 +00:00
|
|
|
return
|
|
|
|
}
|
2024-06-19 15:57:21 +00:00
|
|
|
out = buf
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-07-17 03:20:09 +00:00
|
|
|
// calcGcid calculates Gcid from reader
|
|
|
|
//
|
|
|
|
// Gcid is a custom hash to index a file contents
|
2024-06-19 15:57:21 +00:00
|
|
|
func calcGcid(r io.Reader, size int64) (string, error) {
|
|
|
|
calcBlockSize := func(j int64) int64 {
|
|
|
|
var psize int64 = 0x40000
|
|
|
|
for float64(j)/float64(psize) > 0x200 && psize < 0x200000 {
|
2024-05-31 12:37:21 +00:00
|
|
|
psize <<= 1
|
2024-06-19 15:57:21 +00:00
|
|
|
}
|
|
|
|
return psize
|
|
|
|
}
|
2023-04-04 15:33:48 +00:00
|
|
|
|
2024-06-19 15:57:21 +00:00
|
|
|
totalHash := sha1.New()
|
|
|
|
blockHash := sha1.New()
|
|
|
|
readSize := calcBlockSize(size)
|
|
|
|
for {
|
|
|
|
blockHash.Reset()
|
|
|
|
if n, err := io.CopyN(blockHash, r, readSize); err != nil && n == 0 {
|
|
|
|
if err != io.EOF {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
totalHash.Write(blockHash.Sum(nil))
|
2023-04-04 15:33:48 +00:00
|
|
|
}
|
2024-06-19 15:57:21 +00:00
|
|
|
return hex.EncodeToString(totalHash.Sum(nil)), nil
|
2023-04-04 15:33:48 +00:00
|
|
|
}
|
2024-07-17 03:20:09 +00:00
|
|
|
|
|
|
|
// calcCid calculates Cid from source
|
|
|
|
//
|
|
|
|
// Cid is a simplified version of Gcid
|
|
|
|
func calcCid(ctx context.Context, src fs.ObjectInfo) (cid string, err error) {
|
|
|
|
srcObj := fs.UnWrapObjectInfo(src)
|
|
|
|
if srcObj == nil {
|
|
|
|
return "", fmt.Errorf("failed to unwrap object from src: %s", src)
|
|
|
|
}
|
|
|
|
|
|
|
|
size := src.Size()
|
|
|
|
hash := sha1.New()
|
|
|
|
var rc io.ReadCloser
|
|
|
|
|
|
|
|
readHash := func(start, length int64) (err error) {
|
|
|
|
end := start + length - 1
|
|
|
|
if rc, err = srcObj.Open(ctx, &fs.RangeOption{Start: start, End: end}); err != nil {
|
|
|
|
return fmt.Errorf("failed to open src with range (%d, %d): %w", start, end, err)
|
|
|
|
}
|
|
|
|
defer fs.CheckClose(rc, &err)
|
|
|
|
_, err = io.Copy(hash, rc)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if size <= 0xF000 { // 61440 = 60KB
|
|
|
|
err = readHash(0, size)
|
|
|
|
} else { // 20KB from three different parts
|
|
|
|
for _, start := range []int64{0, size / 3, size - 0x5000} {
|
|
|
|
err = readHash(start, 0x5000)
|
|
|
|
if err != nil {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to hash: %w", err)
|
|
|
|
}
|
|
|
|
cid = strings.ToUpper(hex.EncodeToString(hash.Sum(nil)))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-09-17 16:09:21 +00:00
|
|
|
// ------------------------------------------------------------ authorization
|
|
|
|
|
2024-07-17 03:20:09 +00:00
|
|
|
// randomly generates device id used for request header 'x-device-id'
|
|
|
|
//
|
|
|
|
// original javascript implementation
|
|
|
|
//
|
|
|
|
// return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, (e) => {
|
|
|
|
// const t = (16 * Math.random()) | 0;
|
|
|
|
// return ("x" == e ? t : (3 & t) | 8).toString(16);
|
|
|
|
// });
|
|
|
|
func genDeviceID() string {
|
|
|
|
base := []byte("xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx")
|
|
|
|
for i, char := range base {
|
|
|
|
switch char {
|
|
|
|
case 'x':
|
|
|
|
base[i] = fmt.Sprintf("%x", rand.Intn(16))[0]
|
|
|
|
case 'y':
|
|
|
|
base[i] = fmt.Sprintf("%x", rand.Intn(16)&3|8)[0]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return string(base)
|
|
|
|
}
|
2024-09-17 16:09:21 +00:00
|
|
|
|
|
|
|
var md5Salt = []string{
|
|
|
|
"C9qPpZLN8ucRTaTiUMWYS9cQvWOE",
|
|
|
|
"+r6CQVxjzJV6LCV",
|
|
|
|
"F",
|
|
|
|
"pFJRC",
|
|
|
|
"9WXYIDGrwTCz2OiVlgZa90qpECPD6olt",
|
|
|
|
"/750aCr4lm/Sly/c",
|
|
|
|
"RB+DT/gZCrbV",
|
|
|
|
"",
|
|
|
|
"CyLsf7hdkIRxRm215hl",
|
|
|
|
"7xHvLi2tOYP0Y92b",
|
|
|
|
"ZGTXXxu8E/MIWaEDB+Sm/",
|
|
|
|
"1UI3",
|
|
|
|
"E7fP5Pfijd+7K+t6Tg/NhuLq0eEUVChpJSkrKxpO",
|
|
|
|
"ihtqpG6FMt65+Xk+tWUH2",
|
|
|
|
"NhXXU9rg4XXdzo7u5o",
|
|
|
|
}
|
|
|
|
|
|
|
|
func md5Sum(text string) string {
|
|
|
|
hash := md5.Sum([]byte(text))
|
|
|
|
return hex.EncodeToString(hash[:])
|
|
|
|
}
|
|
|
|
|
|
|
|
func calcCaptchaSign(deviceID string) (timestamp, sign string) {
|
|
|
|
timestamp = fmt.Sprint(time.Now().UnixMilli())
|
|
|
|
str := fmt.Sprint(clientID, clientVersion, packageName, deviceID, timestamp)
|
|
|
|
for _, salt := range md5Salt {
|
|
|
|
str = md5Sum(str + salt)
|
|
|
|
}
|
|
|
|
sign = "1." + str
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func newCaptchaTokenRequest(action, oldToken string, opt *Options) (req *api.CaptchaTokenRequest) {
|
|
|
|
req = &api.CaptchaTokenRequest{
|
|
|
|
Action: action,
|
|
|
|
CaptchaToken: oldToken, // can be empty initially
|
|
|
|
ClientID: clientID,
|
|
|
|
DeviceID: opt.DeviceID,
|
|
|
|
Meta: new(api.CaptchaTokenMeta),
|
|
|
|
}
|
|
|
|
switch action {
|
|
|
|
case "POST:/v1/auth/signin":
|
|
|
|
req.Meta.UserName = opt.Username
|
|
|
|
default:
|
|
|
|
timestamp, captchaSign := calcCaptchaSign(opt.DeviceID)
|
|
|
|
req.Meta.CaptchaSign = captchaSign
|
|
|
|
req.Meta.Timestamp = timestamp
|
|
|
|
req.Meta.ClientVersion = clientVersion
|
|
|
|
req.Meta.PackageName = packageName
|
|
|
|
req.Meta.UserID = opt.UserID
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// CaptchaTokenSource stores updated captcha tokens in the config file
|
|
|
|
type CaptchaTokenSource struct {
|
|
|
|
mu sync.Mutex
|
|
|
|
m configmap.Mapper
|
|
|
|
opt *Options
|
|
|
|
token *api.CaptchaToken
|
|
|
|
ctx context.Context
|
|
|
|
rst *pikpakClient
|
|
|
|
}
|
|
|
|
|
|
|
|
// initialize CaptchaTokenSource from rclone.conf if possible
|
|
|
|
func newCaptchaTokenSource(ctx context.Context, opt *Options, m configmap.Mapper) *CaptchaTokenSource {
|
|
|
|
token := new(api.CaptchaToken)
|
|
|
|
tokenString, ok := m.Get("captcha_token")
|
|
|
|
if !ok || tokenString == "" {
|
|
|
|
fs.Debugf(nil, "failed to read captcha token out of config file")
|
|
|
|
} else {
|
|
|
|
if err := json.Unmarshal([]byte(tokenString), token); err != nil {
|
|
|
|
fs.Debugf(nil, "failed to parse captcha token out of config file: %v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return &CaptchaTokenSource{
|
|
|
|
m: m,
|
|
|
|
opt: opt,
|
|
|
|
token: token,
|
|
|
|
ctx: ctx,
|
|
|
|
rst: newPikpakClient(getClient(ctx, opt), opt),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// requestToken retrieves captcha token from API
|
|
|
|
func (cts *CaptchaTokenSource) requestToken(ctx context.Context, req *api.CaptchaTokenRequest) (err error) {
|
|
|
|
opts := rest.Opts{
|
|
|
|
Method: "POST",
|
|
|
|
RootURL: "https://user.mypikpak.com/v1/shield/captcha/init",
|
|
|
|
}
|
|
|
|
var info *api.CaptchaToken
|
|
|
|
_, err = cts.rst.CallJSON(ctx, &opts, &req, &info)
|
|
|
|
if err == nil && info.ExpiresIn != 0 {
|
|
|
|
// populate to Expiry
|
|
|
|
info.Expiry = time.Now().Add(time.Duration(info.ExpiresIn) * time.Second)
|
|
|
|
cts.token = info // update with a new one
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func (cts *CaptchaTokenSource) refreshToken(opts *rest.Opts) (string, error) {
|
|
|
|
oldToken := ""
|
|
|
|
if cts.token != nil {
|
|
|
|
oldToken = cts.token.CaptchaToken
|
|
|
|
}
|
|
|
|
action := "GET:/drive/v1/about"
|
|
|
|
if opts.RootURL == "" && opts.Path != "" {
|
|
|
|
action = fmt.Sprintf("%s:%s", opts.Method, opts.Path)
|
|
|
|
} else if u, err := url.Parse(opts.RootURL); err == nil {
|
|
|
|
action = fmt.Sprintf("%s:%s", opts.Method, u.Path)
|
|
|
|
}
|
|
|
|
req := newCaptchaTokenRequest(action, oldToken, cts.opt)
|
|
|
|
if err := cts.requestToken(cts.ctx, req); err != nil {
|
|
|
|
return "", fmt.Errorf("failed to retrieve captcha token from api: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// put it into rclone.conf
|
|
|
|
tokenBytes, err := json.Marshal(cts.token)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("failed to marshal captcha token: %w", err)
|
|
|
|
}
|
|
|
|
cts.m.Set("captcha_token", string(tokenBytes))
|
|
|
|
return cts.token.CaptchaToken, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Invalidate resets existing captcha token for a forced refresh
|
|
|
|
func (cts *CaptchaTokenSource) Invalidate() {
|
|
|
|
cts.mu.Lock()
|
|
|
|
cts.token.CaptchaToken = ""
|
|
|
|
cts.mu.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Token returns a valid captcha token
|
|
|
|
func (cts *CaptchaTokenSource) Token(opts *rest.Opts) (string, error) {
|
|
|
|
cts.mu.Lock()
|
|
|
|
defer cts.mu.Unlock()
|
|
|
|
if cts.token.Valid() {
|
|
|
|
return cts.token.CaptchaToken, nil
|
|
|
|
}
|
|
|
|
return cts.refreshToken(opts)
|
|
|
|
}
|
|
|
|
|
|
|
|
// pikpakClient wraps rest.Client with a handle of captcha token
|
|
|
|
type pikpakClient struct {
|
|
|
|
opt *Options
|
|
|
|
client *rest.Client
|
|
|
|
captcha *CaptchaTokenSource
|
|
|
|
}
|
|
|
|
|
|
|
|
// newPikpakClient takes an (oauth) http.Client and makes a new api instance for pikpak with
|
|
|
|
// * error handler
|
|
|
|
// * root url
|
|
|
|
// * default headers
|
|
|
|
func newPikpakClient(c *http.Client, opt *Options) *pikpakClient {
|
|
|
|
client := rest.NewClient(c).SetErrorHandler(errorHandler).SetRoot(rootURL)
|
|
|
|
for key, val := range map[string]string{
|
|
|
|
"Referer": "https://mypikpak.com/",
|
|
|
|
"x-client-id": clientID,
|
|
|
|
"x-client-version": clientVersion,
|
|
|
|
"x-device-id": opt.DeviceID,
|
|
|
|
// "x-device-model": "firefox%2F129.0",
|
|
|
|
// "x-device-name": "PC-Firefox",
|
|
|
|
// "x-device-sign": fmt.Sprintf("wdi10.%sxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", opt.DeviceID),
|
|
|
|
// "x-net-work-type": "NONE",
|
|
|
|
// "x-os-version": "Win32",
|
|
|
|
// "x-platform-version": "1",
|
|
|
|
// "x-protocol-version": "301",
|
|
|
|
// "x-provider-name": "NONE",
|
|
|
|
// "x-sdk-version": "8.0.3",
|
|
|
|
} {
|
|
|
|
client.SetHeader(key, val)
|
|
|
|
}
|
|
|
|
return &pikpakClient{
|
|
|
|
client: client,
|
|
|
|
opt: opt,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// This should be called right after pikpakClient initialized
|
|
|
|
func (c *pikpakClient) SetCaptchaTokener(ctx context.Context, m configmap.Mapper) *pikpakClient {
|
|
|
|
c.captcha = newCaptchaTokenSource(ctx, c.opt, m)
|
|
|
|
return c
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *pikpakClient) CallJSON(ctx context.Context, opts *rest.Opts, request interface{}, response interface{}) (resp *http.Response, err error) {
|
|
|
|
if c.captcha != nil {
|
|
|
|
token, err := c.captcha.Token(opts)
|
|
|
|
if err != nil || token == "" {
|
|
|
|
return nil, fserrors.FatalError(fmt.Errorf("couldn't get captcha token: %v", err))
|
|
|
|
}
|
|
|
|
if opts.ExtraHeaders == nil {
|
|
|
|
opts.ExtraHeaders = make(map[string]string)
|
|
|
|
}
|
|
|
|
opts.ExtraHeaders["x-captcha-token"] = token
|
|
|
|
}
|
|
|
|
return c.client.CallJSON(ctx, opts, request, response)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *pikpakClient) Call(ctx context.Context, opts *rest.Opts) (resp *http.Response, err error) {
|
|
|
|
return c.client.Call(ctx, opts)
|
|
|
|
}
|