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>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
// 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.newTLSClient()
|
||||
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.apiGet(probeCtx, cookie, "/api/billing-data")
|
||||
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.apiGet(actCtx, cookie, "/app")
|
||||
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) {
|
||||
client, err := c.newTLSClient()
|
||||
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) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if 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
|
||||
}
|
||||
Reference in New Issue
Block a user