Now only the generate/video submit uses proxy.url; cookie-exchange/login, token refresh, credit-balance and project/session bootstrap all go direct. Also splits adobe GenerateVideo (submit=proxy, poll/download=local). Verified live: adobe gpt 1K cookie-exchange via local IP, submit 23.244.30.102 (proxy) vs local 76.209.9.65, 2.0MB PNG. Build+vet pass.
500 lines
15 KiB
Go
500 lines
15 KiB
Go
// Package krea implements the Krea.ai (krea.ai) provider client. The durable
|
|
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
|
|
// Next.js backend reads it directly, so quota and generation just forward the
|
|
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
|
|
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
|
|
package krea
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
http "github.com/bogdanfinn/fhttp"
|
|
tlsclient "github.com/bogdanfinn/tls-client"
|
|
"github.com/bogdanfinn/tls-client/profiles"
|
|
)
|
|
|
|
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
|
|
// frontend) — required as the apikey/bearer when refreshing a session token.
|
|
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
|
|
|
|
const (
|
|
apiBase = "https://www.krea.ai"
|
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
var (
|
|
ErrAuth = errors.New("krea auth failed")
|
|
ErrQuotaExhausted = errors.New("krea quota exhausted")
|
|
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
|
|
)
|
|
|
|
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
|
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
|
|
// dormant account's token never lapses — once the rotating refresh_token is gone
|
|
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
|
|
const refreshLeadSeconds = 600 // 10 minutes
|
|
|
|
type Client struct {
|
|
proxy string
|
|
// freshest cookie per account (key: user id) + a per-account refresh lock, so
|
|
// concurrent callers don't each spend the single-use (rotating) refresh_token —
|
|
// the first refreshes, the rest reuse the cached fresh cookie.
|
|
mu sync.Mutex
|
|
cookies map[string]string
|
|
locks map[string]*sync.Mutex
|
|
// actAt = last /app activation time per account (key: user id); actLocks gives
|
|
// a per-account lock so concurrent generations wait for the first to finish the
|
|
// (once-per-daily-reset) activation instead of each loading /app.
|
|
actAt map[string]int64
|
|
actLocks map[string]*sync.Mutex
|
|
}
|
|
|
|
func NewClient(proxy string) *Client {
|
|
return &Client{
|
|
proxy: strings.TrimSpace(proxy),
|
|
cookies: map[string]string{},
|
|
locks: map[string]*sync.Mutex{},
|
|
actAt: map[string]int64{},
|
|
actLocks: map[string]*sync.Mutex{},
|
|
}
|
|
}
|
|
|
|
func (c *Client) userLock(userID string) *sync.Mutex {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
m, ok := c.locks[userID]
|
|
if !ok {
|
|
m = &sync.Mutex{}
|
|
c.locks[userID] = m
|
|
}
|
|
return m
|
|
}
|
|
|
|
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
|
|
// stored one is (near) expired it spends the refresh_token to mint a new session
|
|
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
|
|
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
|
|
// the refresh_token is dead → the account is gone.
|
|
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
|
|
authVal := authCookieValue(cookie)
|
|
sess, ok := decodeSession(authVal)
|
|
if !ok {
|
|
return cookie, false, nil // unparseable — let the downstream call surface the error
|
|
}
|
|
userID := nestedStr(sess, "user", "id")
|
|
now := time.Now().Unix()
|
|
|
|
lk := c.userLock(userID)
|
|
lk.Lock()
|
|
defer lk.Unlock()
|
|
|
|
// A concurrent caller may already have refreshed this account.
|
|
if userID != "" {
|
|
c.mu.Lock()
|
|
cached := c.cookies[userID]
|
|
c.mu.Unlock()
|
|
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
|
|
return cached, cached != cookie, nil
|
|
}
|
|
}
|
|
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
|
|
return cookie, false, nil // still valid
|
|
}
|
|
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
|
|
if refreshTok == "" {
|
|
return "", false, ErrAuth
|
|
}
|
|
|
|
respBody, status, err := c.refreshPost(ctx, refreshTok)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
|
}
|
|
if status == 400 || status == 401 || status == 403 {
|
|
return "", false, ErrAuth
|
|
}
|
|
if status != 200 {
|
|
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
|
}
|
|
var ns map[string]any
|
|
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
|
|
return "", false, ErrAuth
|
|
}
|
|
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
|
|
if userID != "" {
|
|
c.mu.Lock()
|
|
c.cookies[userID] = newCookie
|
|
c.mu.Unlock()
|
|
}
|
|
return newCookie, true, nil
|
|
}
|
|
|
|
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
|
client, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
|
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"accept": {"*/*"},
|
|
"content-type": {"application/json;charset=UTF-8"},
|
|
"apikey": {kreaAnonKey},
|
|
"authorization": {"Bearer " + kreaAnonKey},
|
|
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
|
|
"x-supabase-api-version": {"2024-01-01"},
|
|
"origin": {apiBase},
|
|
"referer": {apiBase + "/"},
|
|
"user-agent": {userAgent},
|
|
http.HeaderOrderKey: {
|
|
"accept", "content-type", "apikey", "authorization", "x-client-info",
|
|
"x-supabase-api-version", "origin", "referer", "user-agent",
|
|
},
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
return b, resp.StatusCode, err
|
|
}
|
|
|
|
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
|
|
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
|
|
const chunkSize = 3600
|
|
|
|
func cookieVal(cookie, name string) string {
|
|
for _, p := range strings.Split(cookie, ";") {
|
|
p = strings.TrimSpace(p)
|
|
if v, ok := strings.CutPrefix(p, name+"="); ok {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authCookieValue returns the full auth value, transparently reassembling a
|
|
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
|
|
func authCookieValue(cookie string) string {
|
|
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
|
|
return v
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; ; i++ {
|
|
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
|
|
if v == "" {
|
|
break
|
|
}
|
|
b.WriteString(v)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
|
|
// new value (chunked the same way supabase-ssr would if it's large), preserving
|
|
// all other cookies (krea-workspace-id, etc.).
|
|
func replaceAuthCookie(cookie, newValue string) string {
|
|
var out []string
|
|
for _, p := range strings.Split(cookie, ";") {
|
|
t := strings.TrimSpace(p)
|
|
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
if len(newValue) <= chunkSize {
|
|
out = append(out, "sb-superb-auth-token="+newValue)
|
|
} else {
|
|
for i, off := 0, 0; off < len(newValue); i++ {
|
|
end := off + chunkSize
|
|
if end > len(newValue) {
|
|
end = len(newValue)
|
|
}
|
|
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
|
|
off = end
|
|
}
|
|
}
|
|
return strings.Join(out, "; ")
|
|
}
|
|
|
|
// decodeSession base64-decodes the auth cookie value into the session JSON map.
|
|
func decodeSession(authValue string) (map[string]any, bool) {
|
|
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
|
|
if v == "" {
|
|
return nil, false
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(v)
|
|
if err != nil {
|
|
raw, err = base64.RawURLEncoding.DecodeString(v)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal(raw, &m) != nil {
|
|
return nil, false
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
func nestedStr(m map[string]any, k1, k2 string) string {
|
|
if sub, ok := m[k1].(map[string]any); ok {
|
|
return strings.TrimSpace(stringValue(sub[k2]))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toInt64(v any) int64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return int64(x)
|
|
case int64:
|
|
return x
|
|
case int:
|
|
return int64(x)
|
|
case json.Number:
|
|
n, _ := x.Int64()
|
|
return n
|
|
case string:
|
|
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (c *Client) SetProxy(proxy string) {
|
|
c.proxy = strings.TrimSpace(proxy)
|
|
}
|
|
|
|
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
|
|
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
|
|
func IsKreaCookie(value string) bool {
|
|
return strings.Contains(value, "sb-superb-auth-token")
|
|
}
|
|
|
|
// EmailFromCookie decodes the account email straight out of the cookie's embedded
|
|
// Supabase session (no network), handling chunked cookies too.
|
|
func EmailFromCookie(cookie string) string {
|
|
sess, ok := decodeSession(authCookieValue(cookie))
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return nestedStr(sess, "user", "email")
|
|
}
|
|
|
|
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
|
|
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
|
|
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
|
|
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
|
// Detach from the request ctx so a page refresh can't cancel the probe
|
|
// mid-flight (which left accounts stuck at "—").
|
|
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
defer cancel()
|
|
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
|
|
// and at generation via Activate). This probe just reads the current balance.
|
|
body, status, err := c.apiGetP(probeCtx, cookie, "/api/billing-data", false)
|
|
if err != nil {
|
|
return unknownBalance("network: " + err.Error()), nil
|
|
}
|
|
if status == 401 || status == 403 {
|
|
return nil, ErrAuth
|
|
}
|
|
if status != 200 {
|
|
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
|
}
|
|
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
|
|
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
|
|
var bd struct {
|
|
Balance struct {
|
|
Free float64 `json:"free"`
|
|
Total float64 `json:"total"`
|
|
} `json:"balance"`
|
|
}
|
|
if err := json.Unmarshal(body, &bd); err != nil {
|
|
return unknownBalance("non-json"), nil
|
|
}
|
|
remaining := int(bd.Balance.Free) // floor
|
|
return map[string]any{
|
|
"remaining": remaining,
|
|
"used": nil,
|
|
"total": int(bd.Balance.Total),
|
|
"unknown": false,
|
|
"error": nil,
|
|
"email": emptyStringNil(EmailFromCookie(cookie)),
|
|
}, nil
|
|
}
|
|
|
|
// Activate loads the authenticated SSR app page (/app), which is what makes krea
|
|
// grant the account's DAILY free balance — a cold API-only call (billing-data /
|
|
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
|
|
// per daily reset, under a per-account lock: the first caller loads /app while
|
|
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
|
|
// before each generation and by the daily activation sweep. Best-effort.
|
|
func (c *Client) Activate(ctx context.Context, cookie string) {
|
|
key := accountKey(cookie)
|
|
lastReset := (time.Now().Unix() / 86400) * 86400
|
|
c.mu.Lock()
|
|
doneToday := key != "" && c.actAt[key] >= lastReset
|
|
c.mu.Unlock()
|
|
if doneToday {
|
|
return
|
|
}
|
|
lk := c.actLock(key)
|
|
lk.Lock()
|
|
defer lk.Unlock()
|
|
// Re-check after acquiring the lock — another caller may have just activated.
|
|
c.mu.Lock()
|
|
doneToday = key != "" && c.actAt[key] >= lastReset
|
|
c.mu.Unlock()
|
|
if doneToday {
|
|
return
|
|
}
|
|
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
|
|
defer cancel()
|
|
_, _, _ = c.apiGetP(actCtx, cookie, "/app", false)
|
|
c.mu.Lock()
|
|
c.actAt[key] = time.Now().Unix()
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Client) actLock(key string) *sync.Mutex {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
m, ok := c.actLocks[key]
|
|
if !ok {
|
|
m = &sync.Mutex{}
|
|
c.actLocks[key] = m
|
|
}
|
|
return m
|
|
}
|
|
|
|
// accountKey is the stable per-account id (the Supabase user id from the session
|
|
// cookie) used to key activation state — survives cookie rotation.
|
|
func accountKey(cookie string) string {
|
|
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
|
|
return nestedStr(sess, "user", "id")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
|
|
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
|
|
return c.apiGetP(ctx, cookie, path, true)
|
|
}
|
|
|
|
// apiGetP picks the egress: polling / asset resolution run direct (local IP).
|
|
func (c *Client) apiGetP(ctx context.Context, cookie, path string, useProxy bool) ([]byte, int, error) {
|
|
client, err := c.newTLSClientP(useProxy)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"accept": {"*/*"},
|
|
"accept-language": {"en-US,en;q=0.9"},
|
|
"cookie": {cookie},
|
|
"referer": {apiBase + "/"},
|
|
"user-agent": {userAgent},
|
|
"sec-fetch-dest": {"empty"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-site": {"same-origin"},
|
|
http.HeaderOrderKey: {
|
|
"accept", "accept-language", "cookie", "referer", "user-agent",
|
|
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
|
},
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
return b, resp.StatusCode, err
|
|
}
|
|
|
|
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(true) }
|
|
|
|
// newDirectTLSClient egresses on the local IP (never the proxy). Used for
|
|
// reference-image upload, polling and result download.
|
|
func (c *Client) newDirectTLSClient() (tlsclient.HttpClient, error) { return c.newTLSClientP(false) }
|
|
|
|
func (c *Client) newTLSClientP(useProxy bool) (tlsclient.HttpClient, error) {
|
|
options := []tlsclient.HttpClientOption{
|
|
tlsclient.WithTimeoutSeconds(60),
|
|
tlsclient.WithClientProfile(profiles.Chrome_120),
|
|
}
|
|
if useProxy && c.proxy != "" {
|
|
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
}
|
|
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
}
|
|
|
|
func unknownBalance(reason string) map[string]any {
|
|
return map[string]any{
|
|
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
|
}
|
|
}
|
|
|
|
func stringValue(v any) string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case nil:
|
|
return ""
|
|
default:
|
|
b, _ := json.Marshal(x)
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
}
|
|
|
|
func intValue(v any) int {
|
|
switch x := v.(type) {
|
|
case int:
|
|
return x
|
|
case float64:
|
|
return int(x)
|
|
case json.Number:
|
|
n, _ := x.Int64()
|
|
return int(n)
|
|
case string:
|
|
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func emptyStringNil(v string) any {
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil
|
|
}
|
|
return v
|
|
}
|
|
|
|
func clip(b []byte, n int) string {
|
|
s := strings.TrimSpace(string(b))
|
|
if len(s) > n {
|
|
return s[:n]
|
|
}
|
|
return s
|
|
}
|