mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
添加strm生成
添加strm
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
// 115 开放平台 HTTP 客户端(移植自 QMediaSync 的 v115open,去掉 resty 依赖,
|
||||
// 使用 net/http + 简单限流重试;只保留只读能力:授权/列目录/详情/下载直链)。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpenClient 是 115 开放平台客户端。
|
||||
type OpenClient struct {
|
||||
AppID string
|
||||
HTTP *http.Client
|
||||
AccessToken string
|
||||
RefreshTokenStr string
|
||||
|
||||
// 全局 QPS 限流(115 开放平台免费额度较低)
|
||||
lastSecond int64
|
||||
reqInSecond int64
|
||||
}
|
||||
|
||||
var openClientMu sync.Mutex
|
||||
|
||||
// NewOpenClient 构造客户端。
|
||||
func NewOpenClient(appID, accessToken, refreshToken string) *OpenClient {
|
||||
return &OpenClient{
|
||||
AppID: appID,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
AccessToken: accessToken,
|
||||
RefreshTokenStr: refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuthToken 更新认证令牌。
|
||||
func (c *OpenClient) SetAuthToken(accessToken, refreshToken string) {
|
||||
c.AccessToken = accessToken
|
||||
c.RefreshTokenStr = refreshToken
|
||||
}
|
||||
|
||||
// throttle 简单的每秒限流(默认 4 QPS,115 免费应用限额约 5 QPS)。
|
||||
func (c *OpenClient) throttle(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
now := time.Now().Unix()
|
||||
last := atomic.LoadInt64(&c.lastSecond)
|
||||
if last != now {
|
||||
if atomic.CompareAndSwapInt64(&c.lastSecond, last, now) {
|
||||
atomic.StoreInt64(&c.reqInSecond, 0)
|
||||
}
|
||||
}
|
||||
count := atomic.LoadInt64(&c.reqInSecond)
|
||||
if count >= 4 {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
i--
|
||||
continue
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(&c.reqInSecond, count, count+1) {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
// RespState 兼容 115 不同端点返回的 state 类型(proapi 返回布尔、passport 返回数字)。
|
||||
type RespState bool
|
||||
|
||||
func (s *RespState) UnmarshalJSON(data []byte) error {
|
||||
switch string(data) {
|
||||
case "true", "1":
|
||||
*s = true
|
||||
return nil
|
||||
case "false", "0", "null", "":
|
||||
*s = false
|
||||
return nil
|
||||
}
|
||||
var n float64
|
||||
if err := json.Unmarshal(data, &n); err != nil {
|
||||
return fmt.Errorf("115: 无法解析 state 字段 %s", string(data))
|
||||
}
|
||||
*s = n != 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespBase 是 115 开放平台统一响应外壳。
|
||||
type RespBase struct {
|
||||
State RespState `json:"state"`
|
||||
Code int `json:"code"`
|
||||
Errno int `json:"errno"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
|
||||
}
|
||||
|
||||
// doJSON 执行 GET 请求并解析为统一响应;带 AccessToken(access=true 时)。
|
||||
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int) (*RespBase, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
c.throttle(1)
|
||||
req, err := c.buildRequest(ctx, method, rawURL, form, access)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
lastErr = fmt.Errorf("115 接口返回 HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
var base RespBase
|
||||
if err := json.Unmarshal(body, &base); err != nil {
|
||||
return nil, fmt.Errorf("115 接口响应解析失败:%w", err)
|
||||
}
|
||||
base.Raw = body
|
||||
if base.State {
|
||||
return &base, nil
|
||||
}
|
||||
// 业务失败:限流/Token 错误不重试,其余按配置重试
|
||||
if IsThrottleCode(base.Code) || isTokenCode(base.Code) {
|
||||
return &base, nil
|
||||
}
|
||||
lastErr = NewOpenAPIResponseError(base.Code, base.Errno, base.Message, base.Error, "115 接口调用失败")
|
||||
if attempt < retries {
|
||||
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return &base, lastErr
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (c *OpenClient) buildRequest(ctx context.Context, method, rawURL string, form map[string]string, access bool) (*http.Request, error) {
|
||||
method = strings.ToUpper(method)
|
||||
var body io.Reader
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
values := url.Values{}
|
||||
for k, v := range form {
|
||||
values.Set(k, v)
|
||||
}
|
||||
body = bytes.NewBufferString(values.Encode())
|
||||
} else if method == http.MethodGet && len(form) > 0 {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := u.Query()
|
||||
for k, v := range form {
|
||||
query.Set(k, v)
|
||||
}
|
||||
u.RawQuery = query.Encode()
|
||||
rawURL = u.String()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", DefaultUA)
|
||||
if method == http.MethodPost && len(form) > 0 {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if access && c.AccessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.AccessToken)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// doAuthJSON 带 AccessToken 的业务请求。
|
||||
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
|
||||
return c.doJSON(ctx, method, rawURL, form, true, retries)
|
||||
}
|
||||
|
||||
// IsThrottleCode 判断是否为限流错误码。
|
||||
func IsThrottleCode(code int) bool {
|
||||
return code == RequestMaxLimitCode || code == RequestRateLimitCode
|
||||
}
|
||||
|
||||
func isTokenCode(code int) bool {
|
||||
switch code {
|
||||
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, RefreshTokenInvalid:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// openList 解析 data 为对象或数组(StructOrArray 语义)。
|
||||
func openList[T any](raw json.RawMessage) ([]T, error) {
|
||||
var single T
|
||||
if err := json.Unmarshal(raw, &single); err == nil {
|
||||
return []T{single}, nil
|
||||
}
|
||||
var arr []T
|
||||
if err := json.Unmarshal(raw, &arr); err == nil {
|
||||
return arr, nil
|
||||
}
|
||||
return nil, fmt.Errorf("115: data 既不是对象也不是数组")
|
||||
}
|
||||
|
||||
// openFirstList 取 data 的第一个元素。
|
||||
func openFirstList[T any](raw json.RawMessage) (*T, error) {
|
||||
items, err := openList[T](raw)
|
||||
if err != nil || len(items) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &items[0], nil
|
||||
}
|
||||
|
||||
func firstOrEmpty(m map[string]downloadURLData) downloadURLData {
|
||||
for _, v := range m {
|
||||
return v
|
||||
}
|
||||
return downloadURLData{}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockTransport 用 httptest server 替换 API 基址。
|
||||
func mockAPI(t *testing.T, handler http.HandlerFunc) func() {
|
||||
t.Helper()
|
||||
pro := httptest.NewServer(handler)
|
||||
passport := httptest.NewServer(handler)
|
||||
qr := httptest.NewServer(handler)
|
||||
oldPro, oldPassport, oldQR := ProAPIBase, PassportAPIBase, QRCodeAPIBase
|
||||
ProAPIBase, PassportAPIBase, QRCodeAPIBase = pro.URL, passport.URL, qr.URL
|
||||
t.Cleanup(func() {
|
||||
ProAPIBase, PassportAPIBase, QRCodeAPIBase = oldPro, oldPassport, oldQR
|
||||
pro.Close()
|
||||
passport.Close()
|
||||
qr.Close()
|
||||
})
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func TestGetQrCode(t *testing.T) {
|
||||
var called bool
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.URL.Path != "/open/authDeviceCode" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PostFormValue("client_id") != "100195125" {
|
||||
t.Errorf("bad client_id %q", r.PostFormValue("client_id"))
|
||||
}
|
||||
if r.PostFormValue("code_challenge") == "" {
|
||||
t.Errorf("missing code_challenge")
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"uid":"U1","time":1700,"sign":"S1","qrcode":"https://img/qr.png"}}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "", "")
|
||||
qr, err := c.GetQrCode()
|
||||
if err != nil {
|
||||
t.Fatalf("get qr: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("request not hit")
|
||||
}
|
||||
if qr.Uid != "U1" || qr.Qrcode == "" || qr.CodeVerifier == "" || len(qr.CodeVerifier) != 64 {
|
||||
t.Fatalf("bad qr data: %#v", qr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQrCodeScanStatusSequence(t *testing.T) {
|
||||
calls := 0
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/get/status/" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
calls++
|
||||
switch calls {
|
||||
case 1:
|
||||
w.Write([]byte(`{"state":true,"data":{"status":0}}`))
|
||||
case 2:
|
||||
w.Write([]byte(`{"state":true,"data":{"status":1}}`))
|
||||
default:
|
||||
w.Write([]byte(`{"state":true,"data":{"status":2}}`))
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("", "", "")
|
||||
code := &QrCodeData{Uid: "U1", Time: 1700, Sign: "S1"}
|
||||
want := []QrCodeScanStatus{QrCodeScanStatusNotScanned, QrCodeScanStatusScanned, QrCodeScanStatusConfirmed}
|
||||
for i, exp := range want {
|
||||
got, err := c.QrCodeScanStatus(code)
|
||||
if err != nil {
|
||||
t.Fatalf("status %d: %v", i, err)
|
||||
}
|
||||
if got != exp {
|
||||
t.Fatalf("status %d: got %v want %v", i, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenAndRefresh(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/deviceCodeToToken":
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at1","refresh_token":"rt1","expires_in":7200}}`))
|
||||
case "/open/refreshToken":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PostFormValue("refresh_token") != "rt2" {
|
||||
t.Errorf("bad refresh_token %q", r.PostFormValue("refresh_token"))
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("100195125", "", "")
|
||||
token, err := c.GetToken(&QrCodeDataReturn{QrCodeData: QrCodeData{Uid: "U1"}, CodeVerifier: "v"})
|
||||
if err != nil {
|
||||
t.Fatalf("get token: %v", err)
|
||||
}
|
||||
if token.AccessToken != "at1" || c.AccessToken != "at1" {
|
||||
t.Fatalf("bad token: %#v", token)
|
||||
}
|
||||
token, err = c.RefreshToken("rt2")
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if token.AccessToken != "at2" || c.RefreshTokenStr != "rt2" {
|
||||
t.Fatalf("bad refresh result: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenDead(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"state":false,"code":40140119,"message":"refresh_token 已过期"}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at", "rt-dead")
|
||||
_, err := c.RefreshToken("rt-dead")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !IsRefreshTokenDead(err) {
|
||||
t.Fatalf("expected dead refresh token error, got %v", err)
|
||||
}
|
||||
if c.AccessToken != "" {
|
||||
t.Fatalf("dead token should clear access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsListAndDownload(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open/ufile/files":
|
||||
if r.Header.Get("Authorization") != "Bearer at1" {
|
||||
t.Errorf("missing auth header")
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"path":[{"name":"根目录","cid":0}],"data":[
|
||||
{"fid":"100","fc":"0","fn":"Movies","fs":0},
|
||||
{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
|
||||
case "/open/ufile/downurl":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.PostFormValue("pick_code") != "pickA" {
|
||||
t.Errorf("bad pick_code")
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"200":{"file_name":"a.mkv","url":{"url":"https://cdn/x.mkv"}}}}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
files, pathStr, err := c.GetFsList(context.Background(), "0", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("want 2 files, got %d", len(files))
|
||||
}
|
||||
if !files[0].IsDir() {
|
||||
t.Fatalf("first entry should be dir")
|
||||
}
|
||||
if pathStr != "根目录" {
|
||||
t.Fatalf("path_str = %q", pathStr)
|
||||
}
|
||||
url, err := c.GetDownloadURL(context.Background(), "pickA")
|
||||
if err != nil {
|
||||
t.Fatalf("downurl: %v", err)
|
||||
}
|
||||
if url != "https://cdn/x.mkv" {
|
||||
t.Fatalf("bad url %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func (f RemoteFile) IsDir() bool { return f.Category == TypeDir }
|
||||
|
||||
func TestGetFsDetailByCid(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/folder/get_info" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"file_id":"100","file_name":"Movies","file_category":"0","size_byte":123}}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
detail, err := c.GetFsDetailByCid(context.Background(), "100")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.FileId != "100" || detail.FileName != "Movies" {
|
||||
t.Fatalf("bad detail: %#v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelayRoundTrip 中继加解密往返 + 回调解析。
|
||||
func TestRelayRoundTrip(t *testing.T) {
|
||||
RelayEncryptionKey = "unit-test-shared-key"
|
||||
defer func() { RelayEncryptionKey = "" }()
|
||||
|
||||
payload := `{"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`
|
||||
encrypted, err := EncryptRelay(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decrypted, err := DecryptRelay(encrypted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decrypted != payload {
|
||||
t.Fatalf("round trip mismatch: %q", decrypted)
|
||||
}
|
||||
|
||||
provider, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"})
|
||||
if err != nil {
|
||||
t.Fatalf("relay provider should be available when key configured: %v", err)
|
||||
}
|
||||
token, err := provider.Confirm(context.Background(), map[string]string{"data": encrypted})
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !token.Done || token.AccessToken != "at" || token.RefreshToken != "rt" {
|
||||
t.Fatalf("bad token: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayProviderRequiresKey(t *testing.T) {
|
||||
RelayEncryptionKey = ""
|
||||
if _, err := GetOAuthProvider(Source{SourceType: SourceTypeBuiltInRelay, Provider: ProviderQMediaSync, AppID: "QMediaSync"}); err == nil {
|
||||
t.Fatal("relay provider should fail without key")
|
||||
}
|
||||
if RelayAvailable() {
|
||||
t.Fatal("RelayAvailable should be false without key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoviePilotProvider(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/u115/auth_url":
|
||||
w.Write([]byte(`{"auth_url":"https://passport.115/auth?state=ST","state":"ST"}`))
|
||||
case "/u115/token":
|
||||
if r.URL.Query().Get("state") != "ST" {
|
||||
t.Errorf("bad state")
|
||||
}
|
||||
w.Write([]byte(`{"state":true,"data":{"access_token":"at","refresh_token":"rt","expires_in":7200}}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
provider := moviePilotOAuthProvider{authServer: srv.URL}
|
||||
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if result.AuthURL == "" || result.State != "ST" || !result.Polling {
|
||||
t.Fatalf("bad result: %#v", result)
|
||||
}
|
||||
token, err := provider.Poll(context.Background(), "ST")
|
||||
if err != nil {
|
||||
t.Fatalf("poll: %v", err)
|
||||
}
|
||||
if !token.Done || token.AccessToken != "at" {
|
||||
t.Fatalf("bad token: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudDriveBuildAuth(t *testing.T) {
|
||||
provider := cloudDriveOAuthProvider{source: Source{SourceType: SourceTypeThirdPartyService, Provider: ProviderCloudDrive, AppID: "100195313"}}
|
||||
result, err := provider.BuildAuth(context.Background(), OAuthURLRequest{
|
||||
RedirectURL: "http://127.0.0.1:8080/api/strm/oauth/callback",
|
||||
AuthorizationID: "auth-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.AuthURL, "client_id=100195313") {
|
||||
t.Fatalf("bad auth url: %s", result.AuthURL)
|
||||
}
|
||||
parsed, err := url.Parse(result.AuthURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse auth url: %v", err)
|
||||
}
|
||||
state, err := url.QueryUnescape(parsed.Query().Get("state"))
|
||||
if err != nil {
|
||||
t.Fatalf("unescape state: %v", err)
|
||||
}
|
||||
if !strings.Contains(state, "authorization_id=auth-123") {
|
||||
t.Fatalf("missing auth id in state: %s", result.AuthURL)
|
||||
}
|
||||
token, err := provider.Confirm(context.Background(), map[string]string{
|
||||
"access_token": "at", "refresh_token": "rt", "expires_in": "7200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
if !token.Done || token.AccessToken != "at" {
|
||||
t.Fatalf("bad token: %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCatalog(t *testing.T) {
|
||||
if len(BuiltInAppIDSources()) < 50 {
|
||||
t.Fatalf("built-in app catalog too small: %d", len(BuiltInAppIDSources()))
|
||||
}
|
||||
if _, ok := FindSource(SourceTypeBuiltInAppID, ProviderOfficialPKCE, "100195125"); !ok {
|
||||
t.Fatal("媒体播放器 app not found")
|
||||
}
|
||||
if len(BuiltInRelaySources()) == 0 || len(ThirdPartySources()) != 2 {
|
||||
t.Fatal("relay/thrid-party sources broken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 115 开放平台常量与错误类型。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func sha256Sum(data []byte) [32]byte { return sha256.Sum256(data) }
|
||||
func base64StdEncode(data []byte) string { return base64.StdEncoding.EncodeToString(data) }
|
||||
|
||||
// 115 开放平台 API 地址(变量便于测试注入 mock server)
|
||||
var (
|
||||
ProAPIBase = "https://proapi.115.com"
|
||||
PassportAPIBase = "https://passportapi.115.com"
|
||||
QRCodeAPIBase = "https://qrcodeapi.115.com"
|
||||
FSPIsAPIBase = "https://fsapi.115.com"
|
||||
)
|
||||
|
||||
const (
|
||||
// 业务错误码
|
||||
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
|
||||
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
|
||||
AccessAuthInvalid = 40140124 // 访问无效,需刷新
|
||||
RefreshTokenInvalid = 40140116 // 需重新授权
|
||||
TokenRefreshFail = 40140121 // 刷新失败,可重试
|
||||
RequestMaxLimitCode = 770004 // 访问频率过高
|
||||
RequestRateLimitCode = 406 // 达到访问上限
|
||||
|
||||
// 刷新 token 的错误码
|
||||
RefreshTokenFormatInvalid = 40140114
|
||||
RefreshTokenSignInvalid = 40140115
|
||||
RefreshTooFrequent = 40140117
|
||||
RefreshTokenExpired = 40140119
|
||||
RefreshTokenCheckFailed = 40140120
|
||||
)
|
||||
|
||||
// DefaultUA 请求 UA。
|
||||
const DefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-115-OpenAPI/1.0"
|
||||
|
||||
// OpenAPIError 保留 115 开放平台返回的原始错误信息。
|
||||
type OpenAPIError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *OpenAPIError) Error() string {
|
||||
if e.Code == 0 {
|
||||
return fmt.Sprintf("115 接口错误:%s", e.Message)
|
||||
}
|
||||
return fmt.Sprintf("115 接口错误(%d):%s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// NewOpenAPIResponseError 组装接口错误。
|
||||
func NewOpenAPIResponseError(code, errno int, message, errorText, fallback string) error {
|
||||
if code == 0 {
|
||||
code = errno
|
||||
}
|
||||
if message == "" {
|
||||
message = errorText
|
||||
}
|
||||
if code == 0 && message == "" {
|
||||
return fmt.Errorf("%s", fallback)
|
||||
}
|
||||
return NewOpenAPIError(code, message)
|
||||
}
|
||||
|
||||
// NewOpenAPIError 构造接口错误。
|
||||
func NewOpenAPIError(code int, message string) *OpenAPIError {
|
||||
if message == "" {
|
||||
message = "未知错误"
|
||||
}
|
||||
return &OpenAPIError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// IsRefreshTokenDead 判断 refresh_token 是否已无法继续使用。
|
||||
func IsRefreshTokenDead(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var apiErr *OpenAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.Code {
|
||||
case RefreshTokenFormatInvalid, RefreshTokenSignInvalid, RefreshTokenInvalid, RefreshTokenExpired, RefreshTokenCheckFailed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// genCodeChallenge 生成 PKCE code_challenge(sha256(codeVerifier) base64)。
|
||||
func genCodeChallenge(codeVerifier string) string {
|
||||
sum := sha256Sum([]byte(codeVerifier))
|
||||
return base64StdEncode(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// 115 OAuth 授权来源:内置应用(设备码扫码)、QMediaSync/MQFamily 中继
|
||||
// (需共享 AES 密钥)、MoviePilot 轮询、CloudDrive 回跳。
|
||||
// 逻辑移植自 QMediaSync 的 v115auth。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthURLRequest 是发起授权的上下文。
|
||||
type OAuthURLRequest struct {
|
||||
Source Source
|
||||
RedirectURL string // 本服务回调地址(中继/CloudDrive 回跳用)
|
||||
AuthorizationID string
|
||||
QRCode *QrCodeDataReturn `json:"-"` // 设备码模式:调用方预取的二维码
|
||||
}
|
||||
|
||||
// OAuthURLResult 是授权发起结果。
|
||||
type OAuthURLResult struct {
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Polling bool `json:"polling"`
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
// QRCode 非空表示设备码扫码模式(uid/time/sign/qrcode 一并返回)
|
||||
QRCode *QrCodeDataReturn `json:"qrcode,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthTokenResult 是授权完成结果。
|
||||
type OAuthTokenResult struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int64
|
||||
Done bool
|
||||
}
|
||||
|
||||
// OAuthProvider 描述一种 115 授权来源。
|
||||
type OAuthProvider interface {
|
||||
BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error)
|
||||
Confirm(ctx context.Context, payload map[string]string) (OAuthTokenResult, error)
|
||||
Poll(ctx context.Context, state string) (OAuthTokenResult, error)
|
||||
}
|
||||
|
||||
var errUnsupportedOAuthOperation = errors.New("当前授权服务不支持此操作")
|
||||
|
||||
// GetOAuthProvider 按授权来源取 provider:
|
||||
// - official_pkce:设备码扫码(需 AppID)
|
||||
// - qmediasync/mqfamily:中继授权(需配置 strm.115_relay_key)
|
||||
// - moviepilot / clouddrive:第三方服务
|
||||
func GetOAuthProvider(source Source) (OAuthProvider, error) {
|
||||
switch source.Provider {
|
||||
case ProviderOfficialPKCE:
|
||||
if strings.TrimSpace(source.AppID) == "" {
|
||||
return nil, fmt.Errorf("缺少 115 开放平台应用 ID")
|
||||
}
|
||||
return deviceCodeOAuthProvider{}, nil
|
||||
case ProviderQMediaSync, ProviderMQFamily:
|
||||
if !RelayAvailable() {
|
||||
return nil, fmt.Errorf("中继授权需要配置共享加密密钥(设置 strm.115_relay_key)")
|
||||
}
|
||||
return relayOAuthProvider{source: source}, nil
|
||||
case ProviderMoviePilot:
|
||||
return moviePilotOAuthProvider{authServer: oauthServerOr(source.AuthServer, "https://movie-pilot.org")}, nil
|
||||
case ProviderCloudDrive:
|
||||
return cloudDriveOAuthProvider{source: source}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 115 授权来源")
|
||||
}
|
||||
}
|
||||
|
||||
func oauthServerOr(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimRight(strings.TrimSpace(value), "/")
|
||||
}
|
||||
|
||||
// deviceCodeOAuthProvider 走 115 官方设备码扫码(无回调、无共享密钥)。
|
||||
type deviceCodeOAuthProvider struct{}
|
||||
|
||||
func (deviceCodeOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
|
||||
if req.QRCode != nil {
|
||||
return OAuthURLResult{QRCode: req.QRCode, ExpiresIn: 300}, nil
|
||||
}
|
||||
return OAuthURLResult{}, fmt.Errorf("缺少设备码数据")
|
||||
}
|
||||
|
||||
func (deviceCodeOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
|
||||
return OAuthTokenResult{}, errUnsupportedOAuthOperation
|
||||
}
|
||||
|
||||
func (deviceCodeOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
|
||||
return OAuthTokenResult{}, errUnsupportedOAuthOperation
|
||||
}
|
||||
|
||||
// relayOAuthProvider 走 QMediaSync/MQFamily 中继授权。
|
||||
type relayOAuthProvider struct {
|
||||
source Source
|
||||
}
|
||||
|
||||
func (provider relayOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
|
||||
clientID := strings.TrimSpace(provider.source.AppID)
|
||||
if clientID == "" {
|
||||
clientID = BuiltInRelayQ115STRM
|
||||
if provider.source.Provider == ProviderQMediaSync {
|
||||
clientID = BuiltInRelayQMediaSync
|
||||
}
|
||||
}
|
||||
redirectURL := strings.TrimSpace(req.RedirectURL)
|
||||
if redirectURL != "" {
|
||||
var err error
|
||||
redirectURL, err = appendCallbackParams(redirectURL, url.Values{
|
||||
"source": []string{"115"},
|
||||
"authorization_id": []string{req.AuthorizationID},
|
||||
})
|
||||
if err != nil {
|
||||
return OAuthURLResult{}, err
|
||||
}
|
||||
}
|
||||
stateObj := struct {
|
||||
State string `json:"state"`
|
||||
Time int64 `json:"time"`
|
||||
ClientId string `json:"client_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
AuthorizationID string `json:"authorization_id,omitempty"`
|
||||
}{
|
||||
State: RandomString(16),
|
||||
Time: time.Now().Unix(),
|
||||
ClientId: clientID,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizationID: req.AuthorizationID,
|
||||
}
|
||||
stateJSON, _ := json.Marshal(stateObj)
|
||||
stateEncoded, err := EncryptRelay(string(stateJSON))
|
||||
if err != nil {
|
||||
return OAuthURLResult{}, err
|
||||
}
|
||||
baseURL := oauthServerOr(provider.source.AuthServer, "https://api.mqfamily.top")
|
||||
if provider.source.Provider == ProviderQMediaSync {
|
||||
baseURL = oauthServerOr(provider.source.AuthServer, "https://oauth.qmediasync.cn")
|
||||
}
|
||||
return OAuthURLResult{AuthURL: fmt.Sprintf("%s/115.php?action=code&state=%s", baseURL, stateEncoded)}, nil
|
||||
}
|
||||
|
||||
func (provider relayOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
|
||||
data := payload["data"]
|
||||
if data == "" {
|
||||
return OAuthTokenResult{}, fmt.Errorf("缺少中转回调数据")
|
||||
}
|
||||
decryptedData, err := DecryptRelay(data)
|
||||
if err != nil {
|
||||
return OAuthTokenResult{}, err
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(decryptedData), &resp); err != nil {
|
||||
return OAuthTokenResult{}, err
|
||||
}
|
||||
if resp.Data.AccessToken == "" || resp.Data.RefreshToken == "" {
|
||||
if resp.Error != "" {
|
||||
return OAuthTokenResult{}, errors.New(resp.Error)
|
||||
}
|
||||
if resp.Message != "" {
|
||||
return OAuthTokenResult{}, errors.New(resp.Message)
|
||||
}
|
||||
return OAuthTokenResult{}, fmt.Errorf("中转回调未返回访问凭证")
|
||||
}
|
||||
return OAuthTokenResult{AccessToken: resp.Data.AccessToken, RefreshToken: resp.Data.RefreshToken, ExpiresIn: resp.Data.ExpiresIn, Done: true}, nil
|
||||
}
|
||||
|
||||
func (provider relayOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
|
||||
return OAuthTokenResult{}, errUnsupportedOAuthOperation
|
||||
}
|
||||
|
||||
// moviePilotOAuthProvider 走 MoviePilot 的 115 授权服务(轮询)。
|
||||
type moviePilotOAuthProvider struct {
|
||||
authServer string
|
||||
}
|
||||
|
||||
func (provider moviePilotOAuthProvider) BuildAuth(ctx context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
|
||||
endpoint := provider.authServer + "/u115/auth_url"
|
||||
resp, err := httpGetJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return OAuthURLResult{}, err
|
||||
}
|
||||
authURL := stringField(resp, "auth_url")
|
||||
state := stringField(resp, "state")
|
||||
if authURL == "" || state == "" {
|
||||
return OAuthURLResult{}, fmt.Errorf("MoviePilot 授权服务响应缺少 auth_url 或 state")
|
||||
}
|
||||
return OAuthURLResult{AuthURL: authURL, State: state, Polling: true, ExpiresIn: 300}, nil
|
||||
}
|
||||
|
||||
func (provider moviePilotOAuthProvider) Confirm(_ context.Context, _ map[string]string) (OAuthTokenResult, error) {
|
||||
return OAuthTokenResult{}, errUnsupportedOAuthOperation
|
||||
}
|
||||
|
||||
func (provider moviePilotOAuthProvider) Poll(ctx context.Context, state string) (OAuthTokenResult, error) {
|
||||
if state == "" {
|
||||
return OAuthTokenResult{}, fmt.Errorf("缺少授权状态")
|
||||
}
|
||||
endpoint := provider.authServer + "/u115/token?state=" + url.QueryEscape(state)
|
||||
resp, err := httpGetJSON(ctx, endpoint)
|
||||
if err != nil {
|
||||
return OAuthTokenResult{}, err
|
||||
}
|
||||
return tokenResultFromMap(resp), nil
|
||||
}
|
||||
|
||||
// cloudDriveOAuthProvider 走 CloudDrive 中转(115 授权页 → zhenyunpan 换 token → 回跳)。
|
||||
type cloudDriveOAuthProvider struct {
|
||||
source Source
|
||||
}
|
||||
|
||||
func (provider cloudDriveOAuthProvider) BuildAuth(_ context.Context, req OAuthURLRequest) (OAuthURLResult, error) {
|
||||
if strings.TrimSpace(req.RedirectURL) == "" {
|
||||
return OAuthURLResult{}, fmt.Errorf("CloudDrive 授权需要回跳地址")
|
||||
}
|
||||
callback, err := appendCallbackParams(req.RedirectURL, url.Values{
|
||||
"source": []string{"115"},
|
||||
"authorization_id": []string{req.AuthorizationID},
|
||||
})
|
||||
if err != nil {
|
||||
return OAuthURLResult{}, err
|
||||
}
|
||||
clientID := strings.TrimSpace(provider.source.AppID)
|
||||
if clientID == "" {
|
||||
clientID = "100195313"
|
||||
}
|
||||
redirectURI := strings.TrimSpace(provider.source.RedirectURI)
|
||||
if redirectURI == "" {
|
||||
redirectURI = "https://redirect115.zhenyunpan.com"
|
||||
}
|
||||
authURL, _ := url.Parse("https://passportapi.115.com/open/authorize")
|
||||
query := authURL.Query()
|
||||
query.Set("client_id", clientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("state", callback)
|
||||
authURL.RawQuery = query.Encode()
|
||||
return OAuthURLResult{AuthURL: authURL.String(), ExpiresIn: 300}, nil
|
||||
}
|
||||
|
||||
func (provider cloudDriveOAuthProvider) Confirm(_ context.Context, payload map[string]string) (OAuthTokenResult, error) {
|
||||
expiresIn, _ := strconv.ParseInt(payload["expires_in"], 10, 64)
|
||||
token := OAuthTokenResult{
|
||||
AccessToken: payload["access_token"],
|
||||
RefreshToken: payload["refresh_token"],
|
||||
ExpiresIn: expiresIn,
|
||||
}
|
||||
token.Done = token.AccessToken != "" && token.RefreshToken != ""
|
||||
if !token.Done {
|
||||
return OAuthTokenResult{}, fmt.Errorf("CloudDrive 回调未返回访问凭证")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (provider cloudDriveOAuthProvider) Poll(_ context.Context, _ string) (OAuthTokenResult, error) {
|
||||
return OAuthTokenResult{}, errUnsupportedOAuthOperation
|
||||
}
|
||||
|
||||
// ─── 工具 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func appendCallbackParams(rawURL string, params url.Values) (string, error) {
|
||||
callbackURL, err := url.Parse(rawURL)
|
||||
if err != nil || callbackURL.Scheme == "" || callbackURL.Host == "" {
|
||||
return "", fmt.Errorf("OAuth 回跳地址无效")
|
||||
}
|
||||
if callbackURL.Fragment != "" {
|
||||
fragmentPath, fragmentQuery, hasQuery := strings.Cut(callbackURL.Fragment, "?")
|
||||
fragmentValues := url.Values{}
|
||||
if hasQuery {
|
||||
fragmentValues, err = url.ParseQuery(fragmentQuery)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for key, values := range params {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
fragmentValues.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
callbackURL.Fragment = fragmentPath + "?" + fragmentValues.Encode()
|
||||
return callbackURL.String(), nil
|
||||
}
|
||||
query := callbackURL.Query()
|
||||
for key, values := range params {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
query.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
callbackURL.RawQuery = query.Encode()
|
||||
return callbackURL.String(), nil
|
||||
}
|
||||
|
||||
func httpGetJSON(ctx context.Context, endpoint string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", DefaultUA)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("授权服务返回 HTTP %d:%s", resp.StatusCode, string(body))
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nested, ok := data["data"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
if _, exists := data[key]; !exists {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func tokenResultFromMap(data map[string]any) OAuthTokenResult {
|
||||
if nested, ok := data["data"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
if _, exists := data[key]; !exists {
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
expiresIn := int64(0)
|
||||
switch value := data["expires_in"].(type) {
|
||||
case float64:
|
||||
expiresIn = int64(value)
|
||||
case json.Number:
|
||||
expiresIn, _ = value.Int64()
|
||||
case string:
|
||||
expiresIn, _ = strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
token := OAuthTokenResult{
|
||||
AccessToken: stringField(data, "access_token"),
|
||||
RefreshToken: stringField(data, "refresh_token"),
|
||||
ExpiresIn: expiresIn,
|
||||
}
|
||||
token.Done = token.AccessToken != "" && token.RefreshToken != ""
|
||||
return token
|
||||
}
|
||||
|
||||
func stringField(data map[string]any, key string) string {
|
||||
value, ok := data[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case fmt.Stringer:
|
||||
return v.String()
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 中继回调 AES 加解密(与 QMediaSync 同算法:AES-256-CBC + URL-safe Base64) ───
|
||||
|
||||
// EncryptRelay 加密中继授权 state / 回调数据。
|
||||
func EncryptRelay(plaintext string) (string, error) {
|
||||
return encryptAES(plaintext, RelayEncryptionKey)
|
||||
}
|
||||
|
||||
// DecryptRelay 解密中继回调数据。
|
||||
func DecryptRelay(encrypted string) (string, error) {
|
||||
return decryptAES(encrypted, RelayEncryptionKey)
|
||||
}
|
||||
|
||||
func encryptAES(plaintext, keyText string) (string, error) {
|
||||
if keyText == "" {
|
||||
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(keyText))
|
||||
block, err := aes.NewCipher(keyHash[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
padding := aes.BlockSize - len(plaintext)%aes.BlockSize
|
||||
padtext := append([]byte(plaintext), make([]byte, padding)...)
|
||||
for i := 0; i < padding; i++ {
|
||||
padtext[len(plaintext)+i] = byte(padding)
|
||||
}
|
||||
ciphertext := make([]byte, aes.BlockSize+len(padtext))
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
mode.CryptBlocks(ciphertext[aes.BlockSize:], padtext)
|
||||
encoded := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
encoded = strings.ReplaceAll(encoded, "+", "-")
|
||||
encoded = strings.ReplaceAll(encoded, "/", "_")
|
||||
return strings.TrimRight(encoded, "="), nil
|
||||
}
|
||||
|
||||
func decryptAES(encrypted, keyText string) (string, error) {
|
||||
if keyText == "" {
|
||||
return "", errors.New("加密密钥不能为空(请配置 strm.115_relay_key)")
|
||||
}
|
||||
encrypted = strings.ReplaceAll(encrypted, "-", "+")
|
||||
encrypted = strings.ReplaceAll(encrypted, "_", "/")
|
||||
switch len(encrypted) % 4 {
|
||||
case 2:
|
||||
encrypted += "=="
|
||||
case 3:
|
||||
encrypted += "="
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) < aes.BlockSize {
|
||||
return "", errors.New("密文长度不足")
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(keyText))
|
||||
block, err := aes.NewCipher(keyHash[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv := data[:aes.BlockSize]
|
||||
ciphertext := data[aes.BlockSize:]
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
mode.CryptBlocks(ciphertext, ciphertext)
|
||||
if len(ciphertext) == 0 {
|
||||
return "", errors.New("解密结果为空")
|
||||
}
|
||||
padding := int(ciphertext[len(ciphertext)-1])
|
||||
if padding <= 0 || padding > aes.BlockSize {
|
||||
return "", errors.New("解密填充非法")
|
||||
}
|
||||
return string(ciphertext[:len(ciphertext)-padding]), nil
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// 115 开放平台只读 API:列目录、详情、下载直链、授权(二维码/换 token/刷新)。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ─── 文件模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type FileType string
|
||||
|
||||
const (
|
||||
TypeFile FileType = "1"
|
||||
TypeDir FileType = "0"
|
||||
)
|
||||
|
||||
// RemoteFile 是 115 文件列表中的一个条目。
|
||||
type RemoteFile struct {
|
||||
FileId string `json:"fid"` // 文件 ID
|
||||
Pid string `json:"pid"` // 父文件夹 ID
|
||||
Category FileType `json:"fc"` // 0 文件夹 1 文件
|
||||
FileName string `json:"fn"` // 文件名
|
||||
PickCode string `json:"pc"` // 提取码
|
||||
Utime int64 `json:"upt"` // 修改时间
|
||||
Ptime int64 `json:"uppt"` // 上传时间
|
||||
Sha1 string `json:"sha1"` // SHA1
|
||||
FileSize int64 `json:"fs"` // 大小
|
||||
Fta string `json:"fta"` // 0/2 未上传完成,1 已完成
|
||||
}
|
||||
|
||||
func (f RemoteFile) ModifiedAt() int64 {
|
||||
if f.Utime > 0 {
|
||||
return f.Utime
|
||||
}
|
||||
return f.Ptime
|
||||
}
|
||||
|
||||
type FileListResp struct {
|
||||
RespBase
|
||||
Path []struct {
|
||||
Name string `json:"name"`
|
||||
FileId string `json:"cid"`
|
||||
} `json:"path"`
|
||||
PathStr string `json:"path_str"`
|
||||
}
|
||||
|
||||
// GetFsList 列目录(cid=0 为根目录)。
|
||||
func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, string, error) {
|
||||
if cid == "" {
|
||||
cid = "0"
|
||||
}
|
||||
params := map[string]string{"cid": cid}
|
||||
if limit > 0 {
|
||||
params["limit"] = fmt.Sprint(limit)
|
||||
}
|
||||
if offset > 0 {
|
||||
params["offset"] = fmt.Sprint(offset)
|
||||
}
|
||||
params["cur"] = "1"
|
||||
params["show_dir"] = "1"
|
||||
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// state=false(token 过期、限流、业务失败等)绝不能当作空目录返回:
|
||||
// 同步流程会据此认为远端已清空并清理本地 .strm/元数据文件。
|
||||
if !resp.State {
|
||||
return nil, "", NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败")
|
||||
}
|
||||
var list FileListResp
|
||||
list.RespBase = *resp
|
||||
if len(resp.Raw) > 0 {
|
||||
_ = json.Unmarshal(resp.Raw, &list)
|
||||
}
|
||||
files, err := openList[RemoteFile](resp.Data)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("115: 解析文件列表失败:%w", err)
|
||||
}
|
||||
pathStr := make([]string, 0, len(list.Path))
|
||||
for _, item := range list.Path {
|
||||
if item.FileId == "0" {
|
||||
continue
|
||||
}
|
||||
pathStr = append(pathStr, item.Name)
|
||||
}
|
||||
return files, strings.Join(pathStr, "/"), nil
|
||||
}
|
||||
|
||||
// GetFsDetailByCid 查询文件(夹)详情。
|
||||
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
|
||||
params := map[string]string{"file_id": fileId}
|
||||
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/folder/get_info", params, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return openFirstList[RemoteFileDetail](resp.Data)
|
||||
}
|
||||
|
||||
// RemoteFileDetail 是文件详情。
|
||||
type RemoteFileDetail struct {
|
||||
FileId string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
PickCode string `json:"pick_code"`
|
||||
Sha1 string `json:"sha1"`
|
||||
Category FileType `json:"file_category"`
|
||||
SizeByte int64 `json:"size_byte"`
|
||||
Paths []struct {
|
||||
FileId string `json:"file_id"`
|
||||
Name string `json:"file_name"`
|
||||
} `json:"paths"`
|
||||
}
|
||||
|
||||
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type downloadURLData struct {
|
||||
FileName string `json:"file_name"`
|
||||
PickCode string `json:"pick_code"`
|
||||
Sha1 string `json:"sha1"`
|
||||
URL struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"url"`
|
||||
}
|
||||
|
||||
// GetDownloadURL 获取下载直链(pickcode)。
|
||||
func (c *OpenClient) GetDownloadURL(ctx context.Context, pickCode string) (string, error) {
|
||||
params := map[string]string{"pick_code": pickCode}
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var data map[string]downloadURLData
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
return "", fmt.Errorf("115: 解析下载地址失败:%w", err)
|
||||
}
|
||||
first := firstOrEmpty(data)
|
||||
if first.URL.URL == "" {
|
||||
return "", fmt.Errorf("115: 下载地址为空(文件可能未上传完成或已被删除)")
|
||||
}
|
||||
return first.URL.URL, nil
|
||||
}
|
||||
|
||||
// ─── 授权(设备码扫码) ──────────────────────────────────────────────────────
|
||||
|
||||
// QrCodeScanStatus 扫码状态。
|
||||
type QrCodeScanStatus int
|
||||
|
||||
const (
|
||||
QrCodeScanStatusExpired QrCodeScanStatus = 5
|
||||
QrCodeScanStatusNotScanned QrCodeScanStatus = 2
|
||||
QrCodeScanStatusScanned QrCodeScanStatus = 3
|
||||
QrCodeScanStatusConfirmed QrCodeScanStatus = 4
|
||||
)
|
||||
|
||||
func (s QrCodeScanStatus) String() string {
|
||||
switch s {
|
||||
case QrCodeScanStatusNotScanned:
|
||||
return "waiting"
|
||||
case QrCodeScanStatusScanned:
|
||||
return "scanned"
|
||||
case QrCodeScanStatusConfirmed:
|
||||
return "confirmed"
|
||||
default:
|
||||
return "expired"
|
||||
}
|
||||
}
|
||||
|
||||
func (s QrCodeScanStatus) Tip() string {
|
||||
switch s {
|
||||
case QrCodeScanStatusNotScanned:
|
||||
return "等待扫码"
|
||||
case QrCodeScanStatusScanned:
|
||||
return "已扫码,请在 115 客户端确认"
|
||||
case QrCodeScanStatusConfirmed:
|
||||
return "授权成功"
|
||||
default:
|
||||
return "二维码已过期"
|
||||
}
|
||||
}
|
||||
|
||||
// QrCodeData 是设备码二维码数据。
|
||||
type QrCodeData struct {
|
||||
Uid string `json:"uid"`
|
||||
Time int64 `json:"time"`
|
||||
Sign string `json:"sign"`
|
||||
Qrcode string `json:"qrcode"` // 二维码图片 URL
|
||||
}
|
||||
|
||||
// QrCodeDataReturn 含 PKCE code_verifier。
|
||||
type QrCodeDataReturn struct {
|
||||
QrCodeData
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
// TokenData 是 115 访问令牌。
|
||||
type TokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// GetQrCode 获取设备码登录二维码。
|
||||
func (c *OpenClient) GetQrCode() (*QrCodeDataReturn, error) {
|
||||
codeVerifier := RandomString(64)
|
||||
params := map[string]string{
|
||||
"client_id": c.AppID,
|
||||
"code_challenge": genCodeChallenge(codeVerifier),
|
||||
"code_challenge_method": "sha256",
|
||||
}
|
||||
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/authDeviceCode", params, false, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := openFirstList[QrCodeData](resp.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QrCodeDataReturn{QrCodeData: *code, CodeVerifier: codeVerifier}, nil
|
||||
}
|
||||
|
||||
// QrCodeScanStatus 查询扫码状态。
|
||||
func (c *OpenClient) QrCodeScanStatus(codeData *QrCodeData) (QrCodeScanStatus, error) {
|
||||
if codeData == nil {
|
||||
return QrCodeScanStatusExpired, fmt.Errorf("空二维码数据")
|
||||
}
|
||||
params := map[string]string{
|
||||
"uid": codeData.Uid,
|
||||
"time": fmt.Sprint(codeData.Time),
|
||||
"sign": codeData.Sign,
|
||||
}
|
||||
resp, err := c.doJSON(context.Background(), "GET", QRCodeAPIBase+"/get/status/", params, false, 1)
|
||||
if err != nil {
|
||||
return QrCodeScanStatusExpired, err
|
||||
}
|
||||
status, err := openFirstList[struct {
|
||||
Status int `json:"status"` // 0 未扫码 1 已扫码 2 已确认
|
||||
}](resp.Data)
|
||||
if err != nil {
|
||||
return QrCodeScanStatusExpired, err
|
||||
}
|
||||
switch status.Status {
|
||||
case 1:
|
||||
return QrCodeScanStatusScanned, nil
|
||||
case 2:
|
||||
return QrCodeScanStatusConfirmed, nil
|
||||
case 0:
|
||||
return QrCodeScanStatusNotScanned, nil
|
||||
default:
|
||||
return QrCodeScanStatusExpired, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken 用设备码换访问令牌。
|
||||
func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
|
||||
if qrCode == nil || qrCode.Uid == "" {
|
||||
return nil, fmt.Errorf("空二维码数据")
|
||||
}
|
||||
params := map[string]string{
|
||||
"uid": qrCode.Uid,
|
||||
"code_verifier": qrCode.CodeVerifier,
|
||||
}
|
||||
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/deviceCodeToToken", params, false, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := openFirstList[TokenData](resp.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌。
|
||||
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
||||
if refreshToken == "" {
|
||||
refreshToken = c.RefreshTokenStr
|
||||
}
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("没有可用的 refresh_token")
|
||||
}
|
||||
params := map[string]string{"refresh_token": refreshToken}
|
||||
resp, err := c.doJSON(context.Background(), "POST", PassportAPIBase+"/open/refreshToken", params, false, 0)
|
||||
if err != nil && resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.State {
|
||||
apiErr := NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 开放平台刷新访问凭证失败")
|
||||
if IsRefreshTokenDead(apiErr) {
|
||||
c.SetAuthToken("", "")
|
||||
}
|
||||
return nil, apiErr
|
||||
}
|
||||
token, err := openFirstList[TokenData](resp.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ─── 用户信息 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// UserInfo 是 115 用户信息。
|
||||
type UserInfo struct {
|
||||
UserId json.Number `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
// FetchUserInfo 获取用户信息。
|
||||
func (c *OpenClient) FetchUserInfo(ctx context.Context) (*UserInfo, error) {
|
||||
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/user/info", nil, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info UserInfo
|
||||
if err := json.Unmarshal(resp.Data, &info); err != nil {
|
||||
return nil, fmt.Errorf("115: 解析用户信息失败:%w", err)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
package cloud115
|
||||
|
||||
import "math/rand"
|
||||
|
||||
const randCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// RandomString 生成指定长度的随机字符串(PKCE code_verifier 等)。
|
||||
func RandomString(length int) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = randCharset[rand.Intn(len(randCharset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user