diff --git a/.gitignore b/.gitignore index 1d71f0d60..d56e0e161 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ output/ /public/dist/* /!public/dist/README.md -.VSCodeCounter \ No newline at end of file +.VSCodeCounter/OpenList +/OpenList diff --git a/drivers/all.go b/drivers/all.go index 7e1c24bba..de5dfa41b 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -62,6 +62,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/quark_open" _ "github.com/OpenListTeam/OpenList/v4/drivers/quark_uc" _ "github.com/OpenListTeam/OpenList/v4/drivers/quark_uc_tv" + _ "github.com/OpenListTeam/OpenList/v4/drivers/qihoo360" _ "github.com/OpenListTeam/OpenList/v4/drivers/s3" _ "github.com/OpenListTeam/OpenList/v4/drivers/seafile" _ "github.com/OpenListTeam/OpenList/v4/drivers/sftp" diff --git a/drivers/qihoo360/driver.go b/drivers/qihoo360/driver.go new file mode 100644 index 000000000..0268c6758 --- /dev/null +++ b/drivers/qihoo360/driver.go @@ -0,0 +1,678 @@ +package qihoo360 + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/errs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +type Qihoo360 struct { + model.Storage + Addition + authInfo *AuthResp + authExpire int64 +} + +func (d *Qihoo360) Config() driver.Config { + return config +} + +func (d *Qihoo360) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *Qihoo360) Init(ctx context.Context) error { + // Test authentication + _, err := d.getAuth() + return err +} + +func (d *Qihoo360) Drop(ctx context.Context) error { + d.authInfo = nil + return nil +} + +func (d *Qihoo360) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + path := dir.GetPath() + if path == "" { + path = d.RootFolderPath + } + if path == "" { + path = "/" + } + + // Ensure directory paths end with / (required by API for non-root paths) + if path != "/" && !strings.HasSuffix(path, "/") { + path += "/" + } + + files, err := d.getFiles(path, 0, 100) + if err != nil { + return nil, err + } + + return utils.SliceConvert(files, func(src File) (model.Obj, error) { + return src, nil + }) +} + +func (d *Qihoo360) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + // Get file ID (nid) + nid := file.GetID() + if nid == "" { + return nil, fmt.Errorf("file id is empty") + } + + // Get download URL from API + downloadUrl, err := d.getDownloadUrl(nid) + if err != nil { + return nil, err + } + + if downloadUrl == "" { + return nil, fmt.Errorf("download url is empty") + } + + return &model.Link{ + URL: downloadUrl, + Header: http.Header{ + "User-Agent": []string{"yunpan_mcp_server"}, + }, + }, nil +} + +func (d *Qihoo360) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + userDetail, err := d.getUserDetail() + if err != nil { + return nil, err + } + + // Parse total and used sizes from strings to int64 + totalSize := int64(0) + usedSize := int64(0) + + if total, err := strconv.ParseInt(userDetail.Data.TotalSize, 10, 64); err == nil { + totalSize = total + } + if used, err := strconv.ParseInt(userDetail.Data.UsedSize, 10, 64); err == nil { + usedSize = used + } + + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: totalSize, + UsedSpace: usedSize, + }, + }, nil +} + +func (d *Qihoo360) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + path := parentDir.GetPath() + if path == "" { + path = d.RootFolderPath + } + if path == "" { + path = "/" + } + + // Ensure path ends with / + if path[len(path)-1] != '/' { + path += "/" + } + // Ensure dirName ends with / + if dirName[len(dirName)-1] != '/' { + dirName += "/" + } + + fname := path + dirName + + params := map[string]string{ + "fname": fname, + } + + var resp CommonResp + _, err := d.request("File.makeDir", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("make dir failed: %s", resp.Errmsg) + } + + return nil, nil +} + +func (d *Qihoo360) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + srcPath := srcObj.GetPath() + if srcPath == "" { + // Try to construct path from name + srcPath = d.RootFolderPath + if srcPath == "" { + srcPath = "/" + } + if srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + srcPath += srcObj.GetName() + if srcObj.IsDir() && srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + } + + dstPath := dstDir.GetPath() + if dstPath == "" { + dstPath = d.RootFolderPath + } + if dstPath == "" { + dstPath = "/" + } + if dstPath[len(dstPath)-1] != '/' { + dstPath += "/" + } + + params := map[string]string{ + "src_name": srcPath, + "new_name": dstPath, + } + + var resp CommonResp + _, err := d.request("File.move", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("move failed: %s", resp.Errmsg) + } + + return nil, nil +} + +func (d *Qihoo360) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + srcPath := srcObj.GetPath() + if srcPath == "" { + // Try to construct path from name + srcPath = d.RootFolderPath + if srcPath == "" { + srcPath = "/" + } + if srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + srcPath += srcObj.GetName() + if srcObj.IsDir() && srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + } + + // new_name should be just the name, not full path + if srcObj.IsDir() && newName[len(newName)-1] != '/' { + newName += "/" + } + + params := map[string]string{ + "src_name": srcPath, + "new_name": newName, + } + + var resp CommonResp + _, err := d.request("File.rename", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("rename failed: %s", resp.Errmsg) + } + + return nil, nil +} + +func (d *Qihoo360) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + // Copy is not documented in ecs_mcp_server + return nil, errs.NotSupport +} + +func (d *Qihoo360) Remove(ctx context.Context, obj model.Obj) error { + srcPath := obj.GetPath() + if srcPath == "" { + // Try to construct path from name + srcPath = d.RootFolderPath + if srcPath == "" { + srcPath = "/" + } + if srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + srcPath += obj.GetName() + if obj.IsDir() && srcPath[len(srcPath)-1] != '/' { + srcPath += "/" + } + } + + params := map[string]string{ + "fname": srcPath, + } + + var resp CommonResp + // fname parameter is excluded from sign calculation + _, err := d.request("File.delete", params, &resp, "fname") + if err != nil { + return err + } + + if resp.Errno != 0 { + return fmt.Errorf("remove failed: %s", resp.Errmsg) + } + + return nil +} + +func (d *Qihoo360) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + dstPath := dstDir.GetPath() + if dstPath == "" { + dstPath = d.RootFolderPath + } + if dstPath == "" { + dstPath = "/" + } + if dstPath[len(dstPath)-1] != '/' { + dstPath += "/" + } + + fname := dstPath + file.GetName() + fsize := file.GetSize() + now := time.Now().Unix() + + // Calculate file hash + const chunkSize = 524288 // 512KB per chunk + numChunks := (fsize + chunkSize - 1) / chunkSize + + var blockHashes []string + var blocks []struct { + data []byte + offset int64 + size int64 + hash string + } + + // Read file and calculate chunk hashes + for i := int64(0); i < numChunks; i++ { + size := chunkSize + if i == numChunks-1 { + size = int(fsize - i*chunkSize) + } + + buf := make([]byte, size) + n, err := io.ReadFull(file, buf) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + buf = buf[:n] + + // Calculate SHA1 hash for this block + hash := sha1.Sum(buf) + blockHash := hex.EncodeToString(hash[:]) + blockHashes = append(blockHashes, blockHash) + + blocks = append(blocks, struct { + data []byte + offset int64 + size int64 + hash string + }{ + data: buf, + offset: i * chunkSize, + size: int64(n), + hash: blockHash, + }) + } + + // Calculate file hash (SHA1 of concatenated block hashes) + fhashStr := strings.Join(blockHashes, "") + fhash := sha1.Sum([]byte(fhashStr)) + fhashHex := hex.EncodeToString(fhash[:]) + + // Get upload address + uploadAddr, err := d.getUploadAddr(fname, fsize, fhashHex, now, now) + if err != nil { + return nil, err + } + + // Check for instant upload (server already has the file) + // When file exists, HTTP is null + httpVal, httpOk := uploadAddr.Data.HTTP.(string) + if !httpOk || httpVal == "" { + return nil, nil // Instant upload success (file already exists) + } + + // Build upload host + uploadHost := httpVal + if uploadAddr.Data.IsHttps == 1 { + uploadHost = "https://" + uploadHost + } else { + uploadHost = "http://" + uploadHost + } + + // Get upload token + var tk string + if tkVal, ok := uploadAddr.Data.Tk.(string); ok { + tk = tkVal + } + + // Prepare block info for preload + blockInfoList := make([]BlockInfo, len(blocks)) + for i, block := range blocks { + blockInfoList[i] = BlockInfo{ + BHash: block.hash, + BIdx: i + 1, + BOffset: block.offset, + BSize: block.size, + } + } + + // Preload - send block info + preloadResp, err := d.preloadBlocks(ctx, uploadHost, fname, fsize, fhashHex, now, now, tk, blockInfoList) + if err != nil { + return nil, err + } + + // Upload each block + for i, block := range blocks { + blockInfo := preloadResp.Data.BlockInfo[i] + // Note: use user token (d.authInfo.Data.Token), not blockInfo.Token + err = d.uploadBlock(ctx, uploadHost, block.data, block.hash, i+1, block.offset, block.size, + fname, fsize, blockInfo.Q, blockInfo.T, d.authInfo.Data.Token, preloadResp.Data.Tid) + if err != nil { + return nil, fmt.Errorf("upload block %d failed: %w", i+1, err) + } + + // Update progress + if up != nil { + up(float64(block.size)) + } + } + + // Commit - merge blocks + // Note: use user token (d.authInfo.Data.Token), not blockInfo.Token + commitResp, err := d.commitUpload(ctx, uploadHost, preloadResp.Data.BlockInfo[0].Q, preloadResp.Data.BlockInfo[0].T, + d.authInfo.Data.Token, preloadResp.Data.Tid) + if err != nil { + return nil, err + } + + // If autoCommit is true (non-zero), file is already added (instant upload), use data from commit + if commitResp.Data.AutoCommit != 0 { + return &File{ + Name: file.GetName(), + Type: "0", + Nid: commitResp.Data.Nid, + CountSize: fmt.Sprintf("%d", commitResp.Data.Size), + CreateTimeTS: fmt.Sprintf("%d", commitResp.Data.CreateTime), + ModifyTimeTS: fmt.Sprintf("%d", commitResp.Data.ModifyTime), + Path: fname, + }, nil + } + + // Call Sync.addFileToApi to finalize the upload and get file info + addFileResp, err := d.addFileToApi(commitResp.Data.Tk) + if err != nil { + return nil, err + } + + // Set the full path + addFileResp.Data.File.Path = fname + + return &addFileResp.Data.File, nil +} + +func (d *Qihoo360) preloadBlocks(ctx context.Context, uploadHost, fname string, fsize int64, fhash string, fctime, fmtime int64, tk string, blocks []BlockInfo) (*PreloadResp, error) { + // Build query parameters + queryParams := map[string]string{ + "method": "Upload.request4Web", + "owner_qid": d.authInfo.Data.Qid, + "qid": d.authInfo.Data.Qid, + "devtype": "ecs_openapi", + "devid": "node-sdk-v16.20.2", // device id + "v": "1.0.1", + "ofmt": "json", + "devname": "EYUN_WEB_UPLOAD", + "rtick": fmt.Sprintf("%d", time.Now().Unix()), + } + + // Build URL + url := fmt.Sprintf("%s/intf.php", uploadHost) + for k, v := range queryParams { + if strings.Contains(url, "?") { + url += "&" + } else { + url += "?" + } + url += k + "=" + v + } + + // Prepare block_info JSON + blockInfoMap := map[string]interface{}{ + "request": map[string]interface{}{ + "block_info": blocks, + }, + } + blockInfoJSON, _ := json.Marshal(blockInfoMap) + + // Create multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add form fields + writer.WriteField("owner_qid", d.authInfo.Data.Qid) + writer.WriteField("fname", fname) + writer.WriteField("fsize", fmt.Sprintf("%d", fsize)) + writer.WriteField("fctime", fmt.Sprintf("%d", fctime)) + writer.WriteField("fmtime", fmt.Sprintf("%d", fmtime)) + writer.WriteField("fhash", fhash) + writer.WriteField("qid", d.authInfo.Data.Qid) + writer.WriteField("fattr", "0") + writer.WriteField("token", d.authInfo.Data.Token) + writer.WriteField("tk", tk) + writer.WriteField("devtype", "ecs_openapi") + + // Add file part with block_info JSON + part, _ := writer.CreateFormFile("file", "block_info.json") + part.Write(blockInfoJSON) + writer.Close() + + // Send request + req, _ := http.NewRequestWithContext(ctx, "POST", url, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Access-Token", d.authInfo.Data.AccessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var preloadResp PreloadResp + if err := json.Unmarshal(body, &preloadResp); err != nil { + return nil, err + } + + if preloadResp.Errno != 0 { + return nil, fmt.Errorf("preload failed: %s", preloadResp.Errmsg) + } + + return &preloadResp, nil +} + +func (d *Qihoo360) uploadBlock(ctx context.Context, uploadHost string, data []byte, bhash string, bidx int, boffset, bsize int64, filename string, filesize int64, q, t, token, tid string) error { + // Build query parameters + queryParams := map[string]string{ + "method": "Upload.block4Web", + "owner_qid": d.authInfo.Data.Qid, + "qid": d.authInfo.Data.Qid, + "devtype": "ecs_openapi", + "devid": "node-sdk-v16.20.2", + "v": "1.0.1", + "ofmt": "json", + "devname": "EYUN_WEB_UPLOAD", + "rtick": fmt.Sprintf("%d", time.Now().Unix()), + } + + // Build URL + url := fmt.Sprintf("%s/intf.php", uploadHost) + for k, v := range queryParams { + if strings.Contains(url, "?") { + url += "&" + } else { + url += "?" + } + url += k + "=" + v + } + + // Create multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add chunk data + part, _ := writer.CreateFormFile("file", "chunk") + part.Write(data) + + // Add form fields + writer.WriteField("bhash", bhash) + writer.WriteField("bidx", strconv.Itoa(bidx)) + writer.WriteField("boffset", fmt.Sprintf("%d", boffset)) + writer.WriteField("bsize", fmt.Sprintf("%d", bsize)) + writer.WriteField("filename", filename) + writer.WriteField("filesize", fmt.Sprintf("%d", filesize)) + writer.WriteField("q", q) + writer.WriteField("t", t) + writer.WriteField("token", token) + writer.WriteField("tid", tid) + writer.Close() + + // Send request + req, _ := http.NewRequestWithContext(ctx, "POST", url, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Access-Token", d.authInfo.Data.AccessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var result CommonResp + if err := json.Unmarshal(body, &result); err != nil { + return err + } + + if result.Errno != 0 { + return fmt.Errorf("upload block failed: %s", result.Errmsg) + } + + return nil +} + +func (d *Qihoo360) commitUpload(ctx context.Context, uploadHost, q, t, token, tid string) (*CommitResp, error) { + // Build query parameters + queryParams := map[string]string{ + "method": "Upload.commit4Web", + "owner_qid": d.authInfo.Data.Qid, + "qid": d.authInfo.Data.Qid, + "devtype": "ecs_openapi", + "devid": "node-sdk-v16.20.2", + "v": "1.0.1", + "ofmt": "json", + "devname": "EYUN_WEB_UPLOAD", + "rtick": fmt.Sprintf("%d", time.Now().Unix()), + } + + // Build URL + url := fmt.Sprintf("%s/intf.php", uploadHost) + for k, v := range queryParams { + if strings.Contains(url, "?") { + url += "&" + } else { + url += "?" + } + url += k + "=" + v + } + + // Create multipart form + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add form fields + writer.WriteField("q", q) + writer.WriteField("t", t) + writer.WriteField("token", token) + writer.WriteField("tid", tid) + writer.Close() + + // Send request + req, _ := http.NewRequestWithContext(ctx, "POST", url, &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Access-Token", d.authInfo.Data.AccessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var result CommitResp + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if result.Errno != 0 { + return nil, fmt.Errorf("commit failed: %s", result.Errmsg) + } + + return &result, nil +} + +func (d *Qihoo360) addFileToApi(tk string) (*AddFileResp, error) { + params := map[string]string{ + "qid": d.authInfo.Data.Qid, + "tk": tk, + } + + var resp AddFileResp + _, err := d.request("Sync.addFileToApi", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("add file to api failed: %s", resp.Errmsg) + } + + return &resp, nil +} + +var _ driver.Driver = (*Qihoo360)(nil) diff --git a/drivers/qihoo360/meta.go b/drivers/qihoo360/meta.go new file mode 100644 index 000000000..57d261f37 --- /dev/null +++ b/drivers/qihoo360/meta.go @@ -0,0 +1,24 @@ +package qihoo360 + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootPath + APIKey string `json:"api_key" required:"true" help:"360 AI Cloud API Key (yunpan_ prefix)"` +} + +var config = driver.Config{ + Name: "Qihoo360", + LocalSort: true, + OnlyProxy: true, + DefaultRoot: "/", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &Qihoo360{} + }) +} diff --git a/drivers/qihoo360/types.go b/drivers/qihoo360/types.go new file mode 100644 index 000000000..a61e4789e --- /dev/null +++ b/drivers/qihoo360/types.go @@ -0,0 +1,167 @@ +package qihoo360 + +import ( + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" +) + +type File struct { + Name string `json:"name"` + Type string `json:"type"` // "1" for directory, "0" for file + Nid string `json:"nid"` + CountSize string `json:"count_size"` + CreateTimeTS string `json:"create_time"` + ModifyTimeTS string `json:"modify_time"` + Path string `json:"-"` // Full path, not from API +} + +func (f File) GetName() string { + return f.Name +} + +func (f File) GetSize() int64 { + size, _ := strconv.ParseInt(f.CountSize, 10, 64) + return size +} + +func (f File) ModTime() time.Time { + timestamp, _ := strconv.ParseInt(f.ModifyTimeTS, 10, 64) + return time.Unix(timestamp, 0) +} + +func (f File) CreateTime() time.Time { + timestamp, _ := strconv.ParseInt(f.CreateTimeTS, 10, 64) + return time.Unix(timestamp, 0) +} + +func (f File) IsDir() bool { + return f.Type == "1" +} + +func (f File) GetID() string { + return f.Nid +} + +func (f File) GetPath() string { + return f.Path +} + +func (f File) GetHash() utils.HashInfo { + return utils.HashInfo{} +} + +type FileListResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + NodeList []File `json:"node_list"` + } `json:"data"` +} + +type AuthResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + AccessTokenExpire int64 `json:"access_token_expire"` + Qid string `json:"qid"` + } `json:"data"` + TraceId string `json:"trace_id"` +} + +type CommonResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` +} + +type UploadAddrResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + HTTP interface{} `json:"http"` // can be string or null + IsHttps int `json:"is_https"` // 0 or 1, not boolean + Tk interface{} `json:"tk"` // can be string or null + Addr2 string `json:"addr_2"` + NodeInfo []File `json:"node_info"` // returned when file exists (instant upload) + AutoCommit int `json:"autoCommit"` + FileHash string `json:"fhash"` + FileName string `json:"fname"` + FileSize string `json:"fsize"` + IsCreateDir bool `json:"is_createdir"` + } `json:"data"` +} + +type BlockInfo struct { + BHash string `json:"bhash"` + BIdx int `json:"bidx"` + BOffset int64 `json:"boffset"` + BSize int64 `json:"bsize"` + Q string `json:"q,omitempty"` + T string `json:"t,omitempty"` + Token string `json:"token,omitempty"` + Tid string `json:"tid,omitempty"` +} + +type PreloadResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + BlockInfo []BlockInfo `json:"block_info"` + Tid string `json:"tid"` + Tk string `json:"tk"` + HTTP string `json:"http"` + Addr2 string `json:"addr_2"` + IsHttps bool `json:"is_https"` + } `json:"data"` +} + +type DownloadUrlResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + DownloadUrl string `json:"downloadUrl"` + } `json:"data"` +} + +type CommitResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + Nid string `json:"nid"` + Name string `json:"fname"` + Size int64 `json:"fsize"` + CreateTime int64 `json:"fctime"` + ModifyTime int64 `json:"fmtime"` + Tk string `json:"tk"` + AutoCommit int `json:"autoCommit"` // 0 or 1 + } `json:"data"` +} + +type AddFileResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + File File `json:"file"` + } `json:"data"` +} + +type UserDetailResp struct { + Errno int `json:"errno"` + Errmsg string `json:"errmsg"` + Data struct { + Name string `json:"name"` + TotalSize string `json:"total_size"` + UsedSize string `json:"used_size"` + AvailableSize int64 `json:"available_size"` + IsVip bool `json:"is_vip"` + VipDesc string `json:"vip_desc"` + ExpireDay int `json:"expire_day"` + Expire string `json:"expire"` + } `json:"data"` +} + +var _ model.Obj = (*File)(nil) diff --git a/drivers/qihoo360/util.go b/drivers/qihoo360/util.go new file mode 100644 index 000000000..f64b50f89 --- /dev/null +++ b/drivers/qihoo360/util.go @@ -0,0 +1,377 @@ +package qihoo360 + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "net/url" + pathpkg "path" + "sort" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + log "github.com/sirupsen/logrus" +) + +const ( + ApiUrl = "https://openapi.eyun.360.cn/intf.php" + ClientID = "e4757e933b6486c08ed206ecb6d5d9e684fcb4e2" + ClientSecret = "885fd3231f1c1e37c9f462261a09b8c38cde0c2b" + SecretKey = "e7b24b112a44fdd9ee93bdf998c6ca0e" +) + +// phpUrlEncode encodes a string in PHP/JS style used by SDK +// JavaScript's encodeURIComponent keeps - _ . ! ~ * ' ( ) unencoded, +// but the sign function encodes them again +func phpUrlEncode(str string) string { + // First, do standard encoding but keep certain chars + encoded := url.QueryEscape(str) + // url.QueryEscape already encodes most things, but we need to ensure + // these specific characters are encoded as the JS does + replacer := strings.NewReplacer( + "!", "%21", + "'", "%27", + "(", "%28", + ")", "%29", + "*", "%2A", + ",", "%2C", + "~", "%7E", + ) + encoded = replacer.Replace(encoded) + // %20 should be + (last step) + encoded = strings.ReplaceAll(encoded, "%20", "+") + return encoded +} + +// generateSign generates MD5 signature for API request +func generateSign(params map[string]string) string { + // Sort keys alphabetically + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build key=encodedValue string + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + encodedValue := phpUrlEncode(params[k]) + pairs = append(pairs, fmt.Sprintf("%s=%s", k, encodedValue)) + } + str := strings.Join(pairs, "&") + + // Append secret key + str += SecretKey + + // Calculate MD5 + hash := md5.Sum([]byte(str)) + return hex.EncodeToString(hash[:]) +} + +func (d *Qihoo360) getAuth() (*AuthResp, error) { + // Check if we have cached auth and it's not expired (with 5 min buffer) + if d.authInfo != nil && d.authExpire > 0 && time.Now().Unix() < d.authExpire-300 { + return d.authInfo, nil + } + + params := map[string]string{ + "method": "Oauth.getAccessTokenByApiKey", + "client_id": ClientID, + "client_secret": ClientSecret, + "api_key": d.APIKey, + "grant_type": "authorization_code", + } + + // Build URL with query parameters (no sign needed for auth request) + var resp AuthResp + req := base.RestyClient.R().SetResult(&resp) + for k, v := range params { + req.SetQueryParam(k, v) + } + + res, err := req.Get(ApiUrl) + if err != nil { + // Clear auth cache on error to force re-authentication next time + d.authInfo = nil + d.authExpire = 0 + return nil, err + } + + log.Debugf("Auth Response: %s", res.String()) + + if resp.Errno != 0 { + // Clear auth cache on error to force re-authentication next time + d.authInfo = nil + d.authExpire = 0 + return nil, fmt.Errorf("auth failed: %s", resp.Errmsg) + } + + // Cache auth info + d.authInfo = &resp + // access_token_expire is already a Unix timestamp, not a duration + if resp.Data.AccessTokenExpire > 0 { + d.authExpire = resp.Data.AccessTokenExpire + } else { + // Default to 1 hour if not provided + d.authExpire = time.Now().Unix() + 3600 + } + + return &resp, nil +} + +func (d *Qihoo360) request(method string, params map[string]string, result interface{}, excluded ...string) ([]byte, error) { + return d.requestWithRetry(method, params, result, 0, excluded...) +} + +func (d *Qihoo360) requestWithRetry(method string, params map[string]string, result interface{}, retryCount int, excluded ...string) ([]byte, error) { + // Prevent infinite retry loops + const maxRetries = 2 + if retryCount >= maxRetries { + return nil, fmt.Errorf("max retries (%d) exceeded for method %s", maxRetries, method) + } + + // Get auth if not already authenticated or expired + if d.authInfo == nil || d.authExpire <= 0 || time.Now().Unix() >= d.authExpire-300 { + _, err := d.getAuth() + if err != nil { + return nil, err + } + } + + // Ensure authInfo is set before proceeding + if d.authInfo == nil { + return nil, fmt.Errorf("authentication failed: no auth info") + } + + // Build excluded params map + excludedMap := make(map[string]bool) + if len(excluded) > 0 { + for _, key := range excluded { + excludedMap[key] = true + } + } + + // Build params for sign (excluding specified params) + signParams := map[string]string{ + "method": method, + "access_token": d.authInfo.Data.AccessToken, + "qid": d.authInfo.Data.Qid, + } + + // Add params to sign if not excluded + for k, v := range params { + if !excludedMap[k] { + signParams[k] = v + } + } + + // Generate sign + sign := generateSign(signParams) + + log.Debugf("Request method: %s", method) + + // File.getList, Sync.getVerifiedDownLoadUrl, and Sync.getUploadFileAddr use GET + var err error + + if method == "File.getList" || method == "Sync.getVerifiedDownLoadUrl" || method == "Sync.getUploadFileAddr" || method == "User.getUserDetail" { + // GET request: params in query string + allParams := map[string]string{ + "method": method, + "access_token": d.authInfo.Data.AccessToken, + "qid": d.authInfo.Data.Qid, + "sign": sign, + } + for k, v := range params { + if !excludedMap[k] { + allParams[k] = v + } + } + req := base.RestyClient.R(). + SetQueryParams(allParams). + SetResult(result). + SetHeader("Access-Token", d.authInfo.Data.AccessToken) + if method == "Sync.getVerifiedDownLoadUrl" { + req.SetHeader("User-Agent", "yunpan_mcp_server") + } + _, err = req.Get(ApiUrl) + if err != nil { + return nil, err + } + } else { + // POST request: basic params in query, all params in form + queryParams := map[string]string{ + "method": method, + "access_token": d.authInfo.Data.AccessToken, + "qid": d.authInfo.Data.Qid, + "sign": sign, + } + + formData := make(map[string]string) + for k, v := range params { + formData[k] = v + } + + _, err = base.RestyClient.R(). + SetQueryParams(queryParams). + SetFormData(formData). + SetResult(result). + SetHeader("Access-Token", d.authInfo.Data.AccessToken). + SetHeader("Content-Type", "application/x-www-form-urlencoded"). + Post(ApiUrl) + if err != nil { + return nil, err + } + } + + log.Debugf("Response data received") + + // Check if we got an authentication error from the API + // If errno is -1 or -2, it usually means token is invalid/expired + if resp, ok := result.(*FileListResp); ok { + if resp.Errno == -1 || resp.Errno == -2 { + log.Debugf("Auth token expired (errno: %d), clearing cache and retrying (attempt %d)", resp.Errno, retryCount+1) + d.authInfo = nil + d.authExpire = 0 + // Retry with fresh auth + return d.requestWithRetry(method, params, result, retryCount+1, excluded...) + } + } else if resp, ok := result.(*DownloadUrlResp); ok { + if resp.Errno == -1 || resp.Errno == -2 { + log.Debugf("Auth token expired (errno: %d), clearing cache and retrying (attempt %d)", resp.Errno, retryCount+1) + d.authInfo = nil + d.authExpire = 0 + return d.requestWithRetry(method, params, result, retryCount+1, excluded...) + } + } else if resp, ok := result.(*UserDetailResp); ok { + if resp.Errno == -1 || resp.Errno == -2 { + log.Debugf("Auth token expired (errno: %d), clearing cache and retrying (attempt %d)", resp.Errno, retryCount+1) + d.authInfo = nil + d.authExpire = 0 + return d.requestWithRetry(method, params, result, retryCount+1, excluded...) + } + } else if resp, ok := result.(*CommonResp); ok { + if resp.Errno == -1 || resp.Errno == -2 { + log.Debugf("Auth token expired (errno: %d), clearing cache and retrying (attempt %d)", resp.Errno, retryCount+1) + d.authInfo = nil + d.authExpire = 0 + return d.requestWithRetry(method, params, result, retryCount+1, excluded...) + } + } + + return nil, nil +} + +func (d *Qihoo360) getFiles(path string, page int, pageSize int) ([]File, error) { + params := map[string]string{ + "path": path, + "page": fmt.Sprintf("%d", page), + "page_size": fmt.Sprintf("%d", pageSize), + } + + var resp FileListResp + _, err := d.request("File.getList", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("get files failed: %s", resp.Errmsg) + } + + // Normalize name display and full path for each file/dir + for i := range resp.Data.NodeList { + rawName := resp.Data.NodeList[i].Name + // Trim leading slash and trailing slash for dir name + trimmed := strings.TrimPrefix(rawName, "/") + trimmed = strings.TrimSuffix(trimmed, "/") + base := pathpkg.Base(trimmed) + resp.Data.NodeList[i].Name = base + + // Construct full path + var fullPath string + if path == "/" { + fullPath = "/" + base + } else { + fullPath = path + base + } + if resp.Data.NodeList[i].Type == "1" && !strings.HasSuffix(fullPath, "/") { + fullPath += "/" + } + resp.Data.NodeList[i].Path = fullPath + } + + return resp.Data.NodeList, nil +} + +func (d *Qihoo360) getDownloadUrl(nid string) (string, error) { + params := map[string]string{ + "nid": nid, + } + + var resp DownloadUrlResp + _, err := d.request("Sync.getVerifiedDownLoadUrl", params, &resp) + if err != nil { + return "", err + } + + if resp.Errno != 0 { + return "", fmt.Errorf("get download url failed: %s", resp.Errmsg) + } + + return resp.Data.DownloadUrl, nil +} + +func (d *Qihoo360) getUploadAddr(fname string, fsize int64, fhash string, fctime, fmtime int64) (*UploadAddrResp, error) { + // Build all query parameters + params := map[string]string{ + "owner_qid": d.authInfo.Data.Qid, + "fname": fname, + "fsize": fmt.Sprintf("%d", fsize), + "fctime": fmt.Sprintf("%d", fctime), + "fmtime": fmt.Sprintf("%d", fmtime), + "fhash": fhash, + "qid": d.authInfo.Data.Qid, + "fattr": "0", + "token": d.authInfo.Data.Token, + "tk": "", + "devtype": "ecs_openapi", + } + + // Calculate sign using only specific parameters (per SDK) + signParams := map[string]string{ + "fhash": fhash, + "qid": d.authInfo.Data.Qid, + "method": "Sync.getUploadFileAddr", + "fname": fname, + "fsize": fmt.Sprintf("%d", fsize), + "access_token": d.authInfo.Data.AccessToken, + } + params["sign"] = generateSign(signParams) + + var resp UploadAddrResp + _, err := d.request("Sync.getUploadFileAddr", params, &resp) + if err != nil { + return nil, err + } + if resp.Errno != 0 { + return nil, fmt.Errorf("get upload addr failed: %s", resp.Errmsg) + } + return &resp, nil +} + +func (d *Qihoo360) getUserDetail() (*UserDetailResp, error) { + params := map[string]string{} + + var resp UserDetailResp + _, err := d.request("User.getUserDetail", params, &resp) + if err != nil { + return nil, err + } + + if resp.Errno != 0 { + return nil, fmt.Errorf("get user detail failed: %s", resp.Errmsg) + } + + return &resp, nil +}