feat(sora): 强制Sora走curl_cffi sidecar并完善校验测试
This commit is contained in:
@@ -271,18 +271,27 @@ type SoraConfig struct {
|
|||||||
|
|
||||||
// SoraClientConfig 直连 Sora 客户端配置
|
// SoraClientConfig 直连 Sora 客户端配置
|
||||||
type SoraClientConfig struct {
|
type SoraClientConfig struct {
|
||||||
BaseURL string `mapstructure:"base_url"`
|
BaseURL string `mapstructure:"base_url"`
|
||||||
TimeoutSeconds int `mapstructure:"timeout_seconds"`
|
TimeoutSeconds int `mapstructure:"timeout_seconds"`
|
||||||
MaxRetries int `mapstructure:"max_retries"`
|
MaxRetries int `mapstructure:"max_retries"`
|
||||||
PollIntervalSeconds int `mapstructure:"poll_interval_seconds"`
|
PollIntervalSeconds int `mapstructure:"poll_interval_seconds"`
|
||||||
MaxPollAttempts int `mapstructure:"max_poll_attempts"`
|
MaxPollAttempts int `mapstructure:"max_poll_attempts"`
|
||||||
RecentTaskLimit int `mapstructure:"recent_task_limit"`
|
RecentTaskLimit int `mapstructure:"recent_task_limit"`
|
||||||
RecentTaskLimitMax int `mapstructure:"recent_task_limit_max"`
|
RecentTaskLimitMax int `mapstructure:"recent_task_limit_max"`
|
||||||
Debug bool `mapstructure:"debug"`
|
Debug bool `mapstructure:"debug"`
|
||||||
UseOpenAITokenProvider bool `mapstructure:"use_openai_token_provider"`
|
UseOpenAITokenProvider bool `mapstructure:"use_openai_token_provider"`
|
||||||
Headers map[string]string `mapstructure:"headers"`
|
Headers map[string]string `mapstructure:"headers"`
|
||||||
UserAgent string `mapstructure:"user_agent"`
|
UserAgent string `mapstructure:"user_agent"`
|
||||||
DisableTLSFingerprint bool `mapstructure:"disable_tls_fingerprint"`
|
DisableTLSFingerprint bool `mapstructure:"disable_tls_fingerprint"`
|
||||||
|
CurlCFFISidecar SoraCurlCFFISidecarConfig `mapstructure:"curl_cffi_sidecar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoraCurlCFFISidecarConfig Sora 专用 curl_cffi sidecar 配置
|
||||||
|
type SoraCurlCFFISidecarConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
BaseURL string `mapstructure:"base_url"`
|
||||||
|
Impersonate string `mapstructure:"impersonate"`
|
||||||
|
TimeoutSeconds int `mapstructure:"timeout_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoraStorageConfig 媒体存储配置
|
// SoraStorageConfig 媒体存储配置
|
||||||
@@ -1123,6 +1132,10 @@ func setDefaults() {
|
|||||||
viper.SetDefault("sora.client.headers", map[string]string{})
|
viper.SetDefault("sora.client.headers", map[string]string{})
|
||||||
viper.SetDefault("sora.client.user_agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
viper.SetDefault("sora.client.user_agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
|
||||||
viper.SetDefault("sora.client.disable_tls_fingerprint", false)
|
viper.SetDefault("sora.client.disable_tls_fingerprint", false)
|
||||||
|
viper.SetDefault("sora.client.curl_cffi_sidecar.enabled", true)
|
||||||
|
viper.SetDefault("sora.client.curl_cffi_sidecar.base_url", "http://sora-curl-cffi-sidecar:8080")
|
||||||
|
viper.SetDefault("sora.client.curl_cffi_sidecar.impersonate", "chrome131")
|
||||||
|
viper.SetDefault("sora.client.curl_cffi_sidecar.timeout_seconds", 60)
|
||||||
|
|
||||||
viper.SetDefault("sora.storage.type", "local")
|
viper.SetDefault("sora.storage.type", "local")
|
||||||
viper.SetDefault("sora.storage.local_path", "")
|
viper.SetDefault("sora.storage.local_path", "")
|
||||||
@@ -1526,6 +1539,15 @@ func (c *Config) Validate() error {
|
|||||||
c.Sora.Client.RecentTaskLimitMax < c.Sora.Client.RecentTaskLimit {
|
c.Sora.Client.RecentTaskLimitMax < c.Sora.Client.RecentTaskLimit {
|
||||||
c.Sora.Client.RecentTaskLimitMax = c.Sora.Client.RecentTaskLimit
|
c.Sora.Client.RecentTaskLimitMax = c.Sora.Client.RecentTaskLimit
|
||||||
}
|
}
|
||||||
|
if c.Sora.Client.CurlCFFISidecar.TimeoutSeconds < 0 {
|
||||||
|
return fmt.Errorf("sora.client.curl_cffi_sidecar.timeout_seconds must be non-negative")
|
||||||
|
}
|
||||||
|
if !c.Sora.Client.CurlCFFISidecar.Enabled {
|
||||||
|
return fmt.Errorf("sora.client.curl_cffi_sidecar.enabled must be true")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.Sora.Client.CurlCFFISidecar.BaseURL) == "" {
|
||||||
|
return fmt.Errorf("sora.client.curl_cffi_sidecar.base_url is required")
|
||||||
|
}
|
||||||
if c.Sora.Storage.MaxConcurrentDownloads < 0 {
|
if c.Sora.Storage.MaxConcurrentDownloads < 0 {
|
||||||
return fmt.Errorf("sora.storage.max_concurrent_downloads must be non-negative")
|
return fmt.Errorf("sora.storage.max_concurrent_downloads must be non-negative")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1024,3 +1024,52 @@ func TestValidateConfigErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSoraCurlCFFISidecarDefaults(t *testing.T) {
|
||||||
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.Sora.Client.CurlCFFISidecar.Enabled {
|
||||||
|
t.Fatalf("Sora curl_cffi sidecar should be enabled by default")
|
||||||
|
}
|
||||||
|
if cfg.Sora.Client.CurlCFFISidecar.BaseURL == "" {
|
||||||
|
t.Fatalf("Sora curl_cffi sidecar base_url should not be empty by default")
|
||||||
|
}
|
||||||
|
if cfg.Sora.Client.CurlCFFISidecar.Impersonate == "" {
|
||||||
|
t.Fatalf("Sora curl_cffi sidecar impersonate should not be empty by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSoraCurlCFFISidecarRequired(t *testing.T) {
|
||||||
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Sora.Client.CurlCFFISidecar.Enabled = false
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sora.client.curl_cffi_sidecar.enabled must be true") {
|
||||||
|
t.Fatalf("Validate() error = %v, want sidecar enabled error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSoraCurlCFFISidecarBaseURLRequired(t *testing.T) {
|
||||||
|
resetViperWithJWTSecret(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Sora.Client.CurlCFFISidecar.BaseURL = " "
|
||||||
|
err = cfg.Validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sora.client.curl_cffi_sidecar.base_url is required") {
|
||||||
|
t.Fatalf("Validate() error = %v, want sidecar base_url required error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1630,6 +1630,14 @@ func shouldAttemptSoraTokenRecover(statusCode int, rawURL string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SoraDirectClient) doHTTP(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
func (c *SoraDirectClient) doHTTP(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
|
||||||
|
if c != nil && c.cfg != nil && c.cfg.Sora.Client.CurlCFFISidecar.Enabled {
|
||||||
|
resp, err := c.doHTTPViaCurlCFFISidecar(req, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
enableTLS := c == nil || c.cfg == nil || !c.cfg.Sora.Client.DisableTLSFingerprint
|
enableTLS := c == nil || c.cfg == nil || !c.cfg.Sora.Client.DisableTLSFingerprint
|
||||||
if c.httpUpstream != nil {
|
if c.httpUpstream != nil {
|
||||||
accountID := int64(0)
|
accountID := int64(0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
@@ -639,3 +640,144 @@ func TestSoraDirectClient_PostVideoForWatermarkFree(t *testing.T) {
|
|||||||
require.Equal(t, "/backend-api/sentinel/req", upstream.calls[0].Path)
|
require.Equal(t, "/backend-api/sentinel/req", upstream.calls[0].Path)
|
||||||
require.Equal(t, "/backend/project_y/post", upstream.calls[1].Path)
|
require.Equal(t, "/backend/project_y/post", upstream.calls[1].Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type soraClientFallbackUpstream struct {
|
||||||
|
doWithTLSCalls int32
|
||||||
|
respBody string
|
||||||
|
respStatusCode int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *soraClientFallbackUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||||
|
return nil, errors.New("unexpected Do call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *soraClientFallbackUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ bool) (*http.Response, error) {
|
||||||
|
atomic.AddInt32(&u.doWithTLSCalls, 1)
|
||||||
|
if u.err != nil {
|
||||||
|
return nil, u.err
|
||||||
|
}
|
||||||
|
statusCode := u.respStatusCode
|
||||||
|
if statusCode <= 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
body := u.respBody
|
||||||
|
if body == "" {
|
||||||
|
body = `{"ok":true}`
|
||||||
|
}
|
||||||
|
return newSoraClientMockResponse(statusCode, body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraDirectClient_DoHTTP_UsesCurlCFFISidecarWhenEnabled(t *testing.T) {
|
||||||
|
var captured soraCurlCFFISidecarRequest
|
||||||
|
sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, http.MethodPost, r.Method)
|
||||||
|
require.Equal(t, "/request", r.URL.Path)
|
||||||
|
raw, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &captured))
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status_code": http.StatusOK,
|
||||||
|
"headers": map[string]any{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Sidecar": []string{"yes"},
|
||||||
|
},
|
||||||
|
"body_base64": base64.StdEncoding.EncodeToString([]byte(`{"ok":true}`)),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer sidecar.Close()
|
||||||
|
|
||||||
|
upstream := &soraClientFallbackUpstream{}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Client: config.SoraClientConfig{
|
||||||
|
BaseURL: "https://sora.chatgpt.com/backend",
|
||||||
|
CurlCFFISidecar: config.SoraCurlCFFISidecarConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BaseURL: sidecar.URL,
|
||||||
|
Impersonate: "chrome131",
|
||||||
|
TimeoutSeconds: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := NewSoraDirectClient(cfg, upstream, nil)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://sora.chatgpt.com/backend/me", strings.NewReader("hello-sidecar"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Set("User-Agent", "test-ua")
|
||||||
|
|
||||||
|
resp, err := client.doHTTP(req, "http://127.0.0.1:18080", &Account{ID: 1})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.JSONEq(t, `{"ok":true}`, string(body))
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&upstream.doWithTLSCalls))
|
||||||
|
require.Equal(t, "http://127.0.0.1:18080", captured.ProxyURL)
|
||||||
|
require.Equal(t, "chrome131", captured.Impersonate)
|
||||||
|
require.Equal(t, "https://sora.chatgpt.com/backend/me", captured.URL)
|
||||||
|
decodedReqBody, err := base64.StdEncoding.DecodeString(captured.BodyBase64)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "hello-sidecar", string(decodedReqBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraDirectClient_DoHTTP_CurlCFFISidecarFailureReturnsError(t *testing.T) {
|
||||||
|
sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||||
|
}))
|
||||||
|
defer sidecar.Close()
|
||||||
|
|
||||||
|
upstream := &soraClientFallbackUpstream{respBody: `{"fallback":true}`}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Client: config.SoraClientConfig{
|
||||||
|
BaseURL: "https://sora.chatgpt.com/backend",
|
||||||
|
CurlCFFISidecar: config.SoraCurlCFFISidecarConfig{
|
||||||
|
Enabled: true,
|
||||||
|
BaseURL: sidecar.URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := NewSoraDirectClient(cfg, upstream, nil)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://sora.chatgpt.com/backend/me", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.doHTTP(req, "", &Account{ID: 2})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "sora curl_cffi sidecar")
|
||||||
|
require.Equal(t, int32(0), atomic.LoadInt32(&upstream.doWithTLSCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoraDirectClient_DoHTTP_CurlCFFISidecarDisabledUsesLegacyStack(t *testing.T) {
|
||||||
|
upstream := &soraClientFallbackUpstream{respBody: `{"legacy":true}`}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Sora: config.SoraConfig{
|
||||||
|
Client: config.SoraClientConfig{
|
||||||
|
BaseURL: "https://sora.chatgpt.com/backend",
|
||||||
|
CurlCFFISidecar: config.SoraCurlCFFISidecarConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BaseURL: "http://127.0.0.1:18080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client := NewSoraDirectClient(cfg, upstream, nil)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://sora.chatgpt.com/backend/me", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.doHTTP(req, "", &Account{ID: 3})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.JSONEq(t, `{"legacy":true}`, string(body))
|
||||||
|
require.Equal(t, int32(1), atomic.LoadInt32(&upstream.doWithTLSCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertSidecarHeaderValue_NilAndSlice(t *testing.T) {
|
||||||
|
require.Nil(t, convertSidecarHeaderValue(nil))
|
||||||
|
require.Equal(t, []string{"a", "b"}, convertSidecarHeaderValue([]any{"a", " ", "b"}))
|
||||||
|
}
|
||||||
|
|||||||
238
backend/internal/service/sora_curl_cffi_sidecar.go
Normal file
238
backend/internal/service/sora_curl_cffi_sidecar.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||||
|
)
|
||||||
|
|
||||||
|
const soraCurlCFFISidecarDefaultTimeoutSeconds = 60
|
||||||
|
|
||||||
|
type soraCurlCFFISidecarRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
BodyBase64 string `json:"body_base64,omitempty"`
|
||||||
|
ProxyURL string `json:"proxy_url,omitempty"`
|
||||||
|
Impersonate string `json:"impersonate,omitempty"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type soraCurlCFFISidecarResponse struct {
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Headers map[string]any `json:"headers"`
|
||||||
|
BodyBase64 string `json:"body_base64"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraDirectClient) doHTTPViaCurlCFFISidecar(req *http.Request, proxyURL string) (*http.Response, error) {
|
||||||
|
if req == nil || req.URL == nil {
|
||||||
|
return nil, errors.New("request url is nil")
|
||||||
|
}
|
||||||
|
if c == nil || c.cfg == nil {
|
||||||
|
return nil, errors.New("sora curl_cffi sidecar config is nil")
|
||||||
|
}
|
||||||
|
if !c.cfg.Sora.Client.CurlCFFISidecar.Enabled {
|
||||||
|
return nil, errors.New("sora curl_cffi sidecar is disabled")
|
||||||
|
}
|
||||||
|
endpoint := c.curlCFFISidecarEndpoint()
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, errors.New("sora curl_cffi sidecar base_url is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := readAndRestoreRequestBody(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar read request body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string][]string, len(req.Header)+1)
|
||||||
|
for key, vals := range req.Header {
|
||||||
|
copied := make([]string, len(vals))
|
||||||
|
copy(copied, vals)
|
||||||
|
headers[key] = copied
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Host) != "" {
|
||||||
|
if _, ok := headers["Host"]; !ok {
|
||||||
|
headers["Host"] = []string{req.Host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := soraCurlCFFISidecarRequest{
|
||||||
|
Method: req.Method,
|
||||||
|
URL: req.URL.String(),
|
||||||
|
Headers: headers,
|
||||||
|
ProxyURL: strings.TrimSpace(proxyURL),
|
||||||
|
Impersonate: c.curlCFFIImpersonate(),
|
||||||
|
TimeoutSeconds: c.curlCFFISidecarTimeoutSeconds(),
|
||||||
|
}
|
||||||
|
if len(bodyBytes) > 0 {
|
||||||
|
payload.BodyBase64 = base64.StdEncoding.EncodeToString(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar marshal request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sidecarReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, endpoint, bytes.NewReader(encoded))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar build request failed: %w", err)
|
||||||
|
}
|
||||||
|
sidecarReq.Header.Set("Content-Type", "application/json")
|
||||||
|
sidecarReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
httpClient := &http.Client{Timeout: time.Duration(payload.TimeoutSeconds) * time.Second}
|
||||||
|
sidecarResp, err := httpClient.Do(sidecarReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer sidecarResp.Body.Close()
|
||||||
|
|
||||||
|
sidecarRespBody, err := io.ReadAll(io.LimitReader(sidecarResp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar read response failed: %w", err)
|
||||||
|
}
|
||||||
|
if sidecarResp.StatusCode != http.StatusOK {
|
||||||
|
redacted := truncateForLog([]byte(logredact.RedactText(string(sidecarRespBody))), 512)
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar http status=%d body=%s", sidecarResp.StatusCode, redacted)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payloadResp soraCurlCFFISidecarResponse
|
||||||
|
if err := json.Unmarshal(sidecarRespBody, &payloadResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar parse response failed: %w", err)
|
||||||
|
}
|
||||||
|
if msg := strings.TrimSpace(payloadResp.Error); msg != "" {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar upstream error: %s", msg)
|
||||||
|
}
|
||||||
|
statusCode := payloadResp.StatusCode
|
||||||
|
if statusCode <= 0 {
|
||||||
|
statusCode = payloadResp.Status
|
||||||
|
}
|
||||||
|
if statusCode <= 0 {
|
||||||
|
return nil, errors.New("sora curl_cffi sidecar response missing status code")
|
||||||
|
}
|
||||||
|
|
||||||
|
responseBody := []byte(payloadResp.Body)
|
||||||
|
if strings.TrimSpace(payloadResp.BodyBase64) != "" {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(payloadResp.BodyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sora curl_cffi sidecar decode body failed: %w", err)
|
||||||
|
}
|
||||||
|
responseBody = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
respHeaders := make(http.Header)
|
||||||
|
for key, rawVal := range payloadResp.Headers {
|
||||||
|
for _, v := range convertSidecarHeaderValue(rawVal) {
|
||||||
|
respHeaders.Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Header: respHeaders,
|
||||||
|
Body: io.NopCloser(bytes.NewReader(responseBody)),
|
||||||
|
ContentLength: int64(len(responseBody)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAndRestoreRequestBody(req *http.Request) ([]byte, error) {
|
||||||
|
if req == nil || req.Body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
bodyBytes, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = req.Body.Close()
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
req.ContentLength = int64(len(bodyBytes))
|
||||||
|
return bodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraDirectClient) curlCFFISidecarEndpoint() string {
|
||||||
|
if c == nil || c.cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.BaseURL)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if path := strings.TrimSpace(parsed.Path); path == "" || path == "/" {
|
||||||
|
parsed.Path = "/request"
|
||||||
|
}
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraDirectClient) curlCFFISidecarTimeoutSeconds() int {
|
||||||
|
if c == nil || c.cfg == nil {
|
||||||
|
return soraCurlCFFISidecarDefaultTimeoutSeconds
|
||||||
|
}
|
||||||
|
timeoutSeconds := c.cfg.Sora.Client.CurlCFFISidecar.TimeoutSeconds
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
return soraCurlCFFISidecarDefaultTimeoutSeconds
|
||||||
|
}
|
||||||
|
return timeoutSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SoraDirectClient) curlCFFIImpersonate() string {
|
||||||
|
if c == nil || c.cfg == nil {
|
||||||
|
return "chrome131"
|
||||||
|
}
|
||||||
|
impersonate := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.Impersonate)
|
||||||
|
if impersonate == "" {
|
||||||
|
return "chrome131"
|
||||||
|
}
|
||||||
|
return impersonate
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertSidecarHeaderValue(raw any) []string {
|
||||||
|
switch val := raw.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{val}
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(val))
|
||||||
|
for _, item := range val {
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(item))
|
||||||
|
if s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
out := make([]string, 0, len(val))
|
||||||
|
for _, item := range val {
|
||||||
|
if strings.TrimSpace(item) != "" {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(val))
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{s}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -402,6 +402,21 @@ sora:
|
|||||||
# Disable TLS fingerprint for Sora upstream
|
# Disable TLS fingerprint for Sora upstream
|
||||||
# 关闭 Sora 上游 TLS 指纹伪装
|
# 关闭 Sora 上游 TLS 指纹伪装
|
||||||
disable_tls_fingerprint: false
|
disable_tls_fingerprint: false
|
||||||
|
# curl_cffi sidecar for Sora only (required)
|
||||||
|
# 仅 Sora 链路使用的 curl_cffi sidecar(必需)
|
||||||
|
curl_cffi_sidecar:
|
||||||
|
# Sora 强制通过 sidecar 请求,必须启用
|
||||||
|
# Sora is forced to use sidecar only; keep enabled=true
|
||||||
|
enabled: true
|
||||||
|
# Sidecar base URL (default endpoint: /request)
|
||||||
|
# sidecar 基础地址(默认请求端点:/request)
|
||||||
|
base_url: "http://sora-curl-cffi-sidecar:8080"
|
||||||
|
# curl_cffi impersonate profile, e.g. chrome131/chrome124/safari18_0
|
||||||
|
# curl_cffi 指纹伪装 profile,例如 chrome131/chrome124/safari18_0
|
||||||
|
impersonate: "chrome131"
|
||||||
|
# Sidecar request timeout (seconds)
|
||||||
|
# sidecar 请求超时(秒)
|
||||||
|
timeout_seconds: 60
|
||||||
storage:
|
storage:
|
||||||
# Storage type (local only for now)
|
# Storage type (local only for now)
|
||||||
# 存储类型(首发仅支持 local)
|
# 存储类型(首发仅支持 local)
|
||||||
|
|||||||
Reference in New Issue
Block a user