// 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 }