diff --git a/README.md b/README.md index 4a94dbfcf..18c41369b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Thank you for your support and understanding of the OpenList project. - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk Docs](https://alidocs.dingtalk.com/) - [x] Easy to deploy and out-of-the-box - [x] File preview (PDF, markdown, code, plain text, ...) - [x] Image preview in gallery mode diff --git a/README_cn.md b/README_cn.md index 55fe22106..88e7beed9 100644 --- a/README_cn.md +++ b/README_cn.md @@ -95,6 +95,7 @@ OpenList 是一个由 OpenList 团队独立维护的开源项目,遵循 AGPL-3 - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [微云](https://www.weiyun.com) + - [x] [钉钉文档](https://alidocs.dingtalk.com/) - [x] 部署方便,开箱即用 - [x] 文件预览(PDF、markdown、代码、纯文本等) - [x] 画廊模式下的图片预览 diff --git a/README_ja.md b/README_ja.md index 261223de3..8c7178234 100644 --- a/README_ja.md +++ b/README_ja.md @@ -94,6 +94,7 @@ OpenListプロジェクトへのご支援とご理解をありがとうござい - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk ドキュメント](https://alidocs.dingtalk.com/) - [x] [MediaFire](https://www.mediafire.com) - [x] 簡単にデプロイでき、すぐに使える - [x] ファイルプレビュー(PDF、markdown、コード、テキストなど) diff --git a/README_nl.md b/README_nl.md index d3be2703f..7767df795 100644 --- a/README_nl.md +++ b/README_nl.md @@ -95,6 +95,7 @@ Dank u voor uw ondersteuning en begrip - [x] [OpenList](https://github.com/OpenListTeam/OpenList) - [x] [Teldrive](https://github.com/tgdrive/teldrive) - [x] [Weiyun](https://www.weiyun.com) + - [x] [DingTalk-documenten](https://alidocs.dingtalk.com/) - [x] Eenvoudig te implementeren en direct te gebruiken - [x] Bestandsvoorbeeld (PDF, markdown, code, platte tekst, ...) - [x] Afbeeldingsvoorbeeld in galerijweergave diff --git a/drivers/alidoc/driver.go b/drivers/alidoc/driver.go new file mode 100644 index 000000000..3d1a35112 --- /dev/null +++ b/drivers/alidoc/driver.go @@ -0,0 +1,147 @@ +package alidoc + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +type AliDoc struct { + model.Storage + Addition + + client *resty.Client +} + +func (d *AliDoc) Config() driver.Config { + return config +} + +func (d *AliDoc) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *AliDoc) Init(ctx context.Context) error { + d.Cookie = strings.TrimSpace(d.Cookie) + d.RootFolderID = strings.TrimSpace(d.RootFolderID) + if d.Cookie == "" { + return fmt.Errorf("cookie is empty") + } + if d.RootFolderID == "" { + return fmt.Errorf("root folder id is empty") + } + d.client = newClient() + if err := d.checkCookie(ctx); err != nil { + return err + } + return nil +} + +func (d *AliDoc) Drop(ctx context.Context) error { + d.client = nil + return nil +} + +func (d *AliDoc) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + items, err := d.list(ctx, dir.GetID()) + if err != nil { + return nil, err + } + + objs := make([]model.Obj, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.DentryUUID) == "" || strings.TrimSpace(item.Name) == "" { + continue + } + objs = append(objs, toObj(item)) + } + return objs, nil +} + +func (d *AliDoc) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, fmt.Errorf("alidoc does not support directory links") + } + resp, err := d.download(ctx, file.GetID()) + if err != nil { + return nil, err + } + url, err := firstDownloadURL(resp) + if err != nil { + return nil, err + } + return &model.Link{ + URL: url, + Header: http.Header{ + "User-Agent": []string{base.UserAgent}, + "Referer": []string{apiBase + "/"}, + }, + }, nil +} + +func (d *AliDoc) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/createfolder", map[string]string{ + "dentryType": "folder", + "name": dirName, + "parentDentryUuid": parentDir.GetID(), + "conflictHandleStrategy": "auto_rename", + }) + if err != nil { + return nil, err + } + return nil, nil +} + +func (d *AliDoc) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/move", map[string]interface{}{ + "targetParentDentryUuid": dstDir.GetID(), + "sourceDentryUuid": srcObj.GetID(), + "operateFrom": 1, + }) + if err != nil { + return nil, err + } + return srcObj, nil +} + +func (d *AliDoc) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + err := d.post(ctx, "/box/api/v2/dentry/rename", map[string]string{ + "dentryUuid": srcObj.GetID(), + "name": newName, + }) + if err != nil { + return nil, err + } + srcObj.(*model.Object).Name = newName + return srcObj, nil +} + +func (d *AliDoc) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + return d.post(ctx, "/box/api/v2/dentry/copy", map[string]interface{}{ + "sourceDentryUuid": srcObj.GetID(), + "targetParentDentryUuid": dstDir.GetID(), + "operateFrom": 1, + "onlyCopyMeta": false, + }) +} + +func (d *AliDoc) Remove(ctx context.Context, obj model.Obj) error { + return d.post(ctx, "/box/api/v1/dentry/recycle", map[string]string{ + "dentryUuid": obj.GetID(), + }) +} + +var ( + _ driver.Driver = (*AliDoc)(nil) + _ driver.MkdirResult = (*AliDoc)(nil) + _ driver.MoveResult = (*AliDoc)(nil) + _ driver.RenameResult = (*AliDoc)(nil) + _ driver.Copy = (*AliDoc)(nil) + _ driver.Remove = (*AliDoc)(nil) +) diff --git a/drivers/alidoc/meta.go b/drivers/alidoc/meta.go new file mode 100644 index 000000000..3c0fa19a9 --- /dev/null +++ b/drivers/alidoc/meta.go @@ -0,0 +1,23 @@ +package alidoc + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + driver.RootID + Cookie string `json:"cookie" type:"text" required:"true" help:"钉钉文档网页 Cookie"` +} + +var config = driver.Config{ + Name: "AliDoc", + LocalSort: true, + DefaultRoot: "", +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &AliDoc{} + }) +} diff --git a/drivers/alidoc/types.go b/drivers/alidoc/types.go new file mode 100644 index 000000000..171d401a0 --- /dev/null +++ b/drivers/alidoc/types.go @@ -0,0 +1,87 @@ +package alidoc + +type apiResp struct { + Status int `json:"status"` + IsSuccess bool `json:"isSuccess"` + Message string `json:"message"` + Msg string `json:"msg"` +} + +func (r apiResp) ErrMessage() string { + if r.Message != "" { + return r.Message + } + if r.Msg != "" { + return r.Msg + } + return "" +} + +type listResp struct { + apiResp + Data listData `json:"data"` +} + +type listData struct { + Children []dentry `json:"children"` +} + +type dentry struct { + DentryType string `json:"dentryType"` + DentryUUID string `json:"dentryUuid"` + ParentDentryUUID string `json:"parentDentryUuid"` + Name string `json:"name"` + Path string `json:"path"` + FileSize int64 `json:"fileSize"` + CreatedTime int64 `json:"createdTime"` + UpdatedTime int64 `json:"updatedTime"` + ContentType string `json:"contentType"` + Extension string `json:"extension"` + DentryStatistic struct { + ChildrenCount int `json:"childrenCount"` + } `json:"dentryStatistic"` + URL struct { + PCChildAppPreviewURL string `json:"pcChildAppPreviewUrl"` + PCChildAppURL string `json:"pcChildAppUrl"` + } `json:"url"` +} + +type downloadResp struct { + apiResp + Data downloadData `json:"data"` +} + +type downloadData struct { + OSSURLPreSignatureInfo struct { + PreSignURLs []string `json:"preSignUrls"` + } `json:"ossUrlPreSignatureInfo"` +} + +type uploadInfoResp struct { + apiResp + Data uploadInfoData `json:"data"` +} + +type uploadInfoData struct { + CurrentTimestamp int64 `json:"currentTimestamp"` + FileUploadProtocolConfig uploadProtocolConfig `json:"fileUploadProtocolConfig"` + STSSignatureInfo uploadSTSSignatureInfo `json:"stsSignatureInfo"` + UploadKey string `json:"uploadKey"` + UploadType string `json:"uploadType"` +} + +type uploadProtocolConfig struct { + MinPartSize int64 `json:"minPartSize"` +} + +type uploadSTSSignatureInfo struct { + AccelerateCname string `json:"accelerateCname"` + AccessKeyID string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` + AccessToken string `json:"accessToken"` + AccessTokenExpiration int64 `json:"accessTokenExpiration"` + Bucket string `json:"bucket"` + Cname string `json:"cname"` + EndPoint string `json:"endPoint"` + ObjectKey string `json:"objectKey"` +} diff --git a/drivers/alidoc/upload.go b/drivers/alidoc/upload.go new file mode 100644 index 000000000..a8c2eacb7 --- /dev/null +++ b/drivers/alidoc/upload.go @@ -0,0 +1,263 @@ +package alidoc + +import ( + "context" + "fmt" + "io" + "math" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/model" + netutil "github.com/OpenListTeam/OpenList/v4/internal/net" + streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/avast/retry-go" + "github.com/google/uuid" +) + +const ( + defaultAliDocMultipartThreshold = 16 * 1024 * 1024 + defaultAliDocPartSize = 100 * 1024 + maxAliDocMultipartParts = 10000 +) + +func (d *AliDoc) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) error { + size := file.GetSize() + useMultipart := size > defaultAliDocMultipartThreshold + info, err := d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, useMultipart) + if err != nil { + return err + } + if size > 0 { + partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize) + if size > partSize && !useMultipart { + useMultipart = true + info, err = d.getUploadInfo(ctx, dstDir.GetID(), file.GetName(), size, true) + if err != nil { + return err + } + } + } + + if useMultipart { + err = d.multipartUpload(ctx, file, size, info, up) + } else { + err = d.singleUpload(ctx, file, size, info, up) + } + if err != nil { + return err + } + if err := d.commitUpload(ctx, dstDir.GetID(), file.GetName(), size, info.Data.UploadKey); err != nil { + return err + } + return nil +} + +func (d *AliDoc) getUploadInfo(ctx context.Context, parentDentryUUID, name string, fileSize int64, multipart bool) (uploadInfoResp, error) { + var result uploadInfoResp + body := map[string]interface{}{ + "uploadType": "STS_SIGNATURE", + "supportUploadTypes": []string{"STS_SIGNATURE", "HTTP_TO_CENTER"}, + "parentDentryUuid": parentDentryUUID, + "fileSize": fileSize, + "name": name, + "multipart": multipart, + } + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + "/box/api/v2/file/uploadinfo") + if err != nil { + return result, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return result, err + } + if strings.TrimSpace(result.Data.STSSignatureInfo.Bucket) == "" { + return result, fmt.Errorf("empty upload bucket") + } + return result, nil +} + +func (d *AliDoc) commitUpload(ctx context.Context, parentDentryUUID, name string, fileSize int64, uploadKey string) error { + uploadKey = strings.TrimSpace(uploadKey) + if uploadKey == "" { + return fmt.Errorf("empty upload key") + } + + var result apiResp + body := map[string]interface{}{ + "parentDentryUuid": parentDentryUUID, + "uploadKey": uploadKey, + "fileSize": fileSize, + "name": name, + "toPrevDentryUuid": nil, + "toNextDentryUuid": nil, + "batchId": uuid.NewString(), + "batchUploadType": 1, + "batchParentDentryUuid": parentDentryUUID, + } + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + "/box/api/v2/file/commit") + if err != nil { + return err + } + return checkResp(resp, result) +} + +func calcAliDocPartSize(fileSize, minPartSize int64) int64 { + partSize := minPartSize + if partSize <= 0 { + partSize = defaultAliDocPartSize + } + if fileSize <= 0 { + return partSize + } + minRequired := int64(math.Ceil(float64(fileSize) / maxAliDocMultipartParts)) + if minRequired > partSize { + partSize = minRequired + } + return partSize +} + +func (d *AliDoc) singleUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error { + bucket, objectKey, err := d.newOSSBucket(info) + if err != nil { + return err + } + err = bucket.PutObject( + objectKey, + driver.NewLimitedUploadStream(ctx, io.TeeReader(src, driver.NewProgress(size, up))), + ) + if err != nil { + return err + } + up(100) + return nil +} + +func (d *AliDoc) multipartUpload(ctx context.Context, src model.FileStreamer, size int64, info uploadInfoResp, up driver.UpdateProgress) error { + bucket, objectKey, err := d.newOSSBucket(info) + if err != nil { + return err + } + + imur, err := bucket.InitiateMultipartUpload(objectKey, oss.Sequential()) + if err != nil { + return err + } + + partSize := calcAliDocPartSize(size, info.Data.FileUploadProtocolConfig.MinPartSize) + partNum := int((size + partSize - 1) / partSize) + parts := make([]oss.UploadPart, 0, partNum) + ss, err := streamPkg.NewStreamSectionReader(src, int(partSize), &up) + if err != nil { + return err + } + + var offset int64 + for partNumber := 1; partNumber <= partNum; partNumber++ { + if err := ctx.Err(); err != nil { + return err + } + length := partSize + if remain := size - offset; remain < length { + length = remain + } + + reader, err := ss.GetSectionReader(offset, length) + if err != nil { + return err + } + var part oss.UploadPart + var uploadErr error + err = retry.Do(func() error { + if _, err := reader.Seek(0, io.SeekStart); err != nil { + return err + } + part, uploadErr = bucket.UploadPart( + imur, + driver.NewLimitedUploadStream(ctx, reader), + length, + partNumber, + ) + return uploadErr + }, + retry.Context(ctx), + retry.Attempts(3), + retry.DelayType(retry.BackOffDelay), + retry.Delay(time.Second), + ) + ss.FreeSectionReader(reader) + if err != nil { + return err + } + parts = append(parts, part) + up(100 * float64(len(parts)) / float64(partNum+1)) + offset += length + } + + _, err = bucket.CompleteMultipartUpload(imur, parts) + if err != nil { + return err + } + up(100) + return nil +} + +func (d *AliDoc) newOSSBucket(info uploadInfoResp) (*oss.Bucket, string, error) { + sts := info.Data.STSSignatureInfo + objectKey := strings.TrimSpace(sts.ObjectKey) + if objectKey == "" { + objectKey = strings.TrimSpace(info.Data.UploadKey) + } + if objectKey == "" { + return nil, "", fmt.Errorf("empty upload object key") + } + + endpoint, useCname := pickAliDocOSSEndpoint(sts) + if endpoint == "" { + return nil, "", fmt.Errorf("empty upload endpoint") + } + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + endpoint = "https://" + endpoint + } + + options := []oss.ClientOption{oss.SecurityToken(sts.AccessToken)} + if useCname { + options = append(options, oss.UseCname(true)) + } + client, err := netutil.NewOSSClient( + endpoint, + sts.AccessKeyID, + sts.AccessKeySecret, + options..., + ) + if err != nil { + return nil, "", err + } + bucket, err := client.Bucket(sts.Bucket) + if err != nil { + return nil, "", err + } + return bucket, objectKey, nil +} + +func pickAliDocOSSEndpoint(sts uploadSTSSignatureInfo) (endpoint string, useCname bool) { + if endpoint = strings.TrimSpace(sts.EndPoint); endpoint != "" { + return endpoint, false + } + if endpoint = strings.TrimSpace(sts.Cname); endpoint != "" { + return endpoint, true + } + if endpoint = strings.TrimSpace(sts.AccelerateCname); endpoint != "" { + return endpoint, true + } + return "", false +} diff --git a/drivers/alidoc/util.go b/drivers/alidoc/util.go new file mode 100644 index 000000000..5e7d8cfa6 --- /dev/null +++ b/drivers/alidoc/util.go @@ -0,0 +1,135 @@ +package alidoc + +import ( + "context" + "fmt" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/go-resty/resty/v2" +) + +const apiBase = "https://alidocs.dingtalk.com" + +func (d *AliDoc) request(ctx context.Context) *resty.Request { + return d.client.R(). + SetContext(ctx). + SetHeader("Cookie", d.Cookie). + SetHeader("Accept", "application/json, text/plain, */*"). + SetHeader("Referer", apiBase+"/"). + SetHeader("Origin", apiBase) +} + +func msToTime(v int64) time.Time { + if v <= 0 { + return time.Time{} + } + return time.UnixMilli(v) +} + +func checkResp(resp *resty.Response, result apiResp) error { + if resp != nil && resp.IsError() { + if msg := result.ErrMessage(); msg != "" { + return fmt.Errorf("%s", msg) + } + return fmt.Errorf("http error: %d", resp.StatusCode()) + } + if !result.IsSuccess || result.Status != 200 { + msg := result.ErrMessage() + if msg == "" { + msg = "request failed" + } + return fmt.Errorf("%s", msg) + } + return nil +} + +func toObj(item dentry) model.Obj { + return &model.Object{ + ID: item.DentryUUID, + Name: item.Name, + Size: item.FileSize, + Modified: msToTime(item.UpdatedTime), + Ctime: msToTime(item.CreatedTime), + IsFolder: item.DentryType == "folder", + } +} + +func firstDownloadURL(resp downloadResp) (string, error) { + if len(resp.Data.OSSURLPreSignatureInfo.PreSignURLs) == 0 { + return "", fmt.Errorf("empty download url") + } + return resp.Data.OSSURLPreSignatureInfo.PreSignURLs[0], nil +} + +func newClient() *resty.Client { + client := base.NewRestyClient() + client.SetHeader("User-Agent", base.UserAgent) + return client +} + +func (d *AliDoc) post(ctx context.Context, path string, body interface{}) error { + var result apiResp + resp, err := d.request(ctx). + SetBody(body). + SetResult(&result). + SetError(&result). + Post(apiBase + path) + if err != nil { + return err + } + return checkResp(resp, result) +} + +func (d *AliDoc) checkCookie(ctx context.Context) error { + var result apiResp + resp, err := d.request(ctx). + SetResult(&result). + SetError(&result). + Get(apiBase + "/portal/api/v1/mine/info") + if err != nil { + return err + } + return checkResp(resp, result) +} + +func (d *AliDoc) list(ctx context.Context, dentryUUID string) ([]dentry, error) { + var result listResp + resp, err := d.request(ctx). + SetQueryParam("dentryUuid", dentryUUID). + SetQueryParam("withParentAncestors", "true"). + SetQueryParam("orderType", "SORT_KEY"). + SetQueryParam("sortType", "desc"). + SetQueryParam("listDentrySource", "2"). + SetQueryParam("pageSize", "1000"). + SetResult(&result). + SetError(&result). + Get(apiBase + "/box/api/v2/dentry/list") + if err != nil { + return nil, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return nil, err + } + return result.Data.Children, nil +} + +func (d *AliDoc) download(ctx context.Context, dentryUUID string) (downloadResp, error) { + var result downloadResp + resp, err := d.request(ctx). + SetQueryParam("dentryUuid", dentryUUID). + SetQueryParam("version", "1"). + SetQueryParam("supportDownloadTypes", "URL_PRE_SIGNATURE,HTTP_TO_CENTER"). + SetQueryParam("downloadType", "URL_PRE_SIGNATURE"). + SetResult(&result). + SetError(&result). + Get(apiBase + "/box/api/v2/file/download") + if err != nil { + return result, err + } + if err := checkResp(resp, result.apiResp); err != nil { + return result, err + } + return result, nil +} diff --git a/drivers/all.go b/drivers/all.go index 4af88dc00..7687faaf2 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -13,6 +13,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/189_tv" _ "github.com/OpenListTeam/OpenList/v4/drivers/189pc" _ "github.com/OpenListTeam/OpenList/v4/drivers/alias" + _ "github.com/OpenListTeam/OpenList/v4/drivers/alidoc" _ "github.com/OpenListTeam/OpenList/v4/drivers/alist_v3" _ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive" _ "github.com/OpenListTeam/OpenList/v4/drivers/aliyundrive_open" diff --git a/drivers/wps/driver.go b/drivers/wps/driver.go index 847425b11..810048a7d 100644 --- a/drivers/wps/driver.go +++ b/drivers/wps/driver.go @@ -38,25 +38,23 @@ func (d *Wps) Init(ctx context.Context) error { d.client = base.NewRestyClient() - resp, err := d.request(ctx).SetResult(&d.login).Get("https://account.kdocs.cn/api/v3/islogin") + d.login = &loginState{} + resp, err := d.request(ctx).SetResult(d.login).Get("https://account.kdocs.cn/api/v3/islogin") if err != nil { return err } if !resp.IsSuccess() { return fmt.Errorf("failed to check login status, status code: %d, body: %s", resp.StatusCode(), resp.String()) } - + if d.login.CompanyID == 0 { + return fmt.Errorf("wps company id is empty, please check business account login") + } return nil } func (d *Wps) Drop(ctx context.Context) error { - - if d.client != nil { - d.client = nil - } - if d.login != nil { - d.login = nil - } + d.client = nil + d.login = nil return nil } diff --git a/drivers/wps/meta.go b/drivers/wps/meta.go index a520bb433..1befb7cd0 100644 --- a/drivers/wps/meta.go +++ b/drivers/wps/meta.go @@ -16,7 +16,7 @@ var config = driver.Config{ Name: "WPS", LocalSort: true, DefaultRoot: "/", - Alert: "", + CheckStatus: true, } func init() {