From 6a1dee086ad8af95f8317582715bb3e2b22ac46d Mon Sep 17 00:00:00 2001 From: syscc Date: Sun, 12 Jul 2026 15:23:38 +0800 Subject: [PATCH 1/2] feat(drivers/139): merge multiple share roots --- drivers/139/driver.go | 151 ++++++++++++++++++ drivers/139/meta.go | 3 +- drivers/139/share_test.go | 35 +++++ drivers/139/types.go | 51 ++++++ drivers/139/util.go | 318 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 drivers/139/share_test.go diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 6386a2f256..326ede56e7 100644 --- a/drivers/139/driver.go +++ b/drivers/139/driver.go @@ -2,12 +2,15 @@ package _139 import ( "context" + "encoding/base64" "encoding/xml" "fmt" "io" "net/http" + "net/url" "path" "strconv" + "strings" "time" "github.com/OpenListTeam/OpenList/v4/drivers/base" @@ -32,6 +35,72 @@ type Yun139 struct { RootPath string } +type shareRef struct { + LinkID string + Password string + NodeID string +} + +const multiShareRefPrefix = "shares:" + +func encodeShareRef(linkID, password, nodeID string) string { + return url.PathEscape(linkID) + "|" + url.PathEscape(password) + "|" + url.PathEscape(nodeID) +} + +func decodeShareRef(id string) (shareRef, bool) { + parts := strings.SplitN(id, "|", 3) + if len(parts) != 3 { + return shareRef{}, false + } + linkID, err1 := url.PathUnescape(parts[0]) + password, err2 := url.PathUnescape(parts[1]) + nodeID, err3 := url.PathUnescape(parts[2]) + if err1 != nil || err2 != nil || err3 != nil || linkID == "" { + return shareRef{}, false + } + return shareRef{LinkID: linkID, Password: password, NodeID: nodeID}, true +} + +func encodeShareRefs(refs []shareRef) string { + if len(refs) == 1 { + ref := refs[0] + return encodeShareRef(ref.LinkID, ref.Password, ref.NodeID) + } + data, err := utils.Json.Marshal(refs) + if err != nil { + return "" + } + return multiShareRefPrefix + base64.RawURLEncoding.EncodeToString(data) +} + +func decodeShareRefs(id string) ([]shareRef, bool) { + if !strings.HasPrefix(id, multiShareRefPrefix) { + ref, ok := decodeShareRef(id) + if !ok { + return nil, false + } + return []shareRef{ref}, true + } + data, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, multiShareRefPrefix)) + if err != nil { + return nil, false + } + var refs []shareRef + if err = utils.Json.Unmarshal(data, &refs); err != nil || len(refs) == 0 { + return nil, false + } + return refs, true +} + +func (d *Yun139) shareRootEntries() []shareRef { + entries := d.shareEntries() + refs := make([]shareRef, 0, len(entries)) + for _, entry := range entries { + refs = append(refs, shareRef{LinkID: entry.LinkID, Password: entry.Password, NodeID: "root"}) + } + return refs +} + func (d *Yun139) Config() driver.Config { return config } @@ -41,6 +110,26 @@ func (d *Yun139) GetAddition() driver.Additional { } func (d *Yun139) Init(ctx context.Context) error { + if d.Addition.Type == MetaShare { + if len(d.Addition.RootFolderID) == 0 { + d.RootFolderID = "root" + } + d.LinkID = d.Addition.LinkID + if d.ref == nil && (d.Authorization != "" || (d.Username != "" && d.Password != "")) { + if len(d.Authorization) == 0 { + log.Infof("139yun: authorization is empty, trying to login with password.") + newAuth, err := d.loginWithPassword() + log.Debugf("newAuth: Ok: %s", newAuth) + if err != nil { + return fmt.Errorf("login with password failed: %w", err) + } + } + if err := d.refreshToken(); err != nil { + return err + } + } + return nil + } if d.ref == nil { if len(d.Authorization) == 0 { if d.Username != "" && d.Password != "" { @@ -107,6 +196,13 @@ func (d *Yun139) Init(ctx context.Context) error { if err != nil { return err } + case MetaShare: + if len(d.Addition.RootFolderID) == 0 { + d.RootFolderID = "root" + } + if len(d.shareEntries()) == 0 { + return fmt.Errorf("link_id is empty") + } case MetaFamily: if len(d.Addition.RootFolderID) == 0 { // Attempt to obtain data.path as the root via a query and persist it. @@ -134,6 +230,45 @@ func (d *Yun139) InitReference(storage driver.Driver) error { return errs.NotSupport } +func (d *Yun139) Get(ctx context.Context, path string) (model.Obj, error) { + if d.Addition.Type != MetaShare { + return nil, errs.NotImplement + } + if path == "/" { + return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil + } + if obj, err := d.shareGetObj(path); err == nil { + return obj, nil + } + return nil, errs.ObjectNotFound +} + +func (d *Yun139) shareEntries() []struct{ LinkID, Password string } { + raw := strings.TrimSpace(d.LinkID) + if raw == "" { + return nil + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == ',' || r == ';' + }) + entries := make([]struct{ LinkID, Password string }, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + entry := struct{ LinkID, Password string }{LinkID: part} + if linkID, password, ok := strings.Cut(part, "#"); ok { + entry.LinkID = strings.TrimSpace(linkID) + entry.Password = strings.TrimSpace(password) + } + if entry.LinkID != "" { + entries = append(entries, entry) + } + } + return entries +} + func (d *Yun139) Drop(ctx context.Context) error { if d.cron != nil { d.cron.Stop() @@ -152,12 +287,23 @@ func (d *Yun139) List(ctx context.Context, dir model.Obj, args model.ListArgs) ( return d.familyGetFiles(dir.GetID()) case MetaGroup: return d.groupGetFiles(dir.GetID()) + case MetaShare: + if dir.GetID() == "root" { + return d.shareGetMergedFiles(d.shareRootEntries()) + } + if refs, ok := decodeShareRefs(dir.GetID()); ok { + return d.shareGetMergedFiles(refs) + } + return d.shareGetFilesWithRef(shareRef{LinkID: d.LinkID, NodeID: dir.GetID()}, dir.GetID()) default: return nil, errs.NotImplement } } func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + if file.IsDir() { + return nil, errs.NotFile + } var url string var err error switch d.Addition.Type { @@ -169,6 +315,11 @@ func (d *Yun139) Link(ctx context.Context, file model.Obj, args model.LinkArgs) url, err = d.familyGetLink(file.GetID(), file.GetPath()) case MetaGroup: url, err = d.groupGetLink(file.GetID(), file.GetPath()) + case MetaShare: + if refs, ok := decodeShareRefs(file.GetID()); ok && len(refs) > 0 { + return d.shareGetLinkWithRef(refs[0], refs[0].NodeID, args.Type) + } + return d.shareGetLinkWithRef(shareRef{LinkID: d.LinkID, NodeID: "root"}, file.GetID(), args.Type) default: return nil, errs.NotImplement } diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 91d54fd300..c61ac861d3 100644 --- a/drivers/139/meta.go +++ b/drivers/139/meta.go @@ -12,7 +12,8 @@ type Addition struct { Password string `json:"password" required:"true" secret:"true"` MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."` driver.RootID - Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"` + Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"` + LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."` CloudID string `json:"cloud_id"` UserDomainID string `json:"user_domain_id" help:"ud_id in Cookie, fill in to show disk usage"` CustomUploadPartSize int64 `json:"custom_upload_part_size" type:"number" default:"0" help:"0 for auto"` diff --git a/drivers/139/share_test.go b/drivers/139/share_test.go new file mode 100644 index 0000000000..f22920cd50 --- /dev/null +++ b/drivers/139/share_test.go @@ -0,0 +1,35 @@ +package _139 + +import ( + "testing" +) + +func TestShareEntriesAndRefEncoding(t *testing.T) { + d := &Yun139{Addition: Addition{ + Type: MetaShare, + LinkID: "2w2KLTrz2Y8bd,2uR1zFho3YNcj,2w2KLzpSwt57p#f95e", + }} + + entries := d.shareEntries() + if len(entries) != 3 { + t.Fatalf("expected 3 share entries, got %d", len(entries)) + } + if entries[2].LinkID != "2w2KLzpSwt57p" || entries[2].Password != "f95e" { + t.Fatalf("unexpected password share entry: %+v", entries[2]) + } + + refs := []shareRef{ + {LinkID: entries[0].LinkID, Password: entries[0].Password, NodeID: "root-a"}, + {LinkID: entries[2].LinkID, Password: entries[2].Password, NodeID: "root-b"}, + } + encoded := encodeShareRefs(refs) + decoded, ok := decodeShareRefs(encoded) + if !ok || len(decoded) != len(refs) { + t.Fatalf("failed to decode merged share refs: %q", encoded) + } + for i := range refs { + if decoded[i] != refs[i] { + t.Fatalf("unexpected decoded ref at %d: %+v", i, decoded[i]) + } + } +} diff --git a/drivers/139/types.go b/drivers/139/types.go index 8c23cdcbb2..a7401a3aa1 100644 --- a/drivers/139/types.go +++ b/drivers/139/types.go @@ -9,6 +9,7 @@ const ( MetaFamily string = "family" MetaGroup string = "group" MetaPersonalNew string = "personal_new" + MetaShare string = "share" ) type BaseResp struct { @@ -285,6 +286,56 @@ type PersonalUploadUrlResp struct { } } +type ShareCatalog struct { + CaID string `json:"caId"` + CaName string `json:"caName"` + UdTime string `json:"udTime"` +} + +type ShareContent struct { + CoID string `json:"coId"` + CoName string `json:"coName"` + CoSize int64 `json:"coSize"` + CoType int `json:"coType"` + UdTime string `json:"udTime"` + CoPath string `json:"coPath"` + PresentURL string `json:"presentURL"` + DownloadURL string `json:"downloadURL"` +} + +type ShareListResp struct { + BaseResp + Data struct { + LKName string `json:"lkName"` + Passwd string `json:"password"` + CaLst []ShareCatalog `json:"caLst"` + CoLst []ShareContent `json:"coLst"` + } `json:"data"` +} + +type ShareContentInfo struct { + PresentURL string `json:"presentURL"` + DownloadURL string `json:"cdnDownLoadUrl"` +} + +type ShareDownloadResp struct { + BaseResp + Data struct { + DownloadURL string `json:"downloadURL"` + RedrURL string `json:"redrUrl"` + ExtInfo struct { + CDNDownloadURL string `json:"cdnDownloadUrl"` + } `json:"extInfo"` + } `json:"data"` +} + +type ShareContentInfoResp struct { + BaseResp + Data struct { + ContentInfo ShareContentInfo `json:"contentInfo"` + } `json:"data"` +} + type QueryRoutePolicyResp struct { Success bool `json:"success"` Code string `json:"code"` diff --git a/drivers/139/util.go b/drivers/139/util.go index 3ff123ba38..11fe783d6b 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -2,6 +2,7 @@ package _139 import ( "bytes" + "compress/gzip" "context" "crypto/aes" "crypto/cipher" @@ -23,6 +24,7 @@ import ( "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/internal/op" "github.com/OpenListTeam/OpenList/v4/pkg/utils" @@ -485,6 +487,257 @@ func (d *Yun139) groupGetLink(contentId string, path string) (string, error) { return jsoniter.Get(res, "data", "downloadURL").ToString(), nil } +func (d *Yun139) sharePost(pathname string, data interface{}, resp interface{}) ([]byte, error) { + crypto := NewYunCrypto() + encryptedBody, err := crypto.Encrypt(data) + if err != nil { + return nil, err + } + + url := "https://share-kd-njs.yun.139.com" + pathname + req := base.RestyClient.R() + auth := d.getAuthorization() + if auth != "" && !strings.HasPrefix(strings.ToLower(auth), "basic ") { + auth = "Basic " + auth + } + headers := map[string]string{ + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json;charset=UTF-8", + "X-Deviceinfo": "||9|12.27.0|firefox|140.0|12b780037221ab547c682223327dc9cd||linux unknow|1920X526|zh-CN|||", + "hcy-cool-flag": "1", + "CMS-DEVICE": "default", + "x-m4c-caller": "PC", + "X-Yun-Api-Version": "v1", + "Origin": "https://yun.139.com", + "Referer": "https://yun.139.com/", + } + if auth != "" { + headers["Authorization"] = auth + } + req.SetHeaders(headers) + req.SetBody(encryptedBody) + + res, err := req.Post(url) + if err != nil { + return nil, err + } + + decryptedText, err := crypto.Decrypt(res.String()) + if err != nil { + log.Errorf("[139Share] Decryption failed, raw response: %s", res.String()) + return nil, fmt.Errorf("decryption failed: %v, raw: %s", err, res.String()) + } + + if resp != nil { + if err = utils.Json.Unmarshal([]byte(decryptedText), resp); err != nil { + return nil, err + } + } + return []byte(decryptedText), nil +} + +func (d *Yun139) shareGetFiles(pCaID string) ([]model.Obj, error) { + return d.shareGetFilesWithRef(shareRef{LinkID: d.Addition.LinkID, NodeID: pCaID}, pCaID) +} + +func (d *Yun139) shareGetFilesWithRef(ref shareRef, pCaID string) ([]model.Obj, error) { + if ref.NodeID == "" { + ref.NodeID = "root" + } + if pCaID == "" { + pCaID = ref.NodeID + } + data := base.Json{ + "getOutLinkInfoReq": base.Json{ + "account": d.getAccount(), + "linkID": ref.LinkID, + "passwd": ref.Password, + "pCaID": pCaID, + }, + } + var resp ShareListResp + _, err := d.sharePost("/yun-share/richlifeApp/devapp/IOutLink/getOutLinkInfoV6", data, &resp) + if err != nil { + return nil, err + } + files := make([]model.Obj, 0) + for _, catalog := range resp.Data.CaLst { + modTime, _ := time.ParseInLocation("20060102150405", catalog.UdTime, utils.CNLoc) + f := model.Object{ + ID: encodeShareRef(ref.LinkID, ref.Password, catalog.CaID), + Name: catalog.CaName, + Modified: modTime, + IsFolder: true, + } + files = append(files, &f) + } + for _, content := range resp.Data.CoLst { + name := content.CoName + size := content.CoSize + modTime, _ := time.ParseInLocation("20060102150405", content.UdTime, utils.CNLoc) + f := model.Object{ + ID: encodeShareRef(ref.LinkID, ref.Password, content.CoID), + Name: name, + Size: size, + Modified: modTime, + } + files = append(files, &f) + } + + return files, nil +} + +func (d *Yun139) shareGetMergedFiles(refs []shareRef) ([]model.Obj, error) { + files := make([]model.Obj, 0) + indices := make(map[string]int) + var firstErr error + for _, ref := range refs { + items, err := d.shareGetFilesWithRef(ref, ref.NodeID) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + for _, item := range items { + idx, exists := indices[item.GetName()] + if !exists { + indices[item.GetName()] = len(files) + files = append(files, item) + continue + } + if !files[idx].IsDir() || !item.IsDir() { + continue + } + existingRefs, ok1 := decodeShareRefs(files[idx].GetID()) + itemRefs, ok2 := decodeShareRefs(item.GetID()) + if !ok1 || !ok2 { + continue + } + mergedRefs := append(existingRefs, itemRefs...) + files[idx] = &model.Object{ + ID: encodeShareRefs(mergedRefs), + Name: item.GetName(), + Modified: item.ModTime(), + IsFolder: true, + } + } + } + if len(files) == 0 && firstErr != nil { + return nil, firstErr + } + return files, nil +} + +func (d *Yun139) shareGetObj(reqPath string) (model.Obj, error) { + reqPath = utils.FixAndCleanPath(reqPath) + if reqPath == "/" { + return &model.Object{ID: "root", Name: "root", IsFolder: true, Path: "/"}, nil + } + parts := strings.Split(strings.Trim(reqPath, "/"), "/") + currentRefs := d.shareRootEntries() + currentPath := "" + var currentObj model.Obj + for idx, part := range parts { + items, err := d.shareGetMergedFiles(currentRefs) + if err != nil { + return nil, err + } + matched := false + for _, item := range items { + if item.GetName() != part { + continue + } + matched = true + currentObj = item + currentPath = path.Join(currentPath, item.GetName()) + if item.IsDir() { + itemRefs, ok := decodeShareRefs(item.GetID()) + if !ok { + return nil, errs.ObjectNotFound + } + currentRefs = itemRefs + break + } + if idx != len(parts)-1 { + return nil, errs.ObjectNotFound + } + } + if !matched { + return nil, errs.ObjectNotFound + } + } + if currentObj == nil { + return nil, errs.ObjectNotFound + } + if setter, ok := currentObj.(model.SetPath); ok { + setter.SetPath(currentPath) + } + return currentObj, nil +} + +func (d *Yun139) shareGetLink(coID string, linkType string) (*model.Link, error) { + return d.shareGetLinkWithRef(shareRef{LinkID: d.Addition.LinkID, NodeID: "root"}, coID, linkType) +} + +func (d *Yun139) shareGetLinkWithRef(ref shareRef, coID string, linkType string) (*model.Link, error) { + data := base.Json{ + "getContentInfoFromOutLinkReq": base.Json{ + "contentId": coID, + "linkID": ref.LinkID, + "passwd": ref.Password, + "account": d.getAccount(), + }, + } + var resp ShareContentInfoResp + body, err := d.sharePost("/yun-share/richlifeApp/devapp/IOutLink/getContentInfoFromOutLink", data, &resp) + if err != nil { + return nil, err + } + + res := resp.Data.ContentInfo + if linkType == "video_preview" || linkType == "preview" || linkType == "thumb" { + if res.PresentURL == "" { + return nil, fmt.Errorf("failed to get preview link") + } + return &model.Link{URL: res.PresentURL}, nil + } + + if d.getAccount() == "" { + return nil, fmt.Errorf("139 share download requires account authentication") + } + + downloadReq := base.Json{ + "dlFromOutLinkReqV3": base.Json{ + "account": d.getAccount(), + "linkID": ref.LinkID, + "passwd": ref.Password, + "coIDLst": base.Json{ + "item": []string{coID}, + }, + }, + } + var downloadResp ShareDownloadResp + downloadBody, err := d.sharePost("/yun-share/richlifeApp/devapp/IOutLink/dlFromOutLinkV3", downloadReq, &downloadResp) + if err != nil { + return nil, err + } + if downloadResp.Data.ExtInfo.CDNDownloadURL != "" { + return &model.Link{URL: downloadResp.Data.ExtInfo.CDNDownloadURL}, nil + } + if downloadResp.Data.RedrURL != "" { + return &model.Link{URL: downloadResp.Data.RedrURL}, nil + } + if downloadResp.Data.DownloadURL != "" { + return &model.Link{URL: downloadResp.Data.DownloadURL}, nil + } + + log.Debugf("[139Share] content info without embedded download url: %s", string(body)) + log.Debugf("[139Share] download response without direct url: %s", string(downloadBody)) + return nil, fmt.Errorf("failed to get link") +} + func unicode(str string) string { textQuoted := strconv.QuoteToASCII(str) textUnquoted := textQuoted[1 : len(textQuoted)-1] @@ -995,6 +1248,71 @@ func aesCbcDecrypt(ciphertext []byte, key []byte, iv []byte) ([]byte, error) { return pkcs7_unpad(decrypted) } +type YunCrypto struct { + Key []byte + BlockSize int +} + +func NewYunCrypto() *YunCrypto { + return &YunCrypto{ + Key: []byte("PVGDwmcvfs1uV3d1"), + BlockSize: aes.BlockSize, + } +} + +func (y *YunCrypto) Encrypt(data interface{}) (string, error) { + jsonData, err := utils.Json.Marshal(data) + if err != nil { + return "", err + } + iv := make([]byte, y.BlockSize) + if _, err := io.ReadFull(crypto_rand.Reader, iv); err != nil { + return "", err + } + ciphertext, err := aesCbcEncrypt(jsonData, y.Key, iv) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(append(iv, ciphertext...)), nil +} + +func (y *YunCrypto) Decrypt(b64Data string) (string, error) { + b64Data = strings.Join(strings.Fields(b64Data), "") + raw, err := base64.StdEncoding.DecodeString(b64Data) + if err != nil { + return "", err + } + if len(raw) < y.BlockSize { + return "", errors.New("data too short") + } + iv := raw[:y.BlockSize] + ciphertext := raw[y.BlockSize:] + decrypted, err := aesCbcDecrypt(ciphertext, y.Key, iv) + if err != nil { + if len(ciphertext)%aes.BlockSize != 0 { + return "", err + } + block, blockErr := aes.NewCipher(y.Key) + if blockErr != nil { + return "", blockErr + } + decrypted = make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, ciphertext) + decrypted = []byte(strings.TrimSpace(string(decrypted))) + } + if len(decrypted) > 2 && decrypted[0] == 0x1f && decrypted[1] == 0x8b { + reader, err := gzip.NewReader(bytes.NewReader(decrypted)) + if err == nil { + defer reader.Close() + unzipped, err := io.ReadAll(reader) + if err == nil { + return string(unzipped), nil + } + } + } + return string(decrypted), nil +} + // sortedJsonStringify 对 JSON 对象进行排序并字符串化。 func sortedJsonStringify(obj interface{}) (string, error) { if obj == nil { From a3d59695aa3491d13e6c201dda8262b2d987f2e2 Mon Sep 17 00:00:00 2001 From: syscc Date: Sun, 12 Jul 2026 20:45:00 +0800 Subject: [PATCH 2/2] fix(drivers/139): fallback to share preview without account --- drivers/139/util.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/drivers/139/util.go b/drivers/139/util.go index 11fe783d6b..10221ac545 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -705,6 +705,9 @@ func (d *Yun139) shareGetLinkWithRef(ref shareRef, coID string, linkType string) } if d.getAccount() == "" { + if res.PresentURL != "" { + return &model.Link{URL: res.PresentURL}, nil + } return nil, fmt.Errorf("139 share download requires account authentication") }