diff --git a/drivers/189pc/driver.go b/drivers/189pc/driver.go new file mode 100644 index 00000000000..6e4107e03a6 --- /dev/null +++ b/drivers/189pc/driver.go @@ -0,0 +1,284 @@ +package _189pc + +import ( + "context" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" +) + +type Yun189PC struct { + model.Storage + Addition + + identity string + + client *resty.Client + putClient *resty.Client + + loginParam *LoginParam + tokenInfo *AppSessionResp +} + +func (y *Yun189PC) Config() driver.Config { + return config +} + +func (y *Yun189PC) GetAddition() driver.Additional { + return y.Addition +} + +func (y *Yun189PC) Init(ctx context.Context, storage model.Storage) (err error) { + y.Storage = storage + if err = utils.Json.UnmarshalFromString(y.Storage.Addition, &y.Addition); err != nil { + return err + } + + // 处理个人云和家庭云参数 + if y.isFamily() && y.RootFolderID == "-11" { + y.RootFolderID = "" + } + if !y.isFamily() && y.RootFolderID == "" { + y.RootFolderID = "-11" + y.FamilyID = "" + } + + // 初始化请求客户端 + if y.client == nil { + y.client = base.NewRestyClient().SetHeaders(map[string]string{ + "Accept": "application/json;charset=UTF-8", + "Referer": WEB_URL, + }) + } + if y.putClient == nil { + y.putClient = base.NewRestyClient().SetTimeout(120 * time.Second) + } + + // 避免重复登陆 + identity := utils.GetMD5Encode(y.Username + y.Password) + if !y.isLogin() || y.identity != identity { + y.identity = identity + if err = y.login(); err != nil { + return + } + } + + // 处理家庭云ID + if y.isFamily() && y.FamilyID == "" { + if y.FamilyID, err = y.getFamilyID(); err != nil { + return err + } + } + return +} + +func (y *Yun189PC) Drop(ctx context.Context) error { + return nil +} + +func (y *Yun189PC) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + return y.getFiles(ctx, dir.GetID()) +} + +func (y *Yun189PC) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + var downloadUrl struct { + URL string `json:"fileDownloadUrl"` + } + + fullUrl := API_URL + if y.isFamily() { + fullUrl += "/family/file" + } + fullUrl += "/getFileDownloadUrl.action" + + _, err := y.get(fullUrl, func(r *resty.Request) { + r.SetContext(ctx) + r.SetQueryParam("fileId", file.GetID()) + if y.isFamily() { + r.SetQueryParams(map[string]string{ + "familyId": y.FamilyID, + }) + } else { + r.SetQueryParams(map[string]string{ + "dt": "3", + "flag": "1", + }) + } + }, &downloadUrl) + if err != nil { + return nil, err + } + + // 重定向获取真实链接 + downloadUrl.URL = strings.Replace(strings.ReplaceAll(downloadUrl.URL, "&", "&"), "http://", "https://", 1) + res, err := base.NoRedirectClient.R().SetContext(ctx).Get(downloadUrl.URL) + if err != nil { + return nil, err + } + if res.StatusCode() == 302 { + downloadUrl.URL = res.Header().Get("location") + } + + like := &model.Link{ + URL: downloadUrl.URL, + Header: http.Header{ + "User-Agent": []string{base.UserAgent}, + }, + } + + // 获取链接有效时常 + strs := regexp.MustCompile(`(?i)expire[^=]*=([0-9]*)`).FindStringSubmatch(downloadUrl.URL) + if len(strs) == 2 { + timestamp, err := strconv.ParseInt(strs[1], 10, 64) + if err == nil { + expired := time.Duration(timestamp-time.Now().Unix()) * time.Second + like.Expiration = &expired + } + } + return like, nil +} + +func (y *Yun189PC) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + fullUrl := API_URL + if y.isFamily() { + fullUrl += "/family/file" + } + fullUrl += "/createFolder.action" + + _, err := y.post(fullUrl, func(req *resty.Request) { + req.SetContext(ctx) + req.SetQueryParams(map[string]string{ + "folderName": dirName, + "relativePath": "", + }) + if y.isFamily() { + req.SetQueryParams(map[string]string{ + "familyId": y.FamilyID, + "parentId": parentDir.GetID(), + }) + } else { + req.SetQueryParams(map[string]string{ + "parentFolderId": parentDir.GetID(), + }) + } + }, nil) + return err +} + +func (y *Yun189PC) Move(ctx context.Context, srcObj, dstDir model.Obj) error { + _, err := y.post(API_URL+"/batch/createBatchTask.action", func(req *resty.Request) { + req.SetContext(ctx) + req.SetFormData(map[string]string{ + "type": "MOVE", + "taskInfos": MustString(utils.Json.MarshalToString( + []BatchTaskInfo{ + { + FileId: srcObj.GetID(), + FileName: srcObj.GetName(), + IsFolder: BoolToNumber(srcObj.IsDir()), + }, + })), + "targetFolderId": dstDir.GetID(), + }) + if y.isFamily() { + req.SetFormData(map[string]string{ + "familyId": y.FamilyID, + }) + } + }, nil) + return err +} + +func (y *Yun189PC) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + queryParam := make(map[string]string) + fullUrl := API_URL + method := http.MethodPost + if y.isFamily() { + fullUrl += "/family/file" + method = http.MethodGet + queryParam["familyId"] = y.FamilyID + } + if srcObj.IsDir() { + fullUrl += "/renameFolder.action" + queryParam["folderId"] = srcObj.GetID() + queryParam["destFolderName"] = newName + } else { + fullUrl += "/renameFile.action" + queryParam["fileId"] = srcObj.GetID() + queryParam["destFileName"] = newName + } + _, err := y.request(fullUrl, method, func(req *resty.Request) { + req.SetContext(ctx) + req.SetQueryParams(queryParam) + }, nil, nil) + return err +} + +func (y *Yun189PC) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + _, err := y.post(API_URL+"/batch/createBatchTask.action", func(req *resty.Request) { + req.SetContext(ctx) + req.SetFormData(map[string]string{ + "type": "COPY", + "taskInfos": MustString(utils.Json.MarshalToString( + []BatchTaskInfo{ + { + FileId: srcObj.GetID(), + FileName: srcObj.GetName(), + IsFolder: BoolToNumber(srcObj.IsDir()), + }, + })), + "targetFolderId": dstDir.GetID(), + "targetFileName": dstDir.GetName(), + }) + if y.isFamily() { + req.SetFormData(map[string]string{ + "familyId": y.FamilyID, + }) + } + }, nil) + return err +} + +func (y *Yun189PC) Remove(ctx context.Context, obj model.Obj) error { + _, err := y.post(API_URL+"/batch/createBatchTask.action", func(req *resty.Request) { + req.SetContext(ctx) + req.SetFormData(map[string]string{ + "type": "DELETE", + "taskInfos": MustString(utils.Json.MarshalToString( + []*BatchTaskInfo{ + { + FileId: obj.GetID(), + FileName: obj.GetName(), + IsFolder: BoolToNumber(obj.IsDir()), + }, + })), + }) + + if y.isFamily() { + req.SetFormData(map[string]string{ + "familyId": y.FamilyID, + }) + } + }, nil) + return err +} + +func (y *Yun189PC) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { + if y.RapidUpload { + return y.FastUpload(ctx, dstDir, stream, up) + } + return y.CommonUpload(ctx, dstDir, stream, up) +} + +func (y *Yun189PC) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { + return nil, errs.NotSupport +} diff --git a/drivers/189pc/help.go b/drivers/189pc/help.go new file mode 100644 index 00000000000..abeceb3eb4c --- /dev/null +++ b/drivers/189pc/help.go @@ -0,0 +1,131 @@ +package _189pc + +import ( + "bytes" + "crypto/aes" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/alist-org/alist/v3/pkg/utils/random" +) + +func clientSuffix() map[string]string { + rand := random.Rand + return map[string]string{ + "clientType": PC, + "version": VERSION, + "channelId": CHANNEL_ID, + "rand": fmt.Sprintf("%d_%d", rand.Int63n(1e5), rand.Int63n(1e10)), + } +} + +// 带params的SignatureOfHmac HMAC签名 +func signatureOfHmac(sessionSecret, sessionKey, operate, fullUrl, dateOfGmt, param string) string { + urlpath := regexp.MustCompile(`://[^/]+((/[^/\s?#]+)*)`).FindStringSubmatch(fullUrl)[1] + mac := hmac.New(sha1.New, []byte(sessionSecret)) + data := fmt.Sprintf("SessionKey=%s&Operate=%s&RequestURI=%s&Date=%s", sessionKey, operate, urlpath, dateOfGmt) + if param != "" { + data += fmt.Sprintf("¶ms=%s", param) + } + mac.Write([]byte(data)) + return strings.ToUpper(hex.EncodeToString(mac.Sum(nil))) +} + +// RAS 加密用户名密码 +func RsaEncrypt(publicKey, origData string) string { + block, _ := pem.Decode([]byte(publicKey)) + pubInterface, _ := x509.ParsePKIXPublicKey(block.Bytes) + data, _ := rsa.EncryptPKCS1v15(rand.Reader, pubInterface.(*rsa.PublicKey), []byte(origData)) + return strings.ToUpper(hex.EncodeToString(data)) +} + +// aes 加密params +func AesECBEncrypt(data, key string) string { + block, _ := aes.NewCipher([]byte(key)) + paddingData := PKCS7Padding([]byte(data), block.BlockSize()) + decrypted := make([]byte, len(paddingData)) + size := block.BlockSize() + for src, dst := paddingData, decrypted; len(src) > 0; src, dst = src[size:], dst[size:] { + block.Encrypt(dst[:size], src[:size]) + } + return strings.ToUpper(hex.EncodeToString(decrypted)) +} + +func PKCS7Padding(ciphertext []byte, blockSize int) []byte { + padding := blockSize - len(ciphertext)%blockSize + padtext := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padtext...) +} + +// 获取http规范的时间 +func getHttpDateStr() string { + return time.Now().UTC().Format(http.TimeFormat) +} + +// 时间戳 +func timestamp() int64 { + return time.Now().UTC().UnixNano() / 1e6 +} + +func MustParseTime(str string) *time.Time { + lastOpTime, _ := time.ParseInLocation("2006-01-02 15:04:05", str, time.Local) + return &lastOpTime +} + +func toFamilyOrderBy(o string) string { + switch o { + case "filename": + return "1" + case "filesize": + return "2" + case "lastOpTime": + return "3" + default: + return "1" + } +} + +func toDesc(o string) string { + switch o { + case "desc": + return "true" + case "asc": + fallthrough + default: + return "false" + } +} + +func ParseHttpHeader(str string) map[string]string { + header := make(map[string]string) + for _, value := range strings.Split(str, "&") { + i := strings.Index(value, "=") + header[strings.TrimSpace(value[0:i])] = strings.TrimSpace(value[i+1:]) + } + return header +} + +func MustString(str string, err error) string { + return str +} + +func MustToBytes(b []byte, err error) []byte { + return b +} + +func BoolToNumber(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/drivers/189pc/meta.go b/drivers/189pc/meta.go new file mode 100644 index 00000000000..e8eb20155af --- /dev/null +++ b/drivers/189pc/meta.go @@ -0,0 +1,33 @@ +package _189pc + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + Username string `json:"username" required:"true"` + Password string `json:"password" required:"true"` + VCode string `json:"validate_code"` + RootFolderID string `json:"root_folder_id"` + OrderBy string `json:"order_by" type:"select" options:"filename,filesize,lastOpTime" default:"filename"` + OrderDirection string `json:"order_direction" type:"select" options:"asc,desc" default:"asc"` + Type string `json:"type" type:"select" options:"personal,family" default:"personal"` + FamilyID string `json:"family_id"` + RapidUpload bool `json:"rapid_upload"` +} + +func (a Addition) GetRootId() string { + return a.RootFolderID +} + +var config = driver.Config{ + Name: "189CloudPC", + DefaultRoot: "-11", +} + +func init() { + op.RegisterDriver(config, func() driver.Driver { + return &Yun189PC{} + }) +} diff --git a/drivers/189pc/types.go b/drivers/189pc/types.go new file mode 100644 index 00000000000..0a6d4f9f887 --- /dev/null +++ b/drivers/189pc/types.go @@ -0,0 +1,246 @@ +package _189pc + +import ( + "encoding/xml" + "fmt" + "sort" + "strings" + "time" +) + +// 居然有四种返回方式 +type RespErr struct { + ResCode string `json:"res_code"` + ResMessage string `json:"res_message"` + + XMLName xml.Name `xml:"error"` + Code string `json:"code" xml:"code"` + Message string `json:"message" xml:"message"` + + // Code string `json:"code"` + Msg string `json:"msg"` + + ErrorCode string `json:"errorCode"` + ErrorMsg string `json:"errorMsg"` +} + +// 登陆需要的参数 +type LoginParam struct { + // 加密后的用户名和密码 + RsaUsername string + RsaPassword string + + // rsa密钥 + jRsaKey string + + // 请求头参数 + Lt string + ReqId string + + // 表单参数 + ParamId string + + // 验证码 + CaptchaToken string +} + +// 登陆加密相关 +type EncryptConfResp struct { + Result int `json:"result"` + Data struct { + UpSmsOn string `json:"upSmsOn"` + Pre string `json:"pre"` + PreDomain string `json:"preDomain"` + PubKey string `json:"pubKey"` + } `json:"data"` +} + +type LoginResp struct { + Msg string `json:"msg"` + Result int `json:"result"` + ToUrl string `json:"toUrl"` +} + +// 刷新session返回 +type UserSessionResp struct { + ResCode int `json:"res_code"` + ResMessage string `json:"res_message"` + + LoginName string `json:"loginName"` + + KeepAlive int `json:"keepAlive"` + GetFileDiffSpan int `json:"getFileDiffSpan"` + GetUserInfoSpan int `json:"getUserInfoSpan"` + + // 个人云 + SessionKey string `json:"sessionKey"` + SessionSecret string `json:"sessionSecret"` + // 家庭云 + FamilySessionKey string `json:"familySessionKey"` + FamilySessionSecret string `json:"familySessionSecret"` +} + +// 登录返回 +type AppSessionResp struct { + UserSessionResp + + IsSaveName string `json:"isSaveName"` + + // 会话刷新Token + AccessToken string `json:"accessToken"` + //Token刷新 + RefreshToken string `json:"refreshToken"` +} + +// 家庭云账户 +type FamilyInfoListResp struct { + FamilyInfoResp []FamilyInfoResp `json:"familyInfoResp"` +} +type FamilyInfoResp struct { + Count int `json:"count"` + CreateTime string `json:"createTime"` + FamilyID int `json:"familyId"` + RemarkName string `json:"remarkName"` + Type int `json:"type"` + UseFlag int `json:"useFlag"` + UserRole int `json:"userRole"` +} + +/*文件部分*/ +// 文件 +type Cloud189File struct { + CreateDate string `json:"createDate"` + FileCata int64 `json:"fileCata"` + Icon struct { + //iconOption 5 + SmallUrl string `json:"smallUrl"` + LargeUrl string `json:"largeUrl"` + + // iconOption 10 + Max600 string `json:"max600"` + MediumURL string `json:"mediumUrl"` + } `json:"icon"` + ID int64 `json:"id"` + LastOpTime string `json:"lastOpTime"` + Md5 string `json:"md5"` + MediaType int `json:"mediaType"` + Name string `json:"name"` + Orientation int64 `json:"orientation"` + Rev string `json:"rev"` + Size int64 `json:"size"` + StarLabel int64 `json:"starLabel"` + + parseTime *time.Time +} + +func (c *Cloud189File) GetSize() int64 { return c.Size } +func (c *Cloud189File) GetName() string { return c.Name } +func (c *Cloud189File) ModTime() time.Time { + if c.parseTime == nil { + c.parseTime = MustParseTime(c.LastOpTime) + } + return *c.parseTime +} +func (c *Cloud189File) IsDir() bool { return false } +func (c *Cloud189File) GetID() string { return fmt.Sprint(c.ID) } +func (c *Cloud189File) GetPath() string { return "" } +func (c *Cloud189File) Thumb() string { return c.Icon.SmallUrl } + +// 文件夹 +type Cloud189Folder struct { + ID int64 `json:"id"` + ParentID int64 `json:"parentId"` + Name string `json:"name"` + + FileCata int64 `json:"fileCata"` + FileCount int64 `json:"fileCount"` + + LastOpTime string `json:"lastOpTime"` + CreateDate string `json:"createDate"` + + FileListSize int64 `json:"fileListSize"` + Rev string `json:"rev"` + StarLabel int64 `json:"starLabel"` + + parseTime *time.Time +} + +func (c *Cloud189Folder) GetSize() int64 { return 0 } +func (c *Cloud189Folder) GetName() string { return c.Name } +func (c *Cloud189Folder) ModTime() time.Time { + if c.parseTime == nil { + c.parseTime = MustParseTime(c.LastOpTime) + } + return *c.parseTime +} +func (c *Cloud189Folder) IsDir() bool { return true } +func (c *Cloud189Folder) GetID() string { return fmt.Sprint(c.ID) } +func (c *Cloud189Folder) GetPath() string { return "" } + +type Cloud189FilesResp struct { + //ResCode int `json:"res_code"` + //ResMessage string `json:"res_message"` + FileListAO struct { + Count int `json:"count"` + FileList []Cloud189File `json:"fileList"` + FolderList []Cloud189Folder `json:"folderList"` + } `json:"fileListAO"` +} + +// TaskInfo 任务信息 +type BatchTaskInfo struct { + // FileId 文件ID + FileId string `json:"fileId"` + // FileName 文件名 + FileName string `json:"fileName"` + // IsFolder 是否是文件夹,0-否,1-是 + IsFolder int `json:"isFolder"` + // SrcParentId 文件所在父目录ID + //SrcParentId string `json:"srcParentId"` +} + +/* 上传部分 */ +type InitMultiUploadResp struct { + //Code string `json:"code"` + Data struct { + UploadType int `json:"uploadType"` + UploadHost string `json:"uploadHost"` + UploadFileID string `json:"uploadFileId"` + FileDataExists int `json:"fileDataExists"` + } `json:"data"` +} +type UploadUrlsResp struct { + Code string `json:"code"` + UploadUrls map[string]Part `json:"uploadUrls"` +} +type Part struct { + RequestURL string `json:"requestURL"` + RequestHeader string `json:"requestHeader"` +} + +type Params map[string]string + +func (p Params) Set(k, v string) { + p[k] = v +} + +func (p Params) Encode() string { + if p == nil { + return "" + } + var buf strings.Builder + keys := make([]string, 0, len(p)) + for k := range p { + keys = append(keys, k) + } + sort.Strings(keys) + for i := range keys { + if buf.Len() > 0 { + buf.WriteByte('&') + } + buf.WriteString(keys[i]) + buf.WriteByte('=') + buf.WriteString(p[keys[i]]) + } + return buf.String() +} diff --git a/drivers/189pc/utils.go b/drivers/189pc/utils.go new file mode 100644 index 00000000000..b75eddf6c51 --- /dev/null +++ b/drivers/189pc/utils.go @@ -0,0 +1,663 @@ +package _189pc + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "math" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/conf" + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/internal/op" + "github.com/alist-org/alist/v3/internal/setting" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" + "github.com/google/uuid" + jsoniter "github.com/json-iterator/go" +) + +const ( + ACCOUNT_TYPE = "02" + APP_ID = "8025431004" + CLIENT_TYPE = "10020" + VERSION = "6.2" + + WEB_URL = "https://cloud.189.cn" + AUTH_URL = "https://open.e.189.cn" + API_URL = "https://api.cloud.189.cn" + UPLOAD_URL = "https://upload.cloud.189.cn" + + RETURN_URL = "https://m.cloud.189.cn/zhuanti/2020/loginErrorPc/index.html" + + PC = "TELEPC" + MAC = "TELEMAC" + + CHANNEL_ID = "web_cloud.189.cn" +) + +func (y *Yun189PC) request(url, method string, callback base.ReqCallback, params Params, resp interface{}) ([]byte, error) { + dateOfGmt := getHttpDateStr() + sessionKey := y.tokenInfo.SessionKey + sessionSecret := y.tokenInfo.SessionSecret + if y.isFamily() { + sessionKey = y.tokenInfo.FamilySessionKey + sessionSecret = y.tokenInfo.FamilySessionSecret + } + + req := y.client.R().SetQueryParams(clientSuffix()).SetHeaders(map[string]string{ + "Date": dateOfGmt, + "SessionKey": sessionKey, + "X-Request-ID": uuid.NewString(), + }) + + // 设置params + var paramsData string + if params != nil { + paramsData = AesECBEncrypt(params.Encode(), sessionSecret[:16]) + req.SetQueryParam("params", paramsData) + } + req.SetHeader("Signature", signatureOfHmac(sessionSecret, sessionKey, method, url, dateOfGmt, paramsData)) + + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + res, err := req.Execute(method, url) + if err != nil { + return nil, err + } + var erron RespErr + utils.Json.Unmarshal(res.Body(), &erron) + + if erron.ResCode != "" { + return nil, fmt.Errorf("res_code: %s ,res_msg: %s", erron.ResCode, erron.ResMessage) + } + if erron.Code != "" && erron.Code != "SUCCESS" { + if erron.Msg != "" { + return nil, fmt.Errorf("code: %s ,msg: %s", erron.Code, erron.Msg) + } + if erron.Message != "" { + return nil, fmt.Errorf("code: %s ,msg: %s", erron.Code, erron.Message) + } + return nil, fmt.Errorf(res.String()) + } + switch erron.ErrorCode { + case "": + break + case "InvalidSessionKey": + if err = y.refreshSession(); err != nil { + return nil, err + } + return y.request(url, method, callback, params, resp) + default: + return nil, fmt.Errorf("err_code: %s ,err_msg: %s", erron.ErrorCode, erron.ErrorMsg) + } + + if strings.Contains(res.String(), "userSessionBO is null") { + if err = y.refreshSession(); err != nil { + return nil, err + } + return y.request(url, method, callback, params, resp) + } + + resCode := utils.Json.Get(res.Body(), "res_code").ToInt64() + message := utils.Json.Get(res.Body(), "res_message").ToString() + switch resCode { + case 0: + return res.Body(), nil + default: + return nil, fmt.Errorf("res_code: %d ,res_msg: %s", resCode, message) + } +} + +func (y *Yun189PC) get(url string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + return y.request(url, http.MethodGet, callback, nil, resp) +} + +func (y *Yun189PC) post(url string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + return y.request(url, http.MethodPost, callback, nil, resp) +} + +func (y *Yun189PC) getFiles(ctx context.Context, fileId string) ([]model.Obj, error) { + fullUrl := API_URL + if y.isFamily() { + fullUrl += "/family/file" + } + fullUrl += "/listFiles.action" + + res := make([]model.Obj, 0, 130) + for pageNum := 1; pageNum < 100; pageNum++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + var resp Cloud189FilesResp + _, err := y.get(fullUrl, func(r *resty.Request) { + r.SetContext(ctx) + r.SetQueryParams(map[string]string{ + "folderId": fileId, + "fileType": "0", + "mediaAttr": "0", + "iconOption": "5", + "pageNum": fmt.Sprint(pageNum), + "pageSize": "130", + }) + if y.isFamily() { + r.SetQueryParams(map[string]string{ + "familyId": y.FamilyID, + "orderBy": toFamilyOrderBy(y.OrderBy), + "descending": toDesc(y.OrderDirection), + }) + } else { + r.SetQueryParams(map[string]string{ + "recursive": "0", + "orderBy": y.OrderBy, + "descending": toDesc(y.OrderDirection), + }) + } + }, &resp) + if err != nil { + return nil, err + } + // 获取完毕跳出 + if resp.FileListAO.Count == 0 { + break + } + + for i := 0; i < len(resp.FileListAO.FolderList); i++ { + res = append(res, &resp.FileListAO.FolderList[i]) + } + for i := 0; i < len(resp.FileListAO.FileList); i++ { + res = append(res, &resp.FileListAO.FileList[i]) + } + } + return res, nil +} + +func (y *Yun189PC) login() (err error) { + // 初始化登陆所需参数 + if y.loginParam == nil { + if err = y.initLoginParam(); err != nil { + // 验证码也通过错误返回 + return err + } + } + + defer func() { + // 销毁验证码 + y.VCode = "" + // 销毁登陆参数 + y.loginParam = nil + // 遇到错误,重新加载登陆参数 + if err != nil { + if err1 := y.initLoginParam(); err1 != nil { + err = fmt.Errorf("err1: %s \nerr2: %s", err, err1) + } + } + }() + + param := y.loginParam + var loginresp LoginResp + _, err = y.client.R(). + ForceContentType("application/json;charset=UTF-8").SetResult(&loginresp). + SetHeaders(map[string]string{ + "REQID": param.ReqId, + "lt": param.Lt, + }). + SetFormData(map[string]string{ + "appKey": APP_ID, + "accountType": ACCOUNT_TYPE, + "userName": param.RsaUsername, + "password": param.RsaPassword, + "validateCode": y.VCode, + "captchaToken": param.CaptchaToken, + "returnUrl": RETURN_URL, + "mailSuffix": "@189.cn", + "dynamicCheck": "FALSE", + "clientType": CLIENT_TYPE, + "cb_SaveName": "1", + "isOauth2": "false", + "state": "", + "paramId": param.ParamId, + }). + Post(AUTH_URL + "/api/logbox/oauth2/loginSubmit.do") + if err != nil { + return err + } + if loginresp.ToUrl == "" { + return fmt.Errorf("login failed,No toUrl obtained, msg: %s", loginresp.Msg) + } + + // 获取Session + var erron RespErr + var tokenInfo AppSessionResp + _, err = y.client.R(). + SetResult(&tokenInfo).SetError(&erron). + SetQueryParams(clientSuffix()). + SetQueryParam("redirectURL", url.QueryEscape(loginresp.ToUrl)). + Post(API_URL + "/getSessionForPC.action") + if err != nil { + return + } + + if erron.ResCode != "" { + err = fmt.Errorf(erron.ResMessage) + return + } + if tokenInfo.ResCode != 0 { + err = fmt.Errorf(tokenInfo.ResMessage) + return + } + y.tokenInfo = &tokenInfo + return +} + +/* 初始化登陆需要的参数 +* 如果遇到验证码返回错误 + */ +func (y *Yun189PC) initLoginParam() error { + // 清除cookie + jar, _ := cookiejar.New(nil) + y.client.SetCookieJar(jar) + + res, err := y.client.R(). + SetQueryParams(map[string]string{ + "appId": APP_ID, + "clientType": CLIENT_TYPE, + "returnURL": RETURN_URL, + "timeStamp": fmt.Sprint(timestamp()), + }). + Get(WEB_URL + "/api/portal/unifyLoginForPC.action") + if err != nil { + return err + } + + param := LoginParam{ + CaptchaToken: regexp.MustCompile(`'captchaToken' value='(.+?)'`).FindStringSubmatch(res.String())[1], + Lt: regexp.MustCompile(`lt = "(.+?)"`).FindStringSubmatch(res.String())[1], + ParamId: regexp.MustCompile(`paramId = "(.+?)"`).FindStringSubmatch(res.String())[1], + ReqId: regexp.MustCompile(`reqId = "(.+?)"`).FindStringSubmatch(res.String())[1], + // jRsaKey: regexp.MustCompile(`"j_rsaKey" value="(.+?)"`).FindStringSubmatch(res.String())[1], + } + + // 获取rsa公钥 + var encryptConf EncryptConfResp + _, err = y.client.R(). + ForceContentType("application/json;charset=UTF-8").SetResult(&encryptConf). + SetFormData(map[string]string{"appId": APP_ID}). + Post(AUTH_URL + "/api/logbox/config/encryptConf.do") + if err != nil { + return err + } + + param.jRsaKey = fmt.Sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", encryptConf.Data.PubKey) + param.RsaUsername = encryptConf.Data.Pre + RsaEncrypt(param.jRsaKey, y.Username) + param.RsaPassword = encryptConf.Data.Pre + RsaEncrypt(param.jRsaKey, y.Password) + + // 判断是否需要验证码 + res, err = y.client.R(). + SetFormData(map[string]string{ + "appKey": APP_ID, + "accountType": ACCOUNT_TYPE, + "userName": param.RsaUsername, + }). + Post(AUTH_URL + "/api/logbox/oauth2/needcaptcha.do") + if err != nil { + return err + } + + y.loginParam = ¶m + if res.String() != "0" { + imgRes, err := y.client.R(). + SetQueryParams(map[string]string{ + "token": param.CaptchaToken, + "REQID": param.ReqId, + "rnd": fmt.Sprint(timestamp()), + }). + Get(AUTH_URL + "/api/logbox/oauth2/picCaptcha.do") + if err != nil { + return fmt.Errorf("failed to obtain verification code") + } + + // 尝试使用ocr + vRes, err := base.RestyClient.R(). + SetMultipartField("image", "validateCode.png", "image/png", bytes.NewReader(imgRes.Body())). + Post(setting.GetStr(conf.OcrApi)) + if err == nil && jsoniter.Get(vRes.Body(), "status").ToInt() == 200 { + y.VCode = jsoniter.Get(vRes.Body(), "result").ToString() + } + + // ocr无法处理,返回验证码图片给前端 + if len(y.VCode) != 4 { + return fmt.Errorf("need validate code: data:image/png;base64,%s", base64.StdEncoding.EncodeToString(res.Body())) + } + } + return nil +} + +// 刷新会话 +func (y *Yun189PC) refreshSession() (err error) { + var erron RespErr + var userSessionResp UserSessionResp + _, err = y.client.R(). + SetResult(&userSessionResp).SetError(&erron). + SetQueryParams(clientSuffix()). + SetQueryParams(map[string]string{ + "appId": APP_ID, + "accessToken": y.tokenInfo.AccessToken, + }). + SetHeader("X-Request-ID", uuid.NewString()). + Get(API_URL + "/getSessionForPC.action") + if err != nil { + return err + } + + // 错误影响正常访问,下线该储存 + defer func() { + if err != nil { + y.GetStorage().SetStatus(fmt.Sprintf("%+v", err.Error())) + op.MustSaveDriverStorage(y) + } + }() + + switch erron.ResCode { + case "": + break + case "UserInvalidOpenToken": + if err = y.login(); err != nil { + return err + } + default: + err = fmt.Errorf("res_code: %s ,res_msg: %s", erron.ResCode, erron.ResMessage) + return + } + + switch userSessionResp.ResCode { + case 0: + y.tokenInfo.UserSessionResp = userSessionResp + default: + err = fmt.Errorf("code: %d , msg: %s", userSessionResp.ResCode, userSessionResp.ResMessage) + } + return +} + +// 普通上传 +func (y *Yun189PC) CommonUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (err error) { + const DEFAULT int64 = 10485760 + var count = int64(math.Ceil(float64(file.GetSize()) / float64(DEFAULT))) + + params := Params{ + "parentFolderId": dstDir.GetID(), + "fileName": url.QueryEscape(file.GetName()), + "fileSize": fmt.Sprint(file.GetSize()), + "sliceSize": fmt.Sprint(DEFAULT), + "lazyCheck": "1", + } + + fullUrl := UPLOAD_URL + if y.isFamily() { + params.Set("familyId", y.FamilyID) + fullUrl += "/family" + } else { + //params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) + fullUrl += "/person" + } + + // 初始化上传 + var initMultiUpload InitMultiUploadResp + _, err = y.request(fullUrl+"/initMultiUpload", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, params, &initMultiUpload) + if err != nil { + return err + } + + fileMd5 := md5.New() + silceMd5 := md5.New() + silceMd5Hexs := make([]string, 0, count) + byteData := bytes.NewBuffer(make([]byte, DEFAULT)) + for i := int64(1); i <= count; i++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // 读取块 + byteData.Reset() + silceMd5.Reset() + _, err := io.CopyN(io.MultiWriter(fileMd5, silceMd5, byteData), file, DEFAULT) + if err != io.EOF && err != io.ErrUnexpectedEOF && err != nil { + return err + } + + // 计算块md5并进行hex和base64编码 + md5Bytes := silceMd5.Sum(nil) + silceMd5Hexs = append(silceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Bytes))) + silceMd5Base64 := base64.StdEncoding.EncodeToString(md5Bytes) + + // 获取上传链接 + var uploadUrl UploadUrlsResp + _, err = y.request(fullUrl+"/getMultiUploadUrls", http.MethodGet, + func(req *resty.Request) { + req.SetContext(ctx) + }, Params{ + "partInfo": fmt.Sprintf("%d-%s", i, silceMd5Base64), + "uploadFileId": initMultiUpload.Data.UploadFileID, + }, &uploadUrl) + if err != nil { + return err + } + + // 开始上传 + uploadData := uploadUrl.UploadUrls[fmt.Sprint("partNumber_", i)] + res, err := y.putClient.R(). + SetContext(ctx). + SetQueryParams(clientSuffix()). + SetHeaders(ParseHttpHeader(uploadData.RequestHeader)). + SetBody(byteData). + Put(uploadData.RequestURL) + if err != nil { + return err + } + if res.StatusCode() != http.StatusOK { + return fmt.Errorf("updload fail,msg: %s", res.String()) + } + up(int(i * 100 / count)) + } + + fileMd5Hex := strings.ToUpper(hex.EncodeToString(fileMd5.Sum(nil))) + sliceMd5Hex := fileMd5Hex + if file.GetSize() > DEFAULT { + sliceMd5Hex = strings.ToUpper(utils.GetMD5Encode(strings.Join(silceMd5Hexs, "\n"))) + } + + // 提交上传 + _, err = y.request(fullUrl+"/commitMultiUploadFile", http.MethodGet, + func(req *resty.Request) { + req.SetContext(ctx) + }, Params{ + "uploadFileId": initMultiUpload.Data.UploadFileID, + "fileMd5": fileMd5Hex, + "sliceMd5": sliceMd5Hex, + "lazyCheck": "1", + "isLog": "0", + "opertype": "3", + }, nil) + return err +} + +// 快传 +func (y *Yun189PC) FastUpload(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (err error) { + // 需要获取完整文件md5,必须支持 io.Seek + if _, ok := file.GetReadCloser().(*os.File); !ok { + r, err := utils.CreateTempFile(file) + if err != nil { + return err + } + file.Close() + file.SetReadCloser(r) + } + + const DEFAULT int64 = 10485760 + count := int(math.Ceil(float64(file.GetSize()) / float64(DEFAULT))) + + // 优先计算所需信息 + fileMd5 := md5.New() + silceMd5 := md5.New() + silceMd5Hexs := make([]string, 0, count) + silceMd5Base64s := make([]string, 0, count) + for i := 1; i <= count; i++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + silceMd5.Reset() + if _, err := io.CopyN(io.MultiWriter(fileMd5, silceMd5), file, DEFAULT); err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return err + } + md5Byte := silceMd5.Sum(nil) + silceMd5Hexs = append(silceMd5Hexs, strings.ToUpper(hex.EncodeToString(md5Byte))) + silceMd5Base64s = append(silceMd5Base64s, fmt.Sprint(i, "-", base64.StdEncoding.EncodeToString(md5Byte))) + } + file.GetReadCloser().(*os.File).Seek(0, io.SeekStart) + + fileMd5Hex := strings.ToUpper(hex.EncodeToString(fileMd5.Sum(nil))) + sliceMd5Hex := fileMd5Hex + if file.GetSize() > DEFAULT { + sliceMd5Hex = strings.ToUpper(utils.GetMD5Encode(strings.Join(silceMd5Hexs, "\n"))) + } + + // 检测是否支持快传 + params := Params{ + "parentFolderId": dstDir.GetID(), + "fileName": url.QueryEscape(file.GetName()), + "fileSize": fmt.Sprint(file.GetSize()), + "fileMd5": fileMd5Hex, + "sliceSize": fmt.Sprint(DEFAULT), + "sliceMd5": sliceMd5Hex, + } + + fullUrl := UPLOAD_URL + if y.isFamily() { + params.Set("familyId", y.FamilyID) + fullUrl += "/family" + } else { + //params.Set("extend", `{"opScene":"1","relativepath":"","rootfolderid":""}`) + fullUrl += "/person" + } + + var uploadInfo InitMultiUploadResp + _, err = y.request(fullUrl+"/initMultiUpload", http.MethodGet, func(req *resty.Request) { + req.SetContext(ctx) + }, params, &uploadInfo) + if err != nil { + return err + } + + // 网盘中不存在该文件,开始上传 + if uploadInfo.Data.FileDataExists != 1 { + var uploadUrls UploadUrlsResp + _, err = y.request(fullUrl+"/getMultiUploadUrls", http.MethodGet, + func(req *resty.Request) { + req.SetContext(ctx) + }, Params{ + "uploadFileId": uploadInfo.Data.UploadFileID, + "partInfo": strings.Join(silceMd5Base64s, ","), + }, &uploadUrls) + if err != nil { + return err + } + + for i := 1; i <= count; i++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + uploadData := uploadUrls.UploadUrls[fmt.Sprint("partNumber_", i)] + res, err := y.putClient.R(). + SetContext(ctx). + SetQueryParams(clientSuffix()). + SetHeaders(ParseHttpHeader(uploadData.RequestHeader)). + SetBody(io.LimitReader(file, DEFAULT)). + Put(uploadData.RequestURL) + if err != nil { + return err + } + if res.StatusCode() != http.StatusOK { + return fmt.Errorf("updload fail,msg: %s", res.String()) + } + up(int(i * 100 / count)) + } + } + + // 提交 + _, err = y.request(fullUrl+"/commitMultiUploadFile", http.MethodGet, + func(req *resty.Request) { + req.SetContext(ctx) + }, Params{ + "uploadFileId": uploadInfo.Data.UploadFileID, + "isLog": "0", + "opertype": "3", + }, nil) + return err +} + +func (y *Yun189PC) isFamily() bool { + return y.Type == "family" +} + +func (y *Yun189PC) isLogin() bool { + if y.tokenInfo == nil { + return false + } + _, err := y.get(API_URL+"/getUserInfo.action", nil, nil) + return err == nil +} + +// 获取家庭云所有用户信息 +func (y *Yun189PC) getFamilyInfoList() ([]FamilyInfoResp, error) { + var resp FamilyInfoListResp + _, err := y.get(API_URL+"/family/manage/getFamilyList.action", nil, &resp) + if err != nil { + return nil, err + } + return resp.FamilyInfoResp, nil +} + +// 抽取家庭云ID +func (y *Yun189PC) getFamilyID() (string, error) { + infos, err := y.getFamilyInfoList() + if err != nil { + return "", err + } + if len(infos) == 0 { + return "", fmt.Errorf("cannot get automatically,please input family_id") + } + for _, info := range infos { + if strings.Contains(y.tokenInfo.LoginName, info.RemarkName) { + return fmt.Sprint(info.FamilyID), nil + } + } + return fmt.Sprint(infos[0].FamilyID), nil +} diff --git a/drivers/all.go b/drivers/all.go index c02a9765c4f..87836a62f5c 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -4,6 +4,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/123" _ "github.com/alist-org/alist/v3/drivers/139" _ "github.com/alist-org/alist/v3/drivers/189" + _ "github.com/alist-org/alist/v3/drivers/189pc" _ "github.com/alist-org/alist/v3/drivers/aliyundrive" _ "github.com/alist-org/alist/v3/drivers/baidu_netdisk" _ "github.com/alist-org/alist/v3/drivers/ftp" diff --git a/drivers/base/client.go b/drivers/base/client.go index 0c1c2e9b45e..36b66e4c81d 100644 --- a/drivers/base/client.go +++ b/drivers/base/client.go @@ -8,7 +8,7 @@ import ( ) var NoRedirectClient *resty.Client -var RestyClient = resty.New() +var RestyClient = NewRestyClient() var HttpClient = &http.Client{} var UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" var DefaultTimeout = time.Second * 10 @@ -20,7 +20,11 @@ func init() { }), ) NoRedirectClient.SetHeader("user-agent", UserAgent) - RestyClient.SetHeader("user-agent", UserAgent) - RestyClient.SetRetryCount(3) - RestyClient.SetTimeout(DefaultTimeout) +} + +func NewRestyClient() *resty.Client { + return resty.New(). + SetHeader("user-agent", UserAgent). + SetRetryCount(3). + SetTimeout(DefaultTimeout) }