diff --git a/drivers/all.go b/drivers/all.go index 197a936d0..bff915ede 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -44,6 +44,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/local" _ "github.com/OpenListTeam/OpenList/v4/drivers/mediatrack" _ "github.com/OpenListTeam/OpenList/v4/drivers/mega" + _ "github.com/OpenListTeam/OpenList/v4/drivers/micloud" _ "github.com/OpenListTeam/OpenList/v4/drivers/misskey" _ "github.com/OpenListTeam/OpenList/v4/drivers/mopan" _ "github.com/OpenListTeam/OpenList/v4/drivers/netease_music" diff --git a/drivers/micloud/driver.go b/drivers/micloud/driver.go new file mode 100644 index 000000000..81fadebe1 --- /dev/null +++ b/drivers/micloud/driver.go @@ -0,0 +1,224 @@ +package micloud + +import ( + "context" + "fmt" + "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/internal/op" +) + +type MiCloud struct { + model.Storage + Addition + client *MiCloudClient // 小米云盘客户端 +} + +func (d *MiCloud) Config() driver.Config { + return config +} + +func (d *MiCloud) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *MiCloud) Init(ctx context.Context) error { + // 初始化小米云盘客户端 + client, err := NewMiCloudClient(d.UserId, d.ServiceToken, d.DeviceId) + if err != nil { + return err + } + d.client = client + // 当 cookie(含 serviceToken)刷新时,写回 Addition 并持久化 + d.client.SetOnCookieUpdate(func(userId, serviceToken, deviceId string) { + // 更新 Addition + d.UserId = userId + d.ServiceToken = serviceToken + d.DeviceId = deviceId + // 持久化到数据库 + op.MustSaveDriverStorage(d) + }) + + // 登录或刷新token + if err := d.client.Login(); err != nil { + return fmt.Errorf("failed to login to MiCloud: %w", err) + } + // 启动后台自动续期 serviceToken + d.client.StartAutoRenewal() + + return nil +} + +func (d *MiCloud) Drop(ctx context.Context) error { + // 停止自动续期 + if d.client != nil { + d.client.StopAutoRenewal() + } + return nil +} + +func (d *MiCloud) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 获取目录ID + folderId := d.client.rootId + if dir.GetID() != "" { + folderId = dir.GetID() + } + + // 获取目录列表 + files, err := d.client.GetFolder(folderId) + if err != nil { + return nil, err + } + + var objects []model.Obj + for _, file := range files { + obj := ConvertFileToObj(file) + objects = append(objects, obj) + } + + return objects, nil +} + +func (d *MiCloud) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + // 直接获取直链 + url, err := d.client.GetFileDownLoadUrl(file.GetID()) + if err != nil { + return nil, err + } + return &model.Link{URL: url}, nil +} + +func (d *MiCloud) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 创建目录 + folderId := d.client.rootId + if parentDir.GetID() != "" { + folderId = parentDir.GetID() + } + + newDirId, err := d.client.CreateFolder(dirName, folderId) + if err != nil { + return nil, err + } + + // 创建目录对象 + obj := &model.Object{ + ID: newDirId, + Name: dirName, + Size: 0, + Modified: time.Now(), + IsFolder: true, + } + + return obj, nil +} + +func (d *MiCloud) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 移动文件/目录 + movedObj, err := d.client.Move(srcObj.GetID(), dstDir.GetID()) + if err != nil { + return nil, err + } + + obj := ConvertFileToObj(*movedObj) + + return obj, nil +} + +func (d *MiCloud) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 重命名文件/目录 + renamedObj, err := d.client.Rename(srcObj.GetID(), newName) + if err != nil { + return nil, err + } + + obj := ConvertFileToObj(*renamedObj) + + return obj, nil +} + +func (d *MiCloud) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + return nil, errs.NotSupport +} + +func (d *MiCloud) Remove(ctx context.Context, obj model.Obj) error { + if d.client == nil { + return fmt.Errorf("MiCloud client not initialized") + } + + // 删除文件/目录 + return d.client.Delete(obj.GetID()) +} + +func (d *MiCloud) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 上传文件 + uploadedObj, err := d.client.Upload(dstDir.GetID(), file, up) + if err != nil { + return nil, err + } + + obj := ConvertFileToObj(*uploadedObj) + + return obj, nil +} + +func (d *MiCloud) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { + return nil, errs.NotImplement +} + +func (d *MiCloud) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *MiCloud) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { + return nil, errs.NotImplement +} + +func (d *MiCloud) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { + return nil, errs.NotImplement +} + +func (d *MiCloud) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + if d.client == nil { + return nil, fmt.Errorf("MiCloud client not initialized") + } + + // 获取存储详情 + details, err := d.client.GetStorageDetails() + if err != nil { + return nil, err + } + + return details, nil +} + +//func (d *MiCloud) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { +// return nil, errs.NotSupport +//} + +var _ driver.Driver = (*MiCloud)(nil) diff --git a/drivers/micloud/meta.go b/drivers/micloud/meta.go new file mode 100644 index 000000000..d06732e42 --- /dev/null +++ b/drivers/micloud/meta.go @@ -0,0 +1,34 @@ +package micloud + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID // 使用RootID作为根目录标识 + UserId string `json:"user_id" required:"true" help:"小米云盘用户ID"` + ServiceToken string `json:"service_token" required:"true" help:"小米云盘服务令牌"` + DeviceId string `json:"device_id" required:"true" help:"设备ID"` +} + +var config = driver.Config{ + Name: "MiCloud", + LocalSort: true, // 本地排序 + OnlyLinkMFile: false, + OnlyProxy: false, // 允许直链 + NoCache: false, + NoUpload: false, // 支持上传 + NeedMs: false, + DefaultRoot: "0", // 根目录ID为0 + CheckStatus: true, // 检查状态 + //Alert: "注意:需要提供正确的用户ID、服务令牌和设备ID", + NoOverwriteUpload: false, + NoLinkURL: false, // Link 返回直链 URL +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &MiCloud{} + }) +} diff --git a/drivers/micloud/types.go b/drivers/micloud/types.go new file mode 100644 index 000000000..52c418aac --- /dev/null +++ b/drivers/micloud/types.go @@ -0,0 +1,87 @@ +package micloud + +// 由于json包在类型定义中没有直接使用,但可能在方法中需要,我们保留它 +// 实际上json包在MiCloudClient的实现中使用了,所以保留 + +// File 小米云盘文件结构 +type File struct { + Sha1 string `json:"sha1"` + ModifyTime uint `json:"modifyTime"` + Size int64 `json:"size"` + CreateTime uint `json:"createTime"` + Name string `json:"name"` + Id string `json:"id"` + Type string `json:"type"` // "file" or "folder" + Revision string `json:"revision"` + IsActive bool `json:"isActive"` +} + +// API响应结构 +type Msg struct { + Result string `json:"result"` + Retryable bool `json:"retryable"` + Code int `json:"code"` + Data struct { + HasMore bool `json:"has_more"` + List []File `json:"list"` + } `json:"data"` +} + +// 上传相关结构 +type UploadJson struct { + Content UploadContent `json:"content"` +} + +type UploadContent struct { + Name string `json:"name"` + ParentId string `json:"parentId"` + Storage interface{} `json:"storage"` +} + +// Detailed storage payloads used by MiCloud KSS flow +type UploadStorage struct { + Size int64 `json:"size"` + Sha1 string `json:"sha1"` + Kss interface{} `json:"kss"` + UploadId string `json:"uploadId"` + Exists bool `json:"exists"` +} + +type UploadExistedStorage struct { + UploadId string `json:"uploadId"` + Exists bool `json:"exists"` +} + +type UploadKss struct { + BlockInfos []BlockInfo `json:"block_infos"` +} + +type Kss struct { + Stat string `json:"stat"` + NodeUrls interface{} `json:"node_urls"` + SecureKey string `json:"secure_key"` + ContentCacheKey string `json:"contentCacheKey"` + FileMeta string `json:"file_meta"` + CommitMetas []map[string]string `json:"commit_metas"` +} + +type BlockInfo struct { + Blob struct{} `json:"blob"` + Sha1 string `json:"sha1"` + Md5 string `json:"md5"` + Size int64 `json:"size"` +} + +// 常量定义 +const ( + BaseUri = "https://i.mi.com" + GetFiles = BaseUri + "/drive/user/files/%s?jsonpCallback=callback" + GetFolders = BaseUri + "/drive/user/folders/%s/children" + GetDirectDL = BaseUri + "/drive/v2/user/files/download" + AutoRenewal = BaseUri + "/status/setting?type=AutoRenewal&inactiveTime=10&_dc=%d" + CreateFile = BaseUri + "/drive/user/files/create" + UploadFile = BaseUri + "/drive/user/files" + DeleteFiles = BaseUri + "/drive/v2/user/records/filemanager" + CreateFolder = BaseUri + "/drive/v2/user/folders/create" + ChunkSize = 4194304 // 4MB +) diff --git a/drivers/micloud/util.go b/drivers/micloud/util.go new file mode 100644 index 000000000..064ee8c38 --- /dev/null +++ b/drivers/micloud/util.go @@ -0,0 +1,644 @@ +package micloud + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/OpenListTeam/OpenList/v4/pkg/utils" + "github.com/tidwall/gjson" +) + +// MiCloudClient 小米云盘客户端 +type MiCloudClient struct { + userId string + serviceToken string + deviceId string + httpClient *http.Client + rootId string + pathIdCache map[string]string // 路径到ID的缓存 + cancelRenew context.CancelFunc + onCookieUpdate func(userId, serviceToken, deviceId string) +} + +// NewMiCloudClient 创建小米云盘客户端 +func NewMiCloudClient(userId, serviceToken, deviceId string) (*MiCloudClient, error) { + client := &MiCloudClient{ + userId: userId, + serviceToken: serviceToken, + deviceId: deviceId, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + rootId: "0", + pathIdCache: make(map[string]string), + } + + return client, nil +} + +// StartAutoRenewal 周期性调用自动续期接口,刷新 serviceToken 等 Cookie +func (c *MiCloudClient) StartAutoRenewal() { + // 如果已在运行,先停止 + c.StopAutoRenewal() + ctx, cancel := context.WithCancel(context.Background()) + c.cancelRenew = cancel + ticker := time.NewTicker(30 * time.Second) + go func() { + defer ticker.Stop() + for { + select { + case <-ticker.C: + ts := time.Now().UnixMilli() + url := fmt.Sprintf(AutoRenewal, ts) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + // 加上基础头与 cookie + req.Header.Set("User-Agent", "Mozilla/5.0") + req.Header.Set("Referer", "https://i.mi.com/") + req.AddCookie(&http.Cookie{Name: "userId", Value: c.userId}) + req.AddCookie(&http.Cookie{Name: "serviceToken", Value: c.serviceToken}) + req.AddCookie(&http.Cookie{Name: "deviceId", Value: c.deviceId}) + resp, err := c.httpClient.Do(req) + if err != nil { + continue + } + // 若服务端下发新 cookie,则更新 + c.updateCookiesFromResponse(resp) + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + case <-ctx.Done(): + return + } + } + }() +} + +// StopAutoRenewal 停止自动续期 +func (c *MiCloudClient) StopAutoRenewal() { + if c.cancelRenew != nil { + c.cancelRenew() + c.cancelRenew = nil + } +} + +// 在收到响应时,若包含新的 cookie,刷新到客户端 +func (c *MiCloudClient) updateCookiesFromResponse(resp *http.Response) { + for _, ck := range resp.Cookies() { + switch ck.Name { + case "serviceToken": + c.serviceToken = ck.Value + case "userId": + c.userId = ck.Value + case "deviceId": + c.deviceId = ck.Value + } + } + if c.onCookieUpdate != nil { + c.onCookieUpdate(c.userId, c.serviceToken, c.deviceId) + } +} + +// SetOnCookieUpdate 注册 Cookie 刷新回调(用于把新的 serviceToken 持久化到存储配置) +func (c *MiCloudClient) SetOnCookieUpdate(cb func(userId, serviceToken, deviceId string)) { + c.onCookieUpdate = cb +} + +// Login 登录或验证登录状态 +func (c *MiCloudClient) Login() error { + // 验证当前token是否有效 + resp, err := c.Get(fmt.Sprintf(GetFolders, c.rootId)) + if err != nil { + return fmt.Errorf("验证登录状态失败: %w", err) + } + + result := gjson.Get(string(resp), "result").String() + if result != "ok" { + return fmt.Errorf("登录验证失败,可能服务令牌无效") + } + + return nil +} + +// GetFolder 获取文件夹内容 +func (c *MiCloudClient) GetFolder(folderId string) ([]File, error) { + result, err := c.Get(fmt.Sprintf(GetFolders, folderId)) + if err != nil { + return nil, err + } + + var msg Msg + if err := json.Unmarshal(result, &msg); err != nil { + return nil, err + } + + if msg.Result == "ok" { + return msg.Data.List, nil + } else { + return nil, fmt.Errorf("获取文件夹信息失败: %s", string(result)) + } +} + +// 直链下载已使用 v2 接口实现,去除 JSONP 下载方式 + +// GetFileDownLoadUrl 获取文件下载URL +func (c *MiCloudClient) GetFileDownLoadUrl(fileId string) (string, error) { + // 使用 v2 接口获取直链 + ts := time.Now().UnixMilli() + ids := fmt.Sprintf("[%q]", fileId) + q := url.Values{} + q.Set("ts", fmt.Sprintf("%d", ts)) + q.Set("ids", ids) + // 注意:ids 需要作为原始 JSON 数组,不进行额外转义 + full := GetDirectDL + "?" + q.Encode() + // Encode 会对 ids 进行转义成 %5B%22...%22%5D,符合示例 + resp, err := c.Get(full) + if err != nil { + return "", err + } + if gjson.Get(string(resp), "result").String() != "ok" { + return "", fmt.Errorf("获取直链失败: %s", string(resp)) + } + // 兼容 downLoads / downloads + dl := gjson.Get(string(resp), "data.downLoads.0.downloadUrl").String() + if dl == "" { + dl = gjson.Get(string(resp), "data.downloads.0.downloadUrl").String() + } + if dl == "" { + return "", fmt.Errorf("直链为空") + } + return dl, nil +} + +// JSONP 下载信息方法已废弃 + +// DeleteFile 删除文件 +func (c *MiCloudClient) DeleteFile(fileId, fType string) error { + // 构造删除记录 + record := []struct { + Id string `json:"id"` + Type string `json:"type"` + }{{ + Id: fileId, + Type: fType, + }} + + content, _ := json.Marshal(record) + + resp, err := c.PostForm(DeleteFiles, url.Values{ + "operateType": []string{"DELETE"}, + "operateRecords": []string{string(content)}, + "serviceToken": []string{c.serviceToken}, + }) + if err != nil { + return err + } + + if result := gjson.Get(string(resp), "result").String(); result == "ok" { + return nil + } else { + return fmt.Errorf("删除失败: %s", string(resp)) + } +} + +// CreateFolder 创建文件夹 +func (c *MiCloudClient) CreateFolder(name, parentId string) (string, error) { + resp, err := c.PostForm(CreateFolder, url.Values{ + "name": []string{name}, + "parentId": []string{parentId}, + "serviceToken": []string{c.serviceToken}, + }) + if err != nil { + return "", err + } + + if result := gjson.Get(string(resp), "result").String(); result == "ok" { + return gjson.Get(string(resp), "data.id").String(), nil + } else { + return "", fmt.Errorf("创建目录失败: %s", string(resp)) + } +} + +// Get 执行GET请求 +func (c *MiCloudClient) Get(url string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + // 设置请求头 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Referer", "https://i.mi.com/") + + // 设置Cookie + req.AddCookie(&http.Cookie{ + Name: "userId", + Value: c.userId, + }) + req.AddCookie(&http.Cookie{ + Name: "serviceToken", + Value: c.serviceToken, + }) + req.AddCookie(&http.Cookie{ + Name: "deviceId", + Value: c.deviceId, + }) + + result, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer result.Body.Close() + // 更新 cookie(若有) + c.updateCookiesFromResponse(result) + + if result.StatusCode == http.StatusFound { + return c.Get(result.Header.Get("Location")) + } + if result.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("登录授权失败") + } + + body, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + if gjson.Get(string(body), "R").Int() == 401 { + return nil, fmt.Errorf("登录授权失败") + } + + return body, nil +} + +// PostForm 执行POST表单请求 +func (c *MiCloudClient) PostForm(url string, values url.Values) ([]byte, error) { + req, err := http.NewRequest("POST", url, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + // 设置请求头 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + req.Header.Set("Referer", "https://i.mi.com/") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // 设置Cookie + req.AddCookie(&http.Cookie{ + Name: "userId", + Value: c.userId, + }) + req.AddCookie(&http.Cookie{ + Name: "serviceToken", + Value: c.serviceToken, + }) + req.AddCookie(&http.Cookie{ + Name: "deviceId", + Value: c.deviceId, + }) + + result, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer result.Body.Close() + // 更新 cookie(若有) + c.updateCookiesFromResponse(result) + + if result.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("登录授权失败") + } + + body, err := io.ReadAll(result.Body) + if err != nil { + return nil, err + } + + if gjson.Get(string(body), "R").Int() == 401 { + return nil, fmt.Errorf("登录授权失败") + } + + return body, nil +} + +// pathToId 路径转ID +func (c *MiCloudClient) pathToId(path string) (string, error) { + if path == "/" || path == "" { + return c.rootId, nil + } + + // 检查缓存 + if id, ok := c.pathIdCache[path]; ok { + return id, nil + } + + // 逐级解析路径 + paths := strings.Split(strings.Trim(path, "/"), "/") + currentId := c.rootId + + for _, p := range paths { + if p == "" { + continue + } + + // 获取当前目录下的文件列表 + files, err := c.GetFolder(currentId) + if err != nil { + return "", fmt.Errorf("获取目录 %s 失败: %w", path, err) + } + + // 查找对应的目录 + found := false + for _, file := range files { + if file.Name == p && file.Type == "folder" { + currentId = file.Id + found = true + break + } + } + + if !found { + return "", fmt.Errorf("路径不存在: %s", path) + } + } + + // 缓存结果 + c.pathIdCache[path] = currentId + return currentId, nil +} + +// Delete 删除文件或目录 +func (c *MiCloudClient) Delete(fileId string) error { + // 首先获取文件信息以确定类型 + // 这里我们假设在删除时已经知道文件类型,可以传入"file"或"folder" + // 通常在OpenList中,删除操作会先获取对象信息,所以我们可以假定知道类型 + return c.DeleteFile(fileId, "file") // 默认为文件,实际使用时应根据实际情况传入类型 +} + +// Move 移动文件或目录(小米云盘API可能不直接支持移动,需要复制后删除) +func (c *MiCloudClient) Move(fileId, targetParentId string) (*File, error) { + // 小米云盘API没有直接的移动接口,我们先实现一个简单的版本 + // 实际上可能需要先复制再删除,或者检查API是否有相关功能 + return nil, fmt.Errorf("移动功能暂未实现") +} + +// Rename 重命名文件或目录 +func (c *MiCloudClient) Rename(fileId, newName string) (*File, error) { + // 小米云盘API没有直接的重命名接口,可能需要通过其他方式实现 + return nil, fmt.Errorf("重命名功能暂未实现") +} + +// Upload 上传文件的完整实现 +func (c *MiCloudClient) Upload(parentId string, file model.FileStreamer, up driver.UpdateProgress) (*File, error) { + fileSize := file.GetSize() + fileName := file.GetName() + + // 缓存完整流并计算整体 SHA1(不破坏后续读取) + var upPtr = model.UpdateProgress(up) + tmpF, fileSha1, err := streamPkg.CacheFullAndHash(file, &upPtr, utils.SHA1) + if err != nil { + return nil, fmt.Errorf("缓存与计算SHA1失败: %w", err) + } + + // 计算块信息 + blocks, err := c.getFileBlocks(tmpF, fileSize) + if err != nil { + return nil, fmt.Errorf("计算文件分片失败: %w", err) + } + + // 组装创建分片请求 + upJson := UploadJson{ + Content: UploadContent{ + Name: fileName, + Storage: UploadStorage{ + Size: fileSize, + Sha1: fileSha1, + Kss: UploadKss{BlockInfos: blocks}, + }, + }, + } + data, _ := json.Marshal(upJson) + + // 创建分片 + form := url.Values{} + form.Add("data", string(data)) + form.Add("serviceToken", c.serviceToken) + resp, err := c.PostForm(CreateFile, form) + if err != nil { + return nil, err + } + if gjson.Get(string(resp), "result").String() != "ok" { + return nil, fmt.Errorf("创建文件分片失败: %s", string(resp)) + } + + // 文件已存在于云端,直接完成 + if gjson.Get(string(resp), "data.storage.exists").Bool() { + data := UploadJson{Content: UploadContent{ + Name: fileName, + Storage: UploadExistedStorage{ + UploadId: gjson.Get(string(resp), "data.storage.uploadId").String(), + Exists: true, + }, + }} + return c.finalizeCreate(parentId, data) + } + + // 不存在则上传每个分块 + kss := gjson.Get(string(resp), "data.storage.kss") + nodeUrls := kss.Get("node_urls").Array() + fileMeta := kss.Get("file_meta").String() + blockMetas := kss.Get("block_metas").Array() + if len(nodeUrls) == 0 || fileMeta == "" { + return nil, fmt.Errorf("暂无可用上传节点") + } + apiNode := nodeUrls[0].String() + + // 逐块上传 + var commitMetas []map[string]string + var uploaded int64 + for i, blk := range blockMetas { + cm, sz, err := c.uploadBlock(apiNode, fileMeta, tmpF, int64(i), blk) + if err != nil { + return nil, err + } + commitMetas = append(commitMetas, cm) + uploaded += sz + if up != nil && fileSize > 0 { + up(float64(uploaded) * 100 / float64(fileSize)) + } + } + + // 完成上传 + data2 := UploadJson{Content: UploadContent{ + Name: fileName, + Storage: UploadStorage{ + Size: fileSize, + Sha1: fileSha1, + Kss: Kss{ + Stat: "OK", + NodeUrls: nodeUrls, + SecureKey: kss.Get("secure_key").String(), + ContentCacheKey: kss.Get("contentCacheKey").String(), + FileMeta: kss.Get("file_meta").String(), + CommitMetas: commitMetas, + }, + UploadId: gjson.Get(string(resp), "data.storage.uploadId").String(), + Exists: false, + }, + }} + return c.finalizeCreate(parentId, data2) +} + +// finalizeCreate 最终创建文件 +func (c *MiCloudClient) finalizeCreate(parentId string, data UploadJson) (*File, error) { + dataJson, err := json.Marshal(data) + if err != nil { + return nil, err + } + form := url.Values{} + form.Add("data", string(dataJson)) + form.Add("serviceToken", c.serviceToken) + form.Add("parentId", parentId) + + request, err := http.NewRequest("POST", UploadFile, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + // headers + request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") + request.Header.Set("Referer", "https://i.mi.com/") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // cookies + request.AddCookie(&http.Cookie{Name: "userId", Value: c.userId}) + request.AddCookie(&http.Cookie{Name: "serviceToken", Value: c.serviceToken}) + request.AddCookie(&http.Cookie{Name: "deviceId", Value: c.deviceId}) + + resp, err := c.httpClient.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if gjson.Get(string(body), "result").String() != "ok" { + return nil, fmt.Errorf("创建文件失败: %s", string(body)) + } + // assemble result + fileId := gjson.Get(string(body), "data.id").String() + rName := gjson.Get(string(body), "data.name").String() + rSize := gjson.Get(string(body), "data.size").Int() + rType := gjson.Get(string(body), "data.type").String() + return &File{Id: fileId, Name: rName, Size: rSize, Type: rType}, nil +} + +// getFileBlocks 计算文件分片(4MB一块) +func (c *MiCloudClient) getFileBlocks(tmp model.File, fileSize int64) ([]BlockInfo, error) { + if fileSize <= ChunkSize { + // small file: compute full hashes + sha1Str, err := utils.HashFile(utils.SHA1, tmp) + if err != nil { + return nil, err + } + md5Str, err := utils.HashFile(utils.MD5, tmp) + if err != nil { + return nil, err + } + return []BlockInfo{{Blob: struct{}{}, Sha1: sha1Str, Md5: md5Str, Size: fileSize}}, nil + } + num := int((fileSize + ChunkSize - 1) / ChunkSize) + blocks := make([]BlockInfo, 0, num) + for i := 0; i < num; i++ { + off := int64(i) * ChunkSize + sz := int64(ChunkSize) + if off+sz > fileSize { + sz = fileSize - off + } + sr := io.NewSectionReader(tmp, off, sz) + sha1Str, err := utils.HashReader(utils.SHA1, sr) + if err != nil { + return nil, err + } + sr = io.NewSectionReader(tmp, off, sz) + md5Str, err := utils.HashReader(utils.MD5, sr) + if err != nil { + return nil, err + } + blocks = append(blocks, BlockInfo{Blob: struct{}{}, Sha1: sha1Str, Md5: md5Str, Size: sz}) + } + return blocks, nil +} + +// uploadBlock 上传单个分块 +func (c *MiCloudClient) uploadBlock(apiNode, fileMeta string, tmp model.File, idx int64, blk gjson.Result) (map[string]string, int64, error) { + if blk.Get("is_existed").Int() == 1 { + return map[string]string{"commit_meta": blk.Get("commit_meta").String()}, 0, nil + } + uploadURL := apiNode + "/upload_block_chunk?chunk_pos=0&file_meta=" + fileMeta + "&block_meta=" + blk.Get("block_meta").String() + off := idx * ChunkSize + // read chunk + sz := int64(ChunkSize) + buf := make([]byte, sz) + n, err := tmp.ReadAt(buf, off) + if err != nil && err != io.EOF { + return nil, 0, err + } + buf = buf[:n] + req, _ := http.NewRequest("POST", uploadURL, strings.NewReader(string(buf))) + req.Header.Set("DNT", "1") + req.Header.Set("Origin", "https://i.mi.com") + req.Header.Set("Referer", "https://i.mi.com/drive") + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if gjson.Get(string(body), "stat").String() != "BLOCK_COMPLETED" { + return nil, 0, fmt.Errorf("block not completed: %s", string(body)) + } + return map[string]string{"commit_meta": gjson.Get(string(body), "commit_meta").String()}, int64(n), nil +} + +// 上传小文件(小于4MB) +// uploadSmallFile/uploadLargeFile paths were deprecated and removed. The unified Upload handles both cases. + +// GetStorageDetails 获取存储详情 +func (c *MiCloudClient) GetStorageDetails() (*model.StorageDetails, error) { + return nil, fmt.Errorf("获取存储详情功能暂未实现") +} + +// ConvertFileToObj 将小米云盘文件转换为OpenList对象 +func ConvertFileToObj(file File) *model.Object { + // 小米云时间戳可能是毫秒或秒,这里智能判断 + toTime := func(ts uint) time.Time { + v := int64(ts) + if v <= 0 { + return time.Time{} + } + if v > 1_000_000_000_000 { // > ~2001-09 in ms + return time.UnixMilli(v) + } + return time.Unix(v, 0) + } + + obj := &model.Object{ + ID: file.Id, + Name: file.Name, + Size: file.Size, + Modified: toTime(file.ModifyTime), + Ctime: toTime(file.CreateTime), + IsFolder: file.Type == "folder", + } + + return obj +} diff --git a/go.mod b/go.mod index 7d27d07b1..ead37788c 100644 --- a/go.mod +++ b/go.mod @@ -93,6 +93,9 @@ require ( github.com/minio/xxml v0.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/otiai10/mint v1.6.3 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect ) diff --git a/go.sum b/go.sum index d631b5b65..a1bc05554 100644 --- a/go.sum +++ b/go.sum @@ -626,6 +626,12 @@ github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543 h1:6Y51mutOvRGRx6K github.com/taruti/bytepool v0.0.0-20160310082835-5e3a9ea56543/go.mod h1:jpwqYA8KUVEvSUJHkCXsnBRJCSKP1BMa81QZ6kvRpow= github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhgwZDDc= github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=