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,441 @@
|
||||
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
|
||||
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
|
||||
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
|
||||
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
|
||||
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
|
||||
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://imagine.vyro.ai"
|
||||
teamsBase = "https://teams-imagine.vyro.ai"
|
||||
authBase = "https://auth.vyro.ai"
|
||||
webOrigin = "https://www.imagine.art"
|
||||
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("imagine auth failed")
|
||||
ErrQuotaExhausted = errors.New("imagine quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
|
||||
// dormant account's rotating refreshToken never lapses.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest credential per account (key: user id) + a per-account refresh lock,
|
||||
// so concurrent callers don't each spend the rotating refresh_token — the first
|
||||
// refreshes, the rest reuse the cached fresh credential.
|
||||
mu sync.Mutex
|
||||
creds map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type credential struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
// Email is the real account email — supplied at import, used for display and
|
||||
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
|
||||
// must be carried across refreshes (the refresh response omits it).
|
||||
Email string `json:"email,omitempty"`
|
||||
// ParentID is a canvas node the account OWNS, used as the generation's
|
||||
// parent_id. Imagine rejects any parent the account doesn't own ("user does
|
||||
// not have access to parent asset") and silently orphans a parent-less
|
||||
// generation (charged but never produced) — so it's supplied at import and
|
||||
// carried across refreshes.
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
}
|
||||
|
||||
func parseCred(s string) (credential, bool) {
|
||||
var cr credential
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
|
||||
return cr, false
|
||||
}
|
||||
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
|
||||
return cr, false
|
||||
}
|
||||
return cr, true
|
||||
}
|
||||
|
||||
func buildCred(token, refresh, email, parentID string) string {
|
||||
b, _ := json.Marshal(credential{
|
||||
Token: strings.TrimSpace(token),
|
||||
RefreshToken: strings.TrimSpace(refresh),
|
||||
Email: strings.TrimSpace(email),
|
||||
ParentID: strings.TrimSpace(parentID),
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParentIDFromCred returns the canvas parent node id supplied at import.
|
||||
func ParentIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cr.ParentID)
|
||||
}
|
||||
|
||||
func looksLikeJWT(s string) bool {
|
||||
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
|
||||
}
|
||||
|
||||
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
|
||||
// a JSON object carrying a non-empty token + refreshToken that both look like
|
||||
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
|
||||
func IsImagineToken(value string) bool {
|
||||
cr, ok := parseCred(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
|
||||
}
|
||||
|
||||
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
|
||||
func jwtClaims(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// tolerate padded variants
|
||||
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func userIDFromToken(token string) string {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(stringValue(claims["sub"]))
|
||||
}
|
||||
|
||||
func tokenExp(token string) int64 {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt64(claims["exp"])
|
||||
}
|
||||
|
||||
// EmailFromCred returns the real account email supplied at import; if absent it
|
||||
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
|
||||
func EmailFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if e := strings.TrimSpace(cr.Email); e != "" {
|
||||
return e
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
|
||||
func UserIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RefreshIfNeeded returns a credential whose access token is still valid: if the
|
||||
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
|
||||
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
|
||||
// the caller must persist the new credential (both tokens rotate). ErrAuth means
|
||||
// the refreshToken is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return cred, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
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.creds[userID]
|
||||
c.mu.Unlock()
|
||||
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
|
||||
return cached, cached != cred, nil
|
||||
}
|
||||
}
|
||||
if tokenExp(cr.Token)-refreshLeadSeconds > now {
|
||||
return cred, false, nil // still valid
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
|
||||
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 rb struct {
|
||||
Result struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newRefresh := rb.Result.RefreshToken
|
||||
if strings.TrimSpace(newRefresh) == "" {
|
||||
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
|
||||
}
|
||||
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.creds[userID] = newCred
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCred, 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
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + refreshToken},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
|
||||
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
|
||||
// normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return unknownBalance("bad credential"), nil
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
body, status, err := c.apiGet(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID)
|
||||
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
|
||||
}
|
||||
var cb struct {
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &cb); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": cb.Total,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCred(cred)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "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
|
||||
}
|
||||
|
||||
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...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small util
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func uuid4() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
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 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 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
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll
|
||||
// the org objects feed until the batch finishes, then download the produced
|
||||
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
|
||||
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
|
||||
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string) ([]byte, map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"placeholderUuid": uuid4(),
|
||||
"promptWithoutManipulation": prompt,
|
||||
"modeId": 0,
|
||||
})
|
||||
// parent_id MUST be a canvas node this account owns — the server rejects a
|
||||
// foreign id ("user does not have access to parent asset") and silently
|
||||
// orphans a parent-less generation (charged but never produced). It's supplied
|
||||
// at import (credential.parentId).
|
||||
fields := map[string]string{
|
||||
"style_id": strconv.Itoa(styleID),
|
||||
"aspect_ratio": aspectRatio,
|
||||
"resolution": resolution,
|
||||
"variation": "txt2img",
|
||||
"prompt": prompt,
|
||||
"is_enhance": "0",
|
||||
"count": "1",
|
||||
"clientVersion": "1",
|
||||
"org_id": userID,
|
||||
"use_plugin": "false",
|
||||
"metadata": string(metadata),
|
||||
}
|
||||
if pid := strings.TrimSpace(cr.ParentID); pid != "" {
|
||||
fields["parent_id"] = pid
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status == 402 {
|
||||
return nil, nil, ErrQuotaExhausted
|
||||
}
|
||||
if status != 200 && status != 201 {
|
||||
return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
var jobs []struct {
|
||||
BatchID string `json:"batchId"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
batchID := jobs[0].BatchID
|
||||
|
||||
imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err := c.download(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}, nil
|
||||
}
|
||||
|
||||
// pollImage polls the org objects feed until the entry for our batch finishes,
|
||||
// then extracts its asset URL (image_url is a JSON-encoded array string).
|
||||
func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
|
||||
url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image"
|
||||
for {
|
||||
body, status, err := c.apiGet(ctx, token, url)
|
||||
if err == nil && status == 200 {
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Status string `json:"status"`
|
||||
Code int `json:"code"`
|
||||
// Current shape: the produced asset lives at url.generation[0].
|
||||
URL struct {
|
||||
Generation []string `json:"generation"`
|
||||
} `json:"url"`
|
||||
ImageURL string `json:"image_url"` // legacy fallback
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(body, &resp) == nil {
|
||||
for _, o := range resp.Data {
|
||||
if o.BatchID != batchID {
|
||||
continue
|
||||
}
|
||||
st := strings.ToLower(strings.TrimSpace(o.Status))
|
||||
switch {
|
||||
case st == "finished" || o.Code == 2:
|
||||
if u := firstNonEmpty(o.URL.Generation); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
if u := firstImageURL(o.ImageURL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
case st == "failed" || st == "error":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank string in a slice.
|
||||
func firstNonEmpty(ss []string) string {
|
||||
for _, s := range ss {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstImageURL parses the image_url field — a JSON-encoded array of URLs — and
|
||||
// returns the first one. Tolerates a bare string too.
|
||||
func firstImageURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var urls []string
|
||||
if json.Unmarshal([]byte(raw), &urls) == nil {
|
||||
for _, u := range urls {
|
||||
if strings.TrimSpace(u) != "" {
|
||||
return strings.TrimSpace(u)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http") {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, url string) ([]byte, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"},
|
||||
"user-agent": {userAgent},
|
||||
"referer": {webOrigin + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// apiPost issues a POST with a raw body + content-type, carrying the bearer token.
|
||||
func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"content-type": {contentType},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "content-type", "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
|
||||
}
|
||||
Reference in New Issue
Block a user