更新grok
This commit is contained in:
@@ -28,16 +28,23 @@ require (
|
|||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
|
||||||
|
github.com/chromedp/chromedp v0.15.1 // indirect
|
||||||
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
|||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||||
|
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||||
|
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -41,6 +47,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -54,6 +62,12 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq
|
|||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"backend/internal/config"
|
"backend/internal/config"
|
||||||
@@ -122,6 +123,11 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
kreaClient := krea.NewClient("")
|
kreaClient := krea.NewClient("")
|
||||||
imagineClient := imagine.NewClient("")
|
imagineClient := imagine.NewClient("")
|
||||||
grokClient := grok.NewClient("")
|
grokClient := grok.NewClient("")
|
||||||
|
// Keep grok's x-statsig-id anti-bot recipe current by reading grok's own
|
||||||
|
// headless-browser signer output. Event-driven: seed from the persisted
|
||||||
|
// recipe, capture once at startup, then re-capture only on an anti-bot 403
|
||||||
|
// (a reship made the recipe stale). No polling.
|
||||||
|
startGrokStatsigRefresh(siteRepo)
|
||||||
customClient := custom.NewClient()
|
customClient := custom.NewClient()
|
||||||
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
|
v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient)
|
||||||
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
|
||||||
@@ -175,6 +181,33 @@ func NewApp(ctx context.Context) (*App, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startGrokStatsigRefresh wires grok's headless x-statsig-id refresher to the
|
||||||
|
// site-settings store (persisted across restarts) with an app-lifetime context.
|
||||||
|
func startGrokStatsigRefresh(siteRepo *repo.SiteSettingRepository) {
|
||||||
|
const kHeader, kSuffix, kTrailer = "grok.statsig.header", "grok.statsig.suffix", "grok.statsig.trailer"
|
||||||
|
grok.StartStatsigAutoRefresh(context.Background(), 0,
|
||||||
|
func(ctx context.Context) (string, string, int, bool) {
|
||||||
|
h, _ := siteRepo.GetValue(ctx, kHeader)
|
||||||
|
s, _ := siteRepo.GetValue(ctx, kSuffix)
|
||||||
|
tv, _ := siteRepo.GetValue(ctx, kTrailer)
|
||||||
|
if h == "" || s == "" {
|
||||||
|
return "", "", 0, false
|
||||||
|
}
|
||||||
|
t, _ := strconv.Atoi(tv)
|
||||||
|
return h, s, t, true
|
||||||
|
},
|
||||||
|
func(ctx context.Context, h, s string, t int) {
|
||||||
|
if err := siteRepo.UpsertValues(ctx, map[string]string{
|
||||||
|
kHeader: h,
|
||||||
|
kSuffix: s,
|
||||||
|
kTrailer: strconv.Itoa(t),
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("grok statsig: persist recipe failed: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
if a.maintenanceCancel != nil {
|
if a.maintenanceCancel != nil {
|
||||||
a.maintenanceCancel()
|
a.maintenanceCancel()
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package grok
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The x-statsig-id recipe (49-byte header + salt suffix) encodes grok's per-load
|
||||||
|
// browser fingerprint "F". grok recomputes F server-side from the homepage
|
||||||
|
// seed+curves and the byte-indexing that derives it ROTATES on every web reship,
|
||||||
|
// so any hand-ported or baked-in recipe goes stale (403 "Request rejected by
|
||||||
|
// anti-bot rules.") within days. The durable fix is to let grok's OWN signer
|
||||||
|
// produce the recipe: we drive a headless Chrome to grok.com, read the exact
|
||||||
|
// string grok feeds to crypto.subtle.digest (reveals the current suffix) and one
|
||||||
|
// of the x-statsig-id headers its client attaches (reveals the current 49-byte
|
||||||
|
// header + trailer), then feed those into statsigID's existing per-request
|
||||||
|
// computation. Because we read grok's live output we never reverse-engineer the
|
||||||
|
// (rotating) obfuscation; a reship just needs a re-capture, which the auto-refresh
|
||||||
|
// loop does on a timer and whenever a live request hits an anti-bot 403.
|
||||||
|
//
|
||||||
|
// The captured recipe is persisted (site settings) so a restart keeps the last
|
||||||
|
// good values, and every failure is non-fatal — statsigID keeps using whatever
|
||||||
|
// recipe is currently live (persisted → last capture → static defaults).
|
||||||
|
|
||||||
|
// hookJS is installed before grok's own scripts run. It records (a) every
|
||||||
|
// crypto.subtle.digest input that contains the statsig salt marker — the plaintext
|
||||||
|
// "METHOD!/path!counter<suffix>" — and (b) every 70-byte x-statsig-id header grok
|
||||||
|
// attaches to its API calls. Both are only produced once grok's signer succeeds
|
||||||
|
// (after its keyframe animation settles), so a short wait yields valid values.
|
||||||
|
const statsigHookJS = `
|
||||||
|
(() => {
|
||||||
|
window.__statsigDigests = []; window.__statsigCaps = [];
|
||||||
|
try {
|
||||||
|
const td = new TextDecoder('utf-8', {fatal:false});
|
||||||
|
const orig = crypto.subtle.digest.bind(crypto.subtle);
|
||||||
|
crypto.subtle.digest = function(alg, data) {
|
||||||
|
try {
|
||||||
|
let buf = data; if (data && data.buffer) buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||||
|
const txt = td.decode(new Uint8Array(buf.slice ? buf.slice(0) : buf));
|
||||||
|
if (txt.indexOf('` + statsigSaltPrefix + `') >= 0) window.__statsigDigests.push(txt);
|
||||||
|
} catch (e) {}
|
||||||
|
return orig(alg, data);
|
||||||
|
};
|
||||||
|
const of = window.fetch;
|
||||||
|
window.fetch = function(input, init) {
|
||||||
|
try {
|
||||||
|
let hdrs = (init && init.headers); let sid = null;
|
||||||
|
if (hdrs) { if (typeof hdrs.get === 'function') sid = hdrs.get('x-statsig-id'); else for (const k in hdrs) if (k.toLowerCase() === 'x-statsig-id') sid = hdrs[k]; }
|
||||||
|
if (sid && sid.length === 94) window.__statsigCaps.push(sid);
|
||||||
|
} catch (e) {}
|
||||||
|
return of.apply(this, arguments);
|
||||||
|
};
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`
|
||||||
|
|
||||||
|
var (
|
||||||
|
statsigRefreshTrigger = make(chan struct{}, 1)
|
||||||
|
statsigRefreshStarted sync.Once
|
||||||
|
statsigLastCapture time.Time
|
||||||
|
statsigLastCaptureMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetStatsigRecipe atomically replaces the live (header, suffix, trailer) used by
|
||||||
|
// statsigID and drops the per-session challenge cache so the next request adopts
|
||||||
|
// the new values. headerHex must decode to 49 bytes; suffix must be non-empty.
|
||||||
|
func SetStatsigRecipe(headerHex, suffix string, trailer int) error {
|
||||||
|
h, err := hex.DecodeString(strings.TrimSpace(headerHex))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(h) != 49 {
|
||||||
|
return errors.New("statsig: header must be 49 bytes")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(suffix) == "" {
|
||||||
|
return errors.New("statsig: empty suffix")
|
||||||
|
}
|
||||||
|
if trailer < 0 || trailer > 255 {
|
||||||
|
return errors.New("statsig: trailer out of range")
|
||||||
|
}
|
||||||
|
statsigMu.Lock()
|
||||||
|
statsigHeader = h
|
||||||
|
statsigSuffix = suffix
|
||||||
|
statsigTrailer = byte(trailer)
|
||||||
|
statsigCache = map[string]statsigChallenge{}
|
||||||
|
statsigMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chromeExecPath resolves the Chrome/Chromium binary for headless capture, from
|
||||||
|
// GROK_STATSIG_CHROME or the usual names on PATH. Empty means none available.
|
||||||
|
func chromeExecPath() string {
|
||||||
|
if p := strings.TrimSpace(os.Getenv("GROK_STATSIG_CHROME")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, name := range []string{"google-chrome-stable", "google-chrome", "chromium", "chromium-browser"} {
|
||||||
|
if p, err := exec.LookPath(name); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type statsigCapResult struct {
|
||||||
|
Digs []string `json:"digs"`
|
||||||
|
Caps []string `json:"caps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureStatsigRecipe drives a headless Chrome to grok.com and reads grok's own
|
||||||
|
// signer output, returning the current (headerHex, suffix, trailer). It never
|
||||||
|
// mutates global state. Returns an error if Chrome is unavailable or grok's
|
||||||
|
// signer output could not be observed (e.g. blocked before the signer ran).
|
||||||
|
func CaptureStatsigRecipe(ctx context.Context) (headerHex, suffix string, trailer int, err error) {
|
||||||
|
chromePath := chromeExecPath()
|
||||||
|
if chromePath == "" {
|
||||||
|
return "", "", 0, errors.New("statsig: no chrome binary (set GROK_STATSIG_CHROME)")
|
||||||
|
}
|
||||||
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
|
chromedp.ExecPath(chromePath),
|
||||||
|
chromedp.Flag("headless", "new"),
|
||||||
|
chromedp.Flag("no-sandbox", true),
|
||||||
|
chromedp.Flag("disable-gpu", true),
|
||||||
|
chromedp.Flag("disable-dev-shm-usage", true),
|
||||||
|
chromedp.UserAgent(userAgent),
|
||||||
|
)
|
||||||
|
if proxy := strings.TrimSpace(os.Getenv("GROK_STATSIG_PROXY")); proxy != "" {
|
||||||
|
opts = append(opts, chromedp.ProxyServer(proxy))
|
||||||
|
}
|
||||||
|
allocCtx, cancel := chromedp.NewExecAllocator(ctx, opts...)
|
||||||
|
defer cancel()
|
||||||
|
bctx, cancel2 := chromedp.NewContext(allocCtx)
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
|
var res statsigCapResult
|
||||||
|
runErr := chromedp.Run(bctx,
|
||||||
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||||
|
_, err := page.AddScriptToEvaluateOnNewDocument(statsigHookJS).Do(ctx)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
chromedp.Navigate(apiBase+"/"),
|
||||||
|
chromedp.Sleep(14*time.Second),
|
||||||
|
chromedp.Evaluate(`({digs: window.__statsigDigests || [], caps: window.__statsigCaps || []})`, &res),
|
||||||
|
)
|
||||||
|
if runErr != nil {
|
||||||
|
return "", "", 0, runErr
|
||||||
|
}
|
||||||
|
return parseStatsigCapture(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatsigCapture extracts (headerHex, suffix, trailer) from the observed
|
||||||
|
// digest plaintexts and x-statsig-id headers of one page load. Both must be
|
||||||
|
// present and are self-consistent (same per-load fingerprint F).
|
||||||
|
func parseStatsigCapture(res statsigCapResult) (headerHex, suffix string, trailer int, err error) {
|
||||||
|
for _, d := range res.Digs {
|
||||||
|
if i := strings.Index(d, statsigSaltPrefix); i >= 0 {
|
||||||
|
suffix = d[i:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if suffix == "" {
|
||||||
|
return "", "", 0, errors.New("statsig: no signer digest observed (blocked before signer ran?)")
|
||||||
|
}
|
||||||
|
var header []byte
|
||||||
|
for _, sid := range res.Caps {
|
||||||
|
raw, derr := base64.RawStdEncoding.DecodeString(sid)
|
||||||
|
if derr != nil || len(raw) != 70 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := raw[0] // header[0] is 0x00, so the XOR mask key == byte 0
|
||||||
|
r := make([]byte, 70)
|
||||||
|
for i := range raw {
|
||||||
|
r[i] = raw[i] ^ key
|
||||||
|
}
|
||||||
|
if r[0] != 0x00 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
header = r[0:49]
|
||||||
|
trailer = int(r[69])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if header == nil {
|
||||||
|
return "", "", 0, errors.New("statsig: no valid x-statsig-id header observed")
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(header), suffix, trailer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerStatsigRefresh asks the auto-refresh loop to re-capture soon (coalesced).
|
||||||
|
// Called when a live request hits an anti-bot 403 — the recipe likely went stale.
|
||||||
|
func TriggerStatsigRefresh() {
|
||||||
|
select {
|
||||||
|
case statsigRefreshTrigger <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartStatsigAutoRefresh launches the headless capture loop once. It is
|
||||||
|
// event-driven, NOT a polling loop: it seeds the live recipe from persisted
|
||||||
|
// values (load), captures once at startup so the process starts fresh, and then
|
||||||
|
// re-captures ONLY when a live request hits an anti-bot 403 (TriggerStatsigRefresh)
|
||||||
|
// — i.e. exactly when the recipe actually went stale on a grok reship. The recipe
|
||||||
|
// has no fixed clock expiry, so there is nothing useful to poll on. An optional
|
||||||
|
// safetyInterval>0 adds a slow backstop re-capture; pass 0 to disable it (default).
|
||||||
|
// Captured recipes are handed to `save` for persistence. No-op when a manual
|
||||||
|
// GROK_STATSIG_* override is set or no Chrome binary is available.
|
||||||
|
func StartStatsigAutoRefresh(ctx context.Context, safetyInterval time.Duration,
|
||||||
|
load func(context.Context) (headerHex, suffix string, trailer int, ok bool),
|
||||||
|
save func(ctx context.Context, headerHex, suffix string, trailer int)) {
|
||||||
|
|
||||||
|
statsigRefreshStarted.Do(func() {
|
||||||
|
if os.Getenv("GROK_STATSIG_HEADER_HEX") != "" || os.Getenv("GROK_STATSIG_SUFFIX") != "" {
|
||||||
|
log.Printf("grok statsig: manual GROK_STATSIG_* override set — headless auto-refresh disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chromeExecPath() == "" {
|
||||||
|
log.Printf("grok statsig: no chrome binary — headless auto-refresh disabled, using static recipe (set GROK_STATSIG_CHROME to enable)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if load != nil {
|
||||||
|
if h, s, t, ok := load(ctx); ok {
|
||||||
|
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||||
|
log.Printf("grok statsig: persisted recipe invalid, ignoring: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("grok statsig: loaded persisted recipe header[:6]=%s", safePrefix(h, 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go statsigRefreshLoop(ctx, safetyInterval, save)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsigRefreshLoop(ctx context.Context, safetyInterval time.Duration, save func(context.Context, string, string, int)) {
|
||||||
|
refresh := func(reason string) {
|
||||||
|
statsigLastCaptureMu.Lock()
|
||||||
|
if time.Since(statsigLastCapture) < 30*time.Second {
|
||||||
|
statsigLastCaptureMu.Unlock()
|
||||||
|
return // debounce bursts (e.g. a 403 storm across concurrent requests)
|
||||||
|
}
|
||||||
|
statsigLastCapture = time.Now()
|
||||||
|
statsigLastCaptureMu.Unlock()
|
||||||
|
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
h, s, t, err := CaptureStatsigRecipe(cctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("grok statsig: headless capture (%s) failed, keeping current recipe: %v", reason, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := SetStatsigRecipe(h, s, t); err != nil {
|
||||||
|
log.Printf("grok statsig: captured recipe invalid: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if save != nil {
|
||||||
|
save(ctx, h, s, t)
|
||||||
|
}
|
||||||
|
log.Printf("grok statsig: headless recipe refreshed (%s) header[:6]=%s suffix[:24]=%s", reason, safePrefix(h, 12), safePrefix(s, 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
initial := time.NewTimer(10 * time.Second)
|
||||||
|
defer initial.Stop()
|
||||||
|
|
||||||
|
// Optional slow backstop only; disabled (nil channel blocks forever) when
|
||||||
|
// safetyInterval<=0 so the loop stays purely event-driven.
|
||||||
|
var tickC <-chan time.Time
|
||||||
|
if safetyInterval > 0 {
|
||||||
|
ticker := time.NewTicker(safetyInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
tickC = ticker.C
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-initial.C:
|
||||||
|
refresh("startup")
|
||||||
|
case <-tickC:
|
||||||
|
refresh("safety-interval")
|
||||||
|
case <-statsigRefreshTrigger:
|
||||||
|
refresh("anti-bot 403")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safePrefix(s string, n int) string {
|
||||||
|
if len(s) < n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
@@ -448,7 +448,9 @@ func mapStatus(path string, status int, raw []byte) error {
|
|||||||
case status == 403 && isBotChallenge(string(raw)):
|
case status == 403 && isBotChallenge(string(raw)):
|
||||||
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
||||||
// NOT a dead token — transient, so a good account isn't killed by an
|
// NOT a dead token — transient, so a good account isn't killed by an
|
||||||
// IP/anti-bot hiccup.
|
// IP/anti-bot hiccup. A statsig anti-bot rejection means the recipe likely
|
||||||
|
// went stale on a reship; kick the headless refresher to re-capture.
|
||||||
|
TriggerStatsigRefresh()
|
||||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||||
case status == 401 || status == 403:
|
case status == 401 || status == 403:
|
||||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const pageSize = ref(20)
|
|||||||
|
|
||||||
const showAdd = ref(false)
|
const showAdd = ref(false)
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
|
const recharging = ref(null) // the user whose credits are being topped up
|
||||||
|
const rechargeAmt = ref(0)
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
|
|
||||||
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' })
|
const addForm = ref({ email: '', name: '', password: '', role: 'user', credits: 0, notes: '', concurrency_group_id: '' })
|
||||||
@@ -132,9 +134,11 @@ async function saveEdit() {
|
|||||||
// Email + 用户名 are intentionally NOT in the patch — they're displayed
|
// Email + 用户名 are intentionally NOT in the patch — they're displayed
|
||||||
// read-only in the form, and the admin shouldn't be in the habit of
|
// read-only in the form, and the admin shouldn't be in the habit of
|
||||||
// rewriting a user's identity from this page.
|
// rewriting a user's identity from this page.
|
||||||
|
// 积分 intentionally NOT patched here — editing must never touch the live
|
||||||
|
// balance (a stale snapshot would clobber credits the user spent meanwhile).
|
||||||
|
// Credits are adjusted only through the atomic 充值 button (/credits).
|
||||||
const patch = {
|
const patch = {
|
||||||
status: u.status,
|
status: u.status,
|
||||||
credits: u.credits,
|
|
||||||
role: u.role,
|
role: u.role,
|
||||||
notes: u.notes || '',
|
notes: u.notes || '',
|
||||||
concurrency_group_id: u.concurrency_group_id || '',
|
concurrency_group_id: u.concurrency_group_id || '',
|
||||||
@@ -192,6 +196,15 @@ async function quickCredits(u, delta) {
|
|||||||
if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() }
|
if (r.ok) { flash(`已${delta > 0 ? '增加' : '扣除'} ${Math.abs(delta).toLocaleString('en-US')} 积分`); load() }
|
||||||
else flash(r.data?.detail || '调整失败')
|
else flash(r.data?.detail || '调整失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRecharge(u) { recharging.value = u; rechargeAmt.value = 0 }
|
||||||
|
async function doRecharge() {
|
||||||
|
const delta = Math.round(Number(rechargeAmt.value) || 0)
|
||||||
|
if (!delta) { flash('请输入积分数量'); return }
|
||||||
|
// Atomic increment on the server — never overwrites the live balance.
|
||||||
|
await quickCredits(recharging.value, delta)
|
||||||
|
recharging.value = null; rechargeAmt.value = 0
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -263,12 +276,13 @@ async function quickCredits(u, delta) {
|
|||||||
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
<button v-if="!items.length" @click="showAdd = true" class="btn-soft mt-1">新建第一个</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table v-else class="w-full text-sm table-fixed">
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm table-fixed min-w-[1360px]">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-9" /> <!-- select -->
|
<col class="w-9" /> <!-- select -->
|
||||||
<col class="w-40" /> <!-- username -->
|
<col class="w-36" /> <!-- username -->
|
||||||
<col /> <!-- email (flex) -->
|
<col /> <!-- email (flex) -->
|
||||||
<col class="w-36" /> <!-- notes -->
|
<col class="w-24" /> <!-- notes -->
|
||||||
<col class="w-28" /> <!-- concurrency -->
|
<col class="w-28" /> <!-- concurrency -->
|
||||||
<col class="w-20" /> <!-- role -->
|
<col class="w-20" /> <!-- role -->
|
||||||
<col class="w-16" /> <!-- status switch -->
|
<col class="w-16" /> <!-- status switch -->
|
||||||
@@ -279,7 +293,7 @@ async function quickCredits(u, delta) {
|
|||||||
<col class="w-28" /> <!-- registered -->
|
<col class="w-28" /> <!-- registered -->
|
||||||
<col class="w-28" /> <!-- last login -->
|
<col class="w-28" /> <!-- last login -->
|
||||||
<col class="w-32" /> <!-- login IP -->
|
<col class="w-32" /> <!-- login IP -->
|
||||||
<col class="w-24" /> <!-- actions -->
|
<col class="w-32" /> <!-- actions -->
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
|
||||||
@@ -287,7 +301,7 @@ async function quickCredits(u, delta) {
|
|||||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll"
|
||||||
class="chk" title="全选" />
|
class="chk" title="全选" />
|
||||||
</th>
|
</th>
|
||||||
<th class="text-left px-5 py-3 font-medium">用户名</th>
|
<th class="text-left px-3 py-3 font-medium">用户名</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">邮箱</th>
|
<th class="text-left px-3 py-3 font-medium">邮箱</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">备注</th>
|
<th class="text-left px-3 py-3 font-medium">备注</th>
|
||||||
<th class="text-left px-3 py-3 font-medium">并发</th>
|
<th class="text-left px-3 py-3 font-medium">并发</th>
|
||||||
@@ -310,7 +324,7 @@ async function quickCredits(u, delta) {
|
|||||||
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
|
<input type="checkbox" :checked="selected.has(u.id)" @change="toggleSelect(u.id)" @click.stop
|
||||||
class="chk" />
|
class="chk" />
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
<td class="px-3 py-3.5 align-middle text-sm font-medium text-white/90 truncate" :title="u.name || '—'">
|
||||||
{{ u.name || '—' }}
|
{{ u.name || '—' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
<td class="px-3 py-3.5 align-middle text-xs text-white/75 truncate" :title="u.email">
|
||||||
@@ -376,6 +390,9 @@ async function quickCredits(u, delta) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
<td class="px-3 py-3.5 align-middle text-right whitespace-nowrap">
|
||||||
<div class="inline-flex items-center gap-1">
|
<div class="inline-flex items-center gap-1">
|
||||||
|
<button @click="openRecharge(u)" class="act" title="充值">
|
||||||
|
<Icon name="spark" class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
|
<button @click="editing = JSON.parse(JSON.stringify(u))" class="act" title="编辑">
|
||||||
<Icon name="config" class="w-3.5 h-3.5" />
|
<Icon name="config" class="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -387,6 +404,7 @@ async function quickCredits(u, delta) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- pagination -->
|
<!-- pagination -->
|
||||||
<div v-if="!loading && totalPages > 1"
|
<div v-if="!loading && totalPages > 1"
|
||||||
@@ -484,10 +502,6 @@ async function quickCredits(u, delta) {
|
|||||||
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
|
<input v-if="editing.role === 'admin'" value="管理员(唯一,不可更改)" disabled class="field" />
|
||||||
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
|
<SelectMenu v-else v-model="editing.role" :options="ROLE_OPTIONS" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="lbl">积分</label>
|
|
||||||
<input v-model.number="editing.credits" type="number" min="0" step="1" class="field" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="lbl">并发分组 <span class="text-white/35">(限制同时生成数)</span></label>
|
<label class="lbl">并发分组 <span class="text-white/35">(限制同时生成数)</span></label>
|
||||||
<SelectMenu v-model="editing.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
|
<SelectMenu v-model="editing.concurrency_group_id" :options="cgroupOptions" placeholder="选择分组" />
|
||||||
@@ -508,6 +522,38 @@ async function quickCredits(u, delta) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recharge modal -->
|
||||||
|
<div v-if="recharging"
|
||||||
|
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center p-4 overflow-y-auto"
|
||||||
|
@click.self="recharging = null">
|
||||||
|
<div class="card !shadow-2xl my-12 w-full max-w-sm">
|
||||||
|
<div class="px-5 py-4 border-b border-white/[0.06] flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">充值积分</h2>
|
||||||
|
<button @click="recharging = null" class="text-white/40 hover:text-white">
|
||||||
|
<Icon name="close" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="lbl">用户</label>
|
||||||
|
<input :value="recharging.email || recharging.name || recharging.id" disabled class="field font-mono" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">当前积分</label>
|
||||||
|
<input :value="points(recharging.credits).toLocaleString('en-US')" disabled class="field tabular-nums" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="lbl">充值数量 <span class="text-white/35">(正数增加,负数扣除)</span></label>
|
||||||
|
<input v-model.number="rechargeAmt" type="number" step="1" class="field" placeholder="例如 1000" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button @click="recharging = null" class="btn-soft">取消</button>
|
||||||
|
<button @click="doRecharge" class="btn-primary">确认充值</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="toast"
|
<div v-if="toast"
|
||||||
|
|||||||
Reference in New Issue
Block a user