From 237c679c8465cfa1400fc32e4c6e99c5bedae6b4 Mon Sep 17 00:00:00 2001 From: Elegant1E <104549918+Elegant1E@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:00:12 +0800 Subject: [PATCH 01/15] feat: add doubao_new driver --- drivers/all.go | 1 + drivers/doubao_new/driver.go | 590 +++++++++++++++++++++++ drivers/doubao_new/meta.go | 35 ++ drivers/doubao_new/types.go | 182 +++++++ drivers/doubao_new/util.go | 909 +++++++++++++++++++++++++++++++++++ server/handles/down.go | 12 + 6 files changed, 1729 insertions(+) create mode 100644 drivers/doubao_new/driver.go create mode 100644 drivers/doubao_new/meta.go create mode 100644 drivers/doubao_new/types.go create mode 100644 drivers/doubao_new/util.go diff --git a/drivers/all.go b/drivers/all.go index 7e1c24bba..fb68d0395 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -29,6 +29,7 @@ import ( _ "github.com/OpenListTeam/OpenList/v4/drivers/crypt" _ "github.com/OpenListTeam/OpenList/v4/drivers/degoo" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao" + _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_new" _ "github.com/OpenListTeam/OpenList/v4/drivers/doubao_share" _ "github.com/OpenListTeam/OpenList/v4/drivers/dropbox" _ "github.com/OpenListTeam/OpenList/v4/drivers/febbox" diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go new file mode 100644 index 000000000..98a6c5822 --- /dev/null +++ b/drivers/doubao_new/driver.go @@ -0,0 +1,590 @@ +package doubao_new + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "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 DoubaoNew struct { + model.Storage + Addition + TtLogid string +} + +func (d *DoubaoNew) Config() driver.Config { + return config +} + +func (d *DoubaoNew) GetAddition() driver.Additional { + return &d.Addition +} + +func (d *DoubaoNew) Init(ctx context.Context) error { + // TODO login / refresh token + //op.MustSaveDriverStorage(d) + return nil +} + +func (d *DoubaoNew) Drop(ctx context.Context) error { + return nil +} + +func (d *DoubaoNew) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + nodes, err := d.listAllChildren(ctx, dir.GetID()) + if err != nil { + return nil, err + } + + objs := make([]model.Obj, 0, len(nodes)) + for _, node := range nodes { + size := parseSize(node.Extra.Size) + isFolder := node.Type == 0 + obj := &Object{ + Object: model.Object{ + ID: node.NodeToken, + Path: dir.GetID(), + Name: node.Name, + Size: size, + Modified: time.Unix(node.EditTime, 0), + Ctime: time.Unix(node.CreateTime, 0), + IsFolder: isFolder, + }, + ObjToken: node.ObjToken, + NodeType: node.NodeType, + ObjType: node.Type, + URL: node.URL, + } + objs = append(objs, obj) + } + + return objs, nil +} + +func (d *DoubaoNew) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + obj, ok := file.(*Object) + if !ok { + return nil, errors.New("unsupported object type") + } + if obj.IsFolder { + return nil, fmt.Errorf("link is directory") + } + if args.Type == "preview" || args.Type == "thumb" { + if link, err := d.previewLink(ctx, obj, args); err == nil { + return link, nil + } + } + auth := d.resolveAuthorization() + dpop := d.resolveDpop() + if auth == "" || dpop == "" { + return nil, errors.New("missing authorization or dpop") + } + if obj.ObjToken == "" { + return nil, errors.New("missing obj_token") + } + + query := url.Values{} + query.Set("authorization", auth) + query.Set("dpop", dpop) + + downloadURL := DownloadBaseURL + "/space/api/box/stream/download/all/" + obj.ObjToken + "/?" + query.Encode() + + headers := http.Header{ + "Referer": []string{"https://www.doubao.com/"}, + "User-Agent": []string{base.UserAgent}, + } + + return &model.Link{ + URL: downloadURL, + Header: headers, + }, nil +} + +func (d *DoubaoNew) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) (model.Obj, error) { + node, err := d.createFolder(ctx, parentDir.GetID(), dirName) + if err != nil { + return nil, err + } + return &Object{ + Object: model.Object{ + ID: node.NodeToken, + Path: parentDir.GetID(), + Name: node.Name, + Size: parseSize(node.Extra.Size), + Modified: time.Unix(node.EditTime, 0), + Ctime: time.Unix(node.CreateTime, 0), + IsFolder: true, + }, + ObjToken: node.ObjToken, + NodeType: node.NodeType, + ObjType: node.Type, + URL: node.URL, + }, nil +} + +func (d *DoubaoNew) Move(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + if srcObj == nil { + return nil, errors.New("nil source object") + } + if dstDir == nil { + return nil, errors.New("nil destination dir") + } + srcToken := srcObj.GetID() + if srcToken == "" { + if obj, ok := srcObj.(*Object); ok { + srcToken = obj.ObjToken + } + } + if srcToken == "" { + return nil, errors.New("missing source token") + } + if err := d.moveObj(ctx, srcToken, dstDir.GetID()); err != nil { + return nil, err + } + if obj, ok := srcObj.(*Object); ok { + clone := *obj + clone.Path = dstDir.GetID() + return &clone, nil + } + return srcObj, nil +} + +func (d *DoubaoNew) Rename(ctx context.Context, srcObj model.Obj, newName string) (model.Obj, error) { + if srcObj == nil { + return nil, errors.New("nil source object") + } + if srcObj.IsDir() { + if err := d.renameFolder(ctx, srcObj.GetID(), newName); err != nil { + return nil, err + } + } else { + fileToken := "" + if obj, ok := srcObj.(*Object); ok { + fileToken = obj.ObjToken + } + if fileToken == "" { + fileToken = srcObj.GetID() + } + if err := d.renameFile(ctx, fileToken, newName); err != nil { + return nil, err + } + } + + if obj, ok := srcObj.(*Object); ok { + clone := *obj + clone.Name = newName + return &clone, nil + } + return srcObj, nil +} + +func (d *DoubaoNew) Copy(ctx context.Context, srcObj, dstDir model.Obj) (model.Obj, error) { + // TODO copy obj, optional + return nil, errs.NotImplement +} + +func (d *DoubaoNew) Remove(ctx context.Context, obj model.Obj) error { + if obj == nil { + return errors.New("nil object") + } + token := obj.GetID() + if token == "" { + if o, ok := obj.(*Object); ok { + token = o.ObjToken + } + } + if token == "" { + return errors.New("missing object token") + } + return d.removeObj(ctx, []string{token}) +} + +func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileStreamer, up driver.UpdateProgress) (model.Obj, error) { + if file == nil { + return nil, errors.New("nil file") + } + if file.GetSize() <= 0 { + return nil, errors.New("invalid file size") + } + + uploadPrep, err := d.prepareUpload(ctx, file.GetName(), file.GetSize(), dstDir.GetID()) + if err != nil { + return nil, err + } + if uploadPrep.BlockSize <= 0 { + return nil, errors.New("invalid block size from prepare") + } + + tmpFile, err := utils.CreateTempFile(file, file.GetSize()) + if err != nil { + return nil, err + } + defer tmpFile.Close() + + blockSize := uploadPrep.BlockSize + totalSize := file.GetSize() + numBlocks := int((totalSize + blockSize - 1) / blockSize) + blocks := make([]UploadBlock, 0, numBlocks) + blockMeta := make(map[int]UploadBlock, numBlocks) + + for seq := 0; seq < numBlocks; seq++ { + offset := int64(seq) * blockSize + length := blockSize + if remain := totalSize - offset; remain < length { + length = remain + } + buf := make([]byte, int(length)) + n, err := tmpFile.ReadAt(buf, offset) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + buf = buf[:n] + sum := sha256.Sum256(buf) + hash := base64.StdEncoding.EncodeToString(sum[:]) + checksum := adler32String(buf) + + block := UploadBlock{ + Hash: hash, + Seq: seq, + Size: int64(n), + Checksum: checksum, + IsUploaded: true, + } + blocks = append(blocks, block) + blockMeta[seq] = block + } + + needed, err := d.uploadBlocks(ctx, uploadPrep.UploadID, blocks, "explorer") + if err != nil { + return nil, err + } + + if len(needed.NeededUploadBlocks) > 0 { + sort.Slice(needed.NeededUploadBlocks, func(i, j int) bool { + return needed.NeededUploadBlocks[i].Seq < needed.NeededUploadBlocks[j].Seq + }) + const maxMergeBlockCount = 20 + var ( + groupSeqs []int + groupChecksums []string + groupSizes []int64 + groupRealSize int64 + groupExpectSum int64 + groupBuf bytes.Buffer + uploadedBytes int64 + ) + + flushGroup := func() error { + if len(groupSeqs) == 0 { + return nil + } + data := groupBuf.Bytes() + expectLen := groupExpectSum + if len(data) > 0 { + headLen := 32 + if len(data) < headLen { + headLen = len(data) + } + tailLen := 32 + if len(data) < tailLen { + tailLen = len(data) + } + } + if int64(len(data)) != expectLen { + return fmt.Errorf("[doubao_new] merge blocks invalid body len: got=%d expect=%d seqs=%v", len(data), expectLen, groupSeqs) + } + mergeResp, err := d.mergeUploadBlocks(ctx, uploadPrep.UploadID, groupSeqs, groupChecksums, groupSizes, blockSize, data) + if err != nil { + return err + } + if len(mergeResp.SuccessSeqList) != len(groupSeqs) { + return fmt.Errorf("[doubao_new] merge blocks incomplete: %v", mergeResp.SuccessSeqList) + } + success := make(map[int]bool, len(mergeResp.SuccessSeqList)) + for _, seq := range mergeResp.SuccessSeqList { + success[seq] = true + } + for _, seq := range groupSeqs { + if !success[seq] { + return fmt.Errorf("[doubao_new] merge blocks missing seq %d", seq) + } + } + + uploadedBytes += groupRealSize + groupSeqs = groupSeqs[:0] + groupChecksums = groupChecksums[:0] + groupSizes = groupSizes[:0] + groupRealSize = 0 + groupExpectSum = 0 + groupBuf.Reset() + if up != nil { + percent := float64(uploadedBytes) / float64(totalSize) * 100 + up(percent) + } + return nil + } + + for _, item := range needed.NeededUploadBlocks { + if _, ok := blockMeta[item.Seq]; !ok { + return nil, fmt.Errorf("[doubao_new] missing block meta for seq %d", item.Seq) + } + if item.Size <= 0 { + return nil, fmt.Errorf("[doubao_new] invalid block size from needed list: seq=%d size=%d", item.Seq, item.Size) + } + offset := int64(item.Seq) * blockSize + buf := make([]byte, int(item.Size)) + n, err := tmpFile.ReadAt(buf, offset) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return nil, err + } + if n != len(buf) { + return nil, fmt.Errorf("[doubao_new] short read: seq=%d want=%d got=%d", item.Seq, len(buf), n) + } + buf = buf[:n] + realAdler := adler32String(buf) + if realAdler != item.Checksum { + return nil, fmt.Errorf("[doubao_new] block checksum mismatch: seq=%d offset=%d adler32=%s step2=%s", item.Seq, offset, realAdler, item.Checksum) + } + payloadStart := groupBuf.Len() + groupBuf.Write(buf) + payloadEnd := groupBuf.Len() + payloadAdler := adler32String(groupBuf.Bytes()[payloadStart:payloadEnd]) + if payloadAdler != item.Checksum { + return nil, fmt.Errorf("[doubao_new] payload checksum mismatch: seq=%d start=%d end=%d adler32=%s step2=%s", item.Seq, payloadStart, payloadEnd, payloadAdler, item.Checksum) + } + groupSeqs = append(groupSeqs, item.Seq) + groupChecksums = append(groupChecksums, item.Checksum) + groupSizes = append(groupSizes, item.Size) + groupRealSize += int64(n) + groupExpectSum += item.Size + if len(groupSeqs) >= maxMergeBlockCount { + if err := flushGroup(); err != nil { + return nil, err + } + } + } + + if err := flushGroup(); err != nil { + return nil, err + } + if up != nil { + up(100) + } + } else if up != nil { + up(100) + } + + numBlocksFinish := uploadPrep.NumBlocks + if numBlocksFinish <= 0 { + numBlocksFinish = numBlocks + } + finish, err := d.finishUpload(ctx, uploadPrep.UploadID, numBlocksFinish, "explorer") + if err != nil { + return nil, err + } + + nodeToken := finish.Extra.NodeToken + if nodeToken == "" { + nodeToken = finish.FileToken + } + now := time.Now() + return &Object{ + Object: model.Object{ + ID: nodeToken, + Path: dstDir.GetID(), + Name: file.GetName(), + Size: file.GetSize(), + Modified: now, + Ctime: now, + IsFolder: false, + }, + ObjToken: finish.FileToken, + }, nil +} + +func (d *DoubaoNew) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { + // TODO get archive file meta-info, return errs.NotImplement to use an internal archive tool, optional + return nil, errs.NotImplement +} + +func (d *DoubaoNew) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { + // TODO list args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional + return nil, errs.NotImplement +} + +func (d *DoubaoNew) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { + // TODO return link of file args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional + return nil, errs.NotImplement +} + +func (d *DoubaoNew) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { + // TODO extract args.InnerPath path in the archive srcObj to the dstDir location, optional + // a folder with the same name as the archive file needs to be created to store the extracted results if args.PutIntoNewDir + // return errs.NotImplement to use an internal archive tool + return nil, errs.NotImplement +} + +func (d *DoubaoNew) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { + switch args.Method { + case "doubao_preview", "preview": + obj, ok := args.Obj.(*Object) + if !ok { + return nil, errors.New("unsupported object type") + } + info, err := d.getFileInfo(ctx, obj.ObjToken) + if err != nil { + return nil, err + } + entry, ok := info.PreviewMeta.Data["22"] + if !ok || entry.Status != 0 { + return nil, errs.NotSupport + } + + imgExt := ".webp" + pageNums := 1 + if entry.Extra != "" { + var extra PreviewImageExtra + if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { + if extra.ImgExt != "" { + imgExt = extra.ImgExt + } + if extra.PageNums > 0 { + pageNums = extra.PageNums + } + } + } + + return base.Json{ + "version": info.Version, + "img_ext": imgExt, + "page_nums": pageNums, + }, nil + default: + return nil, errs.NotSupport + } +} + +func (d *DoubaoNew) listAllChildren(ctx context.Context, parentToken string) ([]Node, error) { + nodes := make([]Node, 0, 50) + lastLabel := "" + for page := 0; page < 100; page++ { + data, err := d.listChildren(ctx, parentToken, lastLabel) + if err != nil { + return nil, err + } + + if len(data.NodeList) > 0 { + for _, token := range data.NodeList { + node, ok := data.Entities.Nodes[token] + if !ok { + continue + } + nodes = append(nodes, node) + } + } else { + for _, node := range data.Entities.Nodes { + nodes = append(nodes, node) + } + } + + if !data.HasMore || data.LastLabel == "" || data.LastLabel == lastLabel { + break + } + lastLabel = data.LastLabel + } + + if len(nodes) == 0 { + return nil, nil + } + return nodes, nil +} + +func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.LinkArgs) (*model.Link, error) { + auth := d.resolveAuthorization() + dpop := d.resolveDpop() + if auth == "" || dpop == "" { + return nil, errors.New("missing authorization or dpop") + } + if obj.ObjToken == "" { + return nil, errors.New("missing obj_token") + } + info, err := d.getFileInfo(ctx, obj.ObjToken) + if err != nil { + return nil, err + } + + entry, ok := info.PreviewMeta.Data["22"] + if !ok || entry.Status != 0 { + return nil, errors.New("preview not available") + } + + subID := "" + pageIndex := 0 + + if subID == "" { + imgExt := ".webp" + pageNums := 0 + if entry.Extra != "" { + var extra PreviewImageExtra + if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { + if extra.ImgExt != "" { + imgExt = extra.ImgExt + } + pageNums = extra.PageNums + } + } + if pageNums > 0 && pageIndex >= pageNums { + pageIndex = pageNums - 1 + } + subID = fmt.Sprintf("img_%d%s", pageIndex, imgExt) + } + + query := url.Values{} + query.Set("preview_type", "22") + query.Set("sub_id", subID) + if info.Version != "" { + query.Set("version", info.Version) + } + previewURL := fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s?%s", BaseURL, obj.ObjToken, query.Encode()) + + headers := http.Header{ + "Referer": []string{"https://www.doubao.com/"}, + "User-Agent": []string{base.UserAgent}, + "Authorization": []string{auth}, + "Dpop": []string{dpop}, + } + + return &model.Link{ + URL: previewURL, + Header: headers, + }, nil +} + +func parseSize(size string) int64 { + if size == "" { + return 0 + } + val, err := strconv.ParseInt(size, 10, 64) + if err != nil { + return 0 + } + return val +} + +var _ driver.Driver = (*DoubaoNew)(nil) diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go new file mode 100644 index 000000000..1793176da --- /dev/null +++ b/drivers/doubao_new/meta.go @@ -0,0 +1,35 @@ +package doubao_new + +import ( + "github.com/OpenListTeam/OpenList/v4/internal/driver" + "github.com/OpenListTeam/OpenList/v4/internal/op" +) + +type Addition struct { + // Usually one of two + driver.RootID + // define other + Authorization string `json:"authorization" help:"DPoP access token (Authorization header value); optional if present in cookie"` + Dpop string `json:"dpop" help:"DPoP header value; optional if present in cookie"` + Cookie string `json:"cookie" help:"Optional cookie; only used to extract authorization/dpop tokens"` + Debug bool `json:"debug" help:"Enable debug logs for upload"` +} + +var config = driver.Config{ + Name: "DoubaoNew", + LocalSort: true, + OnlyProxy: false, + NoCache: false, + NoUpload: false, + NeedMs: false, + DefaultRoot: "", + CheckStatus: false, + Alert: "", + NoOverwriteUpload: false, +} + +func init() { + op.RegisterDriver(func() driver.Driver { + return &DoubaoNew{} + }) +} diff --git a/drivers/doubao_new/types.go b/drivers/doubao_new/types.go new file mode 100644 index 000000000..4e16dff5f --- /dev/null +++ b/drivers/doubao_new/types.go @@ -0,0 +1,182 @@ +package doubao_new + +import "github.com/OpenListTeam/OpenList/v4/internal/model" + +type BaseResp struct { + Code int `json:"code"` + Msg string `json:"msg,omitempty"` + Message string `json:"message,omitempty"` +} + +type ListResp struct { + BaseResp + Data ListData `json:"data"` +} + +type ListData struct { + HasMore bool `json:"has_more"` + LastLabel string `json:"last_label"` + NodeList []string `json:"node_list"` + Entities struct { + Nodes map[string]Node `json:"nodes"` + Users map[string]User `json:"users"` + } `json:"entities"` +} + +type Node struct { + Token string `json:"token"` + NodeToken string `json:"node_token"` + ObjToken string `json:"obj_token"` + Name string `json:"name"` + Type int `json:"type"` + NodeType int `json:"node_type"` + OwnerID string `json:"owner_id"` + EditUID string `json:"edit_uid"` + CreateTime int64 `json:"create_time"` + EditTime int64 `json:"edit_time"` + URL string `json:"url"` + Extra struct { + Size string `json:"size"` + } `json:"extra"` +} + +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Object struct { + model.Object + ObjToken string + NodeType int + ObjType int + URL string +} + +type CreateFolderResp struct { + BaseResp + Data struct { + Entities struct { + Nodes map[string]Node `json:"nodes"` + } `json:"entities"` + NodeList []string `json:"node_list"` + } `json:"data"` +} + +type FileInfoResp struct { + Code int `json:"code"` + Message string `json:"message"` + Data FileInfo `json:"data"` +} + +type FileInfo struct { + Name string `json:"name"` + NumBlocks int `json:"num_blocks"` + Version string `json:"version"` + MimeType string `json:"mime_type"` + MountPoint string `json:"mount_point"` + PreviewMeta PreviewMeta `json:"preview_meta"` +} + +type PreviewMeta struct { + Data map[string]PreviewMetaEntry `json:"data"` +} + +type PreviewMetaEntry struct { + Status int `json:"status"` + Extra string `json:"extra"` + PreviewFileSize int64 `json:"preview_file_size"` +} + +type PreviewImageExtra struct { + ImgExt string `json:"img_ext"` + PageNums int `json:"page_nums"` +} + +type UserStorageResp struct { + BaseResp + Data UserStorageData `json:"data"` +} + +type UserStorageData struct { + ShowSizeLimit bool `json:"show_size_limit"` + TotalSizeLimitBytes int64 `json:"total_size_limit_bytes"` + UsedSizeBytes int64 `json:"used_size_bytes"` +} + +type UploadPrepareResp struct { + BaseResp + Data UploadPrepareData `json:"data"` +} + +type UploadPrepareData struct { + BlockSize int64 `json:"block_size"` + NumBlocks int `json:"num_blocks"` + OptionBlockSize int64 `json:"option_block_size"` + DedupeSupport bool `json:"dedupe_support"` + UploadID string `json:"upload_id"` +} + +type UploadBlock struct { + Hash string `json:"hash"` + Seq int `json:"seq"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + IsUploaded bool `json:"isUploaded"` +} + +type UploadBlocksResp struct { + BaseResp + Data UploadBlocksData `json:"data"` +} + +type UploadBlocksData struct { + NeededUploadBlocks []UploadBlockNeed `json:"needed_upload_blocks"` +} + +type UploadBlockNeed struct { + Seq int `json:"seq"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + Hash string `json:"hash"` +} + +type UploadMergeResp struct { + BaseResp + Data UploadMergeData `json:"data"` +} + +type UploadMergeData struct { + SuccessSeqList []int `json:"success_seq_list"` +} + +type UploadFinishResp struct { + BaseResp + Data UploadFinishData `json:"data"` +} + +type UploadFinishData struct { + Version string `json:"version"` + DataVersion string `json:"data_version"` + Extra struct { + NodeToken string `json:"node_token"` + } `json:"extra"` + FileToken string `json:"file_token"` +} + +type RemoveResp struct { + BaseResp + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +type TaskStatusResp struct { + BaseResp + Data TaskStatusData `json:"data"` +} + +type TaskStatusData struct { + IsFinish bool `json:"is_finish"` + IsFail bool `json:"is_fail"` +} diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go new file mode 100644 index 000000000..1a2a9e2d9 --- /dev/null +++ b/drivers/doubao_new/util.go @@ -0,0 +1,909 @@ +package doubao_new + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "hash/adler32" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/go-resty/resty/v2" +) + +const ( + BaseURL = "https://my.feishu.cn" + DownloadBaseURL = "https://internal-api-drive-stream.feishu.cn" +) + +var defaultObjTypes = []string{"124", "0", "12", "30", "123", "22"} + +func (d *DoubaoNew) request(ctx context.Context, path string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + + if callback != nil { + callback(req) + } + + res, err := req.Execute(method, BaseURL+path) + if err != nil { + return nil, err + } + if res != nil { + if v := res.Header().Get("X-Tt-Logid"); v != "" { + d.TtLogid = v + } else if v := res.Header().Get("x-tt-logid"); v != "" { + d.TtLogid = v + } + } + + body := res.Body() + var common BaseResp + if err = json.Unmarshal(body, &common); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return body, fmt.Errorf(msg) + } + if common.Code != 0 { + errMsg := common.Msg + if errMsg == "" { + errMsg = common.Message + } + return body, fmt.Errorf("[doubao_new] API error (code: %d): %s", common.Code, errMsg) + } + if resp != nil { + if err = json.Unmarshal(body, resp); err != nil { + return body, err + } + } + + return body, nil +} + +func getCookieValue(cookie, name string) string { + parts := strings.Split(cookie, ";") + prefix := name + "=" + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, prefix) { + return strings.TrimPrefix(part, prefix) + } + } + return "" +} + +func adler32String(data []byte) string { + sum := adler32.Checksum(data) + return strconv.FormatUint(uint64(sum), 10) +} + +func buildCommaHeader(items []string) string { + return strings.Join(items, ",") +} + +func joinIntComma(items []int) string { + if len(items) == 0 { + return "" + } + var sb strings.Builder + for i, v := range items { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(strconv.Itoa(v)) + } + return sb.String() +} + +func previewList(items []string, n int) string { + if n <= 0 || len(items) == 0 { + return "" + } + if len(items) < n { + n = len(items) + } + return strings.Join(items[:n], ",") +} + +func (d *DoubaoNew) resolveAuthorization() string { + auth := strings.TrimSpace(d.Authorization) + if auth == "" && d.Cookie != "" { + if token := getCookieValue(d.Cookie, "LARK_SUITE_ACCESS_TOKEN"); token != "" { + auth = token + } + } + if auth == "" { + return "" + } + if !strings.HasPrefix(auth, "DPoP ") && !strings.HasPrefix(auth, "dpop ") { + auth = "DPoP " + auth + } + return auth +} + +func (d *DoubaoNew) resolveDpop() string { + dpop := strings.TrimSpace(d.Dpop) + if dpop == "" && d.Cookie != "" { + dpop = getCookieValue(d.Cookie, "LARK_SUITE_DPOP") + } + return dpop +} + +func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLabel string) (ListData, error) { + var resp ListResp + _, err := d.request(ctx, "/space/api/explorer/doubao/children/list/", http.MethodGet, func(req *resty.Request) { + values := url.Values{} + for _, t := range defaultObjTypes { + values.Add("obj_type", t) + } + values.Set("length", "50") + values.Set("rank", "0") + values.Set("asc", "0") + values.Set("min_length", "40") + values.Set("thumbnail_width", "1028") + values.Set("thumbnail_height", "1028") + values.Set("thumbnail_policy", "4") + if parentToken != "" { + values.Set("token", parentToken) + } + if lastLabel != "" { + values.Set("last_label", lastLabel) + } + req.SetQueryParamsFromValues(values) + }, &resp) + if err != nil { + return ListData{}, err + } + + return resp.Data, nil +} + +func (d *DoubaoNew) getFileInfo(ctx context.Context, fileToken string) (FileInfo, error) { + var resp FileInfoResp + _, err := d.request(ctx, "/space/api/box/file/info/", http.MethodPost, func(req *resty.Request) { + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "caller": "explorer", + "file_token": fileToken, + "mount_point": "explorer", + "option_params": []string{"preview_meta", "check_cipher"}, + }) + }, &resp) + if err != nil { + return FileInfo{}, err + } + + return resp.Data, nil +} + +func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) (Node, error) { + data := url.Values{} + data.Set("name", name) + data.Set("source", "0") + if parentToken != "" { + data.Set("parent_token", parentToken) + } + + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/create/folder/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return Node{}, err + } + if err := decodeBaseResp(body, res); err != nil { + return Node{}, err + } + + var resp CreateFolderResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return Node{}, fmt.Errorf(msg) + } + + var node Node + if len(resp.Data.NodeList) > 0 { + if n, ok := resp.Data.Entities.Nodes[resp.Data.NodeList[0]]; ok { + node = n + } + } + if node.Token == "" { + for _, n := range resp.Data.Entities.Nodes { + node = n + break + } + } + if node.Token == "" && node.ObjToken == "" && node.NodeToken == "" { + return Node{}, fmt.Errorf("[doubao_new] create folder failed: empty response") + } + if node.NodeToken == "" { + if node.Token != "" { + node.NodeToken = node.Token + } else if node.ObjToken != "" { + node.NodeToken = node.ObjToken + } + } + if node.ObjToken == "" && node.Token != "" { + node.ObjToken = node.Token + } + return node, nil +} + +func (d *DoubaoNew) renameFolder(ctx context.Context, token, name string) error { + if token == "" { + return fmt.Errorf("[doubao_new] rename folder missing token") + } + data := url.Values{} + data.Set("token", token) + data.Set("name", name) + + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/rename/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + return decodeBaseResp(body, res) +} + +func isCsrfTokenError(body []byte, res *resty.Response) bool { + if len(body) == 0 { + return false + } + if strings.Contains(strings.ToLower(string(body)), "csrf token error") { + return true + } + if res != nil && res.StatusCode() == http.StatusForbidden { + return true + } + return false +} + +func doRequestWithCsrf(doRequest func(csrfToken string) (*resty.Response, []byte, error)) (*resty.Response, []byte, error) { + res, body, err := doRequest("") + if err != nil { + return res, body, err + } + if isCsrfTokenError(body, res) { + csrfToken := extractCsrfTokenFromResponse(res) + if csrfToken != "" { + return doRequest(csrfToken) + } + } + return res, body, err +} + +func extractCsrfTokenFromResponse(res *resty.Response) string { + if res == nil || res.Request == nil { + return "" + } + if res.Request.RawRequest != nil { + if csrf := getCookieValue(res.Request.RawRequest.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + return csrf + } + } + if csrf := getCookieValue(res.Request.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + return csrf + } + for _, c := range res.Cookies() { + if c.Name == "_csrf_token" { + return c.Value + } + } + return "" +} + +func decodeBaseResp(body []byte, res *resty.Response) error { + var common BaseResp + if err := json.Unmarshal(body, &common); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return fmt.Errorf(msg) + } + if common.Code != 0 { + errMsg := common.Msg + if errMsg == "" { + errMsg = common.Message + } + return fmt.Errorf("[doubao_new] API error (code: %d): %s", common.Code, errMsg) + } + return nil +} + +func (d *DoubaoNew) renameFile(ctx context.Context, fileToken, name string) error { + if fileToken == "" { + return fmt.Errorf("[doubao_new] rename file missing file token") + } + _, err := d.request(ctx, "/space/api/box/file/update_info/", http.MethodPost, func(req *resty.Request) { + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "file_token": fileToken, + "name": name, + }) + }, nil) + return err +} + +func (d *DoubaoNew) moveObj(ctx context.Context, srcToken, destToken string) error { + if srcToken == "" { + return fmt.Errorf("[doubao_new] move missing src token") + } + data := url.Values{} + data.Set("src_token", srcToken) + if destToken != "" { + data.Set("dest_token", destToken) + } + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/x-www-form-urlencoded") + req.SetBody(data.Encode()) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v2/move/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + return decodeBaseResp(body, res) +} + +func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { + if len(tokens) == 0 { + return fmt.Errorf("[doubao_new] remove missing tokens") + } + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "tokens": tokens, + "apply": 1, + }) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/explorer/v3/remove/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + var resp RemoveResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return fmt.Errorf(msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + if resp.Data.TaskID == "" { + return nil + } + return d.waitTask(ctx, resp.Data.TaskID) +} + +func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.SetHeader("agw-js-conv", "str") + req.SetHeader("content-type", "application/json") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + if d.Cookie != "" { + req.SetHeader("cookie", d.Cookie) + } + req.SetBody(base.Json{}) + + res, err := req.Execute(http.MethodPost, "https://www.doubao.com/alice/aispace/facade/get_user_storage") + if err != nil { + return UserStorageData{}, err + } + + body := res.Body() + var resp UserStorageResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return UserStorageData{}, fmt.Errorf(msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return UserStorageData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + + return resp.Data, nil +} + +func (d *DoubaoNew) waitTask(ctx context.Context, taskID string) error { + const ( + taskPollInterval = time.Second + taskPollMaxAttempts = 120 + ) + var lastErr error + for attempt := 0; attempt < taskPollMaxAttempts; attempt++ { + if attempt > 0 { + if err := waitWithContext(ctx, taskPollInterval); err != nil { + return err + } + } + status, err := d.getTaskStatus(ctx, taskID) + if err != nil { + lastErr = err + continue + } + if status.IsFail { + return fmt.Errorf("[doubao_new] remove task failed: %s", taskID) + } + if status.IsFinish { + return nil + } + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("[doubao_new] remove task timed out: %s", taskID) +} + +func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatusData, error) { + if taskID == "" { + return TaskStatusData{}, fmt.Errorf("[doubao_new] task status missing task_id") + } + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + req.SetQueryParam("task_id", taskID) + res, err := req.Execute(http.MethodGet, BaseURL+"/space/api/explorer/v2/task/") + if err != nil { + return TaskStatusData{}, err + } + body := res.Body() + var resp TaskStatusResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return TaskStatusData{}, fmt.Errorf(msg) + } + if resp.Code != 0 { + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return TaskStatusData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + return resp.Data, nil +} + +func waitWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (d *DoubaoNew) prepareUpload(ctx context.Context, name string, size int64, mountNodeToken string) (UploadPrepareData, error) { + var resp UploadPrepareResp + _, err := d.request(ctx, "/space/api/box/upload/prepare/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.prepare") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + body := base.Json{ + "mount_point": "explorer", + "mount_node_token": "", + "name": name, + "size": size, + "size_checker": true, + } + if mountNodeToken != "" { + body["mount_node_token"] = mountNodeToken + } + req.SetBody(body) + }, &resp) + if err != nil { + return UploadPrepareData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlocks(ctx context.Context, uploadID string, blocks []UploadBlock, mountPoint string) (UploadBlocksData, error) { + if uploadID == "" { + return UploadBlocksData{}, fmt.Errorf("[doubao_new] upload blocks missing upload_id") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadBlocksResp + _, err := d.request(ctx, "/space/api/box/upload/blocks/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.blocks") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetBody(base.Json{ + "blocks": blocks, + "upload_id": uploadID, + "mount_point": mountPoint, + }) + }, &resp) + if err != nil { + return UploadBlocksData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqList []int, checksumList []string, sizeList []int64, blockOriginSize int64, data []byte) (UploadMergeData, error) { + if uploadID == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing upload_id") + } + if len(seqList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty seq list") + } + if len(checksumList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty checksum list") + } + if len(sizeList) != len(seqList) { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks size list mismatch") + } + if blockOriginSize <= 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks invalid block origin size") + } + if len(data) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty data") + } + + seqHeader := joinIntComma(seqList) + checksumHeader := buildCommaHeader(checksumList) + + client := base.NewRestyClient() + client.SetCookieJar(nil) + req := client.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("content-type", "application/octet-stream") + req.Header.Set("x-block-list-checksum", checksumHeader) + req.Header.Set("x-seq-list", seqHeader) + req.SetHeader("x-block-origin-size", strconv.FormatInt(blockOriginSize, 10)) + req.SetHeader("x-command", "space.api.box.stream.upload.merge_block") + req.SetHeader("x-csrftoken", "") + reqID := "" + if buf := make([]byte, 16); true { + if _, err := rand.Read(buf); err == nil { + reqID = hex.EncodeToString(buf) + } + } + if reqID != "" { + req.SetHeader("x-request-id", reqID) + } + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + req.Header.Del("Cookie") + req.Header.Del("cookie") + if req.Header.Get("x-command") == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") + } + req.SetBody(data) + + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?" + values.Encode() + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return UploadMergeData{}, err + } + if v := res.Header().Get("X-Tt-Logid"); v != "" { + d.TtLogid = v + } else if v := res.Header().Get("x-tt-logid"); v != "" { + d.TtLogid = v + } + body := res.Body() + var resp UploadMergeResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return UploadMergeData{}, fmt.Errorf(msg) + } + if resp.Code != 0 { + if res != nil && res.StatusCode() == http.StatusBadRequest && resp.Code == 2 { + success := make([]int, 0, len(seqList)) + offset := 0 + for i, seq := range seqList { + size := sizeList[i] + if size <= 0 { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback invalid size: seq=%d size=%d", seq, size) + } + if offset+int(size) > len(data) { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback payload out of range: seq=%d offset=%d size=%d total=%d", seq, offset, size, len(data)) + } + payload := data[offset : offset+int(size)] + block := UploadBlockNeed{ + Seq: seq, + Size: size, + Checksum: checksumList[i], + } + if err := d.uploadBlockV3(ctx, uploadID, block, payload); err != nil { + return UploadMergeData{SuccessSeqList: success}, err + } + success = append(success, seq) + offset += int(size) + } + return UploadMergeData{SuccessSeqList: success}, nil + } + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return UploadMergeData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block UploadBlockNeed, data []byte) error { + if uploadID == "" { + return fmt.Errorf("[doubao_new] upload v3 block missing upload_id") + } + if block.Seq < 0 { + return fmt.Errorf("[doubao_new] upload v3 block invalid seq") + } + if len(data) == 0 { + return fmt.Errorf("[doubao_new] upload v3 block empty data") + } + + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) + req.SetHeader("x-block-checksum", block.Checksum) + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + + req.SetMultipartFormData(map[string]string{ + "upload_id": uploadID, + "size": strconv.FormatInt(int64(len(data)), 10), + }) + req.SetMultipartField("file", "blob", "application/octet-stream", bytes.NewReader(data)) + + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("seq", strconv.Itoa(block.Seq)) + values.Set("size", strconv.FormatInt(int64(len(data)), 10)) + values.Set("checksum", block.Checksum) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/v3/block/?" + values.Encode() + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return err + } + body := res.Body() + if err := decodeBaseResp(body, res); err != nil { + return err + } + return nil +} + +func (d *DoubaoNew) finishUpload(ctx context.Context, uploadID string, numBlocks int, mountPoint string) (UploadFinishData, error) { + if uploadID == "" { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload missing upload_id") + } + if numBlocks <= 0 { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload invalid num_blocks") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadFinishResp + _, err := d.request(ctx, "/space/api/box/upload/finish/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.finish") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetHeader("biz-scene", "file_upload") + req.SetHeader("biz-ua-type", "Web") + req.SetBody(base.Json{ + "upload_id": uploadID, + "num_blocks": numBlocks, + "mount_point": mountPoint, + "push_open_history_record": 1, + }) + }, &resp) + if err != nil { + return UploadFinishData{}, err + } + return resp.Data, nil +} diff --git a/server/handles/down.go b/server/handles/down.go index d4d634cbe..b488e3751 100644 --- a/server/handles/down.go +++ b/server/handles/down.go @@ -30,6 +30,18 @@ func Down(c *gin.Context) { common.ErrorPage(c, err, 500) return } + if c.Query("type") == "preview" && storage.GetStorage().Driver == "doubao_new" { + link, file, err := fs.Link(c.Request.Context(), rawPath, model.LinkArgs{ + Header: c.Request.Header, + Type: c.Query("type"), + }) + if err != nil { + common.ErrorPage(c, err, 500) + return + } + proxy(c, link, file, storage.GetStorage().ProxyRange) + return + } if common.ShouldProxy(storage, filename) { Proxy(c) return From e02d722b32fbb9de1faedf0d3c84033240fe64e8 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:04:03 +0800 Subject: [PATCH 02/15] chore: use base.UserAgent Signed-off-by: MadDogOwner --- drivers/doubao_new/util.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index 1a2a9e2d9..32d25115a 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -31,7 +31,6 @@ func (d *DoubaoNew) request(ctx context.Context, path string, method string, cal req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -212,7 +211,6 @@ func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -292,7 +290,6 @@ func (d *DoubaoNew) renameFolder(ctx context.Context, token, name string) error req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -415,7 +412,6 @@ func (d *DoubaoNew) moveObj(ctx context.Context, srcToken, destToken string) err req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -451,7 +447,6 @@ func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { req.SetHeader("accept", "application/json, text/plain, */*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -506,7 +501,6 @@ func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") req.SetHeader("agw-js-conv", "str") req.SetHeader("content-type", "application/json") if auth := d.resolveAuthorization(); auth != "" { @@ -586,7 +580,6 @@ func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatu req.SetHeader("accept", "application/json, text/plain, */*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") if auth := d.resolveAuthorization(); auth != "" { req.SetHeader("authorization", auth) } @@ -722,7 +715,6 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL req.SetHeader("accept", "application/json, text/plain, */*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("content-type", "application/octet-stream") req.Header.Set("x-block-list-checksum", checksumHeader) @@ -832,7 +824,6 @@ func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block Up req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) req.SetHeader("x-block-checksum", block.Checksum) From d6b7e3ee8c23113ff2969ba68ac3f8ade8505e0d Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:11:14 +0800 Subject: [PATCH 03/15] fix: remove unused Addition Signed-off-by: MadDogOwner --- drivers/doubao_new/meta.go | 1 - 1 file changed, 1 deletion(-) diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go index 1793176da..bee1ce95d 100644 --- a/drivers/doubao_new/meta.go +++ b/drivers/doubao_new/meta.go @@ -12,7 +12,6 @@ type Addition struct { Authorization string `json:"authorization" help:"DPoP access token (Authorization header value); optional if present in cookie"` Dpop string `json:"dpop" help:"DPoP header value; optional if present in cookie"` Cookie string `json:"cookie" help:"Optional cookie; only used to extract authorization/dpop tokens"` - Debug bool `json:"debug" help:"Enable debug logs for upload"` } var config = driver.Config{ From f3fc89246cc3745ca0488e77a59a86ab4485e55c Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:15:21 +0800 Subject: [PATCH 04/15] feat: implement GetDetails Signed-off-by: MadDogOwner --- drivers/doubao_new/driver.go | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index 98a6c5822..012d1345d 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -418,26 +418,17 @@ func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileSt }, nil } -func (d *DoubaoNew) GetArchiveMeta(ctx context.Context, obj model.Obj, args model.ArchiveArgs) (model.ArchiveMeta, error) { - // TODO get archive file meta-info, return errs.NotImplement to use an internal archive tool, optional - return nil, errs.NotImplement -} - -func (d *DoubaoNew) ListArchive(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) ([]model.Obj, error) { - // TODO list args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional - return nil, errs.NotImplement -} - -func (d *DoubaoNew) Extract(ctx context.Context, obj model.Obj, args model.ArchiveInnerArgs) (*model.Link, error) { - // TODO return link of file args.InnerPath in the archive obj, return errs.NotImplement to use an internal archive tool, optional - return nil, errs.NotImplement -} - -func (d *DoubaoNew) ArchiveDecompress(ctx context.Context, srcObj, dstDir model.Obj, args model.ArchiveDecompressArgs) ([]model.Obj, error) { - // TODO extract args.InnerPath path in the archive srcObj to the dstDir location, optional - // a folder with the same name as the archive file needs to be created to store the extracted results if args.PutIntoNewDir - // return errs.NotImplement to use an internal archive tool - return nil, errs.NotImplement +func (d *DoubaoNew) GetDetails(ctx context.Context) (*model.StorageDetails, error) { + data, err := d.getUserStorage(ctx) + if err != nil { + return nil, err + } + return &model.StorageDetails{ + DiskUsage: model.DiskUsage{ + TotalSpace: data.TotalSizeLimitBytes, + UsedSpace: data.UsedSizeBytes, + }, + }, nil } func (d *DoubaoNew) Other(ctx context.Context, args model.OtherArgs) (interface{}, error) { From 94e8a27de92436dbc2c650549011743b23cce7e1 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:25:39 +0800 Subject: [PATCH 05/15] refactor: replace getCookieValue with cookie.GetStr Signed-off-by: MadDogOwner --- drivers/doubao_new/util.go | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index 32d25115a..e0903d932 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -15,6 +15,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" "github.com/go-resty/resty/v2" ) @@ -81,18 +82,6 @@ func (d *DoubaoNew) request(ctx context.Context, path string, method string, cal return body, nil } -func getCookieValue(cookie, name string) string { - parts := strings.Split(cookie, ";") - prefix := name + "=" - for _, part := range parts { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, prefix) { - return strings.TrimPrefix(part, prefix) - } - } - return "" -} - func adler32String(data []byte) string { sum := adler32.Checksum(data) return strconv.FormatUint(uint64(sum), 10) @@ -129,7 +118,7 @@ func previewList(items []string, n int) string { func (d *DoubaoNew) resolveAuthorization() string { auth := strings.TrimSpace(d.Authorization) if auth == "" && d.Cookie != "" { - if token := getCookieValue(d.Cookie, "LARK_SUITE_ACCESS_TOKEN"); token != "" { + if token := cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN"); token != "" { auth = token } } @@ -145,7 +134,7 @@ func (d *DoubaoNew) resolveAuthorization() string { func (d *DoubaoNew) resolveDpop() string { dpop := strings.TrimSpace(d.Dpop) if dpop == "" && d.Cookie != "" { - dpop = getCookieValue(d.Cookie, "LARK_SUITE_DPOP") + dpop = cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP") } return dpop } @@ -347,11 +336,11 @@ func extractCsrfTokenFromResponse(res *resty.Response) string { return "" } if res.Request.RawRequest != nil { - if csrf := getCookieValue(res.Request.RawRequest.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + if csrf := cookie.GetStr(res.Request.RawRequest.Header.Get("Cookie"), "_csrf_token"); csrf != "" { return csrf } } - if csrf := getCookieValue(res.Request.Header.Get("Cookie"), "_csrf_token"); csrf != "" { + if csrf := cookie.GetStr(res.Request.Header.Get("Cookie"), "_csrf_token"); csrf != "" { return csrf } for _, c := range res.Cookies() { From a9e82dad0b5ac51cb7c91a93cd27a7d6627abe27 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:30:00 +0800 Subject: [PATCH 06/15] chore: req.Header.Del() is case insensitive Signed-off-by: MadDogOwner --- drivers/doubao_new/util.go | 1 - 1 file changed, 1 deletion(-) diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index e0903d932..d9e265e6f 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -726,7 +726,6 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL if dpop := d.resolveDpop(); dpop != "" { req.SetHeader("dpop", dpop) } - req.Header.Del("Cookie") req.Header.Del("cookie") if req.Header.Get("x-command") == "" { return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") From 24de5c389f04a3e2c605f71411f47c95ce629e0a Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:33:31 +0800 Subject: [PATCH 07/15] chore: add upload.go Signed-off-by: MadDogOwner --- drivers/doubao_new/upload.go | 291 +++++++++++++++++++++++++++++++++++ drivers/doubao_new/util.go | 278 --------------------------------- 2 files changed, 291 insertions(+), 278 deletions(-) create mode 100644 drivers/doubao_new/upload.go diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go new file mode 100644 index 000000000..6e4f529c4 --- /dev/null +++ b/drivers/doubao_new/upload.go @@ -0,0 +1,291 @@ +package doubao_new + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/go-resty/resty/v2" +) + +func (d *DoubaoNew) prepareUpload(ctx context.Context, name string, size int64, mountNodeToken string) (UploadPrepareData, error) { + var resp UploadPrepareResp + _, err := d.request(ctx, "/space/api/box/upload/prepare/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.prepare") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + body := base.Json{ + "mount_point": "explorer", + "mount_node_token": "", + "name": name, + "size": size, + "size_checker": true, + } + if mountNodeToken != "" { + body["mount_node_token"] = mountNodeToken + } + req.SetBody(body) + }, &resp) + if err != nil { + return UploadPrepareData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlocks(ctx context.Context, uploadID string, blocks []UploadBlock, mountPoint string) (UploadBlocksData, error) { + if uploadID == "" { + return UploadBlocksData{}, fmt.Errorf("[doubao_new] upload blocks missing upload_id") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadBlocksResp + _, err := d.request(ctx, "/space/api/box/upload/blocks/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.blocks") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetBody(base.Json{ + "blocks": blocks, + "upload_id": uploadID, + "mount_point": mountPoint, + }) + }, &resp) + if err != nil { + return UploadBlocksData{}, err + } + return resp.Data, nil +} + +func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqList []int, checksumList []string, sizeList []int64, blockOriginSize int64, data []byte) (UploadMergeData, error) { + if uploadID == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing upload_id") + } + if len(seqList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty seq list") + } + if len(checksumList) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty checksum list") + } + if len(sizeList) != len(seqList) { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks size list mismatch") + } + if blockOriginSize <= 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks invalid block origin size") + } + if len(data) == 0 { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty data") + } + + seqHeader := joinIntComma(seqList) + checksumHeader := buildCommaHeader(checksumList) + + client := base.NewRestyClient() + client.SetCookieJar(nil) + req := client.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("content-type", "application/octet-stream") + req.Header.Set("x-block-list-checksum", checksumHeader) + req.Header.Set("x-seq-list", seqHeader) + req.SetHeader("x-block-origin-size", strconv.FormatInt(blockOriginSize, 10)) + req.SetHeader("x-command", "space.api.box.stream.upload.merge_block") + req.SetHeader("x-csrftoken", "") + reqID := "" + if buf := make([]byte, 16); true { + if _, err := rand.Read(buf); err == nil { + reqID = hex.EncodeToString(buf) + } + } + if reqID != "" { + req.SetHeader("x-request-id", reqID) + } + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + req.Header.Del("cookie") + if req.Header.Get("x-command") == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") + } + req.SetBody(data) + + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?" + values.Encode() + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return UploadMergeData{}, err + } + if v := res.Header().Get("X-Tt-Logid"); v != "" { + d.TtLogid = v + } else if v := res.Header().Get("x-tt-logid"); v != "" { + d.TtLogid = v + } + body := res.Body() + var resp UploadMergeResp + if err := json.Unmarshal(body, &resp); err != nil { + msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", + res.Status(), + res.Header().Get("Content-Type"), + string(body), + err, + ) + return UploadMergeData{}, fmt.Errorf(msg) + } + if resp.Code != 0 { + if res != nil && res.StatusCode() == http.StatusBadRequest && resp.Code == 2 { + success := make([]int, 0, len(seqList)) + offset := 0 + for i, seq := range seqList { + size := sizeList[i] + if size <= 0 { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback invalid size: seq=%d size=%d", seq, size) + } + if offset+int(size) > len(data) { + return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback payload out of range: seq=%d offset=%d size=%d total=%d", seq, offset, size, len(data)) + } + payload := data[offset : offset+int(size)] + block := UploadBlockNeed{ + Seq: seq, + Size: size, + Checksum: checksumList[i], + } + if err := d.uploadBlockV3(ctx, uploadID, block, payload); err != nil { + return UploadMergeData{SuccessSeqList: success}, err + } + success = append(success, seq) + offset += int(size) + } + return UploadMergeData{SuccessSeqList: success}, nil + } + errMsg := resp.Msg + if errMsg == "" { + errMsg = resp.Message + } + return UploadMergeData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) + } + + return resp.Data, nil +} + +func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block UploadBlockNeed, data []byte) error { + if uploadID == "" { + return fmt.Errorf("[doubao_new] upload v3 block missing upload_id") + } + if block.Seq < 0 { + return fmt.Errorf("[doubao_new] upload v3 block invalid seq") + } + if len(data) == 0 { + return fmt.Errorf("[doubao_new] upload v3 block empty data") + } + + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "*/*") + req.SetHeader("origin", "https://www.doubao.com") + req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) + req.SetHeader("x-block-checksum", block.Checksum) + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + if dpop := d.resolveDpop(); dpop != "" { + req.SetHeader("dpop", dpop) + } + + req.SetMultipartFormData(map[string]string{ + "upload_id": uploadID, + "size": strconv.FormatInt(int64(len(data)), 10), + }) + req.SetMultipartField("file", "blob", "application/octet-stream", bytes.NewReader(data)) + + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("upload_id", uploadID) + values.Set("seq", strconv.Itoa(block.Seq)) + values.Set("size", strconv.FormatInt(int64(len(data)), 10)) + values.Set("checksum", block.Checksum) + values.Set("mount_point", "explorer") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/v3/block/?" + values.Encode() + + res, err := req.Execute(http.MethodPost, urlStr) + if err != nil { + return err + } + body := res.Body() + if err := decodeBaseResp(body, res); err != nil { + return err + } + return nil +} + +func (d *DoubaoNew) finishUpload(ctx context.Context, uploadID string, numBlocks int, mountPoint string) (UploadFinishData, error) { + if uploadID == "" { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload missing upload_id") + } + if numBlocks <= 0 { + return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload invalid num_blocks") + } + if mountPoint == "" { + mountPoint = "explorer" + } + var resp UploadFinishResp + _, err := d.request(ctx, "/space/api/box/upload/finish/", http.MethodPost, func(req *resty.Request) { + values := url.Values{} + values.Set("shouldBypassScsDialog", "true") + values.Set("doubao_storage", "imagex_other") + values.Set("doubao_app_id", "497858") + req.SetQueryParamsFromValues(values) + req.SetHeader("Content-Type", "application/json") + req.SetHeader("x-command", "space.api.box.upload.finish") + req.SetHeader("rpc-persist-doubao-pan", "true") + req.SetHeader("cache-control", "no-cache") + req.SetHeader("pragma", "no-cache") + req.SetHeader("biz-scene", "file_upload") + req.SetHeader("biz-ua-type", "Web") + req.SetBody(base.Json{ + "upload_id": uploadID, + "num_blocks": numBlocks, + "mount_point": mountPoint, + "push_open_history_record": 1, + }) + }, &resp) + if err != nil { + return UploadFinishData{}, err + } + return resp.Data, nil +} diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index d9e265e6f..d3a3f8ccc 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -1,10 +1,7 @@ package doubao_new import ( - "bytes" "context" - "crypto/rand" - "encoding/hex" "encoding/json" "fmt" "hash/adler32" @@ -611,278 +608,3 @@ func waitWithContext(ctx context.Context, d time.Duration) error { return nil } } - -func (d *DoubaoNew) prepareUpload(ctx context.Context, name string, size int64, mountNodeToken string) (UploadPrepareData, error) { - var resp UploadPrepareResp - _, err := d.request(ctx, "/space/api/box/upload/prepare/", http.MethodPost, func(req *resty.Request) { - values := url.Values{} - values.Set("shouldBypassScsDialog", "true") - values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - req.SetQueryParamsFromValues(values) - req.SetHeader("Content-Type", "application/json") - req.SetHeader("x-command", "space.api.box.upload.prepare") - req.SetHeader("rpc-persist-doubao-pan", "true") - req.SetHeader("cache-control", "no-cache") - req.SetHeader("pragma", "no-cache") - body := base.Json{ - "mount_point": "explorer", - "mount_node_token": "", - "name": name, - "size": size, - "size_checker": true, - } - if mountNodeToken != "" { - body["mount_node_token"] = mountNodeToken - } - req.SetBody(body) - }, &resp) - if err != nil { - return UploadPrepareData{}, err - } - return resp.Data, nil -} - -func (d *DoubaoNew) uploadBlocks(ctx context.Context, uploadID string, blocks []UploadBlock, mountPoint string) (UploadBlocksData, error) { - if uploadID == "" { - return UploadBlocksData{}, fmt.Errorf("[doubao_new] upload blocks missing upload_id") - } - if mountPoint == "" { - mountPoint = "explorer" - } - var resp UploadBlocksResp - _, err := d.request(ctx, "/space/api/box/upload/blocks/", http.MethodPost, func(req *resty.Request) { - values := url.Values{} - values.Set("shouldBypassScsDialog", "true") - values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - req.SetQueryParamsFromValues(values) - req.SetHeader("Content-Type", "application/json") - req.SetHeader("x-command", "space.api.box.upload.blocks") - req.SetHeader("rpc-persist-doubao-pan", "true") - req.SetHeader("cache-control", "no-cache") - req.SetHeader("pragma", "no-cache") - req.SetBody(base.Json{ - "blocks": blocks, - "upload_id": uploadID, - "mount_point": mountPoint, - }) - }, &resp) - if err != nil { - return UploadBlocksData{}, err - } - return resp.Data, nil -} - -func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqList []int, checksumList []string, sizeList []int64, blockOriginSize int64, data []byte) (UploadMergeData, error) { - if uploadID == "" { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing upload_id") - } - if len(seqList) == 0 { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty seq list") - } - if len(checksumList) == 0 { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty checksum list") - } - if len(sizeList) != len(seqList) { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks size list mismatch") - } - if blockOriginSize <= 0 { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks invalid block origin size") - } - if len(data) == 0 { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks empty data") - } - - seqHeader := joinIntComma(seqList) - checksumHeader := buildCommaHeader(checksumList) - - client := base.NewRestyClient() - client.SetCookieJar(nil) - req := client.R() - req.SetContext(ctx) - req.SetHeader("accept", "application/json, text/plain, */*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("rpc-persist-doubao-pan", "true") - req.SetHeader("content-type", "application/octet-stream") - req.Header.Set("x-block-list-checksum", checksumHeader) - req.Header.Set("x-seq-list", seqHeader) - req.SetHeader("x-block-origin-size", strconv.FormatInt(blockOriginSize, 10)) - req.SetHeader("x-command", "space.api.box.stream.upload.merge_block") - req.SetHeader("x-csrftoken", "") - reqID := "" - if buf := make([]byte, 16); true { - if _, err := rand.Read(buf); err == nil { - reqID = hex.EncodeToString(buf) - } - } - if reqID != "" { - req.SetHeader("x-request-id", reqID) - } - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) - } - req.Header.Del("cookie") - if req.Header.Get("x-command") == "" { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") - } - req.SetBody(data) - - values := url.Values{} - values.Set("shouldBypassScsDialog", "true") - values.Set("upload_id", uploadID) - values.Set("mount_point", "explorer") - values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?" + values.Encode() - - res, err := req.Execute(http.MethodPost, urlStr) - if err != nil { - return UploadMergeData{}, err - } - if v := res.Header().Get("X-Tt-Logid"); v != "" { - d.TtLogid = v - } else if v := res.Header().Get("x-tt-logid"); v != "" { - d.TtLogid = v - } - body := res.Body() - var resp UploadMergeResp - if err := json.Unmarshal(body, &resp); err != nil { - msg := fmt.Sprintf("[doubao_new] decode response failed (status: %s, content-type: %s, body: %s): %v", - res.Status(), - res.Header().Get("Content-Type"), - string(body), - err, - ) - return UploadMergeData{}, fmt.Errorf(msg) - } - if resp.Code != 0 { - if res != nil && res.StatusCode() == http.StatusBadRequest && resp.Code == 2 { - success := make([]int, 0, len(seqList)) - offset := 0 - for i, seq := range seqList { - size := sizeList[i] - if size <= 0 { - return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback invalid size: seq=%d size=%d", seq, size) - } - if offset+int(size) > len(data) { - return UploadMergeData{SuccessSeqList: success}, fmt.Errorf("[doubao_new] v3 fallback payload out of range: seq=%d offset=%d size=%d total=%d", seq, offset, size, len(data)) - } - payload := data[offset : offset+int(size)] - block := UploadBlockNeed{ - Seq: seq, - Size: size, - Checksum: checksumList[i], - } - if err := d.uploadBlockV3(ctx, uploadID, block, payload); err != nil { - return UploadMergeData{SuccessSeqList: success}, err - } - success = append(success, seq) - offset += int(size) - } - return UploadMergeData{SuccessSeqList: success}, nil - } - errMsg := resp.Msg - if errMsg == "" { - errMsg = resp.Message - } - return UploadMergeData{}, fmt.Errorf("[doubao_new] API error (code: %d): %s", resp.Code, errMsg) - } - - return resp.Data, nil -} - -func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block UploadBlockNeed, data []byte) error { - if uploadID == "" { - return fmt.Errorf("[doubao_new] upload v3 block missing upload_id") - } - if block.Seq < 0 { - return fmt.Errorf("[doubao_new] upload v3 block invalid seq") - } - if len(data) == 0 { - return fmt.Errorf("[doubao_new] upload v3 block empty data") - } - - req := base.RestyClient.R() - req.SetContext(ctx) - req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") - req.SetHeader("rpc-persist-doubao-pan", "true") - req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) - req.SetHeader("x-block-checksum", block.Checksum) - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) - } - - req.SetMultipartFormData(map[string]string{ - "upload_id": uploadID, - "size": strconv.FormatInt(int64(len(data)), 10), - }) - req.SetMultipartField("file", "blob", "application/octet-stream", bytes.NewReader(data)) - - values := url.Values{} - values.Set("shouldBypassScsDialog", "true") - values.Set("upload_id", uploadID) - values.Set("seq", strconv.Itoa(block.Seq)) - values.Set("size", strconv.FormatInt(int64(len(data)), 10)) - values.Set("checksum", block.Checksum) - values.Set("mount_point", "explorer") - values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/v3/block/?" + values.Encode() - - res, err := req.Execute(http.MethodPost, urlStr) - if err != nil { - return err - } - body := res.Body() - if err := decodeBaseResp(body, res); err != nil { - return err - } - return nil -} - -func (d *DoubaoNew) finishUpload(ctx context.Context, uploadID string, numBlocks int, mountPoint string) (UploadFinishData, error) { - if uploadID == "" { - return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload missing upload_id") - } - if numBlocks <= 0 { - return UploadFinishData{}, fmt.Errorf("[doubao_new] finish upload invalid num_blocks") - } - if mountPoint == "" { - mountPoint = "explorer" - } - var resp UploadFinishResp - _, err := d.request(ctx, "/space/api/box/upload/finish/", http.MethodPost, func(req *resty.Request) { - values := url.Values{} - values.Set("shouldBypassScsDialog", "true") - values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - req.SetQueryParamsFromValues(values) - req.SetHeader("Content-Type", "application/json") - req.SetHeader("x-command", "space.api.box.upload.finish") - req.SetHeader("rpc-persist-doubao-pan", "true") - req.SetHeader("cache-control", "no-cache") - req.SetHeader("pragma", "no-cache") - req.SetHeader("biz-scene", "file_upload") - req.SetHeader("biz-ua-type", "Web") - req.SetBody(base.Json{ - "upload_id": uploadID, - "num_blocks": numBlocks, - "mount_point": mountPoint, - "push_open_history_record": 1, - }) - }, &resp) - if err != nil { - return UploadFinishData{}, err - } - return resp.Data, nil -} From 19ce30acb6054a7b170a329c16f1be5a10172e77 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 7 Feb 2026 23:37:52 +0800 Subject: [PATCH 08/15] chore: place functions Signed-off-by: MadDogOwner --- drivers/doubao_new/driver.go | 108 ---------------------------------- drivers/doubao_new/util.go | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 108 deletions(-) diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index 012d1345d..a844c6141 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -12,7 +12,6 @@ import ( "net/http" "net/url" "sort" - "strconv" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -471,111 +470,4 @@ func (d *DoubaoNew) Other(ctx context.Context, args model.OtherArgs) (interface{ } } -func (d *DoubaoNew) listAllChildren(ctx context.Context, parentToken string) ([]Node, error) { - nodes := make([]Node, 0, 50) - lastLabel := "" - for page := 0; page < 100; page++ { - data, err := d.listChildren(ctx, parentToken, lastLabel) - if err != nil { - return nil, err - } - - if len(data.NodeList) > 0 { - for _, token := range data.NodeList { - node, ok := data.Entities.Nodes[token] - if !ok { - continue - } - nodes = append(nodes, node) - } - } else { - for _, node := range data.Entities.Nodes { - nodes = append(nodes, node) - } - } - - if !data.HasMore || data.LastLabel == "" || data.LastLabel == lastLabel { - break - } - lastLabel = data.LastLabel - } - - if len(nodes) == 0 { - return nil, nil - } - return nodes, nil -} - -func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.LinkArgs) (*model.Link, error) { - auth := d.resolveAuthorization() - dpop := d.resolveDpop() - if auth == "" || dpop == "" { - return nil, errors.New("missing authorization or dpop") - } - if obj.ObjToken == "" { - return nil, errors.New("missing obj_token") - } - info, err := d.getFileInfo(ctx, obj.ObjToken) - if err != nil { - return nil, err - } - - entry, ok := info.PreviewMeta.Data["22"] - if !ok || entry.Status != 0 { - return nil, errors.New("preview not available") - } - - subID := "" - pageIndex := 0 - - if subID == "" { - imgExt := ".webp" - pageNums := 0 - if entry.Extra != "" { - var extra PreviewImageExtra - if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { - if extra.ImgExt != "" { - imgExt = extra.ImgExt - } - pageNums = extra.PageNums - } - } - if pageNums > 0 && pageIndex >= pageNums { - pageIndex = pageNums - 1 - } - subID = fmt.Sprintf("img_%d%s", pageIndex, imgExt) - } - - query := url.Values{} - query.Set("preview_type", "22") - query.Set("sub_id", subID) - if info.Version != "" { - query.Set("version", info.Version) - } - previewURL := fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s?%s", BaseURL, obj.ObjToken, query.Encode()) - - headers := http.Header{ - "Referer": []string{"https://www.doubao.com/"}, - "User-Agent": []string{base.UserAgent}, - "Authorization": []string{auth}, - "Dpop": []string{dpop}, - } - - return &model.Link{ - URL: previewURL, - Header: headers, - }, nil -} - -func parseSize(size string) int64 { - if size == "" { - return 0 - } - val, err := strconv.ParseInt(size, 10, 64) - if err != nil { - return 0 - } - return val -} - var _ driver.Driver = (*DoubaoNew)(nil) diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index d3a3f8ccc..087427020 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -3,6 +3,7 @@ package doubao_new import ( "context" "encoding/json" + "errors" "fmt" "hash/adler32" "net/http" @@ -12,6 +13,7 @@ import ( "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/pkg/cookie" "github.com/go-resty/resty/v2" ) @@ -112,6 +114,17 @@ func previewList(items []string, n int) string { return strings.Join(items[:n], ",") } +func parseSize(size string) int64 { + if size == "" { + return 0 + } + val, err := strconv.ParseInt(size, 10, 64) + if err != nil { + return 0 + } + return val +} + func (d *DoubaoNew) resolveAuthorization() string { auth := strings.TrimSpace(d.Authorization) if auth == "" && d.Cookie != "" { @@ -165,6 +178,41 @@ func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLa return resp.Data, nil } +func (d *DoubaoNew) listAllChildren(ctx context.Context, parentToken string) ([]Node, error) { + nodes := make([]Node, 0, 50) + lastLabel := "" + for page := 0; page < 100; page++ { + data, err := d.listChildren(ctx, parentToken, lastLabel) + if err != nil { + return nil, err + } + + if len(data.NodeList) > 0 { + for _, token := range data.NodeList { + node, ok := data.Entities.Nodes[token] + if !ok { + continue + } + nodes = append(nodes, node) + } + } else { + for _, node := range data.Entities.Nodes { + nodes = append(nodes, node) + } + } + + if !data.HasMore || data.LastLabel == "" || data.LastLabel == lastLabel { + break + } + lastLabel = data.LastLabel + } + + if len(nodes) == 0 { + return nil, nil + } + return nodes, nil +} + func (d *DoubaoNew) getFileInfo(ctx context.Context, fileToken string) (FileInfo, error) { var resp FileInfoResp _, err := d.request(ctx, "/space/api/box/file/info/", http.MethodPost, func(req *resty.Request) { @@ -183,6 +231,67 @@ func (d *DoubaoNew) getFileInfo(ctx context.Context, fileToken string) (FileInfo return resp.Data, nil } +func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.LinkArgs) (*model.Link, error) { + auth := d.resolveAuthorization() + dpop := d.resolveDpop() + if auth == "" || dpop == "" { + return nil, errors.New("missing authorization or dpop") + } + if obj.ObjToken == "" { + return nil, errors.New("missing obj_token") + } + info, err := d.getFileInfo(ctx, obj.ObjToken) + if err != nil { + return nil, err + } + + entry, ok := info.PreviewMeta.Data["22"] + if !ok || entry.Status != 0 { + return nil, errors.New("preview not available") + } + + subID := "" + pageIndex := 0 + + if subID == "" { + imgExt := ".webp" + pageNums := 0 + if entry.Extra != "" { + var extra PreviewImageExtra + if err := json.Unmarshal([]byte(entry.Extra), &extra); err == nil { + if extra.ImgExt != "" { + imgExt = extra.ImgExt + } + pageNums = extra.PageNums + } + } + if pageNums > 0 && pageIndex >= pageNums { + pageIndex = pageNums - 1 + } + subID = fmt.Sprintf("img_%d%s", pageIndex, imgExt) + } + + query := url.Values{} + query.Set("preview_type", "22") + query.Set("sub_id", subID) + if info.Version != "" { + query.Set("version", info.Version) + } + previewURL := fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s?%s", BaseURL, obj.ObjToken, query.Encode()) + + headers := http.Header{ + "Referer": []string{"https://www.doubao.com/"}, + "User-Agent": []string{base.UserAgent}, + "Authorization": []string{auth}, + "Dpop": []string{dpop}, + } + + return &model.Link{ + URL: previewURL, + Header: headers, + }, nil +} + func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) (Node, error) { data := url.Values{} data.Set("name", name) From 2cbcacf635bcd6a21124bc17e63167fd42fa74bb Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sun, 8 Feb 2026 00:07:28 +0800 Subject: [PATCH 09/15] fix: update headers in mergeUploadBlocks for consistency Signed-off-by: MadDogOwner --- drivers/doubao_new/upload.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go index 6e4f529c4..2fc70b9e3 100644 --- a/drivers/doubao_new/upload.go +++ b/drivers/doubao_new/upload.go @@ -109,8 +109,8 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL req.SetHeader("referer", "https://www.doubao.com/") req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("content-type", "application/octet-stream") - req.Header.Set("x-block-list-checksum", checksumHeader) - req.Header.Set("x-seq-list", seqHeader) + req.SetHeader("x-block-list-checksum", checksumHeader) + req.SetHeader("x-seq-list", seqHeader) req.SetHeader("x-block-origin-size", strconv.FormatInt(blockOriginSize, 10)) req.SetHeader("x-command", "space.api.box.stream.upload.merge_block") req.SetHeader("x-csrftoken", "") From 85d4b2fe458fb664936fb4dc1515e44e44accc7d Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sun, 8 Feb 2026 17:45:56 +0800 Subject: [PATCH 10/15] fix: non-constant format string in call to fmt.Errorf Signed-off-by: MadDogOwner --- drivers/doubao_new/upload.go | 2 +- drivers/doubao_new/util.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go index 2fc70b9e3..e9ab60b17 100644 --- a/drivers/doubao_new/upload.go +++ b/drivers/doubao_new/upload.go @@ -161,7 +161,7 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL string(body), err, ) - return UploadMergeData{}, fmt.Errorf(msg) + return UploadMergeData{}, fmt.Errorf("%s", msg) } if resp.Code != 0 { if res != nil && res.StatusCode() == http.StatusBadRequest && resp.Code == 2 { diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index 087427020..d4296d5ef 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -63,7 +63,7 @@ func (d *DoubaoNew) request(ctx context.Context, path string, method string, cal string(body), err, ) - return body, fmt.Errorf(msg) + return body, fmt.Errorf("%s", msg) } if common.Code != 0 { errMsg := common.Msg @@ -340,7 +340,7 @@ func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) string(body), err, ) - return Node{}, fmt.Errorf(msg) + return Node{}, fmt.Errorf("%s", msg) } var node Node @@ -466,7 +466,7 @@ func decodeBaseResp(body []byte, res *resty.Response) error { string(body), err, ) - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } if common.Code != 0 { errMsg := common.Msg @@ -575,7 +575,7 @@ func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { string(body), err, ) - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } if resp.Code != 0 { errMsg := resp.Msg @@ -623,7 +623,7 @@ func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) string(body), err, ) - return UserStorageData{}, fmt.Errorf(msg) + return UserStorageData{}, fmt.Errorf("%s", msg) } if resp.Code != 0 { errMsg := resp.Msg @@ -695,7 +695,7 @@ func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatu string(body), err, ) - return TaskStatusData{}, fmt.Errorf(msg) + return TaskStatusData{}, fmt.Errorf("%s", msg) } if resp.Code != 0 { errMsg := resp.Msg From 55d6a6ed02cc0652e57e8e3310bda23284b28183 Mon Sep 17 00:00:00 2001 From: Elegant1E <104549918+Elegant1E@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:12:31 +0800 Subject: [PATCH 11/15] fix: remove hardcoded proxy logic for doubao_new in down handler --- server/handles/down.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/server/handles/down.go b/server/handles/down.go index b488e3751..d4d634cbe 100644 --- a/server/handles/down.go +++ b/server/handles/down.go @@ -30,18 +30,6 @@ func Down(c *gin.Context) { common.ErrorPage(c, err, 500) return } - if c.Query("type") == "preview" && storage.GetStorage().Driver == "doubao_new" { - link, file, err := fs.Link(c.Request.Context(), rawPath, model.LinkArgs{ - Header: c.Request.Header, - Type: c.Query("type"), - }) - if err != nil { - common.ErrorPage(c, err, 500) - return - } - proxy(c, link, file, storage.GetStorage().ProxyRange) - return - } if common.ShouldProxy(storage, filename) { Proxy(c) return From 5fc9b2558cc226332c5d7290f395b731eb33491b Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 21 Mar 2026 11:05:15 +0800 Subject: [PATCH 12/15] feat(doubao_new): prefer proxy to protect user auth Signed-off-by: MadDogOwner --- drivers/doubao_new/meta.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go index bee1ce95d..01a4721cc 100644 --- a/drivers/doubao_new/meta.go +++ b/drivers/doubao_new/meta.go @@ -15,16 +15,14 @@ type Addition struct { } var config = driver.Config{ - Name: "DoubaoNew", - LocalSort: true, - OnlyProxy: false, - NoCache: false, - NoUpload: false, - NeedMs: false, - DefaultRoot: "", - CheckStatus: false, - Alert: "", + Name: "DoubaoNew", + LocalSort: true, + DefaultRoot: "", + Alert: `danger|Do not use 302 if the storage is public accessible. +Otherwise, the download link may leak sensitive information such as access token or signature. +Others may use the leaked link to access all your files.`, NoOverwriteUpload: false, + PreferProxy: true, } func init() { From 744e6f2be98ee151ec19c4e56effadff21f18f65 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Sat, 21 Mar 2026 13:47:37 +0800 Subject: [PATCH 13/15] feat(doubao_new): implement DPoP token generation Signed-off-by: MadDogOwner --- drivers/doubao_new/auth.go | 468 +++++++++++++++++++++++++++++++++++ drivers/doubao_new/driver.go | 8 +- drivers/doubao_new/meta.go | 1 + drivers/doubao_new/upload.go | 30 +-- drivers/doubao_new/util.go | 75 ++---- 5 files changed, 502 insertions(+), 80 deletions(-) create mode 100644 drivers/doubao_new/auth.go diff --git a/drivers/doubao_new/auth.go b/drivers/doubao_new/auth.go new file mode 100644 index 000000000..f918da848 --- /dev/null +++ b/drivers/doubao_new/auth.go @@ -0,0 +1,468 @@ +package doubao_new + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/url" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" + "github.com/go-resty/resty/v2" + "github.com/google/uuid" + "golang.org/x/crypto/pbkdf2" +) + +type Clock interface { + Now() (int64, error) +} + +type SystemClock struct{} + +func (SystemClock) Now() (int64, error) { return time.Now().Unix(), nil } + +type DPoPTokenInput struct { + KeyPair *ecdsa.PrivateKey + ExpiresIn int64 // 默认 15 + + JTI string + HTM string + HTU string + IAT int64 + Nonce string + Clock Clock +} + +type DPoPTokenOutput struct { + DPoPToken string `json:"dpopToken"` + ExpiredTime int64 `json:"expiredTime"` + ExpiresIn int64 `json:"expiresIn"` +} + +type JWTPayload struct { + Exp int64 `json:"exp,omitempty"` + Iat int64 `json:"iat,omitempty"` + Nbf int64 `json:"nbf,omitempty"` + Jti string `json:"jti,omitempty"` + Htm string `json:"htm,omitempty"` + Htu string `json:"htu,omitempty"` + Nonce string `json:"nonce,omitempty"` + Sub string `json:"sub,omitempty"` +} + +type jwkECPrivateKey struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + D string `json:"d"` +} + +type dpopKeyPairEnvelope struct { + PrivateKey *jwkECPrivateKey `json:"privateKey"` + KeyPair *jwkECPrivateKey `json:"keyPair"` + JWK *jwkECPrivateKey `json:"jwk"` +} + +const defaultDPoPKeySecret = "passport-dpop-token-generator" + +type encryptedDpopKeyPair struct { + Data string `json:"data"` + Ciphertext string `json:"ciphertext"` + Encrypted string `json:"encrypted"` + Secret string `json:"secret"` + Password string `json:"password"` + Passphrase string `json:"passphrase"` +} + +func GenerateDPoPToken(in DPoPTokenInput) (*DPoPTokenOutput, error) { + if in.KeyPair == nil { + return nil, errors.New("keyPair required") + } + if in.KeyPair.Curve != elliptic.P256() { + return nil, errors.New("ES256 requires P-256 key") + } + if in.Clock == nil { + in.Clock = SystemClock{} + } + if in.ExpiresIn <= 0 { + in.ExpiresIn = 15 + } + + now, err := in.Clock.Now() + if err != nil { + return nil, err + } + + payload := map[string]any{ + "jti": pickStr(in.JTI, uuid.NewString()), + "htm": pickStr(in.HTM, ""), + "htu": pickStr(in.HTU, ""), + "iat": pickI64(in.IAT, now), + "nonce": pickStr(in.Nonce, uuid.NewString()), + } + if in.ExpiresIn > 0 { + payload["exp"] = payload["iat"].(int64) + in.ExpiresIn + } + + pub := in.KeyPair.PublicKey + header := map[string]any{ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": map[string]string{ + "kty": "EC", + "crv": "P-256", + "x": b64url(pad32(pub.X.Bytes())), + "y": b64url(pad32(pub.Y.Bytes())), + }, + } + + hb, err := json.Marshal(header) + if err != nil { + return nil, err + } + pb, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + hEnc := b64url(hb) + pEnc := b64url(pb) + signingInput := hEnc + "." + pEnc + + sum := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, in.KeyPair, sum[:]) + if err != nil { + return nil, err + } + + sig := append(pad32(r.Bytes()), pad32(s.Bytes())...) + token := signingInput + "." + b64url(sig) + + iat := payload["iat"].(int64) + return &DPoPTokenOutput{ + DPoPToken: token, + ExpiredTime: iat + in.ExpiresIn, + ExpiresIn: in.ExpiresIn, + }, nil +} + +func ParseJWTPayload(token string, out any) error { + token = strings.TrimSpace(trimTokenScheme(token)) + parts := strings.Split(token, ".") + if len(parts) < 2 { + return fmt.Errorf("invalid JWT format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("failed to decode JWT payload: %w", err) + } + if err := json.Unmarshal(payload, out); err != nil { + return fmt.Errorf("failed to parse JWT payload: %w", err) + } + return nil +} + +func parseECPrivateKeyJWK(raw string) (*ecdsa.PrivateKey, error) { + var jwk jwkECPrivateKey + if err := json.Unmarshal([]byte(raw), &jwk); err != nil { + return nil, err + } + if jwk.D == "" || jwk.X == "" || jwk.Y == "" { + var env dpopKeyPairEnvelope + if err := json.Unmarshal([]byte(raw), &env); err != nil { + return nil, err + } + switch { + case env.PrivateKey != nil: + jwk = *env.PrivateKey + case env.KeyPair != nil: + jwk = *env.KeyPair + case env.JWK != nil: + jwk = *env.JWK + default: + return nil, errors.New("missing private key JWK") + } + } + + if jwk.Kty != "" && jwk.Kty != "EC" { + return nil, errors.New("unsupported JWK kty") + } + if jwk.Crv != "" && jwk.Crv != "P-256" { + return nil, errors.New("unsupported JWK curve") + } + if jwk.D == "" || jwk.X == "" || jwk.Y == "" { + return nil, errors.New("incomplete JWK") + } + + xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) + if err != nil { + return nil, fmt.Errorf("invalid jwk x: %w", err) + } + yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) + if err != nil { + return nil, fmt.Errorf("invalid jwk y: %w", err) + } + dBytes, err := base64.RawURLEncoding.DecodeString(jwk.D) + if err != nil { + return nil, fmt.Errorf("invalid jwk d: %w", err) + } + + key := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + }, + D: new(big.Int).SetBytes(dBytes), + } + return validateP256Key(key) +} + +func parseEncryptedDPoPKeyPair(raw string) (*ecdsa.PrivateKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("empty encrypted key pair") + } + + var payload encryptedDpopKeyPair + ciphertext := raw + if strings.HasPrefix(raw, "{") { + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, err + } + switch { + case strings.TrimSpace(payload.Data) != "": + ciphertext = strings.TrimSpace(payload.Data) + case strings.TrimSpace(payload.Ciphertext) != "": + ciphertext = strings.TrimSpace(payload.Ciphertext) + case strings.TrimSpace(payload.Encrypted) != "": + ciphertext = strings.TrimSpace(payload.Encrypted) + default: + return nil, errors.New("missing encrypted dpop payload") + } + } + + decoded, err := decodeBase64Loose(ciphertext) + if err != nil { + return nil, err + } + if len(decoded) <= 12 { + return nil, errors.New("encrypted dpop payload too short") + } + + plain, err := decryptDoubaoKeyPair(decoded, defaultDPoPKeySecret) + if err != nil { + return nil, fmt.Errorf("failed to decrypt with default secret: %w", err) + } + return parseECPrivateKeyJWK(string(plain)) +} + +func decryptDoubaoKeyPair(ciphertext []byte, secret string) ([]byte, error) { + key := pbkdf2.Key([]byte(secret), []byte("fixed-salt"), 100000, 32, sha256.New) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := aead.NonceSize() + if len(ciphertext) <= nonceSize { + return nil, errors.New("ciphertext too short") + } + nonce := ciphertext[:nonceSize] + enc := ciphertext[nonceSize:] + return aead.Open(nil, nonce, enc, nil) +} + +func decodeBase64Loose(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + raw = strings.ReplaceAll(raw, "\n", "") + raw = strings.ReplaceAll(raw, "\r", "") + raw = strings.ReplaceAll(raw, "\t", "") + raw = strings.ReplaceAll(raw, " ", "") + + encodings := []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } + var lastErr error + for _, enc := range encodings { + decoded, err := enc.DecodeString(raw) + if err == nil { + return decoded, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("invalid base64") + } + return nil, lastErr +} + +func validateP256Key(key *ecdsa.PrivateKey) (*ecdsa.PrivateKey, error) { + if key == nil { + return nil, errors.New("nil private key") + } + if key.Curve != elliptic.P256() { + return nil, errors.New("ES256 requires P-256 key") + } + if key.PublicKey.X == nil || key.PublicKey.Y == nil || key.D == nil { + return nil, errors.New("invalid private key") + } + if !key.Curve.IsOnCurve(key.PublicKey.X, key.PublicKey.Y) { + return nil, errors.New("public key is not on P-256 curve") + } + return key, nil +} + +func trimTokenScheme(token string) string { + token = strings.TrimSpace(token) + if i := strings.IndexByte(token, ' '); i > 0 { + scheme := strings.ToLower(strings.TrimSpace(token[:i])) + if scheme == "bearer" || scheme == "dpop" { + return strings.TrimSpace(token[i+1:]) + } + } + return token +} + +func b64url(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func pad32(b []byte) []byte { + if len(b) >= 32 { + return b[len(b)-32:] + } + out := make([]byte, 32) + copy(out[32-len(b):], b) + return out +} + +func pickStr(v, def string) string { + if v != "" { + return v + } + return def +} + +func pickI64(v, def int64) int64 { + if v != 0 { + return v + } + return def +} + +func (d *DoubaoNew) resolveAuthorizationToken() string { + auth := strings.TrimSpace(d.Authorization) + if auth == "" && d.Cookie != "" { + auth = cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN") + } + return trimTokenScheme(auth) +} + +func (d *DoubaoNew) resolveAuthorization() string { + auth := d.resolveAuthorizationToken() + if auth == "" { + return "" + } + return "DPoP " + auth +} + +func (d *DoubaoNew) resolveDpop() string { + dpop := strings.TrimSpace(d.Dpop) + if dpop == "" && d.Cookie != "" { + dpop = cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP") + } + return dpop +} + +func (d *DoubaoNew) resolveDPoPKeyPair() (*ecdsa.PrivateKey, error) { + raw := strings.TrimSpace(d.DpopKeyPair) + if raw == "" { + return nil, nil + } + if cached, ok := d.dpopKeyPairCache.Load(raw); ok { + key, _ := cached.(*ecdsa.PrivateKey) + return key, nil + } + key, err := parseEncryptedDPoPKeyPair(raw) + if err != nil { + return nil, err + } + d.dpopKeyPairCache.Store(raw, key) + return key, nil +} + +func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) { + if key, err := d.resolveDPoPKeyPair(); err != nil { + return "", err + } else if key != nil { + proof, err := GenerateDPoPToken(DPoPTokenInput{ + KeyPair: key, + HTM: strings.ToUpper(strings.TrimSpace(method)), + HTU: normalizeDPoPURL(rawURL), + }) + if err != nil { + return "", err + } + return proof.DPoPToken, nil + } + + static := d.resolveDpop() + if static == "" { + return "", nil + } + if payload, err := parseDPoPPayload(static); err == nil && payload.Exp > 0 { + now := time.Now().Unix() + if payload.Exp <= now+5 { + return "", errors.New("static dpop token expired or near expiry; configure dpop_key_pair for automatic refresh") + } + } + return static, nil +} + +func (d *DoubaoNew) applyAuthHeaders(req *resty.Request, method, rawURL string) error { + if auth := d.resolveAuthorization(); auth != "" { + req.SetHeader("authorization", auth) + } + dpop, err := d.resolveDpopForRequest(method, rawURL) + if err != nil { + return err + } + if dpop != "" { + req.SetHeader("dpop", dpop) + } + return nil +} + +func normalizeDPoPURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + u.Fragment = "" + return u.String() +} + +func parseDPoPPayload(token string) (*JWTPayload, error) { + var payload JWTPayload + if err := ParseJWTPayload(token, &payload); err != nil { + return nil, err + } + return &payload, nil +} diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index a844c6141..d35cf526e 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "sort" + "sync" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -25,6 +26,8 @@ type DoubaoNew struct { model.Storage Addition TtLogid string + + dpopKeyPairCache sync.Map } func (d *DoubaoNew) Config() driver.Config { @@ -90,7 +93,10 @@ func (d *DoubaoNew) Link(ctx context.Context, file model.Obj, args model.LinkArg } } auth := d.resolveAuthorization() - dpop := d.resolveDpop() + dpop, err := d.resolveDpopForRequest(http.MethodGet, DownloadBaseURL+"/space/api/box/stream/download/all/"+obj.ObjToken+"/") + if err != nil { + return nil, err + } if auth == "" || dpop == "" { return nil, errors.New("missing authorization or dpop") } diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go index 01a4721cc..a454c205d 100644 --- a/drivers/doubao_new/meta.go +++ b/drivers/doubao_new/meta.go @@ -11,6 +11,7 @@ type Addition struct { // define other Authorization string `json:"authorization" help:"DPoP access token (Authorization header value); optional if present in cookie"` Dpop string `json:"dpop" help:"DPoP header value; optional if present in cookie"` + DpopKeyPair string `json:"dpop_key_pair" help:"DPoP key pair for refreshing Dpop; optional if present in cookie"` Cookie string `json:"cookie" help:"Optional cookie; only used to extract authorization/dpop tokens"` } diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go index e9ab60b17..20220170a 100644 --- a/drivers/doubao_new/upload.go +++ b/drivers/doubao_new/upload.go @@ -123,18 +123,6 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL if reqID != "" { req.SetHeader("x-request-id", reqID) } - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) - } - req.Header.Del("cookie") - if req.Header.Get("x-command") == "" { - return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") - } - req.SetBody(data) - values := url.Values{} values.Set("shouldBypassScsDialog", "true") values.Set("upload_id", uploadID) @@ -142,6 +130,14 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL values.Set("doubao_storage", "imagex_other") values.Set("doubao_app_id", "497858") urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?" + values.Encode() + if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { + return UploadMergeData{}, err + } + req.Header.Del("cookie") + if req.Header.Get("x-command") == "" { + return UploadMergeData{}, fmt.Errorf("[doubao_new] merge blocks missing x-command header") + } + req.SetBody(data) res, err := req.Execute(http.MethodPost, urlStr) if err != nil { @@ -218,13 +214,6 @@ func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block Up req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) req.SetHeader("x-block-checksum", block.Checksum) - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) - } - req.SetMultipartFormData(map[string]string{ "upload_id": uploadID, "size": strconv.FormatInt(int64(len(data)), 10), @@ -241,6 +230,9 @@ func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block Up values.Set("doubao_storage", "imagex_other") values.Set("doubao_app_id", "497858") urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/v3/block/?" + values.Encode() + if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { + return err + } res, err := req.Execute(http.MethodPost, urlStr) if err != nil { diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index d4296d5ef..f19821461 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -31,11 +31,8 @@ func (d *DoubaoNew) request(ctx context.Context, path string, method string, cal req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, method, BaseURL+path); err != nil { + return nil, err } if callback != nil { @@ -125,30 +122,6 @@ func parseSize(size string) int64 { return val } -func (d *DoubaoNew) resolveAuthorization() string { - auth := strings.TrimSpace(d.Authorization) - if auth == "" && d.Cookie != "" { - if token := cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN"); token != "" { - auth = token - } - } - if auth == "" { - return "" - } - if !strings.HasPrefix(auth, "DPoP ") && !strings.HasPrefix(auth, "dpop ") { - auth = "DPoP " + auth - } - return auth -} - -func (d *DoubaoNew) resolveDpop() string { - dpop := strings.TrimSpace(d.Dpop) - if dpop == "" && d.Cookie != "" { - dpop = cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP") - } - return dpop -} - func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLabel string) (ListData, error) { var resp ListResp _, err := d.request(ctx, "/space/api/explorer/doubao/children/list/", http.MethodGet, func(req *resty.Request) { @@ -233,7 +206,7 @@ func (d *DoubaoNew) getFileInfo(ctx context.Context, fileToken string) (FileInfo func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.LinkArgs) (*model.Link, error) { auth := d.resolveAuthorization() - dpop := d.resolveDpop() + dpop, err := d.resolveDpopForRequest(http.MethodGet, fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s", BaseURL, obj.ObjToken)) if auth == "" || dpop == "" { return nil, errors.New("missing authorization or dpop") } @@ -306,11 +279,8 @@ func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/create/folder/"); err != nil { + return nil, nil, err } if csrfToken != "" { req.SetHeader("x-csrftoken", csrfToken) @@ -385,11 +355,8 @@ func (d *DoubaoNew) renameFolder(ctx context.Context, token, name string) error req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/rename/"); err != nil { + return nil, nil, err } if csrfToken != "" { req.SetHeader("x-csrftoken", csrfToken) @@ -507,11 +474,8 @@ func (d *DoubaoNew) moveObj(ctx context.Context, srcToken, destToken string) err req.SetHeader("accept", "*/*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/move/"); err != nil { + return nil, nil, err } if csrfToken != "" { req.SetHeader("x-csrftoken", csrfToken) @@ -542,11 +506,8 @@ func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { req.SetHeader("accept", "application/json, text/plain, */*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v3/remove/"); err != nil { + return nil, nil, err } if csrfToken != "" { req.SetHeader("x-csrftoken", csrfToken) @@ -598,11 +559,8 @@ func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) req.SetHeader("referer", "https://www.doubao.com/") req.SetHeader("agw-js-conv", "str") req.SetHeader("content-type", "application/json") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodPost, "https://www.doubao.com/alice/aispace/facade/get_user_storage"); err != nil { + return UserStorageData{}, err } if d.Cookie != "" { req.SetHeader("cookie", d.Cookie) @@ -675,11 +633,8 @@ func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatu req.SetHeader("accept", "application/json, text/plain, */*") req.SetHeader("origin", "https://www.doubao.com") req.SetHeader("referer", "https://www.doubao.com/") - if auth := d.resolveAuthorization(); auth != "" { - req.SetHeader("authorization", auth) - } - if dpop := d.resolveDpop(); dpop != "" { - req.SetHeader("dpop", dpop) + if err := d.applyAuthHeaders(req, http.MethodGet, BaseURL+"/space/api/explorer/v2/task/"); err != nil { + return TaskStatusData{}, err } req.SetQueryParam("task_id", taskID) res, err := req.Execute(http.MethodGet, BaseURL+"/space/api/explorer/v2/task/") From 0447c779784d0b19e25043c75e355239f2b08c89 Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Mon, 23 Mar 2026 13:45:45 +0800 Subject: [PATCH 14/15] feat(doubao_new): implement Authorization refresh Signed-off-by: MadDogOwner --- drivers/doubao_new/auth.go | 145 +++++++++++++++++++++++++++-------- drivers/doubao_new/driver.go | 62 +++++++++++---- drivers/doubao_new/meta.go | 11 ++- drivers/doubao_new/types.go | 10 +++ drivers/doubao_new/upload.go | 22 +++--- drivers/doubao_new/util.go | 46 +++++------ 6 files changed, 213 insertions(+), 83 deletions(-) diff --git a/drivers/doubao_new/auth.go b/drivers/doubao_new/auth.go index f918da848..36168cc8f 100644 --- a/drivers/doubao_new/auth.go +++ b/drivers/doubao_new/auth.go @@ -16,12 +16,18 @@ import ( "strings" "time" + "github.com/OpenListTeam/OpenList/v4/drivers/base" "github.com/OpenListTeam/OpenList/v4/pkg/cookie" "github.com/go-resty/resty/v2" "github.com/google/uuid" "golang.org/x/crypto/pbkdf2" ) +const ( + defaultAuthRefreshAheadSeconds = int64(120) + defaultDpopRefreshAheadSeconds = int64(5) +) + type Clock interface { Now() (int64, error) } @@ -367,53 +373,87 @@ func pickI64(v, def int64) int64 { return def } -func (d *DoubaoNew) resolveAuthorizationToken() string { - auth := strings.TrimSpace(d.Authorization) - if auth == "" && d.Cookie != "" { - auth = cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN") - } - return trimTokenScheme(auth) -} - func (d *DoubaoNew) resolveAuthorization() string { - auth := d.resolveAuthorizationToken() + auth := trimTokenScheme(d.Authorization) if auth == "" { return "" } return "DPoP " + auth } -func (d *DoubaoNew) resolveDpop() string { - dpop := strings.TrimSpace(d.Dpop) - if dpop == "" && d.Cookie != "" { - dpop = cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP") +func shouldRefreshJWT(token string, aheadSeconds int64) bool { + if token == "" { + return true } - return dpop + var payload JWTPayload + if err := ParseJWTPayload(token, &payload); err != nil { + return true + } + if payload.Exp <= 0 { + return false + } + return payload.Exp <= time.Now().Unix()+aheadSeconds } -func (d *DoubaoNew) resolveDPoPKeyPair() (*ecdsa.PrivateKey, error) { - raw := strings.TrimSpace(d.DpopKeyPair) - if raw == "" { - return nil, nil +func (d *DoubaoNew) fetchBizAuth(dpop string) (string, error) { + client := base.RestyClient.Clone() + req := client.R() + req.SetHeader("accept", "application/json, text/javascript") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + req.SetHeader("content-type", "application/x-www-form-urlencoded") + if d.Cookie != "" { + req.SetHeader("cookie", d.Cookie) + if csrf := strings.TrimSpace(cookie.GetStr(d.Cookie, "passport_csrf_token")); csrf != "" { + req.SetHeader("x-tt-passport-csrf-token", csrf) + } } - if cached, ok := d.dpopKeyPairCache.Load(raw); ok { - key, _ := cached.(*ecdsa.PrivateKey) - return key, nil + if oldAuth := d.resolveAuthorization(); oldAuth != "" { + req.SetHeader("authorization", oldAuth) } - key, err := parseEncryptedDPoPKeyPair(raw) + if dpop != "" { + req.SetHeader("dpop", dpop) + } + values := url.Values{} + values.Set("client_id", d.AuthClientID) + values.Set("client_type", d.AuthClientType) + values.Set("scope", d.AuthScope) + values.Set("d_pop", dpop) + req.SetBody(values.Encode()) + req.SetQueryParam("aid", d.AppID) + req.SetQueryParam("account_sdk_source", d.AuthSDKSource) + req.SetQueryParam("sdk_version", d.AuthSDKVersion) + + res, err := req.Post(DoubaoURL + "/passport/user/biz_auth/") if err != nil { - return nil, err + return "", err } - d.dpopKeyPairCache.Store(raw, key) - return key, nil + var resp bizAuthResp + if err = json.Unmarshal(res.Body(), &resp); err != nil { + return "", err + } + ok := resp.Message != "success" && strings.TrimSpace(resp.Data.AccessToken) != "" + if !ok { + return "", fmt.Errorf("[doubao_new] %s: %s", resp.Message, resp.Data.Description) + } + return strings.TrimSpace(resp.Data.AccessToken), nil +} + +func (d *DoubaoNew) refreshAuthorizationWithDPoP(dpop string) (string, error) { + token, err := d.fetchBizAuth(dpop) + if err == nil && token != "" { + return token, nil + } + if err == nil { + err = errors.New("biz auth refresh failed") + } + return "", err } func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) { - if key, err := d.resolveDPoPKeyPair(); err != nil { - return "", err - } else if key != nil { + if d.DpopKeyPair != nil { proof, err := GenerateDPoPToken(DPoPTokenInput{ - KeyPair: key, + KeyPair: d.DpopKeyPair, HTM: strings.ToUpper(strings.TrimSpace(method)), HTU: normalizeDPoPURL(rawURL), }) @@ -423,21 +463,62 @@ func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) return proof.DPoPToken, nil } - static := d.resolveDpop() + static := d.Dpop if static == "" { return "", nil } if payload, err := parseDPoPPayload(static); err == nil && payload.Exp > 0 { now := time.Now().Unix() - if payload.Exp <= now+5 { + if payload.Exp <= now+defaultDpopRefreshAheadSeconds { return "", errors.New("static dpop token expired or near expiry; configure dpop_key_pair for automatic refresh") } } return static, nil } +func (d *DoubaoNew) resolveAuthorizationForRequest(method, rawURL string) (string, error) { + if !shouldRefreshJWT(d.Authorization, defaultAuthRefreshAheadSeconds) { + return d.resolveAuthorization(), nil + } + // 刷新 Authorization Token 的前置条件: + // 1. DPoP 密钥对 + // 2. Cookie + // 3. AuthClientID、AuthClientType、AuthScope、AuthSDKSource、AuthSDKVersion + if d.DpopKeyPair == nil || strings.TrimSpace(d.Cookie) == "" || + d.AuthClientID == "" || d.AuthClientType == "" || d.AuthScope == "" || + d.AuthSDKSource == "" || d.AuthSDKVersion == "" { + return d.resolveAuthorization(), nil + } + + d.authRefreshMu.Lock() + defer d.authRefreshMu.Unlock() + + if !shouldRefreshJWT(d.Authorization, defaultAuthRefreshAheadSeconds) { + return d.resolveAuthorization(), nil + } + + refreshDpop, err := d.resolveDpopForRequest(method, rawURL) + if err != nil || refreshDpop == "" { + return "", err + } + + newToken, err := d.refreshAuthorizationWithDPoP(refreshDpop) + if err != nil { + if auth := d.resolveAuthorization(); auth != "" { + return auth, nil + } + return "", err + } + d.Authorization = trimTokenScheme(newToken) + return d.resolveAuthorization(), nil +} + func (d *DoubaoNew) applyAuthHeaders(req *resty.Request, method, rawURL string) error { - if auth := d.resolveAuthorization(); auth != "" { + auth, err := d.resolveAuthorizationForRequest(method, rawURL) + if err != nil { + return err + } + if auth != "" { req.SetHeader("authorization", auth) } dpop, err := d.resolveDpopForRequest(method, rawURL) diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index d35cf526e..f55ad3b73 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -3,6 +3,7 @@ package doubao_new import ( "bytes" "context" + "crypto/ecdsa" "crypto/sha256" "encoding/base64" "encoding/json" @@ -12,6 +13,7 @@ import ( "net/http" "net/url" "sort" + "strings" "sync" "time" @@ -19,6 +21,8 @@ import ( "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" + "github.com/OpenListTeam/OpenList/v4/pkg/cookie" "github.com/OpenListTeam/OpenList/v4/pkg/utils" ) @@ -27,7 +31,15 @@ type DoubaoNew struct { Addition TtLogid string - dpopKeyPairCache sync.Map + // DPoP access token (Authorization header value) + Authorization string + // DPoP header value + Dpop string + // DPoP key pair for generating DPoP + DpopKeyPairStr string + DpopKeyPair *ecdsa.PrivateKey + + authRefreshMu sync.Mutex } func (d *DoubaoNew) Config() driver.Config { @@ -39,12 +51,36 @@ func (d *DoubaoNew) GetAddition() driver.Additional { } func (d *DoubaoNew) Init(ctx context.Context) error { - // TODO login / refresh token - //op.MustSaveDriverStorage(d) + if cookieStr := strings.TrimSpace(d.Cookie); cookieStr != "" { + d.Cookie = cookieStr + auth := trimTokenScheme(cookie.GetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN")) + if auth != "" { + d.Authorization = auth + } + dpop := strings.TrimSpace(cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP")) + if dpop != "" { + d.Dpop = dpop + } + keypair := strings.TrimSpace(cookie.GetStr(d.Cookie, "feishu_dpop_keypair")) + if keypair != "" { + d.DpopKeyPairStr = keypair + d.DpopKeyPair, _ = parseEncryptedDPoPKeyPair(keypair) + } + } return nil } func (d *DoubaoNew) Drop(ctx context.Context) error { + if d.Authorization != "" { + d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN", d.Authorization) + } + if d.Dpop != "" { + d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_DPOP", d.Dpop) + } + if d.DpopKeyPairStr != "" { + d.Cookie = cookie.SetStr(d.Cookie, "feishu_dpop_keypair", d.DpopKeyPairStr) + } + op.MustSaveDriverStorage(d) return nil } @@ -56,8 +92,16 @@ func (d *DoubaoNew) List(ctx context.Context, dir model.Obj, args model.ListArgs objs := make([]model.Obj, 0, len(nodes)) for _, node := range nodes { + if node.NodeToken == "" || node.ObjToken == "" { + continue + } + size := parseSize(node.Extra.Size) isFolder := node.Type == 0 + if isFolder && node.NodeToken == dir.GetID() { + continue + } + obj := &Object{ Object: model.Object{ ID: node.NodeToken, @@ -111,7 +155,7 @@ func (d *DoubaoNew) Link(ctx context.Context, file model.Obj, args model.LinkArg downloadURL := DownloadBaseURL + "/space/api/box/stream/download/all/" + obj.ObjToken + "/?" + query.Encode() headers := http.Header{ - "Referer": []string{"https://www.doubao.com/"}, + "Referer": []string{DoubaoURL + "/"}, "User-Agent": []string{base.UserAgent}, } @@ -301,16 +345,6 @@ func (d *DoubaoNew) Put(ctx context.Context, dstDir model.Obj, file model.FileSt } data := groupBuf.Bytes() expectLen := groupExpectSum - if len(data) > 0 { - headLen := 32 - if len(data) < headLen { - headLen = len(data) - } - tailLen := 32 - if len(data) < tailLen { - tailLen = len(data) - } - } if int64(len(data)) != expectLen { return fmt.Errorf("[doubao_new] merge blocks invalid body len: got=%d expect=%d seqs=%v", len(data), expectLen, groupSeqs) } diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go index a454c205d..27f83a1f6 100644 --- a/drivers/doubao_new/meta.go +++ b/drivers/doubao_new/meta.go @@ -9,10 +9,13 @@ type Addition struct { // Usually one of two driver.RootID // define other - Authorization string `json:"authorization" help:"DPoP access token (Authorization header value); optional if present in cookie"` - Dpop string `json:"dpop" help:"DPoP header value; optional if present in cookie"` - DpopKeyPair string `json:"dpop_key_pair" help:"DPoP key pair for refreshing Dpop; optional if present in cookie"` - Cookie string `json:"cookie" help:"Optional cookie; only used to extract authorization/dpop tokens"` + Cookie string `json:"cookie" required:"true" help:"Web Cookie"` + AppID string `json:"app_id" required:"true" default:"497858" help:"Doubao App ID"` + AuthClientID string `json:"auth_client_id" help:"Doubao Biz Auth Client ID"` + AuthClientType string `json:"auth_client_type" help:"Doubao Biz Auth Client Type"` + AuthScope string `json:"auth_scope" help:"Doubao Biz Auth Scope"` + AuthSDKSource string `json:"auth_sdk_source" help:"Doubao Biz Auth SDK Source"` + AuthSDKVersion string `json:"auth_sdk_version" help:"Doubao Biz Auth SDK Version"` } var config = driver.Config{ diff --git a/drivers/doubao_new/types.go b/drivers/doubao_new/types.go index 4e16dff5f..3b64a2a20 100644 --- a/drivers/doubao_new/types.go +++ b/drivers/doubao_new/types.go @@ -180,3 +180,13 @@ type TaskStatusData struct { IsFinish bool `json:"is_finish"` IsFail bool `json:"is_fail"` } + +type bizAuthResp struct { + Data struct { + AccessToken string `json:"access_token"` + AuthScheme string `json:"auth_scheme"` + ExpiresIn int64 `json:"expires_in"` + Description string `json:"description,omitempty"` + } `json:"data"` + Message string `json:"message"` +} diff --git a/drivers/doubao_new/upload.go b/drivers/doubao_new/upload.go index 20220170a..6f3a8b068 100644 --- a/drivers/doubao_new/upload.go +++ b/drivers/doubao_new/upload.go @@ -21,7 +21,7 @@ func (d *DoubaoNew) prepareUpload(ctx context.Context, name string, size int64, values := url.Values{} values.Set("shouldBypassScsDialog", "true") values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") + values.Set("doubao_app_id", d.AppID) req.SetQueryParamsFromValues(values) req.SetHeader("Content-Type", "application/json") req.SetHeader("x-command", "space.api.box.upload.prepare") @@ -58,7 +58,7 @@ func (d *DoubaoNew) uploadBlocks(ctx context.Context, uploadID string, blocks [] values := url.Values{} values.Set("shouldBypassScsDialog", "true") values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") + values.Set("doubao_app_id", d.AppID) req.SetQueryParamsFromValues(values) req.SetHeader("Content-Type", "application/json") req.SetHeader("x-command", "space.api.box.upload.blocks") @@ -105,8 +105,8 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL req := client.R() req.SetContext(ctx) req.SetHeader("accept", "application/json, text/plain, */*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("content-type", "application/octet-stream") req.SetHeader("x-block-list-checksum", checksumHeader) @@ -128,8 +128,8 @@ func (d *DoubaoNew) mergeUploadBlocks(ctx context.Context, uploadID string, seqL values.Set("upload_id", uploadID) values.Set("mount_point", "explorer") values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/merge_block/?" + values.Encode() + values.Set("doubao_app_id", d.AppID) + urlStr := DownloadBaseURL + "/space/api/box/stream/upload/merge_block/?" + values.Encode() if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { return UploadMergeData{}, err } @@ -209,8 +209,8 @@ func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block Up req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") req.SetHeader("rpc-persist-doubao-pan", "true") req.SetHeader("x-block-seq", strconv.Itoa(block.Seq)) req.SetHeader("x-block-checksum", block.Checksum) @@ -228,8 +228,8 @@ func (d *DoubaoNew) uploadBlockV3(ctx context.Context, uploadID string, block Up values.Set("checksum", block.Checksum) values.Set("mount_point", "explorer") values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") - urlStr := "https://internal-api-drive-stream.feishu.cn/space/api/box/stream/upload/v3/block/?" + values.Encode() + values.Set("doubao_app_id", d.AppID) + urlStr := DownloadBaseURL + "/space/api/box/stream/upload/v3/block/?" + values.Encode() if err := d.applyAuthHeaders(req, http.MethodPost, urlStr); err != nil { return err } @@ -260,7 +260,7 @@ func (d *DoubaoNew) finishUpload(ctx context.Context, uploadID string, numBlocks values := url.Values{} values.Set("shouldBypassScsDialog", "true") values.Set("doubao_storage", "imagex_other") - values.Set("doubao_app_id", "497858") + values.Set("doubao_app_id", d.AppID) req.SetQueryParamsFromValues(values) req.SetHeader("Content-Type", "application/json") req.SetHeader("x-command", "space.api.box.upload.finish") diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index f19821461..2a19de295 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -21,6 +21,7 @@ import ( const ( BaseURL = "https://my.feishu.cn" DownloadBaseURL = "https://internal-api-drive-stream.feishu.cn" + DoubaoURL = "https://www.doubao.com" ) var defaultObjTypes = []string{"124", "0", "12", "30", "123", "22"} @@ -29,8 +30,8 @@ func (d *DoubaoNew) request(ctx context.Context, path string, method string, cal req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, method, BaseURL+path); err != nil { return nil, err } @@ -122,14 +123,14 @@ func parseSize(size string) int64 { return val } -func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLabel string) (ListData, error) { +func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLabel string, length int) (ListData, error) { var resp ListResp _, err := d.request(ctx, "/space/api/explorer/doubao/children/list/", http.MethodGet, func(req *resty.Request) { values := url.Values{} for _, t := range defaultObjTypes { values.Add("obj_type", t) } - values.Set("length", "50") + values.Set("length", strconv.Itoa(length)) values.Set("rank", "0") values.Set("asc", "0") values.Set("min_length", "40") @@ -152,10 +153,11 @@ func (d *DoubaoNew) listChildren(ctx context.Context, parentToken string, lastLa } func (d *DoubaoNew) listAllChildren(ctx context.Context, parentToken string) ([]Node, error) { - nodes := make([]Node, 0, 50) + length := 50 + nodes := make([]Node, 0, length) lastLabel := "" - for page := 0; page < 100; page++ { - data, err := d.listChildren(ctx, parentToken, lastLabel) + for range 100 { + data, err := d.listChildren(ctx, parentToken, lastLabel, length) if err != nil { return nil, err } @@ -253,7 +255,7 @@ func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.Lin previewURL := fmt.Sprintf("%s/space/api/box/stream/download/preview_sub/%s?%s", BaseURL, obj.ObjToken, query.Encode()) headers := http.Header{ - "Referer": []string{"https://www.doubao.com/"}, + "Referer": []string{DoubaoURL + "/"}, "User-Agent": []string{base.UserAgent}, "Authorization": []string{auth}, "Dpop": []string{dpop}, @@ -277,8 +279,8 @@ func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/create/folder/"); err != nil { return nil, nil, err } @@ -353,8 +355,8 @@ func (d *DoubaoNew) renameFolder(ctx context.Context, token, name string) error req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/rename/"); err != nil { return nil, nil, err } @@ -472,8 +474,8 @@ func (d *DoubaoNew) moveObj(ctx context.Context, srcToken, destToken string) err req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v2/move/"); err != nil { return nil, nil, err } @@ -504,8 +506,8 @@ func (d *DoubaoNew) removeObj(ctx context.Context, tokens []string) error { req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "application/json, text/plain, */*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/explorer/v3/remove/"); err != nil { return nil, nil, err } @@ -555,11 +557,11 @@ func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "*/*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") req.SetHeader("agw-js-conv", "str") req.SetHeader("content-type", "application/json") - if err := d.applyAuthHeaders(req, http.MethodPost, "https://www.doubao.com/alice/aispace/facade/get_user_storage"); err != nil { + if err := d.applyAuthHeaders(req, http.MethodPost, DoubaoURL+"/alice/aispace/facade/get_user_storage"); err != nil { return UserStorageData{}, err } if d.Cookie != "" { @@ -567,7 +569,7 @@ func (d *DoubaoNew) getUserStorage(ctx context.Context) (UserStorageData, error) } req.SetBody(base.Json{}) - res, err := req.Execute(http.MethodPost, "https://www.doubao.com/alice/aispace/facade/get_user_storage") + res, err := req.Execute(http.MethodPost, DoubaoURL+"/alice/aispace/facade/get_user_storage") if err != nil { return UserStorageData{}, err } @@ -631,8 +633,8 @@ func (d *DoubaoNew) getTaskStatus(ctx context.Context, taskID string) (TaskStatu req := base.RestyClient.R() req.SetContext(ctx) req.SetHeader("accept", "application/json, text/plain, */*") - req.SetHeader("origin", "https://www.doubao.com") - req.SetHeader("referer", "https://www.doubao.com/") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") if err := d.applyAuthHeaders(req, http.MethodGet, BaseURL+"/space/api/explorer/v2/task/"); err != nil { return TaskStatusData{}, err } From 436bb726b32bfbb6654bb80740020f9fdc25e5cf Mon Sep 17 00:00:00 2001 From: MadDogOwner Date: Mon, 23 Mar 2026 17:02:08 +0800 Subject: [PATCH 15/15] refactor(doubao_new): security improvement Signed-off-by: MadDogOwner --- drivers/doubao_new/auth.go | 120 ++++++++++++++++++++++++----------- drivers/doubao_new/driver.go | 54 ++++++++++------ drivers/doubao_new/meta.go | 3 + drivers/doubao_new/util.go | 37 +++++++++++ 4 files changed, 159 insertions(+), 55 deletions(-) diff --git a/drivers/doubao_new/auth.go b/drivers/doubao_new/auth.go index 36168cc8f..537b6a6ea 100644 --- a/drivers/doubao_new/auth.go +++ b/drivers/doubao_new/auth.go @@ -79,8 +79,6 @@ type dpopKeyPairEnvelope struct { JWK *jwkECPrivateKey `json:"jwk"` } -const defaultDPoPKeySecret = "passport-dpop-token-generator" - type encryptedDpopKeyPair struct { Data string `json:"data"` Ciphertext string `json:"ciphertext"` @@ -162,6 +160,14 @@ func GenerateDPoPToken(in DPoPTokenInput) (*DPoPTokenOutput, error) { }, nil } +func GenerateDPoPKeyPair() (*ecdsa.PrivateKey, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + return validateP256Key(key) +} + func ParseJWTPayload(token string, out any) error { token = strings.TrimSpace(trimTokenScheme(token)) parts := strings.Split(token, ".") @@ -234,7 +240,7 @@ func parseECPrivateKeyJWK(raw string) (*ecdsa.PrivateKey, error) { return validateP256Key(key) } -func parseEncryptedDPoPKeyPair(raw string) (*ecdsa.PrivateKey, error) { +func parseEncryptedDPoPKeyPair(raw, secret string) (*ecdsa.PrivateKey, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, errors.New("empty encrypted key pair") @@ -266,9 +272,9 @@ func parseEncryptedDPoPKeyPair(raw string) (*ecdsa.PrivateKey, error) { return nil, errors.New("encrypted dpop payload too short") } - plain, err := decryptDoubaoKeyPair(decoded, defaultDPoPKeySecret) + plain, err := decryptDoubaoKeyPair(decoded, secret) if err != nil { - return nil, fmt.Errorf("failed to decrypt with default secret: %w", err) + return nil, fmt.Errorf("failed to decrypt with secret: %w", err) } return parseECPrivateKeyJWK(string(plain)) } @@ -381,7 +387,7 @@ func (d *DoubaoNew) resolveAuthorization() string { return "DPoP " + auth } -func shouldRefreshJWT(token string, aheadSeconds int64) bool { +func shouldRefreshJWT(token string) bool { if token == "" { return true } @@ -392,24 +398,30 @@ func shouldRefreshJWT(token string, aheadSeconds int64) bool { if payload.Exp <= 0 { return false } - return payload.Exp <= time.Now().Unix()+aheadSeconds + return payload.Exp <= time.Now().Unix()+defaultAuthRefreshAheadSeconds } -func (d *DoubaoNew) fetchBizAuth(dpop string) (string, error) { +func (d *DoubaoNew) fetchBizAuth(dpop string, public bool) (string, error) { + var reqUrl string client := base.RestyClient.Clone() req := client.R() req.SetHeader("accept", "application/json, text/javascript") req.SetHeader("origin", DoubaoURL) req.SetHeader("referer", DoubaoURL+"/") req.SetHeader("content-type", "application/x-www-form-urlencoded") - if d.Cookie != "" { - req.SetHeader("cookie", d.Cookie) - if csrf := strings.TrimSpace(cookie.GetStr(d.Cookie, "passport_csrf_token")); csrf != "" { - req.SetHeader("x-tt-passport-csrf-token", csrf) + if public { + reqUrl = DoubaoURL + "/passport/anonymity_user/biz_auth/" + } else { + reqUrl = DoubaoURL + "/passport/user/biz_auth/" + if d.Cookie != "" { + req.SetHeader("cookie", d.Cookie) + if csrf := strings.TrimSpace(cookie.GetStr(d.Cookie, "passport_csrf_token")); csrf != "" { + req.SetHeader("x-tt-passport-csrf-token", csrf) + } + } + if oldAuth := d.resolveAuthorization(); oldAuth != "" { + req.SetHeader("authorization", oldAuth) } - } - if oldAuth := d.resolveAuthorization(); oldAuth != "" { - req.SetHeader("authorization", oldAuth) } if dpop != "" { req.SetHeader("dpop", dpop) @@ -424,7 +436,7 @@ func (d *DoubaoNew) fetchBizAuth(dpop string) (string, error) { req.SetQueryParam("account_sdk_source", d.AuthSDKSource) req.SetQueryParam("sdk_version", d.AuthSDKVersion) - res, err := req.Post(DoubaoURL + "/passport/user/biz_auth/") + res, err := req.Post(reqUrl) if err != nil { return "", err } @@ -432,15 +444,14 @@ func (d *DoubaoNew) fetchBizAuth(dpop string) (string, error) { if err = json.Unmarshal(res.Body(), &resp); err != nil { return "", err } - ok := resp.Message != "success" && strings.TrimSpace(resp.Data.AccessToken) != "" - if !ok { + if resp.Message != "success" || resp.Data.AccessToken == "" { return "", fmt.Errorf("[doubao_new] %s: %s", resp.Message, resp.Data.Description) } - return strings.TrimSpace(resp.Data.AccessToken), nil + return resp.Data.AccessToken, nil } func (d *DoubaoNew) refreshAuthorizationWithDPoP(dpop string) (string, error) { - token, err := d.fetchBizAuth(dpop) + token, err := d.fetchBizAuth(dpop, false) if err == nil && token != "" { return token, nil } @@ -451,9 +462,9 @@ func (d *DoubaoNew) refreshAuthorizationWithDPoP(dpop string) (string, error) { } func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) { - if d.DpopKeyPair != nil { + if d.DPoPKeyPair != nil { proof, err := GenerateDPoPToken(DPoPTokenInput{ - KeyPair: d.DpopKeyPair, + KeyPair: d.DPoPKeyPair, HTM: strings.ToUpper(strings.TrimSpace(method)), HTU: normalizeDPoPURL(rawURL), }) @@ -463,37 +474,39 @@ func (d *DoubaoNew) resolveDpopForRequest(method, rawURL string) (string, error) return proof.DPoPToken, nil } - static := d.Dpop + static := d.DPoP if static == "" { return "", nil } - if payload, err := parseDPoPPayload(static); err == nil && payload.Exp > 0 { - now := time.Now().Unix() - if payload.Exp <= now+defaultDpopRefreshAheadSeconds { - return "", errors.New("static dpop token expired or near expiry; configure dpop_key_pair for automatic refresh") + if !d.IgnoreJWTCheck { + if payload, err := parseDPoPPayload(static); err == nil && payload.Exp > 0 { + now := time.Now().Unix() + if payload.Exp <= now+defaultDpopRefreshAheadSeconds { + return "", errors.New("static dpop token expired or near expiry; configure dpop_key_pair for automatic refresh") + } } } return static, nil } +func (d *DoubaoNew) ensureAuthAdditons() bool { + return d.DPoPKeySecret != "" && d.AuthClientID != "" && d.AuthClientType != "" && + d.AuthScope != "" && d.AuthSDKSource != "" && d.AuthSDKVersion != "" +} + func (d *DoubaoNew) resolveAuthorizationForRequest(method, rawURL string) (string, error) { - if !shouldRefreshJWT(d.Authorization, defaultAuthRefreshAheadSeconds) { + if !shouldRefreshJWT(d.Authorization) { return d.resolveAuthorization(), nil } - // 刷新 Authorization Token 的前置条件: - // 1. DPoP 密钥对 - // 2. Cookie - // 3. AuthClientID、AuthClientType、AuthScope、AuthSDKSource、AuthSDKVersion - if d.DpopKeyPair == nil || strings.TrimSpace(d.Cookie) == "" || - d.AuthClientID == "" || d.AuthClientType == "" || d.AuthScope == "" || - d.AuthSDKSource == "" || d.AuthSDKVersion == "" { + + if d.DPoPKeyPair == nil || strings.TrimSpace(d.Cookie) == "" || !d.ensureAuthAdditons() { return d.resolveAuthorization(), nil } d.authRefreshMu.Lock() defer d.authRefreshMu.Unlock() - if !shouldRefreshJWT(d.Authorization, defaultAuthRefreshAheadSeconds) { + if !shouldRefreshJWT(d.Authorization) { return d.resolveAuthorization(), nil } @@ -513,6 +526,41 @@ func (d *DoubaoNew) resolveAuthorizationForRequest(method, rawURL string) (strin return d.resolveAuthorization(), nil } +func (d *DoubaoNew) resolveAuthorizationForPublic() (dpop string, auth string, err error) { + if d.DPoPPublic != "" && !shouldRefreshJWT(d.AuthorizationPublic) { + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil + } + + if !d.ensureAuthAdditons() { + return "", "", fmt.Errorf("[doubao_new] missing auth additions, please fill them all") + } + + d.authRefreshPublicMu.Lock() + defer d.authRefreshPublicMu.Unlock() + + if d.DPoPPublic != "" && !shouldRefreshJWT(d.AuthorizationPublic) { + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil + } + + // generate new public dpop + keypair, err := GenerateDPoPKeyPair() + if err != nil { + return "", "", err + } + proof, err := GenerateDPoPToken(DPoPTokenInput{ + KeyPair: keypair, + }) + d.DPoPPublic = proof.DPoPToken + + // get authorization token + d.AuthorizationPublic, err = d.fetchBizAuth(proof.DPoPToken, true) + if err != nil { + return "", "", err + } + + return d.DPoPPublic, "DPoP " + d.AuthorizationPublic, nil +} + func (d *DoubaoNew) applyAuthHeaders(req *resty.Request, method, rawURL string) error { auth, err := d.resolveAuthorizationForRequest(method, rawURL) if err != nil { diff --git a/drivers/doubao_new/driver.go b/drivers/doubao_new/driver.go index f55ad3b73..2a90cf16c 100644 --- a/drivers/doubao_new/driver.go +++ b/drivers/doubao_new/driver.go @@ -31,15 +31,18 @@ type DoubaoNew struct { Addition TtLogid string - // DPoP access token (Authorization header value) - Authorization string + // DPoP access token (Authorization header value, without DPoP prefix) + Authorization string + AuthorizationPublic string // DPoP header value - Dpop string + DPoP string + DPoPPublic string // DPoP key pair for generating DPoP - DpopKeyPairStr string - DpopKeyPair *ecdsa.PrivateKey + DPoPKeyPairStr string + DPoPKeyPair *ecdsa.PrivateKey - authRefreshMu sync.Mutex + authRefreshMu sync.Mutex + authRefreshPublicMu sync.Mutex } func (d *DoubaoNew) Config() driver.Config { @@ -59,12 +62,12 @@ func (d *DoubaoNew) Init(ctx context.Context) error { } dpop := strings.TrimSpace(cookie.GetStr(d.Cookie, "LARK_SUITE_DPOP")) if dpop != "" { - d.Dpop = dpop + d.DPoP = dpop } keypair := strings.TrimSpace(cookie.GetStr(d.Cookie, "feishu_dpop_keypair")) - if keypair != "" { - d.DpopKeyPairStr = keypair - d.DpopKeyPair, _ = parseEncryptedDPoPKeyPair(keypair) + if keypair != "" && d.DPoPKeySecret != "" { + d.DPoPKeyPairStr = keypair + d.DPoPKeyPair, _ = parseEncryptedDPoPKeyPair(keypair, d.DPoPKeySecret) } } return nil @@ -74,11 +77,11 @@ func (d *DoubaoNew) Drop(ctx context.Context) error { if d.Authorization != "" { d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_ACCESS_TOKEN", d.Authorization) } - if d.Dpop != "" { - d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_DPOP", d.Dpop) + if d.DPoP != "" { + d.Cookie = cookie.SetStr(d.Cookie, "LARK_SUITE_DPOP", d.DPoP) } - if d.DpopKeyPairStr != "" { - d.Cookie = cookie.SetStr(d.Cookie, "feishu_dpop_keypair", d.DpopKeyPairStr) + if d.DPoPKeyPairStr != "" { + d.Cookie = cookie.SetStr(d.Cookie, "feishu_dpop_keypair", d.DPoPKeyPairStr) } op.MustSaveDriverStorage(d) return nil @@ -131,13 +134,26 @@ func (d *DoubaoNew) Link(ctx context.Context, file model.Obj, args model.LinkArg if obj.IsFolder { return nil, fmt.Errorf("link is directory") } - if args.Type == "preview" || args.Type == "thumb" { - if link, err := d.previewLink(ctx, obj, args); err == nil { - return link, nil + var ( + err error + auth, dpop string + ) + if d.ShareLink { + err := d.createShare(ctx, obj) + if err != nil { + return nil, err + } + dpop, auth, err = d.resolveAuthorizationForPublic() + } else { + // TODO: append previewLink() with auth args to support ShareLink + if args.Type == "preview" || args.Type == "thumb" { + if link, err := d.previewLink(ctx, obj, args); err == nil { + return link, nil + } } + auth = d.resolveAuthorization() + dpop, err = d.resolveDpopForRequest(http.MethodGet, DownloadBaseURL+"/space/api/box/stream/download/all/"+obj.ObjToken+"/") } - auth := d.resolveAuthorization() - dpop, err := d.resolveDpopForRequest(http.MethodGet, DownloadBaseURL+"/space/api/box/stream/download/all/"+obj.ObjToken+"/") if err != nil { return nil, err } diff --git a/drivers/doubao_new/meta.go b/drivers/doubao_new/meta.go index 27f83a1f6..2345357bf 100644 --- a/drivers/doubao_new/meta.go +++ b/drivers/doubao_new/meta.go @@ -11,11 +11,14 @@ type Addition struct { // define other Cookie string `json:"cookie" required:"true" help:"Web Cookie"` AppID string `json:"app_id" required:"true" default:"497858" help:"Doubao App ID"` + DPoPKeySecret string `json:"dpop_key_secret" help:"DPoP Key Secret for generating DPoP token"` AuthClientID string `json:"auth_client_id" help:"Doubao Biz Auth Client ID"` AuthClientType string `json:"auth_client_type" help:"Doubao Biz Auth Client Type"` AuthScope string `json:"auth_scope" help:"Doubao Biz Auth Scope"` AuthSDKSource string `json:"auth_sdk_source" help:"Doubao Biz Auth SDK Source"` AuthSDKVersion string `json:"auth_sdk_version" help:"Doubao Biz Auth SDK Version"` + ShareLink bool `json:"share_link" help:"Whether to use share link for download"` + IgnoreJWTCheck bool `json:"ignore_jwt_check" help:"Whether to ignore JWT check to prevent time issue"` } var config = driver.Config{ diff --git a/drivers/doubao_new/util.go b/drivers/doubao_new/util.go index 2a19de295..8ee38625d 100644 --- a/drivers/doubao_new/util.go +++ b/drivers/doubao_new/util.go @@ -267,6 +267,43 @@ func (d *DoubaoNew) previewLink(ctx context.Context, obj *Object, args model.Lin }, nil } +func (d *DoubaoNew) createShare(ctx context.Context, obj *Object) error { + doRequest := func(csrfToken string) (*resty.Response, []byte, error) { + req := base.RestyClient.R() + req.SetContext(ctx) + req.SetHeader("accept", "application/json, text/plain, */*") + req.SetHeader("origin", DoubaoURL) + req.SetHeader("referer", DoubaoURL+"/") + if err := d.applyAuthHeaders(req, http.MethodPost, BaseURL+"/space/api/suite/permission/public/update.v5/"); err != nil { + return nil, nil, err + } + if csrfToken != "" { + req.SetHeader("x-csrftoken", csrfToken) + } + req.SetHeader("Content-Type", "application/json") + req.SetBody(base.Json{ + "external_access_entity": 1, + "link_share_entity": 4, + "token": obj.ObjToken, + "type": obj.ObjType, + }) + res, err := req.Execute(http.MethodPost, BaseURL+"/space/api/suite/permission/public/update.v5/") + if err != nil { + return nil, nil, err + } + return res, res.Body(), nil + } + + res, body, err := doRequestWithCsrf(doRequest) + if err != nil { + return err + } + if err := decodeBaseResp(body, res); err != nil { + return err + } + return nil +} + func (d *DoubaoNew) createFolder(ctx context.Context, parentToken, name string) (Node, error) { data := url.Values{} data.Set("name", name)