package service import ( "fmt" "math" "net/http" "net/url" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/util/soraerror" "github.com/google/uuid" ) type soraChallengeCooldownEntry struct { Until time.Time StatusCode int CFRay string ConsecutiveChallenges int LastChallengeAt time.Time } type soraSidecarSessionEntry struct { SessionKey string ExpiresAt time.Time LastUsedAt time.Time } func (c *SoraDirectClient) cloudflareChallengeCooldownSeconds() int { if c == nil || c.cfg == nil { return 900 } cooldown := c.cfg.Sora.Client.CloudflareChallengeCooldownSeconds if cooldown <= 0 { return 0 } return cooldown } func (c *SoraDirectClient) checkCloudflareChallengeCooldown(account *Account, proxyURL string) error { if c == nil { return nil } if account == nil || account.ID <= 0 { return nil } cooldownSeconds := c.cloudflareChallengeCooldownSeconds() if cooldownSeconds <= 0 { return nil } key := soraAccountProxyKey(account, proxyURL) now := time.Now() c.challengeCooldownMu.RLock() entry, ok := c.challengeCooldowns[key] c.challengeCooldownMu.RUnlock() if !ok { return nil } if !entry.Until.After(now) { c.challengeCooldownMu.Lock() delete(c.challengeCooldowns, key) c.challengeCooldownMu.Unlock() return nil } remaining := int(math.Ceil(entry.Until.Sub(now).Seconds())) if remaining < 1 { remaining = 1 } message := fmt.Sprintf("Sora request cooling down due to recent Cloudflare challenge. Retry in %d seconds.", remaining) if entry.ConsecutiveChallenges > 1 { message = fmt.Sprintf("%s (streak=%d)", message, entry.ConsecutiveChallenges) } if entry.CFRay != "" { message = fmt.Sprintf("%s (last cf-ray: %s)", message, entry.CFRay) } return &SoraUpstreamError{ StatusCode: http.StatusTooManyRequests, Message: message, Headers: make(http.Header), } } func (c *SoraDirectClient) recordCloudflareChallengeCooldown(account *Account, proxyURL string, statusCode int, headers http.Header, body []byte) { if c == nil { return } if account == nil || account.ID <= 0 { return } cooldownSeconds := c.cloudflareChallengeCooldownSeconds() if cooldownSeconds <= 0 { return } key := soraAccountProxyKey(account, proxyURL) now := time.Now() cfRay := soraerror.ExtractCloudflareRayID(headers, body) c.challengeCooldownMu.Lock() c.cleanupExpiredChallengeCooldownsLocked(now) streak := 1 existing, ok := c.challengeCooldowns[key] if ok && now.Sub(existing.LastChallengeAt) <= 30*time.Minute { streak = existing.ConsecutiveChallenges + 1 } effectiveCooldown := soraComputeChallengeCooldownSeconds(cooldownSeconds, streak) until := now.Add(time.Duration(effectiveCooldown) * time.Second) if ok && existing.Until.After(until) { until = existing.Until if existing.ConsecutiveChallenges > streak { streak = existing.ConsecutiveChallenges } if cfRay == "" { cfRay = existing.CFRay } } c.challengeCooldowns[key] = soraChallengeCooldownEntry{ Until: until, StatusCode: statusCode, CFRay: cfRay, ConsecutiveChallenges: streak, LastChallengeAt: now, } c.challengeCooldownMu.Unlock() if c.debugEnabled() { remain := int(math.Ceil(until.Sub(now).Seconds())) if remain < 0 { remain = 0 } c.debugLogf("cloudflare_challenge_cooldown_set key=%s status=%d remain_s=%d streak=%d cf_ray=%s", key, statusCode, remain, streak, cfRay) } } func soraComputeChallengeCooldownSeconds(baseSeconds, streak int) int { if baseSeconds <= 0 { return 0 } if streak < 1 { streak = 1 } multiplier := streak if multiplier > 4 { multiplier = 4 } cooldown := baseSeconds * multiplier if cooldown > 3600 { cooldown = 3600 } return cooldown } func (c *SoraDirectClient) clearCloudflareChallengeCooldown(account *Account, proxyURL string) { if c == nil { return } if account == nil || account.ID <= 0 { return } key := soraAccountProxyKey(account, proxyURL) c.challengeCooldownMu.Lock() _, existed := c.challengeCooldowns[key] if existed { delete(c.challengeCooldowns, key) } c.challengeCooldownMu.Unlock() if existed && c.debugEnabled() { c.debugLogf("cloudflare_challenge_cooldown_cleared key=%s", key) } } func (c *SoraDirectClient) sidecarSessionKey(account *Account, proxyURL string) string { if c == nil || !c.sidecarSessionReuseEnabled() { return "" } if account == nil || account.ID <= 0 { return "" } key := soraAccountProxyKey(account, proxyURL) now := time.Now() ttlSeconds := c.sidecarSessionTTLSeconds() c.sidecarSessionMu.Lock() defer c.sidecarSessionMu.Unlock() c.cleanupExpiredSidecarSessionsLocked(now) if existing, exists := c.sidecarSessions[key]; exists { existing.LastUsedAt = now c.sidecarSessions[key] = existing return existing.SessionKey } expiresAt := now.Add(time.Duration(ttlSeconds) * time.Second) if ttlSeconds <= 0 { expiresAt = now.Add(365 * 24 * time.Hour) } newEntry := soraSidecarSessionEntry{ SessionKey: "sora-" + uuid.NewString(), ExpiresAt: expiresAt, LastUsedAt: now, } c.sidecarSessions[key] = newEntry if c.debugEnabled() { c.debugLogf("sidecar_session_created key=%s ttl_s=%d", key, ttlSeconds) } return newEntry.SessionKey } func (c *SoraDirectClient) cleanupExpiredChallengeCooldownsLocked(now time.Time) { if c == nil || len(c.challengeCooldowns) == 0 { return } for key, entry := range c.challengeCooldowns { if !entry.Until.After(now) { delete(c.challengeCooldowns, key) } } } func (c *SoraDirectClient) cleanupExpiredSidecarSessionsLocked(now time.Time) { if c == nil || len(c.sidecarSessions) == 0 { return } for key, entry := range c.sidecarSessions { if !entry.ExpiresAt.After(now) { delete(c.sidecarSessions, key) } } } func soraAccountProxyKey(account *Account, proxyURL string) string { accountID := int64(0) if account != nil { accountID = account.ID } return fmt.Sprintf("account:%d|proxy:%s", accountID, normalizeSoraProxyKey(proxyURL)) } func normalizeSoraProxyKey(proxyURL string) string { raw := strings.TrimSpace(proxyURL) if raw == "" { return "direct" } parsed, err := url.Parse(raw) if err != nil { return strings.ToLower(raw) } scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) port := strings.TrimSpace(parsed.Port()) if host == "" { return strings.ToLower(raw) } if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { port = "" } if port != "" { host = host + ":" + port } if scheme == "" { scheme = "proxy" } return scheme + "://" + host }