Files
image2api/backend/internal/provider/adobe/client.go
T
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

876 lines
26 KiB
Go

package adobe
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client"
"github.com/bogdanfinn/tls-client/profiles"
)
const (
submitURL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"
image5SubmitURL = "https://image-v5.ff.adobe.io/v1/images/generate-async"
videoSubmitURL = "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"
// Firefly-native video model (project id "firefly-video"): distinct host,
// submit path and storage host from the 3p (veo/luma) video flow.
fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate"
fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image"
uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image"
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
creditsAPIKey = "SunbreakWebUI1"
)
var (
ErrAuth = errors.New("adobe auth failed")
ErrQuotaExhausted = errors.New("adobe quota exhausted")
ErrTemporaryUpstream = errors.New("adobe upstream temporary 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
}
func NewClient(apiKey, proxy string) *Client {
return &Client{
apiKey: defaultString(apiKey, clientID),
proxy: strings.TrimSpace(proxy),
}
}
func (c *Client) SetProxy(proxy string) {
c.proxy = strings.TrimSpace(proxy)
}
func (c *Client) ExchangeCookie(ctx context.Context, cookie string) (*CookieExchangeResult, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, err
}
return exchangeCookieWithTLSClient(ctx, client, cookie)
}
func (c *Client) UploadImage(ctx context.Context, token string, content []byte, contentType, engine string) (string, error) {
client, err := c.newTLSClient()
if err != nil {
return "", err
}
endpoint := uploadURL
if engine == "firefly-video" {
endpoint = fireflyVideoUploadURL
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
if err != nil {
return "", err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"content-type": {defaultString(contentType, "image/png")},
"accept": {"*/*"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"content-type",
"accept",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("adobe upload request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300))
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300))
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
if images, ok := payload["images"].([]any); ok && len(images) > 0 {
if first, ok := images[0].(map[string]any); ok {
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
return id, nil
}
}
}
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
return id, nil
}
return "", errors.New("adobe upload missing blob id")
}
func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
var lastBody []byte
var lastErr error
// Firefly Image 5 uses a different endpoint + request schema (modelVersion
// "image5", resolutionLevel, top-level aspectRatio label, no modelId/size).
endpoint := submitURL
var candidates []map[string]any
if modelID == "firefly-image-5" {
endpoint = image5SubmitURL
candidates = []map[string]any{buildImage5Payload(prompt, aspectRatio, resolution, blobIDs)}
} else {
candidates = BuildImagePayloadCandidates(modelID, prompt, aspectRatio, resolution, blobIDs)
}
for _, payload := range candidates {
respBody, pollURL, err := c.submitImage(ctx, client, token, prompt, endpoint, payload)
if err == nil {
meta, data, pollErr := c.pollImage(ctx, client, token, pollURL)
if pollErr != nil {
return nil, nil, pollErr
}
return data, meta, nil
}
lastBody = respBody
lastErr = err
if errors.Is(err, ErrAuth) || errors.Is(err, ErrQuotaExhausted) {
return nil, nil, err
}
}
// Preserve the temporary classification so the pool retries (overload / 5xx /
// rate-limit) instead of failing the request outright.
if errors.Is(lastErr, ErrTemporaryUpstream) {
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300))
}
return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300))
}
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
// With downloadResult=false it returns nil bytes and the upstream presigned URL
// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL
// on /content instead of persisting the file.
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
client, err := c.newTLSClient()
if err != nil {
return nil, nil, err
}
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs)
endpoint := videoSubmitURL
if engine == "firefly-video" {
endpoint = fireflyVideoSubmitURL
}
respBody, pollURL, err := c.submitVideo(ctx, client, token, endpoint, payload)
if err != nil {
return nil, nil, err
}
_ = respBody
meta, data, pollErr := c.pollVideo(ctx, client, token, pollURL, downloadResult)
if pollErr != nil {
return nil, nil, pollErr
}
return data, meta, nil
}
func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[string]any, error) {
token = strings.TrimSpace(token)
if token == "" {
return map[string]any{}, nil
}
client, err := c.newTLSClient()
if err != nil {
return nil, err
}
for _, rawURL := range profileURLs {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + token},
"accept": {"application/json"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"authorization",
"accept",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil || resp.StatusCode != 200 {
continue
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
continue
}
email := strings.TrimSpace(stringValue(payload["email"]))
displayName := strings.TrimSpace(stringValue(payload["displayName"]))
if displayName == "" {
displayName = strings.TrimSpace(stringValue(payload["name"]))
}
if displayName == "" {
displayName = strings.TrimSpace(stringValue(payload["fullName"]))
}
userID := strings.TrimSpace(stringValue(payload["userId"]))
if userID == "" {
userID = strings.TrimSpace(stringValue(payload["authId"]))
}
if email != "" || displayName != "" || userID != "" {
return map[string]any{
"email": emptyStringNil(email),
"display_name": emptyStringNil(displayName),
"user_id": emptyStringNil(userID),
}, nil
}
}
return map[string]any{}, nil
}
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
token = strings.TrimSpace(token)
if token == "" {
return map[string]any{
"remaining": nil,
"used": nil,
"total": nil,
"available_until": nil,
"unknown": true,
"error": "empty token",
}, nil
}
accountID := ExtractAccountID(token)
if accountID == "" {
return map[string]any{
"remaining": nil,
"used": nil,
"total": nil,
"available_until": nil,
"unknown": true,
"error": "no account id",
}, nil
}
client, err := c.newTLSClient()
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, creditsURL, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + token},
"x-api-key": {creditsAPIKey},
"x-account-id": {accountID},
"accept": {"application/json"},
"content-type": {"application/json"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"x-account-id",
"accept",
"content-type",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return map[string]any{
"remaining": nil,
"used": nil,
"total": nil,
"available_until": nil,
"unknown": true,
"error": "network: " + err.Error(),
}, nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode == 401 {
return nil, ErrAuth
}
if resp.StatusCode != 200 {
return map[string]any{
"remaining": nil,
"used": nil,
"total": nil,
"available_until": nil,
"unknown": true,
"error": fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160)),
}, nil
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return map[string]any{
"remaining": nil,
"used": nil,
"total": nil,
"available_until": nil,
"unknown": true,
"error": "non-json",
}, nil
}
totalInfo, _ := payload["total"].(map[string]any)
quota, _ := totalInfo["quota"].(map[string]any)
return map[string]any{
"remaining": intOrNil(quota["available"]),
"used": intOrNil(quota["used"]),
"total": intOrNil(quota["total"]),
"available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))),
"unknown": false,
"error": nil,
}, nil
}
func (c *Client) submitImage(ctx context.Context, client tlsclient.HttpClient, token, prompt, endpoint string, payload map[string]any) ([]byte, string, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, "", err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://firefly.adobe.com"},
"referer": {"https://firefly.adobe.com/"},
"accept-language": {"en-US,en;q=0.9"},
"sec-ch-ua": {defaultSecCHUA},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-site": {"same-site"},
"sec-fetch-mode": {"cors"},
"sec-fetch-dest": {"empty"},
"user-agent": {defaultUserAgent},
"x-arp-session-id": {buildARPSessionID()},
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"content-type",
"accept",
"origin",
"referer",
"accept-language",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"user-agent",
"x-nonce",
"x-arp-session-id",
},
}
if nonce := buildSubmitNonce(token, prompt); nonce != "" {
req.Header.Set("x-nonce", nonce)
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrTemporaryUpstream
}
// "system under load" / timeout_error = adobe rate-limit/overload (can come on a
// non-5xx) — treat as temporary so the pool retries instead of failing.
if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
return respBody, "", ErrTemporaryUpstream
}
if resp.StatusCode != 200 {
return respBody, "", errors.New("submit rejected")
}
var payloadResp map[string]any
if err := json.Unmarshal(respBody, &payloadResp); err != nil {
return respBody, "", err
}
if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" {
return respBody, override, nil
}
if links, ok := payloadResp["links"].(map[string]any); ok {
if result, ok := links["result"].(map[string]any); ok {
if href := strings.TrimSpace(stringValue(result["href"])); href != "" {
return respBody, href, nil
}
}
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
return respBody, href, nil
}
}
return respBody, "", errors.New("submit ok but no poll url")
}
func (c *Client) pollImage(ctx context.Context, client tlsclient.HttpClient, token, pollURL string) (map[string]any, []byte, error) {
start := time.Now()
for {
if time.Since(start) > 3*time.Minute {
return nil, nil, errors.New("adobe generation timed out")
}
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
if err != nil {
return nil, nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"accept": {"*/*"},
"origin": {"https://firefly.adobe.com"},
"referer": {"https://firefly.adobe.com/"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"authorization",
"accept",
"origin",
"referer",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, nil, readErr
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrTemporaryUpstream
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300))
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, err
}
if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 {
if first, ok := outputs[0].(map[string]any); ok {
if image, ok := first["image"].(map[string]any); ok {
if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" {
data, err := c.download(ctx, client, url)
if err != nil {
return nil, nil, err
}
return payload, data, nil
}
}
}
}
status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"])))
if status == "FAILED" || status == "CANCELLED" || status == "ERROR" {
return nil, nil, fmt.Errorf("adobe job failed: %s", clip(body, 300))
}
time.Sleep(3 * time.Second)
}
}
func (c *Client) submitVideo(ctx context.Context, client tlsclient.HttpClient, token, endpoint string, payload map[string]any) ([]byte, string, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, "", err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, "", err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"x-api-key": {c.apiKey},
"content-type": {"application/json"},
"accept": {"*/*"},
"origin": {"https://firefly.adobe.com"},
"referer": {"https://firefly.adobe.com/"},
"accept-language": {"en-US,en;q=0.9"},
"sec-ch-ua": {defaultSecCHUA},
"sec-ch-ua-mobile": {"?0"},
"sec-ch-ua-platform": {`"Windows"`},
"sec-fetch-site": {"same-site"},
"sec-fetch-mode": {"cors"},
"sec-fetch-dest": {"empty"},
"user-agent": {defaultUserAgent},
"x-arp-session-id": {buildARPSessionID()},
http.HeaderOrderKey: {
"authorization",
"x-api-key",
"content-type",
"accept",
"origin",
"referer",
"accept-language",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"user-agent",
"x-nonce",
"x-arp-session-id",
},
}
// The working video submit (HAR) carries x-nonce just like the image submit.
if prompt, _ := payload["prompt"].(string); prompt != "" {
if nonce := buildSubmitNonce(token, prompt); nonce != "" {
req.Header.Set("x-nonce", nonce)
}
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
return respBody, "", ErrQuotaExhausted
}
// 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 resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return respBody, "", ErrTemporaryUpstream
}
if resp.StatusCode != 200 {
return respBody, "", fmt.Errorf("video submit rejected: %d %s", resp.StatusCode, clip(respBody, 300))
}
var payloadResp map[string]any
if err := json.Unmarshal(respBody, &payloadResp); err != nil {
return respBody, "", err
}
if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" {
return respBody, normalizeVideoPollURL(override), nil
}
if links, ok := payloadResp["links"].(map[string]any); ok {
if result, ok := links["result"].(map[string]any); ok {
if href := strings.TrimSpace(stringValue(result["href"])); href != "" {
return respBody, normalizeVideoPollURL(href), nil
}
}
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
return respBody, normalizeVideoPollURL(href), nil
}
}
return respBody, "", errors.New("video submit ok but no poll url")
}
func (c *Client) pollVideo(ctx context.Context, client tlsclient.HttpClient, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) {
start := time.Now()
for {
if time.Since(start) > 10*time.Minute {
return nil, nil, errors.New("adobe video generation timed out")
}
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
if err != nil {
return nil, nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"authorization": {"Bearer " + strings.TrimSpace(token)},
"accept": {"*/*"},
"origin": {"https://firefly.adobe.com"},
"referer": {"https://firefly.adobe.com/"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"authorization",
"accept",
"origin",
"referer",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, nil, readErr
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, nil, fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300))
}
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
return nil, nil, ErrTemporaryUpstream
}
if resp.StatusCode != 200 {
return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300))
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, err
}
if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 {
if first, ok := outputs[0].(map[string]any); ok {
if video, ok := first["video"].(map[string]any); ok {
if raw := strings.TrimSpace(stringValue(video["presignedUrl"])); raw != "" {
payload["video_url"] = raw
if !downloadResult {
return payload, nil, nil
}
data, err := c.download(ctx, client, raw)
if err != nil {
return nil, nil, err
}
return payload, data, nil
}
}
}
}
status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"])))
if status == "FAILED" || status == "CANCELLED" || status == "ERROR" {
return nil, nil, fmt.Errorf("adobe video job failed: %s", clip(body, 300))
}
time.Sleep(3 * time.Second)
}
}
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"accept",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("adobe download failed: %d %s", resp.StatusCode, clip(body, 200))
}
return io.ReadAll(resp.Body)
}
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
options := []tlsclient.HttpClientOption{
tlsclient.WithTimeoutSeconds(60),
tlsclient.WithClientProfile(profiles.Chrome_133),
tlsclient.WithNotFollowRedirects(),
tlsclient.WithRandomTLSExtensionOrder(),
}
if c.proxy != "" {
options = append(options, tlsclient.WithProxyUrl(c.proxy))
}
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
}
func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClient, cookie string) (*CookieExchangeResult, error) {
cookie = normalizeCookie(cookie)
if cookie == "" {
return nil, ErrAdobeCookieEmpty
}
body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header = http.Header{
"accept": {"*/*"},
"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/"},
"user-agent": {defaultUserAgent},
http.HeaderOrderKey: {
"accept",
"accept-language",
"content-type",
"cookie",
"origin",
"referer",
"user-agent",
},
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("adobe cookie exchange network error: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("adobe cookie exchange upstream %d: %s", resp.StatusCode, clip(respBody, 200))
}
var payload map[string]any
if err := json.Unmarshal(respBody, &payload); err != nil {
return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err)
}
token := strings.TrimSpace(stringValue(payload["access_token"]))
if token == "" {
return nil, errors.New("adobe cookie exchange missing access_token")
}
return &CookieExchangeResult{
AccessToken: token,
ExpiresIn: intValue(payload["expires_in"]),
Raw: payload,
}, nil
}
func buildSubmitNonce(token, prompt string) string {
claims := decodeJWTPayload(token)
userID := strings.TrimSpace(stringValue(claims["user_id"]))
if userID == "" {
userID = strings.TrimSpace(stringValue(claims["aa_id"]))
}
if userID == "" {
userID = strings.TrimSpace(stringValue(claims["sub"]))
}
prompt = strings.TrimSpace(prompt)
if userID == "" || prompt == "" {
return ""
}
if len(prompt) > 256 {
prompt = prompt[:256]
}
sum := sha256.Sum256([]byte(userID + "-" + prompt))
return hex.EncodeToString(sum[:])
}
func ExtractAccountID(token string) string {
claims := decodeJWTPayload(token)
userID := strings.TrimSpace(stringValue(claims["user_id"]))
if userID == "" {
userID = strings.TrimSpace(stringValue(claims["aa_id"]))
}
if userID == "" {
userID = strings.TrimSpace(stringValue(claims["sub"]))
}
return userID
}
func normalizeVideoPollURL(raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
}
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
host := parsed.Hostname()
if !strings.HasPrefix(host, "firefly-epo") {
return raw
}
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) == 0 {
return raw
}
jobID := strings.TrimSpace(parts[len(parts)-1])
hostSuffix := strings.TrimPrefix(host, "firefly-epo")
hostSuffix = strings.SplitN(hostSuffix, ".", 2)[0]
if len(hostSuffix) != 4 {
return raw
}
for _, ch := range hostSuffix {
if ch < '0' || ch > '9' {
return raw
}
}
return "https://bks-epo" + hostSuffix + ".adobe.io/v2/jobs/result/" + jobID + "?host=" + parsed.Host + "/"
}
func clip(v []byte, n int) string {
s := strings.TrimSpace(string(v))
if len(s) <= n {
return s
}
return s[:n]
}