package cloud import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" ) // pan115Provider implements 115 网盘 via cookie auth. // // 115 has removed its desktop clients, so cookies must come from the mobile // app / web (115.com) or a QR-code login (see QR* helpers below). Directory // listing uses the public web API; download resolves a file's pickcode to a // CDN URL that, like Alist's default 115 behaviour, is served by 302 redirect. 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" pan115ProBase = "https://proapi.115.com" ) func new115(cfg map[string]any, client *http.Client) *pan115Provider { web := str(cfg["base"]) if web == "" { web = pan115WebBase } ua := str(cfg["ua"]) if ua == "" { ua = defaultUA } // 115 CDN download URLs work with a plain 302 (Alist's recommended mode), // so offload by default. The global cloud playback setting decides whether // clients receive a STRMURL entry or a /Videos stream entry. proxy := false 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 } func (p *pan115Provider) get(ctx context.Context, u string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("Cookie", p.cookie) req.Header.Set("User-Agent", p.ua) req.Header.Set("Accept", "application/json, text/plain, */*") return p.client.Do(req) } func (p *pan115Provider) Ping(ctx context.Context) error { if p.cookie == "" { return fmt.Errorf("115: missing cookie") } _, err := p.List(ctx, "0") return err } func (p *pan115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) { if dirID == "" { dirID = "0" } const pageSize = 100 out := make([]FileEntry, 0, pageSize) for offset := 0; ; offset += pageSize { q := url.Values{} q.Set("aid", "1") q.Set("cid", dirID) q.Set("o", "user_ptime") q.Set("asc", "0") q.Set("offset", strconv.Itoa(offset)) q.Set("show_dir", "1") q.Set("limit", strconv.Itoa(pageSize)) q.Set("format", "json") resp, err := p.get(ctx, p.webBase+"/files?"+q.Encode()) if err != nil { return nil, err } var r struct { State bool `json:"state"` Error string `json:"error"` Data []struct { Fid string `json:"fid"` // file id (files only) Cid string `json:"cid"` // category id (dirs use this) N string `json:"n"` // name S json.Number `json:"s"` // size Pc string `json:"pc"` // pickcode } `json:"data"` } err = json.NewDecoder(resp.Body).Decode(&r) _ = resp.Body.Close() if err != nil { return nil, fmt.Errorf("115: decode list: %w", err) } if !r.State { return nil, fmt.Errorf("115: list failed: %s", r.Error) } for _, it := range r.Data { isDir := it.Fid == "" id := it.Fid if isDir { id = it.Cid } size, _ := it.S.Int64() out = append(out, FileEntry{ ID: id, Name: it.N, IsDir: isDir, Size: size, PickCode: it.Pc, }) } if len(r.Data) < pageSize { break } } return out, nil } // 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") } 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"` Data string `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("115: decode downurl: %w", err) } if !r.State || r.Data == "" { msg := r.Error if msg == "" { msg = "no data" } return nil, fmt.Errorf("115: download failed: %s", msg) } 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. var nowUnix = func() int64 { return timeNow().Unix() } // ─── QR-code login ─────────────────────────────────────────────────────────── // QRSession is the handle returned by QRStart; the client renders QRImageURL // and polls QRPoll until it returns a cookie. type QRSession struct { UID string `json:"uid"` Time int64 `json:"time"` Sign string `json:"sign"` QRImageURL string `json:"qr_image_url"` } // QR login hosts (overridable for tests). var ( qr115APIBase = "https://qrcodeapi.115.com" qr115PassportBase = "https://passportapi.115.com" ) // QRStart obtains a 115 QR-code login token + image URL. func QRStart(ctx context.Context, client *http.Client) (*QRSession, error) { if client == nil { client = http.DefaultClient } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/api/1.0/web/1.0/token/", nil) req.Header.Set("User-Agent", defaultUA) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var r struct { State int `json:"state"` Data struct { UID string `json:"uid"` Time int64 `json:"time"` Sign string `json:"sign"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("115 qr: decode token: %w", err) } if r.State != 1 || r.Data.UID == "" { return nil, fmt.Errorf("115 qr: token request failed") } return &QRSession{ UID: r.Data.UID, Time: r.Data.Time, Sign: r.Data.Sign, QRImageURL: qr115APIBase + "/api/1.0/web/1.0/qrcode?uid=" + url.QueryEscape(r.Data.UID), }, nil } // QRStatus is the poll result. type QRStatus struct { // State is one of: "waiting" (not scanned), "scanned" (scanned, awaiting // confirmation), "confirmed" (login approved; Cookie populated), // "expired" (token expired/cancelled). State string `json:"state"` Cookie string `json:"cookie,omitempty"` } // QRPoll checks the QR session status; on confirmation it exchanges the token // for a session cookie via the passport API. func QRPoll(ctx context.Context, client *http.Client, sess *QRSession) (*QRStatus, error) { if client == nil { client = http.DefaultClient } if sess == nil || sess.UID == "" { return nil, fmt.Errorf("115 qr: nil session") } q := url.Values{} q.Set("uid", sess.UID) q.Set("time", strconv.FormatInt(sess.Time, 10)) q.Set("sign", sess.Sign) q.Set("_", strconv.FormatInt(timeNow().UnixMilli(), 10)) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, qr115APIBase+"/get/status/?"+q.Encode(), nil) req.Header.Set("User-Agent", defaultUA) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var r struct { State int `json:"state"` Data struct { Status int `json:"status"` // 0 waiting, 1 scanned, 2 confirmed, -1/-2 expired } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return nil, fmt.Errorf("115 qr: decode status: %w", err) } switch r.Data.Status { case 1: return &QRStatus{State: "scanned"}, nil case 2: cookie, err := qr115Exchange(ctx, client, sess.UID) if err != nil { return nil, err } return &QRStatus{State: "confirmed", Cookie: cookie}, nil case 0: return &QRStatus{State: "waiting"}, nil default: return &QRStatus{State: "expired"}, nil } } // qr115Exchange swaps an approved uid for a session cookie. func qr115Exchange(ctx context.Context, client *http.Client, uid string) (string, error) { form := url.Values{} form.Set("account", uid) form.Set("app", "web") req, _ := http.NewRequestWithContext(ctx, http.MethodPost, qr115PassportBase+"/app/1.0/web/1.0/login/qrcode/", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", defaultUA) resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() var r struct { State int `json:"state"` Data struct { Cookie map[string]string `json:"cookie"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return "", fmt.Errorf("115 qr: decode login: %w", err) } if r.State != 1 || len(r.Data.Cookie) == 0 { return "", fmt.Errorf("115 qr: login exchange failed") } parts := make([]string, 0, len(r.Data.Cookie)) for k, v := range r.Data.Cookie { parts = append(parts, k+"="+v) } return strings.Join(parts, "; "), nil }