From cd0b7090f27cf0d266a8bcf795a5035b6c5788b8 Mon Sep 17 00:00:00 2001 From: chiyi Date: Sun, 2 Aug 2026 00:07:17 +0800 Subject: [PATCH] refactor(adobe): switch to express.adobe.com origin, remove ErrAuthPermanent, simplify ARP session/PID --- backend/internal/provider/adobe/client.go | 119 ++++++-------------- backend/internal/provider/adobe/payloads.go | 11 +- backend/internal/provider/adobe/util.go | 46 +------- backend/internal/service/v1.go | 4 - 4 files changed, 47 insertions(+), 133 deletions(-) diff --git a/backend/internal/provider/adobe/client.go b/backend/internal/provider/adobe/client.go index 04c2736..71c30e9 100644 --- a/backend/internal/provider/adobe/client.go +++ b/backend/internal/provider/adobe/client.go @@ -34,7 +34,6 @@ const ( var ( ErrAuth = errors.New("adobe auth failed") - ErrAuthPermanent = errors.New("adobe auth permanently failed") ErrQuotaExhausted = errors.New("adobe quota exhausted") ErrTemporaryUpstream = errors.New("adobe upstream temporary error") ErrDeadUpstream = errors.New("adobe upstream fatal error") @@ -53,21 +52,15 @@ func isContentRejection(status int, body string) bool { return status == 451 && strings.Contains(body, "unsafe") } -func isPermanentAuthError(header string, body []byte) bool { - return strings.EqualFold(header, "user_not_entitled") || - strings.EqualFold(header, "access_error") || - strings.Contains(string(body), "user_not_entitled") || - strings.Contains(string(body), "access_error") -} - var profileURLs = []string{ "https://ims-na1.adobelogin.com/ims/profile/v1", "https://adobeid-na1.services.adobe.com/ims/profile/v1", } type Client struct { - apiKey string - proxy string + apiKey string + proxy string + arpSessionID string // cached per-client, reused across requests (matches adobe2api) } func NewClient(apiKey, proxy string) *Client { @@ -77,6 +70,17 @@ func NewClient(apiKey, proxy string) *Client { } } +// getARPSessionID returns a cached ARP session id matching adobe2api's format: +// base64({"sid":"","ftr":"___dUAL43-mnts-ants-d4_31ck__tt"}) +// Generated once per client and reused — adobe2api reuses the same session id per +// token/profile instead of rotating every request. +func (c *Client) getARPSessionID() string { + if c.arpSessionID != "" { + return c.arpSessionID + } + c.arpSessionID = buildARPSessionID() + return c.arpSessionID +} func (c *Client) SetProxy(proxy string) { c.proxy = strings.TrimSpace(proxy) @@ -93,7 +97,7 @@ func (c *Client) ExchangeCookie(ctx context.Context, cookie string) (*CookieExch // uploadMaxRetries is how many extra in-place attempts a transient upload // failure (transport error / timeout, 429/451/5xx) gets on a fresh connection // before the error is surfaced. -const uploadMaxRetries = 3 +const uploadMaxRetries = 5 // UploadImage stores a reference image and returns its blob id. Transient // failures are retried in place (uploadMaxRetries times); the final error keeps @@ -103,13 +107,6 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte, // Reference-image upload runs on the local IP (not the proxy). body, err, retryable := c.uploadImageOnce(ctx, token, content, contentType, engine) for attempt := 0; err != nil && retryable && attempt < uploadMaxRetries && ctx.Err() == nil; attempt++ { - if wait := time.Duration(attempt+1) * time.Second; wait > 0 { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(wait): - } - } body, err, retryable = c.uploadImageOnce(ctx, token, content, contentType, engine) } if err != nil { @@ -137,7 +134,7 @@ func (c *Client) UploadImage(ctx context.Context, token string, content []byte, // body plus whether a failure is retryable (transport error / 429/451/5xx). // Auth failures (401/403) and other non-200s are not retryable. func (c *Client) uploadImageOnce(ctx context.Context, token string, content []byte, contentType, engine string) ([]byte, error, bool) { - sess, err := c.newUploadTLSClient() + sess, err := c.newDirectTLSClient() if err != nil { return nil, err, false } @@ -179,9 +176,6 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by return nil, err, true } if resp.StatusCode == 401 || resp.StatusCode == 403 { - if isPermanentAuthError(resp.Header.Get("x-access-error"), body) { - return nil, fmt.Errorf("%w (upload %d %s: %s)", ErrAuthPermanent, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)), false - } return nil, fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)), false } if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { @@ -195,19 +189,15 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) { // Only the generate submit goes through the proxy; polling + download run on - // the local IP. If the proxy connection fails, retry once on the local IP. + // the local IP. submitSess, err := c.newTLSClient() if err != nil { return nil, nil, err } - directSess, directErr := c.newDirectTLSClient() pollSess, err := c.newDirectTLSClient() if err != nil { return nil, nil, err } - if directErr != nil { - directSess = pollSess - } var lastBody []byte var lastErr error @@ -223,9 +213,6 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe } for _, payload := range candidates { respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload) - if errors.Is(err, ErrTemporaryUpstream) { - respBody, pollURL, err = c.submitImage(ctx, directSess, token, prompt, endpoint, payload) - } if err == nil { meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL, downloadResult) if pollErr != nil { @@ -235,7 +222,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe } lastBody = respBody lastErr = err - if errors.Is(err, ErrAuth) || errors.Is(err, ErrAuthPermanent) || errors.Is(err, ErrQuotaExhausted) || errors.Is(err, ErrContentRejected) { + if errors.Is(err, ErrAuth) || errors.Is(err, ErrQuotaExhausted) || errors.Is(err, ErrContentRejected) { return nil, nil, err } } @@ -265,14 +252,10 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec if err != nil { return nil, nil, err } - directSess, directErr := c.newDirectTLSClient() pollSess, err := c.newDirectTLSClient() if err != nil { return nil, nil, err } - if directErr != nil { - directSess = pollSess - } payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs) endpoint := videoSubmitURL @@ -280,9 +263,6 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec endpoint = fireflyVideoSubmitURL } respBody, pollURL, err := c.submitVideo(ctx, submitSess, token, endpoint, payload) - if errors.Is(err, ErrTemporaryUpstream) { - respBody, pollURL, err = c.submitVideo(ctx, directSess, token, endpoint, payload) - } if err != nil { return nil, nil, err } @@ -320,7 +300,6 @@ func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[str "user-agent", }, } - defer ReleasePID(token) resp, err := sess.client.Do(req) if err != nil { @@ -429,9 +408,6 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str return nil, err } if resp.StatusCode == 401 { - if isPermanentAuthError(resp.Header.Get("x-access-error"), body) { - return nil, ErrAuthPermanent - } return nil, ErrAuth } if resp.StatusCode != 200 { @@ -484,17 +460,16 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp "x-api-key": {c.apiKey}, "content-type": {"application/json"}, "accept": {"*/*"}, - "origin": {"https://firefly.adobe.com"}, - "referer": {"https://firefly.adobe.com/"}, + "origin": {"https://new.express.adobe.com"}, + "referer": {"https://new.express.adobe.com/"}, "accept-language": {"en-US,en;q=0.9"}, "sec-ch-ua": {sess.fp.secCHUA}, "sec-ch-ua-mobile": {"?0"}, "sec-ch-ua-platform": {sess.fp.platform}, - "sec-fetch-site": {"same-site"}, + "sec-fetch-site": {"cross-site"}, "sec-fetch-mode": {"cors"}, "sec-fetch-dest": {"empty"}, "user-agent": {sess.fp.userAgent}, - "x-arp-session-id": {buildARPSessionID(token)}, http.HeaderOrderKey: { "authorization", "x-api-key", @@ -511,7 +486,6 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp "sec-fetch-dest", "user-agent", "x-nonce", - "x-arp-session-id", }, } if nonce := buildSubmitNonce(token, prompt); nonce != "" { @@ -531,9 +505,6 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") { return respBody, "", ErrQuotaExhausted } - if isPermanentAuthError(resp.Header.Get("x-access-error"), respBody) { - return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuthPermanent, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) - } return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) } // "system under load" / timeout_error = adobe rate-limit/overload (can come on a @@ -547,9 +518,6 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 { return respBody, "", ErrDeadUpstream } - if strings.Contains(string(respBody), "access_error") { - return respBody, "", fmt.Errorf("%w (submit %d: %s)", ErrAuthPermanent, resp.StatusCode, clip(respBody, 300)) - } if resp.StatusCode != 200 { return respBody, "", errors.New("submit rejected") } @@ -588,8 +556,8 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL req.Header = http.Header{ "authorization": {"Bearer " + strings.TrimSpace(token)}, "accept": {"*/*"}, - "origin": {"https://firefly.adobe.com"}, - "referer": {"https://firefly.adobe.com/"}, + "origin": {"https://new.express.adobe.com"}, + "referer": {"https://new.express.adobe.com/"}, "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", @@ -599,7 +567,6 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL "user-agent", }, } - defer ReleasePID(token) resp, err := sess.client.Do(req) if err != nil { @@ -669,17 +636,16 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo "x-api-key": {c.apiKey}, "content-type": {"application/json"}, "accept": {"*/*"}, - "origin": {"https://firefly.adobe.com"}, - "referer": {"https://firefly.adobe.com/"}, + "origin": {"https://new.express.adobe.com"}, + "referer": {"https://new.express.adobe.com/"}, "accept-language": {"en-US,en;q=0.9"}, "sec-ch-ua": {sess.fp.secCHUA}, "sec-ch-ua-mobile": {"?0"}, "sec-ch-ua-platform": {sess.fp.platform}, - "sec-fetch-site": {"same-site"}, + "sec-fetch-site": {"cross-site"}, "sec-fetch-mode": {"cors"}, "sec-fetch-dest": {"empty"}, "user-agent": {sess.fp.userAgent}, - "x-arp-session-id": {buildARPSessionID(token)}, http.HeaderOrderKey: { "authorization", "x-api-key", @@ -696,7 +662,6 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo "sec-fetch-dest", "user-agent", "x-nonce", - "x-arp-session-id", }, } // The working video submit (HAR) carries x-nonce just like the image submit. @@ -720,9 +685,8 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") { return respBody, "", ErrQuotaExhausted } - if isPermanentAuthError(resp.Header.Get("x-access-error"), respBody) { - return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuthPermanent, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) - } + // Surface Adobe's response body — "adobe auth failed" alone hides whether + // it's a bad token, a missing scope, or a WAF/fingerprint block. return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300)) } if isContentRejection(resp.StatusCode, string(respBody)) { @@ -736,9 +700,6 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") { return respBody, "", ErrTemporaryUpstream } - if strings.Contains(string(respBody), "access_error") { - return respBody, "", fmt.Errorf("%w (%d: %s)", ErrAuthPermanent, resp.StatusCode, clip(respBody, 300)) - } if resp.StatusCode != 200 { return respBody, "", fmt.Errorf("video submit rejected: %d %s", resp.StatusCode, clip(respBody, 300)) } @@ -777,8 +738,8 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL req.Header = http.Header{ "authorization": {"Bearer " + strings.TrimSpace(token)}, "accept": {"*/*"}, - "origin": {"https://firefly.adobe.com"}, - "referer": {"https://firefly.adobe.com/"}, + "origin": {"https://new.express.adobe.com"}, + "referer": {"https://new.express.adobe.com/"}, "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "authorization", @@ -788,7 +749,6 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL "user-agent", }, } - defer ReleasePID(token) resp, err := sess.client.Do(req) if err != nil { @@ -800,9 +760,6 @@ func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL return nil, nil, readErr } if resp.StatusCode == 401 || resp.StatusCode == 403 { - if isPermanentAuthError(resp.Header.Get("x-access-error"), body) { - return nil, nil, fmt.Errorf("%w (%d %s: %s)", ErrAuthPermanent, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)) - } return nil, nil, fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)) } if b := string(body); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") { @@ -971,20 +928,16 @@ type tlsSession struct { // image-generation submit goes through the proxy; reference-image upload, // polling and result download run on the local IP. func (c *Client) newTLSClient() (*tlsSession, error) { - return c.newTLSSession(randomFingerprint(), true, 120) + return c.newTLSSession(randomFingerprint(), true) } func (c *Client) newDirectTLSClient() (*tlsSession, error) { - return c.newTLSSession(randomFingerprint(), false, 60) + return c.newTLSSession(randomFingerprint(), false) } -func (c *Client) newUploadTLSClient() (*tlsSession, error) { - return c.newTLSSession(randomFingerprint(), false, 180) -} - -func (c *Client) newTLSSession(fp fingerprint, useProxy bool, timeout int) (*tlsSession, error) { +func (c *Client) newTLSSession(fp fingerprint, useProxy bool) (*tlsSession, error) { options := []tlsclient.HttpClientOption{ - tlsclient.WithTimeoutSeconds(timeout), + tlsclient.WithTimeoutSeconds(60), tlsclient.WithClientProfile(fp.profile), tlsclient.WithNotFollowRedirects(), tlsclient.WithRandomTLSExtensionOrder(), @@ -1023,8 +976,8 @@ func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie s "accept-language": {"zh-CN,zh;q=0.9"}, "content-type": {"application/x-www-form-urlencoded;charset=UTF-8"}, "cookie": {cookie}, - "origin": {"https://firefly.adobe.com"}, - "referer": {"https://firefly.adobe.com/"}, + "origin": {"https://new.express.adobe.com"}, + "referer": {"https://new.express.adobe.com/"}, "user-agent": {sess.fp.userAgent}, http.HeaderOrderKey: { "accept", diff --git a/backend/internal/provider/adobe/payloads.go b/backend/internal/provider/adobe/payloads.go index 1c56deb..ff884cb 100644 --- a/backend/internal/provider/adobe/payloads.go +++ b/backend/internal/provider/adobe/payloads.go @@ -112,6 +112,10 @@ func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any { size := getSize(gptImageSize, resolution, ratio, "1:1") + // Mirrors the captured working gpt-image request shape: modelSpecificPayload.size, + // generationSettings.detailLevel 3, and NO top-level size / outputResolution + // (sending those got 403). Keeps the chosen size via modelSpecificPayload.size + // ("WxH") rather than "auto". base := map[string]any{ "modelId": spec.UpstreamModelID, "modelVersion": spec.UpstreamModelVersion, @@ -120,9 +124,8 @@ func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blo "seeds": []int{int(time.Now().Unix()) % 999999}, "output": map[string]any{"storeInputs": true}, "referenceBlobs": []any{}, - "size": map[string]any{"width": size[0], "height": size[1]}, "generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"}, - "modelSpecificPayload": map[string]any{}, + "modelSpecificPayload": map[string]any{"size": sizeString(size)}, "generationSettings": map[string]any{"detailLevel": 3}, } if len(blobIDs) == 0 { @@ -208,6 +211,10 @@ func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRati return size } +func sizeString(size [2]int) string { + return itoa(size[0]) + "x" + itoa(size[1]) +} + func blobRefs(ids []string, usage string) []any { out := make([]any, 0, len(ids)) for _, id := range ids { diff --git a/backend/internal/provider/adobe/util.go b/backend/internal/provider/adobe/util.go index 5672605..ad800b2 100644 --- a/backend/internal/provider/adobe/util.go +++ b/backend/internal/provider/adobe/util.go @@ -10,20 +10,11 @@ import ( "regexp" "strconv" "strings" - "sync" "time" "github.com/google/uuid" ) -// arpPIDPool maps access tokens to unique PIDs so the same account always -// reuses its PID and different accounts never collide. Guarded by arpPIDMu. -var ( - arpPIDMu sync.Mutex - arpTokenPID = map[string]int{} // token → pid - arpPIDToken = map[int]string{} // pid → token -) - // adobeUserIDPat matches Adobe IMS user IDs embedded in cookies (e.g. // "4BDA81F069FC6DA40A495FAB@AdobeID"). var adobeUserIDPat = regexp.MustCompile(`[A-Fa-f0-9]{20,}@AdobeID`) @@ -104,11 +95,11 @@ func decodeJWTPayload(token string) map[string]any { return out } -func buildARPSessionID(token string) string { +func buildARPSessionID() string { // Matches adobe2api's format exactly: // base64({"sid":"","ftr":"___dUAL43-mnts-ants-d4_31ck__tt"}) // Two fields only (no "ark") — mirrors what a real browser session sends. - ftr := randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(allocPID(token)) + "_dUAL43-mnts-ants-d4_31ck__tt" + ftr := randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(randomInt(1000, 99999)) + "_dUAL43-mnts-ants-d4_31ck__tt" raw := map[string]any{ "sid": uuid.NewString(), "ftr": ftr, @@ -117,39 +108,6 @@ func buildARPSessionID(token string) string { return base64.StdEncoding.EncodeToString(b) } -// allocPID returns a unique PID bound to token. Same token always gets the -// same PID; different tokens never share a PID. Picks randomly from -// [1000, 99999] and retries on collision. -func allocPID(token string) int { - arpPIDMu.Lock() - defer arpPIDMu.Unlock() - - if pid, ok := arpTokenPID[token]; ok { - return pid - } - for { - pid := randomInt(1000, 99999) - if _, used := arpPIDToken[pid]; !used { - arpPIDToken[pid] = token - arpTokenPID[token] = pid - return pid - } - } -} - -// ReleasePID releases the PID bound to token so it can be reused by another -// account. Call this when a token/session is finished (e.g. after the Adobe -// API request completes or on token expiry). -func ReleasePID(token string) { - arpPIDMu.Lock() - defer arpPIDMu.Unlock() - - if pid, ok := arpTokenPID[token]; ok { - delete(arpPIDToken, pid) - delete(arpTokenPID, token) - } -} - func randomHex(n int) string { if n <= 0 { return "" diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index a8b9af9..d9e69bf 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -1550,10 +1550,6 @@ func (s *V1Service) tryAccount(ctx context.Context, eventID, pool string, token }) return data, nil, false, false } - if errors.Is(err, adobe.ErrAuthPermanent) { - s.markTokenDead(ctx, pool, token, kind) - return nil, err, true, false - } isAuth, isQuota, isTemp, isDead := classify(err) if isQuota { s.markTokenFailure(ctx, pool, token, kind, false, true)