diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index bdf8245..6f73d72 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -90,7 +90,7 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"}) return } - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref) + link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return diff --git a/internal/service/cloud/cloud_test.go b/internal/service/cloud/cloud_test.go index 68e842e..dc634b1 100644 --- a/internal/service/cloud/cloud_test.go +++ b/internal/service/cloud/cloud_test.go @@ -2,8 +2,10 @@ package cloud import ( "context" + "encoding/base64" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -77,11 +79,6 @@ func Test115ListAndResolve(t *testing.T) { w.Write([]byte(`{"state":true,"data":[ {"cid":"100","n":"Movies","s":0}, {"fid":"200","n":"Inception.mkv","s":456,"pc":"pick200"}]}`)) - case "/files/download": - if r.URL.Query().Get("pickcode") != "pick200" { - t.Errorf("bad pickcode %q", r.URL.Query().Get("pickcode")) - } - w.Write([]byte(`{"state":true,"file_url":"https://cdn.115/x.mkv?t=1"}`)) default: t.Errorf("unexpected path %s", r.URL.Path) } @@ -92,6 +89,20 @@ func Test115ListAndResolve(t *testing.T) { if err != nil { t.Fatal(err) } + // The downurl endpoint is m115-encrypted end-to-end (the server side + // requires 115's private key), so stub the decrypted payload via the seam + // and assert the pickcode→URL extraction. The live crypto/transport path is + // exercised by integration testing against the real 115 API. + p115, ok := p.(*pan115Provider) + if !ok { + t.Fatalf("expected *pan115Provider, got %T", p) + } + p115.downURLPayload = func(ctx context.Context, pickcode string) ([]byte, error) { + if pickcode != "pick200" { + t.Errorf("bad pickcode %q", pickcode) + } + return []byte(`{"200":{"file_name":"Inception.mkv","file_size":"456","url":{"url":"https://cdn.115/x.mkv?t=1"}}}`), nil + } entries, err := p.List(context.Background(), "") if err != nil { t.Fatalf("list: %v", err) @@ -117,6 +128,41 @@ func Test115ListAndResolve(t *testing.T) { } } +// Test115DownURLEndpointAndError exercises the live fetchDownURLPayload path: +// it must POST an m115-encrypted `data` body to /app/chrome/downurl?t=... and +// surface 115's error when state=false (no decryption needed for that branch). +func Test115DownURLEndpointAndError(t *testing.T) { + var gotData, gotT string + pro := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/app/chrome/downurl" { + t.Errorf("unexpected path %s", r.URL.Path) + } + gotT = r.URL.Query().Get("t") + _ = r.ParseForm() + gotData = r.PostFormValue("data") + w.Write([]byte(`{"state":false,"error":"not exist"}`)) + })) + defer pro.Close() + + p, err := New(Type115, map[string]any{"cookie": "UID=1", "pro_base": pro.URL}, pro.Client()) + if err != nil { + t.Fatal(err) + } + _, err = p.Resolve(context.Background(), "pickX") + if err == nil || !strings.Contains(err.Error(), "not exist") { + t.Fatalf("want upstream error surfaced, got %v", err) + } + if gotT == "" { + t.Errorf("missing t query param") + } + if gotData == "" { + t.Errorf("missing encrypted data body") + } + if _, derr := base64.StdEncoding.DecodeString(gotData); derr != nil { + t.Errorf("data body is not base64: %v", derr) + } +} + func Test115QRFlow(t *testing.T) { // status sequence: waiting → scanned → confirmed calls := 0 diff --git a/internal/service/cloud/pan115.go b/internal/service/cloud/pan115.go index b85aa13..75e2669 100644 --- a/internal/service/cloud/pan115.go +++ b/internal/service/cloud/pan115.go @@ -20,11 +20,20 @@ type pan115Provider struct { cookie string ua string webBase string // https://webapi.115.com (override in tests) + proBase string // https://proapi.115.com (override in tests) client *http.Client proxy bool + + // downURLPayload fetches and decrypts the app/chrome/downurl response for a + // pickcode, returning the raw JSON payload (map of file id -> info). It is a + // seam so tests can bypass the live 115 crypto/transport. + downURLPayload func(ctx context.Context, pickcode string) ([]byte, error) } -const pan115WebBase = "https://webapi.115.com" +const ( + pan115WebBase = "https://webapi.115.com" + pan115ProBase = "https://proapi.115.com" +) func new115(cfg map[string]any, client *http.Client) *pan115Provider { web := str(cfg["base"]) @@ -41,13 +50,20 @@ func new115(cfg map[string]any, client *http.Client) *pan115Provider { if _, ok := cfg["force_proxy"]; ok && boolish(cfg["force_proxy"]) { proxy = true } - return &pan115Provider{ + pro := str(cfg["pro_base"]) + if pro == "" { + pro = pan115ProBase + } + p := &pan115Provider{ cookie: str(cfg["cookie"]), ua: ua, webBase: strings.TrimRight(web, "/"), + proBase: strings.TrimRight(pro, "/"), client: client, proxy: proxy, } + p.downURLPayload = p.fetchDownURLPayload + return p } func (p *pan115Provider) Type() string { return Type115 } @@ -126,39 +142,90 @@ func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, e } // Resolve accepts a pickcode (preferred) and returns the CDN download URL. +// +// 115 deprecated the plain web /files/download endpoint (it no longer returns +// file_url for ordinary cookies). We use the current app/chrome/downurl +// endpoint, which takes an m115-encrypted body and returns an m115-encrypted +// payload mapping the file id to a short-lived, OSS-signed CDN URL suitable for +// a 302 redirect (the same approach Alist's 115 driver uses). func (p *pan115Provider) Resolve(ctx context.Context, pickcode string) (*DirectLink, error) { if pickcode == "" { return nil, fmt.Errorf("115: empty pickcode") } - u := fmt.Sprintf("%s/files/download?pickcode=%s&_=%d", p.webBase, url.QueryEscape(pickcode), nowUnix()) - resp, err := p.get(ctx, u) + raw, err := p.downURLPayload(ctx, pickcode) + if err != nil { + return nil, err + } + var payload map[string]struct { + FileName string `json:"file_name"` + FileSize json.Number `json:"file_size"` + URL struct { + URL string `json:"url"` + } `json:"url"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("115: decode downurl: %w", err) + } + for _, info := range payload { + if info.URL.URL == "" { + continue + } + return &DirectLink{ + URL: info.URL.URL, + Headers: map[string]string{ + "User-Agent": p.ua, + "Cookie": p.cookie, + }, + Proxy: p.proxy, + }, nil + } + return nil, fmt.Errorf("115: download failed: no url") +} + +// fetchDownURLPayload performs the live encrypted app/chrome/downurl request and +// returns the decrypted JSON payload. +func (p *pan115Provider) fetchDownURLPayload(ctx context.Context, pickcode string) ([]byte, error) { + key := m115GenerateKey() + params, err := json.Marshal(map[string]string{"pickcode": pickcode}) + if err != nil { + return nil, err + } + form := url.Values{} + form.Set("data", m115Encode(params, key)) + u := fmt.Sprintf("%s/app/chrome/downurl?t=%d", p.proBase, nowUnix()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", p.cookie) + req.Header.Set("User-Agent", p.ua) + req.Header.Set("Accept", "application/json, text/plain, */*") + resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var r struct { - State bool `json:"state"` - Error string `json:"error"` - FileURL string `json:"file_url"` + State bool `json:"state"` + Error string `json:"error"` + Data string `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return nil, fmt.Errorf("115: decode download: %w", err) + return nil, fmt.Errorf("115: decode downurl: %w", err) } - if !r.State || r.FileURL == "" { + if !r.State || r.Data == "" { msg := r.Error if msg == "" { - msg = "no file_url" + msg = "no data" } return nil, fmt.Errorf("115: download failed: %s", msg) } - return &DirectLink{ - URL: r.FileURL, - Headers: map[string]string{ - "User-Agent": p.ua, - "Cookie": p.cookie, - }, - Proxy: p.proxy, - }, nil + out, err := m115Decode(r.Data, key) + if err != nil { + return nil, fmt.Errorf("115: decrypt downurl: %w", err) + } + return out, nil } // nowUnix is a seam for deterministic tests. diff --git a/internal/service/cloud/pan115_crypto.go b/internal/service/cloud/pan115_crypto.go new file mode 100644 index 0000000..01ee8e9 --- /dev/null +++ b/internal/service/cloud/pan115_crypto.go @@ -0,0 +1,184 @@ +package cloud + +// 115 网盘的 app/chrome/downurl 下载接口要求请求体使用 115 自有的 "m115" 加密协议 +// (RSA + XOR 混淆),返回的直链也以同样方式加密。普通 web cookie 调用旧的 +// /files/download 接口已不再返回 file_url,必须改用该加密接口。 +// +// 下面的实现移植自 MIT 许可的 github.com/SheltonZhu/115driver +// (pkg/crypto/m115),alist 等项目同样采用此实现。仅做最小改动:函数加 m115 +// 前缀以归入本包命名空间。 +// +// Copyright (c) 115driver authors. MIT License. + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "io" + "math/big" +) + +// m115Key is the random 16-byte session key generated per request. +type m115Key [16]byte + +func m115GenerateKey() m115Key { + key := m115Key{} + _, _ = io.ReadFull(rand.Reader, key[:]) + return key +} + +// m115Encode encrypts request input for the downurl endpoint. +func m115Encode(input []byte, key m115Key) string { + buf := make([]byte, 16+len(input)) + copy(buf, key[:]) + copy(buf[16:], input) + m115XORTransform(buf[16:], m115XORDeriveKey(key[:], 4)) + m115ReverseBytes(buf[16:]) + m115XORTransform(buf[16:], m115XORClientKey) + return base64.StdEncoding.EncodeToString(m115RSAEncrypt(buf)) +} + +// m115Decode decrypts the base64 response payload using the request key. +func m115Decode(input string, key m115Key) ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return nil, err + } + data = m115RSADecrypt(data) + output := make([]byte, len(data)-16) + copy(output, data[16:]) + m115XORTransform(output, m115XORDeriveKey(data[:16], 12)) + m115ReverseBytes(output) + m115XORTransform(output, m115XORDeriveKey(key[:], 4)) + return output, nil +} + +func m115ReverseBytes(data []byte) { + for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 { + data[i], data[j] = data[j], data[i] + } +} + +// --- RSA layer --- + +var ( + m115N, _ = big.NewInt(0).SetString( + "8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021"+ + "36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633"+ + "8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1"+ + "0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16) + m115E, _ = big.NewInt(0).SetString("10001", 16) + + m115KeyLength = m115N.BitLen() / 8 +) + +func m115RSAEncrypt(input []byte) []byte { + buf := &bytes.Buffer{} + for remainSize := len(input); remainSize > 0; { + sliceSize := m115KeyLength - 11 + if sliceSize > remainSize { + sliceSize = remainSize + } + m115RSAEncryptSlice(input[:sliceSize], buf) + input = input[sliceSize:] + remainSize -= sliceSize + } + return buf.Bytes() +} + +func m115RSAEncryptSlice(input []byte, w io.Writer) { + padSize := m115KeyLength - len(input) - 3 + padData := make([]byte, padSize) + _, _ = rand.Read(padData) + buf := make([]byte, m115KeyLength) + buf[0], buf[1] = 0, 2 + for i, b := range padData { + buf[2+i] = b%0xff + 0x01 + } + buf[padSize+2] = 0 + copy(buf[padSize+3:], input) + msg := big.NewInt(0).SetBytes(buf) + ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() + if fillSize := m115KeyLength - len(ret); fillSize > 0 { + zeros := make([]byte, fillSize) + _, _ = w.Write(zeros) + } + _, _ = w.Write(ret) +} + +func m115RSADecrypt(input []byte) []byte { + buf := &bytes.Buffer{} + for remainSize := len(input); remainSize > 0; { + sliceSize := m115KeyLength + if sliceSize > remainSize { + sliceSize = remainSize + } + m115RSADecryptSlice(input[:sliceSize], buf) + input = input[sliceSize:] + remainSize -= sliceSize + } + return buf.Bytes() +} + +func m115RSADecryptSlice(input []byte, w io.Writer) { + msg := big.NewInt(0).SetBytes(input) + ret := big.NewInt(0).Exp(msg, m115E, m115N).Bytes() + for i, b := range ret { + if b == 0 && i != 0 { + _, _ = w.Write(ret[i+1:]) + break + } + } +} + +// --- XOR layer --- + +var ( + m115XORKeySeed = []byte{ + 0xf0, 0xe5, 0x69, 0xae, 0xbf, 0xdc, 0xbf, 0x8a, + 0x1a, 0x45, 0xe8, 0xbe, 0x7d, 0xa6, 0x73, 0xb8, + 0xde, 0x8f, 0xe7, 0xc4, 0x45, 0xda, 0x86, 0xc4, + 0x9b, 0x64, 0x8b, 0x14, 0x6a, 0xb4, 0xf1, 0xaa, + 0x38, 0x01, 0x35, 0x9e, 0x26, 0x69, 0x2c, 0x86, + 0x00, 0x6b, 0x4f, 0xa5, 0x36, 0x34, 0x62, 0xa6, + 0x2a, 0x96, 0x68, 0x18, 0xf2, 0x4a, 0xfd, 0xbd, + 0x6b, 0x97, 0x8f, 0x4d, 0x8f, 0x89, 0x13, 0xb7, + 0x6c, 0x8e, 0x93, 0xed, 0x0e, 0x0d, 0x48, 0x3e, + 0xd7, 0x2f, 0x88, 0xd8, 0xfe, 0xfe, 0x7e, 0x86, + 0x50, 0x95, 0x4f, 0xd1, 0xeb, 0x83, 0x26, 0x34, + 0xdb, 0x66, 0x7b, 0x9c, 0x7e, 0x9d, 0x7a, 0x81, + 0x32, 0xea, 0xb6, 0x33, 0xde, 0x3a, 0xa9, 0x59, + 0x34, 0x66, 0x3b, 0xaa, 0xba, 0x81, 0x60, 0x48, + 0xb9, 0xd5, 0x81, 0x9c, 0xf8, 0x6c, 0x84, 0x77, + 0xff, 0x54, 0x78, 0x26, 0x5f, 0xbe, 0xe8, 0x1e, + 0x36, 0x9f, 0x34, 0x80, 0x5c, 0x45, 0x2c, 0x9b, + 0x76, 0xd5, 0x1b, 0x8f, 0xcc, 0xc3, 0xb8, 0xf5, + } + + m115XORClientKey = []byte{ + 0x78, 0x06, 0xad, 0x4c, 0x33, 0x86, 0x5d, 0x18, + 0x4c, 0x01, 0x3f, 0x46, + } +) + +func m115XORDeriveKey(seed []byte, size int) []byte { + key := make([]byte, size) + for i := 0; i < size; i++ { + key[i] = (seed[i] + m115XORKeySeed[size*i]) & 0xff + key[i] ^= m115XORKeySeed[size*(size-i-1)] + } + return key +} + +func m115XORTransform(data []byte, key []byte) { + dataSize, keySize := len(data), len(key) + mod := dataSize % 4 + if mod > 0 { + for i := 0; i < mod; i++ { + data[i] ^= key[i%keySize] + } + } + for i := mod; i < dataSize; i++ { + data[i] ^= key[(i-mod)%keySize] + } +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index c933525..dfe712b 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -221,14 +221,46 @@ func (s *StorageConfigService) CloudList(ctx context.Context, typ, dirID string) } // CloudResolve resolves a cloud file reference to a direct link. -func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef string) (*cloud.DirectLink, error) { - p, err := s.CloudProvider(ctx, typ) +// +// clientUA is the User-Agent of the playback client that will follow the 302 +// redirect. 115/夸克 CDN links are bound to the UA used to request them, so we +// resolve with the client's own UA — that way the pure 302 the host issues +// points at a link the client can fetch directly (true offload). When clientUA +// is empty the provider's default UA is used. +func (s *StorageConfigService) CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error) { + p, err := s.cloudProviderWithUA(ctx, typ, clientUA) if err != nil { return nil, err } return p.Resolve(ctx, fileRef) } +// cloudProviderWithUA builds a provider, overriding the request UA when a +// non-empty clientUA is supplied. +func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, clientUA string) (cloud.Provider, error) { + if !cloud.IsCloudType(typ) { + return nil, fmt.Errorf("not a cloud provider: %q", typ) + } + view, err := s.Get(ctx, typ) + if err != nil { + return nil, err + } + if view == nil { + return nil, fmt.Errorf("%s storage not configured", typ) + } + cfg := view.Config + if strings.TrimSpace(clientUA) != "" { + // Copy so we never mutate the cached view config. + cp := make(map[string]any, len(cfg)+1) + for k, v := range cfg { + cp[k] = v + } + cp["ua"] = clientUA + cfg = cp + } + return cloud.New(typ, cfg, s.client) +} + // cloudLibraryName maps a provider type to a friendly Chinese library name. func cloudLibraryName(typ string) string { switch typ {